feat(desktop): port v0.11.4-v0.15.2 from Tauri to Electron + Discord-parity audio (v0.16.0)
Chronological port of every Tauri release commit (v0.11.4 -> v0.15.2)
into the Electron rebuild, plus Discord-style audio handling that goes
beyond the Tauri original.
Highlights:
- All 17 Tauri release commits ported (audio fixes, custom notification
sound, Discord-style chat UX, profile banner, changelog page,
Discord-parity call UX, screen-share echo + re-watch UX, audio-loop
fix).
- Native napi-rs audio-loopback addon with WASAPI process-loopback:
* EXCLUDE_TARGET_PROCESS_TREE for full-screen shares -> peers
never hear themselves echoed back through the capture.
* INCLUDE_TARGET_PROCESS_TREE for window shares -> only the
picked window's audio is captured, not the whole OS mixer
(Discord parity).
* HWND -> PID resolution via Win32 GetWindowThreadProcessId.
- Discord-style screen-source picker (thumbnail grid, screens vs
apps tabs, live-refreshing thumbnails).
- Hash routing fix for packaged builds (file:// can't resolve
BrowserRouter paths).
- Tauri sources removed (apps/desktop/src-tauri).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,3 @@
|
||||
import { check as checkUpdate } from '@tauri-apps/plugin-updater';
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
export interface UpdateState {
|
||||
@@ -20,38 +18,34 @@ export const IDLE_UPDATE_STATE: UpdateState = {
|
||||
error: null,
|
||||
};
|
||||
|
||||
type UpdateHandle = Awaited<ReturnType<typeof checkUpdate>>;
|
||||
|
||||
let cachedUpdate: UpdateHandle | null = null;
|
||||
// Module-local flag: main owns the real "pending update" state via
|
||||
// electron-updater, but the renderer also needs to refuse installUpdate()
|
||||
// calls that weren't preceded by a successful checkForUpdate().
|
||||
let hasPendingUpdate = false;
|
||||
|
||||
export async function checkForUpdate(): Promise<UpdateState> {
|
||||
if (!isTauriRuntime()) {
|
||||
return { ...IDLE_UPDATE_STATE };
|
||||
}
|
||||
try {
|
||||
const update = await checkUpdate();
|
||||
if (!update) {
|
||||
cachedUpdate = null;
|
||||
const result = await window.electronAPI.checkForUpdate();
|
||||
if (!result.available || !result.info) {
|
||||
hasPendingUpdate = false;
|
||||
return { ...IDLE_UPDATE_STATE };
|
||||
}
|
||||
cachedUpdate = update;
|
||||
hasPendingUpdate = true;
|
||||
return {
|
||||
available: true,
|
||||
version: update.version ?? null,
|
||||
notes: update.body ?? null,
|
||||
version: result.info.version ?? null,
|
||||
notes: result.info.releaseNotes ?? null,
|
||||
downloading: false,
|
||||
downloaded: false,
|
||||
error: null,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
// Swallow "no release on GitHub yet" / network-unreachable cases silently.
|
||||
// The updater endpoint serves `latest.json` from GitHub releases; a fresh
|
||||
// repo or offline machine produces a generic "Could not fetch a valid
|
||||
// release JSON" error that has no actionable information for the user —
|
||||
// logging it on every launch just pollutes the console.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const benign =
|
||||
/could not fetch a valid release json|network|timed? out|failed to fetch|connection/i.test(
|
||||
/could not fetch a valid release json|network|timed? out|failed to fetch|connection|enotfound|econnreset|etimedout|404/i.test(
|
||||
msg,
|
||||
);
|
||||
if (!benign) {
|
||||
@@ -64,25 +58,25 @@ export async function checkForUpdate(): Promise<UpdateState> {
|
||||
}
|
||||
}
|
||||
|
||||
// Downloads + installs the previously checked update. On Windows the app
|
||||
// quits during install (passive installer); on macOS/Linux tauri triggers
|
||||
// a relaunch automatically.
|
||||
// Downloads + installs the previously checked update. On Windows the
|
||||
// app quits during install (passive installer); electron-updater
|
||||
// triggers a relaunch on macOS/Linux.
|
||||
export async function installUpdate(
|
||||
onProgress?: (downloaded: number, total: number | null) => void,
|
||||
): Promise<void> {
|
||||
if (!cachedUpdate) {
|
||||
if (!hasPendingUpdate) {
|
||||
throw new Error('no pending update — call checkForUpdate() first');
|
||||
}
|
||||
let total: number | null = null;
|
||||
let downloaded = 0;
|
||||
await cachedUpdate.downloadAndInstall((event) => {
|
||||
if (event.event === 'Started') {
|
||||
total = event.data.contentLength ?? null;
|
||||
downloaded = 0;
|
||||
} else if (event.event === 'Progress') {
|
||||
downloaded += event.data.chunkLength;
|
||||
}
|
||||
onProgress?.(downloaded, total);
|
||||
});
|
||||
cachedUpdate = null;
|
||||
let unsub: (() => void) | null = null;
|
||||
if (onProgress) {
|
||||
unsub = window.electronAPI.onUpdaterProgress((p) => {
|
||||
onProgress(p.transferred, p.total || null);
|
||||
});
|
||||
}
|
||||
try {
|
||||
await window.electronAPI.downloadInstallUpdate();
|
||||
} finally {
|
||||
unsub?.();
|
||||
hasPendingUpdate = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ export interface AudioSettings {
|
||||
// @livekit/track-processors + its MediaPipe selfie-segmentation model
|
||||
// (~1.5MB) which downloads on first activation.
|
||||
videoBackgroundBlur: boolean;
|
||||
// Preferred camera deviceId from enumerateDevices. null = use browser
|
||||
// default. Persisted across sessions; applied when toggleCamera publishes
|
||||
// a new track. Discord-parity: lets users with multiple cams pin one.
|
||||
videoInputDeviceId: string | null;
|
||||
// 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
|
||||
@@ -46,6 +50,7 @@ const DEFAULTS: AudioSettings = {
|
||||
// Users who want it enable it explicitly in Settings → Sprache.
|
||||
noiseSuppression: false,
|
||||
videoBackgroundBlur: false,
|
||||
videoInputDeviceId: null,
|
||||
ringtoneVolume: 0.9,
|
||||
};
|
||||
|
||||
@@ -128,6 +133,10 @@ function read(): AudioSettings {
|
||||
typeof parsed.videoBackgroundBlur === 'boolean'
|
||||
? parsed.videoBackgroundBlur
|
||||
: DEFAULTS.videoBackgroundBlur,
|
||||
videoInputDeviceId:
|
||||
typeof parsed.videoInputDeviceId === 'string' && parsed.videoInputDeviceId.length > 0
|
||||
? parsed.videoInputDeviceId
|
||||
: DEFAULTS.videoInputDeviceId,
|
||||
ringtoneVolume:
|
||||
typeof parsed.ringtoneVolume === 'number' &&
|
||||
Number.isFinite(parsed.ringtoneVolume) &&
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Renderer-side autostart adapter. Replaces the Tauri version, which
|
||||
// imported from `@tauri-apps/plugin-autostart`. We route through the
|
||||
// Electron preload bridge to `app.setLoginItemSettings()` in the main
|
||||
// process — the OS-native login-items mechanism (Windows registry Run
|
||||
// key, macOS LaunchAgent, Linux .desktop entry).
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
export async function isAutoStartEnabled(): Promise<boolean> {
|
||||
if (!isTauriRuntime()) return false;
|
||||
try {
|
||||
return await window.electronAPI.isAutoStartEnabled();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setAutoStart(enabled: boolean): Promise<void> {
|
||||
if (!isTauriRuntime()) return;
|
||||
await window.electronAPI.setAutoStart(enabled);
|
||||
}
|
||||
@@ -48,6 +48,13 @@ export async function uploadAvatar(userId: string, file: File): Promise<string>
|
||||
throw new Error('only image files are accepted');
|
||||
}
|
||||
const blob = await resizeToSquare(file);
|
||||
return uploadAvatarBlob(userId, blob);
|
||||
}
|
||||
|
||||
// Upload an already-cropped Blob (e.g. from ImageCropDialog) without going
|
||||
// through the legacy center-crop. Caller is responsible for sizing — the
|
||||
// dialog already clamps to MAX_DIM via its outputWidth.
|
||||
export async function uploadAvatarBlob(userId: string, blob: Blob): Promise<string> {
|
||||
const ext = blob.type === 'image/webp' ? 'webp' : 'jpg';
|
||||
// Random filename so old uploads don't get overwritten before we update
|
||||
// the profile row — Supabase Storage CDN caches by URL, so a fresh path
|
||||
@@ -64,6 +71,8 @@ export async function uploadAvatar(userId: string, file: File): Promise<string>
|
||||
return pub.publicUrl;
|
||||
}
|
||||
|
||||
export const AVATAR_TARGET_DIM = MAX_DIM;
|
||||
|
||||
export async function deleteAvatarObject(publicUrl: string): Promise<void> {
|
||||
// Public URLs look like
|
||||
// https://<host>/storage/v1/object/public/profile-avatars/<path>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { supabase } from './supabase';
|
||||
|
||||
const BUCKET = 'profile-banners';
|
||||
// 3:1 hero crop. 1500x500 hits the sweet spot between crisp on a wide
|
||||
// settings preview and a payload that stays well under the 8 MB pre-encode
|
||||
// budget we accept from the user (post-WebP that's typically ~150–300 KB).
|
||||
const TARGET_WIDTH = 1500;
|
||||
const TARGET_HEIGHT = 500;
|
||||
const QUALITY = 0.82;
|
||||
const MAX_INPUT_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
// Resizes the source image to a centred 3:1 crop at TARGET_WIDTH x TARGET_HEIGHT
|
||||
// and re-encodes as WebP (JPEG fallback if WebP encode unsupported).
|
||||
async function resizeToBanner(file: File): Promise<Blob> {
|
||||
const url = URL.createObjectURL(file);
|
||||
try {
|
||||
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const i = new Image();
|
||||
i.onload = () => resolve(i);
|
||||
i.onerror = () => reject(new Error('image load failed'));
|
||||
i.src = url;
|
||||
});
|
||||
|
||||
const srcRatio = img.naturalWidth / img.naturalHeight;
|
||||
const targetRatio = TARGET_WIDTH / TARGET_HEIGHT;
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
let sw = img.naturalWidth;
|
||||
let sh = img.naturalHeight;
|
||||
if (srcRatio > targetRatio) {
|
||||
// Source is wider than 3:1 — crop horizontally, keep full height.
|
||||
sw = Math.round(img.naturalHeight * targetRatio);
|
||||
sx = Math.round((img.naturalWidth - sw) / 2);
|
||||
} else if (srcRatio < targetRatio) {
|
||||
// Source is taller than 3:1 — crop vertically, keep full width.
|
||||
sh = Math.round(img.naturalWidth / targetRatio);
|
||||
sy = Math.round((img.naturalHeight - sh) / 2);
|
||||
}
|
||||
|
||||
// Don't upscale — if the source crop is smaller than the target, render
|
||||
// at the source crop size so we don't waste bytes on synthetic detail.
|
||||
const outWidth = Math.min(TARGET_WIDTH, sw);
|
||||
const outHeight = Math.min(TARGET_HEIGHT, sh);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = outWidth;
|
||||
canvas.height = outHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('canvas context unavailable');
|
||||
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, outWidth, outHeight);
|
||||
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, 'image/webp', QUALITY),
|
||||
);
|
||||
if (blob) return blob;
|
||||
|
||||
const jpeg = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, 'image/jpeg', QUALITY),
|
||||
);
|
||||
if (!jpeg) throw new Error('canvas toBlob returned null');
|
||||
return jpeg;
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadBanner(userId: string, file: File): Promise<string> {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('only image files are accepted');
|
||||
}
|
||||
if (file.size > MAX_INPUT_BYTES) {
|
||||
throw new Error('image must be 8 MB or smaller');
|
||||
}
|
||||
const blob = await resizeToBanner(file);
|
||||
return uploadBannerBlob(userId, blob);
|
||||
}
|
||||
|
||||
// Upload an already-cropped Blob (e.g. from ImageCropDialog). Skips the
|
||||
// legacy center-crop path so the user-chosen framing is preserved.
|
||||
export async function uploadBannerBlob(userId: string, blob: Blob): Promise<string> {
|
||||
const ext = blob.type === 'image/webp' ? 'webp' : 'jpg';
|
||||
// Random filename so old uploads don't get overwritten before we update
|
||||
// the profile row — Supabase Storage CDN caches by URL, so a fresh path
|
||||
// also forces clients to fetch the new image.
|
||||
const name =
|
||||
userId + '/' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8) + '.' + ext;
|
||||
const { error: upErr } = await supabase.storage.from(BUCKET).upload(name, blob, {
|
||||
contentType: blob.type,
|
||||
cacheControl: '604800',
|
||||
upsert: false,
|
||||
});
|
||||
if (upErr) throw upErr;
|
||||
const { data: pub } = supabase.storage.from(BUCKET).getPublicUrl(name);
|
||||
return pub.publicUrl;
|
||||
}
|
||||
|
||||
export const BANNER_TARGET_WIDTH = TARGET_WIDTH;
|
||||
export const BANNER_TARGET_HEIGHT = TARGET_HEIGHT;
|
||||
export const BANNER_MAX_INPUT_BYTES = MAX_INPUT_BYTES;
|
||||
|
||||
export async function deleteBannerObject(publicUrl: string): Promise<void> {
|
||||
// Public URLs look like
|
||||
// https://<host>/storage/v1/object/public/profile-banners/<path>
|
||||
// Extract <path> and remove.
|
||||
const marker = '/object/public/' + BUCKET + '/';
|
||||
const idx = publicUrl.indexOf(marker);
|
||||
if (idx === -1) return;
|
||||
const path = publicUrl.slice(idx + marker.length);
|
||||
const { error } = await supabase.storage.from(BUCKET).remove([path]);
|
||||
if (error) throw error;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// binary assets needed. Intentionally short + low-volume — these fire
|
||||
// multiple times per call and shouldn't feel intrusive.
|
||||
|
||||
type Sfx = 'join' | 'leave' | 'end';
|
||||
type Sfx = 'join' | 'leave' | 'end' | 'mute' | 'unmute' | 'deafen' | 'undeafen';
|
||||
|
||||
let ctx: AudioContext | null = null;
|
||||
|
||||
@@ -59,6 +59,28 @@ export async function playSfx(kind: Sfx): Promise<void> {
|
||||
beep(440, 0.18, 0, 0.14); // A4
|
||||
beep(293.66, 0.26, 0.14, 0.14); // D4
|
||||
break;
|
||||
case 'mute':
|
||||
// Discord-style: short downward blip when mic goes silent. Quick, low
|
||||
// volume so it doesn't fight whatever the user is listening to.
|
||||
beep(880, 0.06, 0, 0.1); // A5
|
||||
beep(660, 0.08, 0.04, 0.1); // E5
|
||||
break;
|
||||
case 'unmute':
|
||||
// Mirror: upward blip when mic comes back.
|
||||
beep(660, 0.06, 0, 0.1);
|
||||
beep(880, 0.08, 0.04, 0.1);
|
||||
break;
|
||||
case 'deafen':
|
||||
// Lower + slightly longer than mute — Discord uses a deeper tone for
|
||||
// deafen so the user can tell the two states apart without looking.
|
||||
beep(660, 0.07, 0, 0.1);
|
||||
beep(392, 0.12, 0.05, 0.1); // G4
|
||||
break;
|
||||
case 'undeafen':
|
||||
// Mirror of deafen: low → mid.
|
||||
beep(392, 0.07, 0, 0.1);
|
||||
beep(660, 0.12, 0.05, 0.1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,3 +93,15 @@ export function playLeaveBeep(): Promise<void> {
|
||||
export function playEndBeep(): Promise<void> {
|
||||
return playSfx('end');
|
||||
}
|
||||
export function playMuteBeep(): Promise<void> {
|
||||
return playSfx('mute');
|
||||
}
|
||||
export function playUnmuteBeep(): Promise<void> {
|
||||
return playSfx('unmute');
|
||||
}
|
||||
export function playDeafenBeep(): Promise<void> {
|
||||
return playSfx('deafen');
|
||||
}
|
||||
export function playUndeafenBeep(): Promise<void> {
|
||||
return playSfx('undeafen');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// Per-participant call-stats polling via WebRTC's getStats() API.
|
||||
// Used by the Discord-style debug overlay (Ctrl+Shift+S). Computes bitrate
|
||||
// deltas across two consecutive samples so the displayed kbps tracks the
|
||||
// live stream rather than the cumulative byte count.
|
||||
|
||||
import type { Participant, RemoteParticipant, Room } from 'livekit-client';
|
||||
|
||||
interface TrackWithStats {
|
||||
getRTCStatsReport?: () => Promise<RTCStatsReport | undefined>;
|
||||
}
|
||||
|
||||
export interface ParticipantTrackStats {
|
||||
audioInKbps?: number;
|
||||
audioOutKbps?: number;
|
||||
videoInKbps?: number;
|
||||
videoOutKbps?: number;
|
||||
packetLossPct?: number;
|
||||
jitterMs?: number;
|
||||
rttMs?: number;
|
||||
}
|
||||
|
||||
export interface ParticipantStats {
|
||||
identity: string;
|
||||
isLocal: boolean;
|
||||
audio: ParticipantTrackStats;
|
||||
video: ParticipantTrackStats;
|
||||
}
|
||||
|
||||
interface ByteSample {
|
||||
bytes: number;
|
||||
timeMs: number;
|
||||
}
|
||||
|
||||
interface SampleCache {
|
||||
// key = `${identity}/${kind}/${dir}` — kind in {audio,video}, dir in {in,out}
|
||||
bytes: Map<string, ByteSample>;
|
||||
// packets cumulative for loss-pct delta calc.
|
||||
pkts: Map<string, { recv: number; lost: number }>;
|
||||
}
|
||||
|
||||
export function makeSampleCache(): SampleCache {
|
||||
return { bytes: new Map(), pkts: new Map() };
|
||||
}
|
||||
|
||||
/** Pull a single stats sample from every participant in the room. Returns
|
||||
* one ParticipantStats entry per participant (local + remote). bitrates are
|
||||
* computed against the previous sample stored in `cache`, so the first
|
||||
* call returns 0 kbps everywhere — call again 1s later for real numbers. */
|
||||
export async function sampleStats(
|
||||
room: Room,
|
||||
cache: SampleCache,
|
||||
): Promise<ParticipantStats[]> {
|
||||
const out: ParticipantStats[] = [];
|
||||
|
||||
const localStats = await collectForParticipant(
|
||||
room.localParticipant,
|
||||
true,
|
||||
cache,
|
||||
);
|
||||
out.push(localStats);
|
||||
|
||||
for (const rp of room.remoteParticipants.values()) {
|
||||
const s = await collectForParticipant(rp, false, cache);
|
||||
out.push(s);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
async function collectForParticipant(
|
||||
participant: Participant,
|
||||
isLocal: boolean,
|
||||
cache: SampleCache,
|
||||
): Promise<ParticipantStats> {
|
||||
const audio: ParticipantTrackStats = {};
|
||||
const video: ParticipantTrackStats = {};
|
||||
|
||||
// Local participant publishes — pull outbound-rtp from audio + video sender.
|
||||
if (isLocal) {
|
||||
for (const pub of participant.audioTrackPublications.values()) {
|
||||
const track = pub.track;
|
||||
if (!track) continue;
|
||||
const stats = await safeGetStats(track);
|
||||
if (stats) {
|
||||
const k = participant.identity + '/audio/out';
|
||||
const r = readOutbound(stats);
|
||||
audio.audioOutKbps = bitrateKbps(cache.bytes, k, r.bytes);
|
||||
}
|
||||
}
|
||||
for (const pub of participant.videoTrackPublications.values()) {
|
||||
const track = pub.track;
|
||||
if (!track) continue;
|
||||
const stats = await safeGetStats(track);
|
||||
if (stats) {
|
||||
const k = participant.identity + '/video/out';
|
||||
const r = readOutbound(stats);
|
||||
video.videoOutKbps = bitrateKbps(cache.bytes, k, r.bytes);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Remote participant — pull inbound-rtp from each subscribed track.
|
||||
const rp = participant as RemoteParticipant;
|
||||
for (const pub of rp.audioTrackPublications.values()) {
|
||||
const track = pub.track;
|
||||
if (!track) continue;
|
||||
const stats = await safeGetStats(track);
|
||||
if (stats) {
|
||||
const k = participant.identity + '/audio/in';
|
||||
const r = readInbound(stats);
|
||||
audio.audioInKbps = bitrateKbps(cache.bytes, k, r.bytes);
|
||||
const lossPct = computeLossPct(cache.pkts, k, r.recv, r.lost);
|
||||
if (lossPct !== undefined) audio.packetLossPct = lossPct;
|
||||
if (r.jitter !== undefined) audio.jitterMs = Math.round(r.jitter * 1000);
|
||||
if (r.rttMs !== undefined) audio.rttMs = r.rttMs;
|
||||
}
|
||||
}
|
||||
for (const pub of rp.videoTrackPublications.values()) {
|
||||
const track = pub.track;
|
||||
if (!track) continue;
|
||||
const stats = await safeGetStats(track);
|
||||
if (stats) {
|
||||
const k = participant.identity + '/video/in';
|
||||
const r = readInbound(stats);
|
||||
video.videoInKbps = bitrateKbps(cache.bytes, k, r.bytes);
|
||||
const lossPct = computeLossPct(cache.pkts, k, r.recv, r.lost);
|
||||
if (lossPct !== undefined) video.packetLossPct = lossPct;
|
||||
if (r.jitter !== undefined) video.jitterMs = Math.round(r.jitter * 1000);
|
||||
if (r.rttMs !== undefined) video.rttMs = r.rttMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { identity: participant.identity, isLocal, audio, video };
|
||||
}
|
||||
|
||||
async function safeGetStats(track: unknown): Promise<RTCStatsReport | null> {
|
||||
const t = track as TrackWithStats;
|
||||
if (typeof t.getRTCStatsReport !== 'function') return null;
|
||||
try {
|
||||
const stats = await t.getRTCStatsReport();
|
||||
return stats ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface InboundRead {
|
||||
bytes: number;
|
||||
recv: number;
|
||||
lost: number;
|
||||
jitter?: number;
|
||||
rttMs?: number;
|
||||
}
|
||||
|
||||
function readInbound(stats: RTCStatsReport): InboundRead {
|
||||
let bytes = 0;
|
||||
let recv = 0;
|
||||
let lost = 0;
|
||||
let jitter: number | undefined;
|
||||
let rttMs: number | undefined;
|
||||
stats.forEach((report: { type?: string; [key: string]: unknown }) => {
|
||||
if (report.type === 'inbound-rtp') {
|
||||
const r = report as unknown as {
|
||||
bytesReceived?: number;
|
||||
packetsReceived?: number;
|
||||
packetsLost?: number;
|
||||
jitter?: number;
|
||||
};
|
||||
bytes += r.bytesReceived ?? 0;
|
||||
recv += r.packetsReceived ?? 0;
|
||||
lost += r.packetsLost ?? 0;
|
||||
if (r.jitter !== undefined) jitter = r.jitter;
|
||||
}
|
||||
if (report.type === 'remote-inbound-rtp') {
|
||||
const r = report as unknown as { roundTripTime?: number };
|
||||
if (r.roundTripTime !== undefined) {
|
||||
rttMs = Math.round(r.roundTripTime * 1000);
|
||||
}
|
||||
}
|
||||
if (report.type === 'candidate-pair') {
|
||||
const r = report as unknown as {
|
||||
nominated?: boolean;
|
||||
currentRoundTripTime?: number;
|
||||
};
|
||||
if (r.nominated && r.currentRoundTripTime !== undefined && rttMs === undefined) {
|
||||
rttMs = Math.round(r.currentRoundTripTime * 1000);
|
||||
}
|
||||
}
|
||||
});
|
||||
const result: InboundRead = { bytes, recv, lost };
|
||||
if (jitter !== undefined) result.jitter = jitter;
|
||||
if (rttMs !== undefined) result.rttMs = rttMs;
|
||||
return result;
|
||||
}
|
||||
|
||||
function readOutbound(stats: RTCStatsReport): { bytes: number } {
|
||||
let bytes = 0;
|
||||
stats.forEach((report: { type?: string; [key: string]: unknown }) => {
|
||||
if (report.type === 'outbound-rtp') {
|
||||
const r = report as unknown as { bytesSent?: number };
|
||||
bytes += r.bytesSent ?? 0;
|
||||
}
|
||||
});
|
||||
return { bytes };
|
||||
}
|
||||
|
||||
function bitrateKbps(
|
||||
cache: Map<string, ByteSample>,
|
||||
key: string,
|
||||
bytes: number,
|
||||
): number {
|
||||
const now = performance.now();
|
||||
const prev = cache.get(key);
|
||||
cache.set(key, { bytes, timeMs: now });
|
||||
if (!prev) return 0;
|
||||
const dtSec = (now - prev.timeMs) / 1000;
|
||||
if (dtSec <= 0) return 0;
|
||||
const dBytes = Math.max(0, bytes - prev.bytes);
|
||||
// bytes -> bits -> kbps.
|
||||
return Math.round((dBytes * 8) / 1000 / dtSec);
|
||||
}
|
||||
|
||||
function computeLossPct(
|
||||
cache: Map<string, { recv: number; lost: number }>,
|
||||
key: string,
|
||||
recv: number,
|
||||
lost: number,
|
||||
): number | undefined {
|
||||
const prev = cache.get(key);
|
||||
cache.set(key, { recv, lost });
|
||||
if (!prev) return undefined;
|
||||
const dRecv = Math.max(0, recv - prev.recv);
|
||||
const dLost = Math.max(0, lost - prev.lost);
|
||||
const total = dRecv + dLost;
|
||||
if (total <= 0) return 0;
|
||||
return Math.round((dLost / total) * 100 * 10) / 10;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// In-app changelog feed.
|
||||
//
|
||||
// The release script (`scripts/release.mjs`) maintains a single
|
||||
// `changelog.json` file alongside `latest.json` on update.netralax.cloud.
|
||||
// The list is newest-first, capped at 200 entries server-side, and rewritten
|
||||
// after every release.
|
||||
|
||||
const CHANGELOG_URL = 'https://update.netralax.cloud/windows/changelog.json';
|
||||
|
||||
export interface ChangelogEntry {
|
||||
version: string;
|
||||
pub_date: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
function isEntry(v: unknown): v is ChangelogEntry {
|
||||
if (!v || typeof v !== 'object') return false;
|
||||
const e = v as Record<string, unknown>;
|
||||
return (
|
||||
typeof e.version === 'string' &&
|
||||
typeof e.pub_date === 'string' &&
|
||||
typeof e.notes === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchChangelog(): Promise<ChangelogEntry[]> {
|
||||
const res = await fetch(CHANGELOG_URL, { cache: 'no-store' });
|
||||
if (!res.ok) {
|
||||
throw new Error('changelog request failed: ' + res.status);
|
||||
}
|
||||
const data: unknown = await res.json();
|
||||
if (!Array.isArray(data)) return [];
|
||||
return data.filter(isEntry);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
parseMessagePayload,
|
||||
type AttachmentHandle,
|
||||
type DecryptedMessage,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
collectConversationAttachments,
|
||||
createPollPayload,
|
||||
summarizePollVotes,
|
||||
} from './conversationFeatures';
|
||||
|
||||
function handle(id: string, mimeType: string): AttachmentHandle {
|
||||
return {
|
||||
id,
|
||||
storagePath: 'conversation/' + id + '.bin',
|
||||
mimeType,
|
||||
sizeBytes: 2048,
|
||||
keyB64: 'key',
|
||||
nonceB64: 'nonce',
|
||||
};
|
||||
}
|
||||
|
||||
function message(id: string, createdAt: string, attachments: AttachmentHandle[]): DecryptedMessage {
|
||||
return {
|
||||
id,
|
||||
conversationId: 'conv-1',
|
||||
senderId: 'sender-1',
|
||||
senderDeviceId: 'device-1',
|
||||
replyToId: null,
|
||||
editedAt: null,
|
||||
deletedAt: null,
|
||||
createdAt,
|
||||
plaintext: JSON.stringify({ v: 1, type: 'text', text: '', attachments }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('collectConversationAttachments', () => {
|
||||
it('indexes media, audio, and files newest first', () => {
|
||||
const first = message('m1', '2026-04-24T09:00:00.000Z', [
|
||||
handle('img-1', 'image/png'),
|
||||
handle('file-1', 'application/pdf'),
|
||||
]);
|
||||
const second = message('m2', '2026-04-24T10:00:00.000Z', [
|
||||
handle('audio-1', 'audio/webm'),
|
||||
handle('video-1', 'video/mp4'),
|
||||
]);
|
||||
|
||||
const index = collectConversationAttachments([first, second]);
|
||||
|
||||
expect(index.media.map((item) => item.handle.id)).toEqual(['video-1', 'img-1']);
|
||||
expect(index.audio.map((item) => item.handle.id)).toEqual(['audio-1']);
|
||||
expect(index.files.map((item) => item.handle.id)).toEqual(['file-1']);
|
||||
expect(index.all.map((item) => item.messageId)).toEqual(['m2', 'm2', 'm1', 'm1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPollPayload', () => {
|
||||
it('creates a parseable encrypted-message poll payload', () => {
|
||||
const payload = createPollPayload(' Lieblings Feature? ', [' Media Drawer ', '', 'Polls']);
|
||||
const parsed = parseMessagePayload(payload);
|
||||
|
||||
expect(parsed.kind).toBe('poll');
|
||||
if (parsed.kind !== 'poll') throw new Error('expected poll payload');
|
||||
expect(parsed.question).toBe('Lieblings Feature?');
|
||||
expect(parsed.options).toEqual([
|
||||
{ id: 'option-1', emoji: '1️⃣', text: 'Media Drawer' },
|
||||
{ id: 'option-2', emoji: '2️⃣', text: 'Polls' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('requires a question and at least two non-empty options', () => {
|
||||
expect(() => createPollPayload('', ['A', 'B'])).toThrow('question');
|
||||
expect(() => createPollPayload('Feature?', ['A', ''])).toThrow('options');
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizePollVotes', () => {
|
||||
it('counts only configured poll options and marks the current user vote', () => {
|
||||
const summary = summarizePollVotes(
|
||||
[
|
||||
{ id: 'option-1', emoji: '1️⃣', text: 'Media Drawer' },
|
||||
{ id: 'option-2', emoji: '2️⃣', text: 'Polls' },
|
||||
],
|
||||
[
|
||||
{ emoji: '1️⃣', count: 3, mine: false },
|
||||
{ emoji: '2️⃣', count: 1, mine: true },
|
||||
{ emoji: '🔥', count: 99, mine: true },
|
||||
],
|
||||
);
|
||||
|
||||
expect(summary.totalVotes).toBe(4);
|
||||
expect(summary.options.map((option) => option.percent)).toEqual([75, 25]);
|
||||
expect(summary.options.map((option) => option.mine)).toEqual([false, true]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
parseMessagePayload,
|
||||
serializeMessagePayload,
|
||||
type AttachmentHandle,
|
||||
type DecryptedMessage,
|
||||
type PollOption,
|
||||
} from '@chat-app/shared/chat';
|
||||
|
||||
export type AttachmentBucket = 'media' | 'audio' | 'files';
|
||||
|
||||
export interface ConversationAttachmentItem {
|
||||
messageId: string;
|
||||
senderId: string;
|
||||
createdAt: string;
|
||||
handle: AttachmentHandle;
|
||||
bucket: AttachmentBucket;
|
||||
}
|
||||
|
||||
export interface ConversationAttachmentIndex {
|
||||
all: ConversationAttachmentItem[];
|
||||
media: ConversationAttachmentItem[];
|
||||
audio: ConversationAttachmentItem[];
|
||||
files: ConversationAttachmentItem[];
|
||||
}
|
||||
|
||||
interface ReactionSummaryInput {
|
||||
emoji: string;
|
||||
count: number;
|
||||
mine: boolean;
|
||||
}
|
||||
|
||||
export interface PollVoteOptionSummary extends PollOption {
|
||||
count: number;
|
||||
mine: boolean;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export interface PollVoteSummary {
|
||||
totalVotes: number;
|
||||
options: PollVoteOptionSummary[];
|
||||
}
|
||||
|
||||
export const POLL_OPTION_EMOJIS = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟'];
|
||||
|
||||
export function collectConversationAttachments(
|
||||
messages: DecryptedMessage[],
|
||||
): ConversationAttachmentIndex {
|
||||
const all: ConversationAttachmentItem[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.deletedAt || !message.plaintext) continue;
|
||||
const parsed = parseMessagePayload(message.plaintext);
|
||||
if (parsed.kind !== 'text') continue;
|
||||
|
||||
for (const handle of parsed.attachments) {
|
||||
all.push({
|
||||
messageId: message.id,
|
||||
senderId: message.senderId,
|
||||
createdAt: message.createdAt,
|
||||
handle,
|
||||
bucket: bucketForMime(handle.mimeType),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
all.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
||||
|
||||
return {
|
||||
all,
|
||||
media: all.filter((item) => item.bucket === 'media'),
|
||||
audio: all.filter((item) => item.bucket === 'audio'),
|
||||
files: all.filter((item) => item.bucket === 'files'),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPollPayload(question: string, optionTexts: string[]): string {
|
||||
const trimmedQuestion = question.trim();
|
||||
if (!trimmedQuestion) throw new Error('poll question is required');
|
||||
|
||||
const options: PollOption[] = optionTexts
|
||||
.map((text) => text.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, POLL_OPTION_EMOJIS.length)
|
||||
.map((text, idx) => ({
|
||||
id: 'option-' + (idx + 1),
|
||||
emoji: POLL_OPTION_EMOJIS[idx]!,
|
||||
text,
|
||||
}));
|
||||
|
||||
if (options.length < 2) throw new Error('poll requires at least two options');
|
||||
|
||||
return serializeMessagePayload({
|
||||
v: 1,
|
||||
type: 'poll',
|
||||
question: trimmedQuestion,
|
||||
options,
|
||||
});
|
||||
}
|
||||
|
||||
export function summarizePollVotes(
|
||||
options: PollOption[],
|
||||
reactions: ReactionSummaryInput[],
|
||||
): PollVoteSummary {
|
||||
const reactionByEmoji = new Map(reactions.map((reaction) => [reaction.emoji, reaction]));
|
||||
const totalVotes = options.reduce(
|
||||
(sum, option) => sum + (reactionByEmoji.get(option.emoji)?.count ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
totalVotes,
|
||||
options: options.map((option) => {
|
||||
const reaction = reactionByEmoji.get(option.emoji);
|
||||
const count = reaction?.count ?? 0;
|
||||
return {
|
||||
...option,
|
||||
count,
|
||||
mine: reaction?.mine ?? false,
|
||||
percent: totalVotes > 0 ? Math.round((count / totalVotes) * 100) : 0,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function bucketForMime(mimeType: string): AttachmentBucket {
|
||||
if (mimeType.startsWith('image/') || mimeType.startsWith('video/')) return 'media';
|
||||
if (mimeType.startsWith('audio/')) return 'audio';
|
||||
return 'files';
|
||||
}
|
||||
@@ -1,145 +1,169 @@
|
||||
import {
|
||||
isRegistered,
|
||||
register,
|
||||
type ShortcutEvent,
|
||||
unregister,
|
||||
} from '@tauri-apps/plugin-global-shortcut';
|
||||
// Global shortcut bindings. Routes all calls through the Electron
|
||||
// preload bridge (`window.electronAPI`). The exported surface is stable
|
||||
// across the Tauri → Electron migration so no calling code changes.
|
||||
//
|
||||
// `isTauriRuntime()` is kept as a named export for back-compat — many
|
||||
// callers import it. The implementation now checks for the Electron
|
||||
// preload marker instead. See `isNativeRuntime` for a forward-looking
|
||||
// name.
|
||||
|
||||
// Maps a KeyboardEvent.code (what our PTT settings store) into the shortcut
|
||||
// string accepted by tauri-plugin-global-shortcut. The plugin follows the
|
||||
// [keyboard-types] crate naming which mostly matches DOM `event.code`, but
|
||||
// single-key aliases (e.g. "Space", "F5") work as-is.
|
||||
type Unsubscribe = () => void;
|
||||
|
||||
export function isNativeRuntime(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.electronAPI !== 'undefined' &&
|
||||
window.electronAPI.platform === 'electron-chatapp-v1'
|
||||
);
|
||||
}
|
||||
|
||||
// Back-compat alias. New code should prefer `isNativeRuntime`.
|
||||
export const isTauriRuntime = isNativeRuntime;
|
||||
|
||||
// Converts a KeyboardEvent.code (what PTT settings store) into an
|
||||
// Electron accelerator token. Electron's accelerator syntax is close
|
||||
// enough to the DOM `.code` naming for most keys; single-key strips the
|
||||
// "Key"/"Digit" prefix that DOM adds.
|
||||
export function codeToShortcut(code: string): string {
|
||||
if (code.startsWith('Key')) return code.slice(3); // KeyV -> V
|
||||
if (code.startsWith('Digit')) return code.slice(5); // Digit1 -> 1
|
||||
// Space, F1..F24, Escape, Enter, Tab, Arrow*, etc. pass through unchanged.
|
||||
if (code.startsWith('Key')) return code.slice(3);
|
||||
if (code.startsWith('Digit')) return code.slice(5);
|
||||
return code;
|
||||
}
|
||||
|
||||
// ---- Single-shortcut event router ----------------------------------------
|
||||
//
|
||||
// Electron's preload delivers all fired/released events on one channel;
|
||||
// we fan out to per-id callbacks here so each caller can register
|
||||
// independently without re-subscribing at the IPC layer.
|
||||
|
||||
interface EventEntry {
|
||||
onPress: () => void;
|
||||
onRelease?: () => void;
|
||||
}
|
||||
|
||||
const handlers = new Map<string, EventEntry>();
|
||||
let firedUnsub: Unsubscribe | null = null;
|
||||
let releasedUnsub: Unsubscribe | null = null;
|
||||
|
||||
function ensureSubscribed(): void {
|
||||
if (!firedUnsub) {
|
||||
firedUnsub = window.electronAPI.onShortcutFired((evt) => {
|
||||
const entry = handlers.get(evt.id);
|
||||
entry?.onPress();
|
||||
});
|
||||
}
|
||||
if (!releasedUnsub) {
|
||||
releasedUnsub = window.electronAPI.onShortcutReleased((evt) => {
|
||||
const entry = handlers.get(evt.id);
|
||||
entry?.onRelease?.();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function registerShortcut(
|
||||
id: string,
|
||||
accelerator: string,
|
||||
kind: 'press' | 'ptt',
|
||||
onPress: () => void,
|
||||
onRelease?: () => void,
|
||||
): Promise<boolean> {
|
||||
if (!isNativeRuntime()) return false;
|
||||
ensureSubscribed();
|
||||
if (handlers.has(id)) {
|
||||
try {
|
||||
await window.electronAPI.unregisterShortcut(id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
handlers.delete(id);
|
||||
}
|
||||
try {
|
||||
const ok = await window.electronAPI.registerShortcut({ id, accelerator, kind });
|
||||
if (!ok) return false;
|
||||
handlers.set(id, onRelease ? { onPress, onRelease } : { onPress });
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
console.warn('registerShortcut failed', { id, accelerator, err });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function unregisterShortcutById(id: string): Promise<void> {
|
||||
if (!isNativeRuntime()) {
|
||||
handlers.delete(id);
|
||||
return;
|
||||
}
|
||||
handlers.delete(id);
|
||||
try {
|
||||
await window.electronAPI.unregisterShortcut(id);
|
||||
} catch (err: unknown) {
|
||||
console.warn('unregisterShortcut failed', { id, err });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- PTT (press + release) ------------------------------------------------
|
||||
//
|
||||
// PTT release semantics under Electron's globalShortcut are simulated —
|
||||
// main auto-fires SHORTCUT_EVT_RELEASED 200ms after each press. See the
|
||||
// main-side shortcuts module for the limitation.
|
||||
|
||||
export async function registerPttShortcut(
|
||||
code: string,
|
||||
onPress: () => void,
|
||||
onRelease: () => void,
|
||||
): Promise<boolean> {
|
||||
const shortcut = codeToShortcut(code);
|
||||
try {
|
||||
if (await isRegistered(shortcut)) {
|
||||
await unregister(shortcut);
|
||||
}
|
||||
await register(shortcut, (event: ShortcutEvent) => {
|
||||
if (event.state === 'Pressed') onPress();
|
||||
else if (event.state === 'Released') onRelease();
|
||||
});
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
console.warn('registerPttShortcut failed', { code, err });
|
||||
return false;
|
||||
}
|
||||
return registerShortcut('ptt', codeToShortcut(code), 'ptt', onPress, onRelease);
|
||||
}
|
||||
|
||||
export async function unregisterPttShortcut(code: string): Promise<void> {
|
||||
const shortcut = codeToShortcut(code);
|
||||
try {
|
||||
if (await isRegistered(shortcut)) {
|
||||
await unregister(shortcut);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn('unregisterPttShortcut failed', { code, err });
|
||||
}
|
||||
export async function unregisterPttShortcut(_code: string): Promise<void> {
|
||||
await unregisterShortcutById('ptt');
|
||||
}
|
||||
|
||||
// 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.
|
||||
// ---- Press-only (toggles like mute/deafen) -------------------------------
|
||||
|
||||
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;
|
||||
}
|
||||
return registerShortcut(`press:${shortcut}`, shortcut, 'press', onPress);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
await unregisterShortcutById(`press:${shortcut}`);
|
||||
}
|
||||
|
||||
// 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
|
||||
// exist — use this guard to skip registration cleanly.
|
||||
export function isTauriRuntime(): boolean {
|
||||
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||
}
|
||||
|
||||
// --- Soundboard shortcuts --------------------------------------------------
|
||||
//
|
||||
// Separate from the single PTT shortcut: the soundboard needs to register
|
||||
// many fire-and-forget press bindings at once, keep track of which ids own
|
||||
// which accelerators so we can unregister just one, and expose conflict
|
||||
// detection for the settings UI.
|
||||
// ---- Soundboard shortcuts ------------------------------------------------
|
||||
|
||||
interface SoundShortcutRegistration {
|
||||
shortcut: string;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
// Map of logical id (sound uuid) -> registration.
|
||||
const soundRegistry = new Map<string, SoundShortcutRegistration>();
|
||||
|
||||
function soundId(id: string): string {
|
||||
return `sound:${id}`;
|
||||
}
|
||||
|
||||
export async function registerSoundShortcut(
|
||||
id: string,
|
||||
code: string,
|
||||
onPress: () => void,
|
||||
): Promise<boolean> {
|
||||
if (!isTauriRuntime()) return false;
|
||||
if (!isNativeRuntime()) return false;
|
||||
const shortcut = codeToShortcut(code);
|
||||
// Unregister any previous binding for this id first — caller may be
|
||||
// re-registering after the user changed the hotkey for the same sound.
|
||||
await unregisterSoundShortcut(id);
|
||||
try {
|
||||
if (await isRegistered(shortcut)) {
|
||||
await unregister(shortcut);
|
||||
}
|
||||
await register(shortcut, (event: ShortcutEvent) => {
|
||||
if (event.state === 'Pressed') onPress();
|
||||
});
|
||||
soundRegistry.set(id, { shortcut, onPress });
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
console.warn('registerSoundShortcut failed', { id, code, err });
|
||||
return false;
|
||||
}
|
||||
const ok = await registerShortcut(soundId(id), shortcut, 'press', onPress);
|
||||
if (ok) soundRegistry.set(id, { shortcut, onPress });
|
||||
return ok;
|
||||
}
|
||||
|
||||
export async function unregisterSoundShortcut(id: string): Promise<void> {
|
||||
const reg = soundRegistry.get(id);
|
||||
if (!reg) return;
|
||||
soundRegistry.delete(id);
|
||||
if (!isTauriRuntime()) return;
|
||||
try {
|
||||
if (await isRegistered(reg.shortcut)) {
|
||||
await unregister(reg.shortcut);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn('unregisterSoundShortcut failed', { id, err });
|
||||
}
|
||||
if (!isNativeRuntime()) return;
|
||||
await unregisterShortcutById(soundId(id));
|
||||
}
|
||||
|
||||
export async function unregisterAllSoundShortcuts(): Promise<void> {
|
||||
@@ -147,8 +171,6 @@ export async function unregisterAllSoundShortcuts(): Promise<void> {
|
||||
await Promise.all(ids.map((id) => unregisterSoundShortcut(id)));
|
||||
}
|
||||
|
||||
// Resolve a DOM code to the registry's current owner (if any). Used by the
|
||||
// settings UI to surface conflicts before saving a new hotkey.
|
||||
export function soundShortcutOwnerFor(code: string): string | null {
|
||||
const shortcut = codeToShortcut(code);
|
||||
for (const [id, reg] of soundRegistry) {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Discord-style live captions. Uses the browser's SpeechRecognition API to
|
||||
// transcribe the LOCAL user's mic, then broadcasts each interim/final result
|
||||
// to peers via the LiveKit DataChannel. Receivers store and display them.
|
||||
//
|
||||
// Privacy note: speech recognition runs in the browser. On Chromium-based
|
||||
// runtimes (incl. Tauri's WebView2 on Windows) this calls into the browser's
|
||||
// own engine, which today reaches Google's cloud — same trade-off as Discord.
|
||||
// We ship a hard off switch and require an explicit user toggle.
|
||||
|
||||
const STORAGE_KEY = 'chatapp.liveCaptions.v1';
|
||||
|
||||
export interface LiveCaptionsSettings {
|
||||
enabled: boolean;
|
||||
/** BCP-47 language tag, e.g. "de-DE" or "en-US". Auto-detect uses navigator.language. */
|
||||
lang: string | null;
|
||||
}
|
||||
|
||||
const DEFAULTS: LiveCaptionsSettings = {
|
||||
enabled: false,
|
||||
lang: null,
|
||||
};
|
||||
|
||||
type Listener = (s: LiveCaptionsSettings) => void;
|
||||
const listeners = new Set<Listener>();
|
||||
let cached: LiveCaptionsSettings | null = null;
|
||||
|
||||
function read(): LiveCaptionsSettings {
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
cached = DEFAULTS;
|
||||
return cached;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<LiveCaptionsSettings>;
|
||||
cached = {
|
||||
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULTS.enabled,
|
||||
lang:
|
||||
typeof parsed.lang === 'string' && parsed.lang.length > 0
|
||||
? parsed.lang
|
||||
: DEFAULTS.lang,
|
||||
};
|
||||
return cached;
|
||||
} catch {
|
||||
cached = DEFAULTS;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
function write(s: LiveCaptionsSettings): void {
|
||||
cached = s;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||
} catch {
|
||||
/* quota / private mode */
|
||||
}
|
||||
for (const l of listeners) l(s);
|
||||
}
|
||||
|
||||
export function getLiveCaptionsSettings(): LiveCaptionsSettings {
|
||||
return read();
|
||||
}
|
||||
|
||||
export function updateLiveCaptionsSettings(
|
||||
patch: Partial<LiveCaptionsSettings>,
|
||||
): LiveCaptionsSettings {
|
||||
const next = { ...read(), ...patch };
|
||||
write(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function subscribeLiveCaptionsSettings(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
// Browser feature-detect. Chromium ships under both names; Firefox lacks it
|
||||
// outright. Returns the constructor or null.
|
||||
type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
|
||||
interface SpeechRecognitionLike extends EventTarget {
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
lang: string;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
abort: () => void;
|
||||
onresult: ((e: SpeechRecognitionEventLike) => void) | null;
|
||||
onerror: ((e: SpeechRecognitionErrorLike) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
}
|
||||
interface SpeechRecognitionEventLike {
|
||||
resultIndex: number;
|
||||
results: ArrayLike<{
|
||||
isFinal: boolean;
|
||||
[index: number]: { transcript: string };
|
||||
length: number;
|
||||
}>;
|
||||
}
|
||||
interface SpeechRecognitionErrorLike {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null {
|
||||
const w = window as unknown as {
|
||||
SpeechRecognition?: SpeechRecognitionCtor;
|
||||
webkitSpeechRecognition?: SpeechRecognitionCtor;
|
||||
};
|
||||
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
|
||||
}
|
||||
|
||||
export function isLiveCaptionsSupported(): boolean {
|
||||
return getSpeechRecognitionCtor() !== null;
|
||||
}
|
||||
|
||||
export type {
|
||||
SpeechRecognitionLike,
|
||||
SpeechRecognitionEventLike,
|
||||
SpeechRecognitionErrorLike,
|
||||
};
|
||||
@@ -0,0 +1,270 @@
|
||||
// Native-loopback renderer sink. Pairs with
|
||||
// electron/modules/audio-loopback.ts and the @chatapp/audio-loopback-native
|
||||
// addon: receives interleaved f32 stereo PCM chunks at 48kHz over IPC,
|
||||
// pushes them into an AudioWorklet ring buffer, and exposes the result
|
||||
// as a real `MediaStreamTrack` that LiveKit can publish as a
|
||||
// ScreenShareAudio publication.
|
||||
//
|
||||
// The WASAPI pipeline excludes our own process tree, so peers in a
|
||||
// video call don't hear themselves echoed back when the user shares
|
||||
// system audio. That's the whole reason we route around Chromium's
|
||||
// 'loopback' source on Windows.
|
||||
//
|
||||
// Strategy: AudioWorklet + MediaStreamAudioDestinationNode is the
|
||||
// portable path that works in every Electron Chromium build we ship
|
||||
// against. MediaStreamTrackGenerator (a newer alternative) was
|
||||
// considered but is gated behind a flag in stable Chromium and isn't
|
||||
// reliable across Electron versions, so we don't take that branch.
|
||||
|
||||
interface AudioLoopbackChunkPayload {
|
||||
captureId: number;
|
||||
samples: Float32Array;
|
||||
}
|
||||
|
||||
export interface LoopbackTrackHandle {
|
||||
/** The audio MediaStreamTrack to publish via LiveKit. */
|
||||
track: MediaStreamTrack;
|
||||
/** Teardown — stops the addon-side capture, disconnects the audio
|
||||
* graph, ends the track. Idempotent. */
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
export class LoopbackAudioUnavailable extends Error {
|
||||
constructor(reason: string) {
|
||||
super('loopback audio unavailable: ' + reason);
|
||||
this.name = 'LoopbackAudioUnavailable';
|
||||
}
|
||||
}
|
||||
|
||||
// AudioWorklet processor source. Identical ring-buffer shape to the
|
||||
// Tauri renderer: a pair of per-channel ring buffers that the main
|
||||
// thread fills as samples arrive; `process()` drains into the output
|
||||
// quantum. Underrun emits silence (a glitch is better than a freeze
|
||||
// for LiveKit's Opus encoder). Hard cap at 300ms keeps a stalled-then-
|
||||
// resumed pipeline from playing back minutes of stale audio.
|
||||
const WORKLET_SOURCE = `
|
||||
class LoopbackAudioProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.bufferSize = 48000 * 0.3 | 0;
|
||||
this.targetFrames = 48000 * 0.08 | 0;
|
||||
this.bufL = new Float32Array(this.bufferSize);
|
||||
this.bufR = new Float32Array(this.bufferSize);
|
||||
this.writePos = 0;
|
||||
this.readPos = 0;
|
||||
this.available = 0;
|
||||
this.port.onmessage = (e) => {
|
||||
const { left, right } = e.data;
|
||||
const len = left.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
this.bufL[this.writePos] = left[i];
|
||||
this.bufR[this.writePos] = right[i];
|
||||
this.writePos = (this.writePos + 1) % this.bufferSize;
|
||||
if (this.available < this.bufferSize) {
|
||||
this.available++;
|
||||
} else {
|
||||
this.readPos = (this.readPos + 1) % this.bufferSize;
|
||||
}
|
||||
}
|
||||
if (this.available > this.targetFrames * 3) {
|
||||
const drop = this.available - this.targetFrames;
|
||||
this.readPos = (this.readPos + drop) % this.bufferSize;
|
||||
this.available -= drop;
|
||||
}
|
||||
};
|
||||
}
|
||||
process(_inputs, outputs) {
|
||||
const output = outputs[0];
|
||||
if (!output || output.length === 0) return true;
|
||||
const out0 = output[0];
|
||||
const out1 = output[1] || output[0];
|
||||
const n = out0.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (this.available > 0) {
|
||||
out0[i] = this.bufL[this.readPos];
|
||||
if (out1 !== out0) out1[i] = this.bufR[this.readPos];
|
||||
this.readPos = (this.readPos + 1) % this.bufferSize;
|
||||
this.available--;
|
||||
} else {
|
||||
out0[i] = 0;
|
||||
if (out1 !== out0) out1[i] = 0;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('loopback-audio-processor', LoopbackAudioProcessor);
|
||||
`;
|
||||
|
||||
let workletModuleUrl: string | null = null;
|
||||
function getWorkletModuleUrl(): string {
|
||||
if (workletModuleUrl) return workletModuleUrl;
|
||||
const blob = new Blob([WORKLET_SOURCE], { type: 'application/javascript' });
|
||||
workletModuleUrl = URL.createObjectURL(blob);
|
||||
return workletModuleUrl;
|
||||
}
|
||||
|
||||
export interface StartLoopbackTrackOptions {
|
||||
/** When set, the capture targets only the picked window's process
|
||||
* tree (INCLUDE_TARGET_PROCESS_TREE) — Discord-parity behaviour for
|
||||
* window-shares. The HWND is the decimal handle parsed from
|
||||
* desktopCapturer's `window:<HWND>:0` source id. When unset, falls
|
||||
* back to the EXCLUDE-self path that captures the whole OS mixer
|
||||
* minus our own PID tree (default for full-screen shares). */
|
||||
windowHwnd?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a native WASAPI process-loopback capture and surface the
|
||||
* result as a `MediaStreamTrack`. Throws `LoopbackAudioUnavailable` if
|
||||
* the platform / build doesn't ship the addon — callers fall through
|
||||
* to the cross-platform getUserMedia path.
|
||||
*
|
||||
* Pass `{ windowHwnd }` to capture only that window's app audio
|
||||
* (window-share path). Omit to capture the whole OS mixer minus our
|
||||
* own PID tree (full-screen-share path).
|
||||
*/
|
||||
export async function startLoopbackTrack(
|
||||
opts?: StartLoopbackTrackOptions,
|
||||
): Promise<LoopbackTrackHandle> {
|
||||
if (typeof window === 'undefined' || !window.electronAPI?.audioLoopback) {
|
||||
throw new LoopbackAudioUnavailable('not an electron runtime with audio-loopback bridge');
|
||||
}
|
||||
const AudioCtor: typeof AudioContext | undefined =
|
||||
typeof window !== 'undefined'
|
||||
? (window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
||||
.webkitAudioContext)
|
||||
: undefined;
|
||||
if (!AudioCtor) {
|
||||
throw new LoopbackAudioUnavailable('WebAudio unavailable');
|
||||
}
|
||||
|
||||
// Pin the context to 48kHz so the worklet's input rate matches the
|
||||
// addon's output rate. If the OS forces a different rate the
|
||||
// constructor throws on some browsers; we surface as Unavailable so
|
||||
// the caller can fall back.
|
||||
let ctx: AudioContext;
|
||||
try {
|
||||
ctx = new AudioCtor({ sampleRate: 48000, latencyHint: 'interactive' });
|
||||
} catch (err: unknown) {
|
||||
throw new LoopbackAudioUnavailable(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.audioWorklet.addModule(getWorkletModuleUrl());
|
||||
} catch (err: unknown) {
|
||||
await ctx.close().catch(() => undefined);
|
||||
throw new LoopbackAudioUnavailable(
|
||||
'audioWorklet load failed: ' +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
|
||||
const node = new AudioWorkletNode(ctx, 'loopback-audio-processor', {
|
||||
numberOfInputs: 0,
|
||||
numberOfOutputs: 1,
|
||||
outputChannelCount: [2],
|
||||
});
|
||||
const dest = ctx.createMediaStreamDestination();
|
||||
node.connect(dest);
|
||||
|
||||
// Kick the AudioContext out of `suspended` before any samples arrive
|
||||
// — the share is triggered from a user click so autoplay policy
|
||||
// allows this. An un-resumed context would buffer everything the
|
||||
// addon produces until the context eventually runs, giving seconds
|
||||
// of initial latency.
|
||||
if (ctx.state !== 'running') {
|
||||
try {
|
||||
await ctx.resume();
|
||||
} catch (err: unknown) {
|
||||
console.warn('loopback ctx.resume failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe BEFORE starting the capture so we don't drop any of the
|
||||
// initial chunks that race the start() promise.
|
||||
let captureIdResolved: number | null = null;
|
||||
const unsubscribe = window.electronAPI.audioLoopback.onChunk(
|
||||
(payload: AudioLoopbackChunkPayload) => {
|
||||
if (captureIdResolved !== null && payload.captureId !== captureIdResolved) {
|
||||
return;
|
||||
}
|
||||
const interleaved = payload.samples;
|
||||
const frames = interleaved.length >> 1;
|
||||
if (frames === 0) return;
|
||||
const left = new Float32Array(frames);
|
||||
const right = new Float32Array(frames);
|
||||
for (let i = 0; i < frames; i++) {
|
||||
left[i] = interleaved[i * 2] ?? 0;
|
||||
right[i] = interleaved[i * 2 + 1] ?? 0;
|
||||
}
|
||||
node.port.postMessage({ left, right }, [left.buffer, right.buffer]);
|
||||
},
|
||||
);
|
||||
|
||||
let startResult: { captureId: number };
|
||||
try {
|
||||
if (typeof opts?.windowHwnd === 'number' && Number.isFinite(opts.windowHwnd)) {
|
||||
// INCLUDE_TARGET_PROCESS_TREE — capture only the picked window's
|
||||
// app. Falls back via the catch below if the addon predates the
|
||||
// startForWindow surface (older .node binary).
|
||||
const startForWindow = window.electronAPI.audioLoopback.startForWindow;
|
||||
if (typeof startForWindow !== 'function') {
|
||||
throw new LoopbackAudioUnavailable(
|
||||
'audioLoopback.startForWindow not exposed — preload + native addon need rebuild',
|
||||
);
|
||||
}
|
||||
startResult = await startForWindow(opts.windowHwnd);
|
||||
} else {
|
||||
startResult = await window.electronAPI.audioLoopback.start();
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
unsubscribe();
|
||||
node.disconnect();
|
||||
await ctx.close().catch(() => undefined);
|
||||
throw new LoopbackAudioUnavailable(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
captureIdResolved = startResult.captureId;
|
||||
|
||||
const tracks = dest.stream.getAudioTracks();
|
||||
const track = tracks[0];
|
||||
if (!track) {
|
||||
unsubscribe();
|
||||
node.disconnect();
|
||||
await ctx.close().catch(() => undefined);
|
||||
await window.electronAPI.audioLoopback
|
||||
.stop(startResult.captureId)
|
||||
.catch(() => undefined);
|
||||
throw new LoopbackAudioUnavailable('MediaStreamDestination produced no track');
|
||||
}
|
||||
|
||||
let stopped = false;
|
||||
const stop = async (): Promise<void> => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
unsubscribe();
|
||||
try {
|
||||
await window.electronAPI.audioLoopback.stop(startResult.captureId);
|
||||
} catch (err: unknown) {
|
||||
console.warn('audioLoopback.stop failed', err);
|
||||
}
|
||||
try {
|
||||
node.disconnect();
|
||||
} catch {
|
||||
/* already disconnected */
|
||||
}
|
||||
try {
|
||||
track.stop();
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
await ctx.close().catch(() => undefined);
|
||||
};
|
||||
|
||||
return { track, stop };
|
||||
}
|
||||
Binary file not shown.
@@ -12,10 +12,20 @@
|
||||
// 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';
|
||||
// NOTE: After the Tauri → Electron migration the native Rust crypto
|
||||
// commands are no longer available — we always fall through to the
|
||||
// WASM path below. The `invoke`-based call-sites are kept in this file
|
||||
// as reference / future re-enable points once we expose main-process
|
||||
// crypto accelerators via IPC again, but `nativeAvailable()` now
|
||||
// unconditionally returns false so they never run.
|
||||
|
||||
// Local shim so the unreachable `invoke` references still type-check
|
||||
// without a dependency on the deleted Tauri package.
|
||||
const invoke = async <T>(_cmd: string, _args?: unknown): Promise<T> => {
|
||||
throw new Error('native crypto invoke disabled post-Electron-migration');
|
||||
};
|
||||
|
||||
function bytesToB64(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
@@ -41,7 +51,11 @@ const flagEnabled = (() => {
|
||||
})();
|
||||
|
||||
function nativeAvailable(): boolean {
|
||||
return flagEnabled && isTauriRuntime();
|
||||
// Crypto native commands deferred for post-Electron-migration. We
|
||||
// run everything through the WASM path until there's a measured
|
||||
// reason to re-introduce a main-process accelerator.
|
||||
void flagEnabled;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
// Thin JS wrapper around the Rust LiveKit bridge commands + events.
|
||||
// Mirrors the subset of `livekit-client` that CallContext actually uses
|
||||
// so the adapter can be swapped behind the `VITE_USE_RUST_LIVEKIT` flag.
|
||||
//
|
||||
// Phase B.1 — only connect/disconnect/data-channel/state events wired.
|
||||
// Mic, camera, screen-share, active-speakers, video rendering ship in
|
||||
// later phases. Components that call missing methods get a typed
|
||||
// "not implemented" error so regressions surface immediately.
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
export const rustLivekitFlag = (() => {
|
||||
const raw = (import.meta as unknown as { env?: { VITE_USE_RUST_LIVEKIT?: string } })
|
||||
.env?.VITE_USE_RUST_LIVEKIT;
|
||||
return raw === 'true' || raw === '1';
|
||||
})();
|
||||
|
||||
export function isRustLivekitAvailable(): boolean {
|
||||
return rustLivekitFlag && isTauriRuntime();
|
||||
}
|
||||
|
||||
export type NativeRoomState =
|
||||
| { state: 'connecting' }
|
||||
| { state: 'connected' }
|
||||
| { state: 'disconnected' };
|
||||
|
||||
export interface NativeParticipantEvent {
|
||||
identity: string;
|
||||
name?: string | undefined;
|
||||
}
|
||||
|
||||
export interface NativeDataEvent {
|
||||
identity: string;
|
||||
payloadB64: string;
|
||||
reliable: boolean;
|
||||
}
|
||||
|
||||
type Listener<T> = (payload: T) => void;
|
||||
|
||||
export class NativeRoom {
|
||||
private unlistens: UnlistenFn[] = [];
|
||||
private stateListeners = new Set<Listener<NativeRoomState>>();
|
||||
private joinListeners = new Set<Listener<NativeParticipantEvent>>();
|
||||
private leaveListeners = new Set<Listener<NativeParticipantEvent>>();
|
||||
private dataListeners = new Set<Listener<NativeDataEvent>>();
|
||||
|
||||
async connect(url: string, token: string): Promise<void> {
|
||||
if (!isRustLivekitAvailable()) {
|
||||
throw new Error('Rust LiveKit backend not available');
|
||||
}
|
||||
await this.subscribeEvents();
|
||||
try {
|
||||
await invoke('livekit_connect', { args: { url, token } });
|
||||
} catch (err: unknown) {
|
||||
await this.teardown();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
try {
|
||||
await invoke('livekit_disconnect');
|
||||
} finally {
|
||||
await this.teardown();
|
||||
}
|
||||
}
|
||||
|
||||
async sendData(payload: Uint8Array, reliable: boolean): Promise<void> {
|
||||
await invoke('livekit_send_data', {
|
||||
payloadB64: bytesToB64(payload),
|
||||
reliable,
|
||||
});
|
||||
}
|
||||
|
||||
onRoomState(fn: Listener<NativeRoomState>): () => void {
|
||||
this.stateListeners.add(fn);
|
||||
return () => this.stateListeners.delete(fn);
|
||||
}
|
||||
onParticipantJoined(fn: Listener<NativeParticipantEvent>): () => void {
|
||||
this.joinListeners.add(fn);
|
||||
return () => this.joinListeners.delete(fn);
|
||||
}
|
||||
onParticipantLeft(fn: Listener<NativeParticipantEvent>): () => void {
|
||||
this.leaveListeners.add(fn);
|
||||
return () => this.leaveListeners.delete(fn);
|
||||
}
|
||||
onDataReceived(fn: Listener<NativeDataEvent>): () => void {
|
||||
this.dataListeners.add(fn);
|
||||
return () => this.dataListeners.delete(fn);
|
||||
}
|
||||
|
||||
private async subscribeEvents(): Promise<void> {
|
||||
// Room state — connected / disconnected.
|
||||
const uState = await listen<NativeRoomState>('livekit:room_state', (evt) => {
|
||||
for (const fn of this.stateListeners) fn(evt.payload);
|
||||
});
|
||||
const uJoined = await listen<NativeParticipantEvent>('livekit:participant_joined', (evt) => {
|
||||
for (const fn of this.joinListeners) fn(evt.payload);
|
||||
});
|
||||
const uLeft = await listen<NativeParticipantEvent>('livekit:participant_left', (evt) => {
|
||||
for (const fn of this.leaveListeners) fn(evt.payload);
|
||||
});
|
||||
const uData = await listen<NativeDataEvent>('livekit:data_received', (evt) => {
|
||||
for (const fn of this.dataListeners) fn(evt.payload);
|
||||
});
|
||||
this.unlistens.push(uState, uJoined, uLeft, uData);
|
||||
}
|
||||
|
||||
private async teardown(): Promise<void> {
|
||||
for (const unlisten of this.unlistens) {
|
||||
try {
|
||||
unlisten();
|
||||
} catch {
|
||||
/* unlistens become no-op after first call */
|
||||
}
|
||||
}
|
||||
this.unlistens = [];
|
||||
this.stateListeners.clear();
|
||||
this.joinListeners.clear();
|
||||
this.leaveListeners.clear();
|
||||
this.dataListeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function bytesToB64(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s);
|
||||
}
|
||||
@@ -1,21 +1,90 @@
|
||||
// Two-tone notification chime generated via WebAudio. No asset file needed.
|
||||
// Throttled so a burst of messages doesn't turn into a machine gun.
|
||||
// Notification chime. Plays either a user-uploaded custom sound (if one
|
||||
// exists in IndexedDB) or the built-in two-tone synth chime. Throttled
|
||||
// so a burst of messages doesn't turn into a machine gun.
|
||||
//
|
||||
// Uses a single persistent AudioContext — a fresh one per notification
|
||||
// lands in the `suspended` state under Chromium's autoplay policy
|
||||
// whenever the user hasn't interacted recently, so the tone silently
|
||||
// never plays.
|
||||
//
|
||||
// The custom AudioBuffer is decoded eagerly: on module init and on any
|
||||
// upload/reset, kick the load so it's ready before the next incoming
|
||||
// message. Playback always awaits `ctx.resume()` before starting the
|
||||
// source so we don't race an async resume in a realtime-event context
|
||||
// that has no prior user gesture.
|
||||
|
||||
import {
|
||||
getCustomNotificationSound,
|
||||
subscribeNotificationSoundChanges,
|
||||
} from './notificationSoundStorage';
|
||||
|
||||
let ctx: AudioContext | null = null;
|
||||
let lastPlay = 0;
|
||||
|
||||
export function playNotificationTone(): void {
|
||||
const now = Date.now();
|
||||
if (now - lastPlay < 800) return;
|
||||
lastPlay = now;
|
||||
// Cached decoded custom sound. `null` = no custom upload (fall back to
|
||||
// synth). `undefined` = not yet loaded. `false` = decode failed.
|
||||
let customBuffer: AudioBuffer | null | undefined | false = undefined;
|
||||
let customLoadInFlight: Promise<void> | null = null;
|
||||
|
||||
function getCtx(): AudioContext | null {
|
||||
if (ctx) return ctx;
|
||||
const AudioCtx =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioCtx) return;
|
||||
if (!AudioCtx) return null;
|
||||
ctx = new AudioCtx();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
const ctx = new AudioCtx();
|
||||
const master = ctx.createGain();
|
||||
master.connect(ctx.destination);
|
||||
async function loadCustomBuffer(c: AudioContext): Promise<void> {
|
||||
try {
|
||||
const record = await getCustomNotificationSound();
|
||||
if (!record) {
|
||||
customBuffer = null;
|
||||
return;
|
||||
}
|
||||
const arr = await record.blob.arrayBuffer();
|
||||
const buf = await c.decodeAudioData(arr.slice(0));
|
||||
customBuffer = buf;
|
||||
} catch (err: unknown) {
|
||||
console.warn('notification: custom sound decode failed', err);
|
||||
customBuffer = false;
|
||||
}
|
||||
}
|
||||
|
||||
function kickLoad(): void {
|
||||
if (customLoadInFlight) return;
|
||||
const c = getCtx();
|
||||
if (!c) return;
|
||||
customLoadInFlight = loadCustomBuffer(c).finally(() => {
|
||||
customLoadInFlight = null;
|
||||
});
|
||||
}
|
||||
|
||||
subscribeNotificationSoundChanges(() => {
|
||||
customBuffer = undefined;
|
||||
customLoadInFlight = null;
|
||||
kickLoad();
|
||||
});
|
||||
|
||||
// Eager-load at module init so the buffer is ready before the first
|
||||
// notification fires. Safe to call at top level — decodeAudioData
|
||||
// doesn't need the context running.
|
||||
kickLoad();
|
||||
|
||||
function playCustom(c: AudioContext, buffer: AudioBuffer): void {
|
||||
const src = c.createBufferSource();
|
||||
src.buffer = buffer;
|
||||
const gain = c.createGain();
|
||||
gain.gain.value = 1;
|
||||
src.connect(gain);
|
||||
gain.connect(c.destination);
|
||||
src.start();
|
||||
}
|
||||
|
||||
function playSynthTone(c: AudioContext): void {
|
||||
const master = c.createGain();
|
||||
master.connect(c.destination);
|
||||
master.gain.value = 0.12;
|
||||
|
||||
const tones: { freq: number; delay: number }[] = [
|
||||
@@ -23,23 +92,51 @@ export function playNotificationTone(): void {
|
||||
{ freq: 1320, delay: 0.08 },
|
||||
];
|
||||
for (const { freq, delay } of tones) {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
const osc = c.createOscillator();
|
||||
const gain = c.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
osc.connect(gain);
|
||||
gain.connect(master);
|
||||
const t0 = ctx.currentTime + delay;
|
||||
const t0 = c.currentTime + delay;
|
||||
gain.gain.setValueAtTime(0, t0);
|
||||
gain.gain.linearRampToValueAtTime(1, t0 + 0.015);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t0 + 0.28);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + 0.3);
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
void ctx.close().catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
}, 600);
|
||||
}
|
||||
|
||||
export function playNotificationTone(): void {
|
||||
const now = Date.now();
|
||||
if (now - lastPlay < 800) return;
|
||||
lastPlay = now;
|
||||
|
||||
void (async () => {
|
||||
const c = getCtx();
|
||||
if (!c) return;
|
||||
if (c.state === 'suspended') {
|
||||
try {
|
||||
await c.resume();
|
||||
} catch {
|
||||
/* autoplay denial — silent this round */
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If the load hasn't finished yet (first notification after a cold
|
||||
// start, before the eager load completed) wait briefly for it so
|
||||
// we don't emit the synth tone over a user-configured custom clip.
|
||||
// Cap the wait so a never-resolving decode doesn't block a message.
|
||||
if (customBuffer === undefined && customLoadInFlight) {
|
||||
const timeout = new Promise<void>((r) => window.setTimeout(r, 500));
|
||||
await Promise.race([customLoadInFlight, timeout]);
|
||||
}
|
||||
|
||||
if (customBuffer && typeof customBuffer !== 'boolean') {
|
||||
playCustom(c, customBuffer);
|
||||
return;
|
||||
}
|
||||
playSynthTone(c);
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// IndexedDB-backed custom notification sound. Single-row object store —
|
||||
// user either has one uploaded clip overriding the built-in two-tone
|
||||
// chime, or doesn't and we fall back to the synthesized tone.
|
||||
//
|
||||
// Separate from `soundboardStorage.ts` because the soundboard is a
|
||||
// multi-entry user library with categories + hotkeys + per-clip gain;
|
||||
// this module only needs "one blob, replace-on-upload, wipe-on-reset".
|
||||
|
||||
export interface NotificationSoundEntry {
|
||||
filename: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
uploadedAt: number;
|
||||
}
|
||||
|
||||
interface StoredNotificationSound extends NotificationSoundEntry {
|
||||
blob: Blob;
|
||||
}
|
||||
|
||||
// 1 MB cap — notification sounds should be short (<5s), and we don't
|
||||
// want an IDB quota bust from a 200MB MP3.
|
||||
export const MAX_NOTIFICATION_SOUND_BYTES = 1 * 1024 * 1024;
|
||||
|
||||
// --- Change observer — the notificationSound module subscribes to
|
||||
// invalidate its cached AudioBuffer when upload/reset happens, so the
|
||||
// next ping uses the fresh sound without an app restart.
|
||||
|
||||
type Listener = () => void;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
export function subscribeNotificationSoundChanges(l: Listener): () => void {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
}
|
||||
|
||||
function notifyChange(): void {
|
||||
for (const l of listeners) {
|
||||
try {
|
||||
l();
|
||||
} catch (err: unknown) {
|
||||
console.warn('notification-sound change listener threw', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DB_NAME = 'netralax-notification';
|
||||
const DB_VERSION = 1;
|
||||
const STORE = 'sound';
|
||||
const KEY = 'current';
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
if (dbPromise) return dbPromise;
|
||||
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE)) {
|
||||
db.createObjectStore(STORE);
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error ?? new Error('indexedDB open failed'));
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
export async function getCustomNotificationSound(): Promise<
|
||||
{ blob: Blob; entry: NotificationSoundEntry } | null
|
||||
> {
|
||||
const db = await openDb();
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = db.transaction(STORE, 'readonly');
|
||||
const req = t.objectStore(STORE).get(KEY);
|
||||
req.onsuccess = () => {
|
||||
const stored = req.result as StoredNotificationSound | undefined;
|
||||
if (!stored) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const { blob, ...entry } = stored;
|
||||
resolve({ blob, entry });
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
t.onerror = () => reject(t.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCustomNotificationSoundMeta(): Promise<NotificationSoundEntry | null> {
|
||||
const res = await getCustomNotificationSound();
|
||||
return res ? res.entry : null;
|
||||
}
|
||||
|
||||
export async function setCustomNotificationSound(file: File): Promise<NotificationSoundEntry> {
|
||||
if (file.size === 0) throw new Error('empty_file');
|
||||
if (file.size > MAX_NOTIFICATION_SOUND_BYTES) throw new Error('sound_too_large');
|
||||
const mime = file.type || 'application/octet-stream';
|
||||
if (!mime.startsWith('audio/')) throw new Error('sound_not_audio');
|
||||
|
||||
const stored: StoredNotificationSound = {
|
||||
filename: file.name || 'notification.audio',
|
||||
mime,
|
||||
size: file.size,
|
||||
uploadedAt: Date.now(),
|
||||
blob: file,
|
||||
};
|
||||
|
||||
const db = await openDb();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const t = db.transaction(STORE, 'readwrite');
|
||||
const req = t.objectStore(STORE).put(stored, KEY);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
t.onerror = () => reject(t.error);
|
||||
});
|
||||
|
||||
notifyChange();
|
||||
const { blob: _blob, ...entry } = stored;
|
||||
return entry;
|
||||
}
|
||||
|
||||
export async function clearCustomNotificationSound(): Promise<void> {
|
||||
const db = await openDb();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const t = db.transaction(STORE, 'readwrite');
|
||||
const req = t.objectStore(STORE).delete(KEY);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
t.onerror = () => reject(t.error);
|
||||
});
|
||||
notifyChange();
|
||||
}
|
||||
@@ -1,29 +1,17 @@
|
||||
import {
|
||||
isPermissionGranted,
|
||||
requestPermission,
|
||||
sendNotification,
|
||||
} from '@tauri-apps/plugin-notification';
|
||||
// OS notifications via the Electron preload bridge. The exported API
|
||||
// stays identical to the Tauri-era version so callers don't change.
|
||||
//
|
||||
// Permission under Electron is implicit ('granted' always). The legacy
|
||||
// ASKED marker in localStorage is kept so we don't re-prompt after a
|
||||
// previously-denied run; we just fast-succeed now.
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
// Tracks whether permission has already been requested this session so we
|
||||
// don't spam the OS prompt. Actual permission state lives in the OS, but we
|
||||
// also persist a "we've asked" marker in localStorage so reloads don't
|
||||
// re-request (OS would block anyway after denial, but calling it every reload
|
||||
// triggers noisy plugin warnings on some platforms).
|
||||
let permissionChecked = false;
|
||||
let permissionGranted = false;
|
||||
|
||||
const ASKED_KEY = 'chatapp.notif.asked';
|
||||
|
||||
function readAskedMarker(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(ASKED_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeAskedMarker(): void {
|
||||
try {
|
||||
window.localStorage.setItem(ASKED_KEY, '1');
|
||||
@@ -36,21 +24,13 @@ export async function ensureNotificationPermission(): Promise<boolean> {
|
||||
if (permissionChecked) return permissionGranted;
|
||||
permissionChecked = true;
|
||||
if (!isTauriRuntime()) {
|
||||
// Web preview / Chrome — Tauri notification plugin not available.
|
||||
permissionGranted = false;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
let granted = await isPermissionGranted();
|
||||
if (!granted && !readAskedMarker()) {
|
||||
// First-install: prompt the user once. After this we remember via the
|
||||
// marker and never re-prompt — the user can re-enable later via OS
|
||||
// system settings if they change their mind.
|
||||
const result = await requestPermission();
|
||||
granted = result === 'granted';
|
||||
writeAskedMarker();
|
||||
}
|
||||
permissionGranted = granted;
|
||||
const state = await window.electronAPI.getNotificationPermission();
|
||||
permissionGranted = state === 'granted';
|
||||
writeAskedMarker();
|
||||
} catch (err: unknown) {
|
||||
permissionGranted = false;
|
||||
console.warn('notification permission check failed', err);
|
||||
@@ -65,7 +45,7 @@ export function isAppFocused(): boolean {
|
||||
interface NotifyOpts {
|
||||
title: string;
|
||||
body?: string;
|
||||
// Force notification even when app is focused. Default: suppress if focused.
|
||||
/** Force notification even when app is focused. Default: suppress if focused. */
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
@@ -75,8 +55,12 @@ export async function notify({ title, body, force = false }: NotifyOpts): Promis
|
||||
const granted = await ensureNotificationPermission();
|
||||
if (!granted) return;
|
||||
try {
|
||||
sendNotification({ title, ...(body ? { body } : {}) });
|
||||
await window.electronAPI.notify({
|
||||
title,
|
||||
body: body ?? '',
|
||||
silent: true,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
console.error('sendNotification failed', err);
|
||||
console.error('notify failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { PresenceState } from '@chat-app/shared/supabase';
|
||||
|
||||
export const PRESENCE_HEARTBEAT_MS = 30_000;
|
||||
export const PRESENCE_DEVICE_STALE_MS = 90_000;
|
||||
|
||||
export function createPeerPresenceChannelName(userId: string, subscriptionId: string): string {
|
||||
return 'peer-presence:' + userId + ':' + subscriptionId;
|
||||
}
|
||||
|
||||
export function getEffectivePresenceState(
|
||||
profileState: PresenceState,
|
||||
latestDeviceSeenAt: string | null,
|
||||
nowMs = Date.now(),
|
||||
): PresenceState {
|
||||
if (profileState === 'offline' || profileState === 'invisible') return profileState;
|
||||
if (!latestDeviceSeenAt) return 'offline';
|
||||
|
||||
const seenMs = Date.parse(latestDeviceSeenAt);
|
||||
if (!Number.isFinite(seenMs)) return 'offline';
|
||||
|
||||
return nowMs - seenMs <= PRESENCE_DEVICE_STALE_MS ? profileState : 'offline';
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getProfileAvatarPreviewUrl, getProfileCardStatusText } from './profileCard';
|
||||
|
||||
describe('getProfileCardStatusText', () => {
|
||||
it('uses the trimmed custom status when one is set', () => {
|
||||
expect(getProfileCardStatusText(' am Coden ')).toBe('am Coden');
|
||||
});
|
||||
|
||||
it('falls back to a neutral empty-status label', () => {
|
||||
expect(getProfileCardStatusText('')).toBe('Kein Status gesetzt');
|
||||
expect(getProfileCardStatusText(null)).toBe('Kein Status gesetzt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProfileAvatarPreviewUrl', () => {
|
||||
it('returns a trimmed avatar url when one is available', () => {
|
||||
expect(getProfileAvatarPreviewUrl(' https://example.com/avatar.png ')).toBe(
|
||||
'https://example.com/avatar.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when no avatar image can be opened', () => {
|
||||
expect(getProfileAvatarPreviewUrl(null)).toBeNull();
|
||||
expect(getProfileAvatarPreviewUrl('')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
const EMPTY_STATUS_TEXT = 'Kein Status gesetzt';
|
||||
|
||||
export function getProfileCardStatusText(statusMessage: string | null | undefined): string {
|
||||
const trimmed = statusMessage?.trim() ?? '';
|
||||
return trimmed || EMPTY_STATUS_TEXT;
|
||||
}
|
||||
|
||||
export function getProfileAvatarPreviewUrl(avatarUrl: string | null | undefined): string | null {
|
||||
const trimmed = avatarUrl?.trim() ?? '';
|
||||
return trimmed || null;
|
||||
}
|
||||
@@ -39,6 +39,22 @@ export function createPipeline(
|
||||
if (!AudioCtx) return null;
|
||||
try {
|
||||
const ctx = new AudioCtx();
|
||||
// A fresh AudioContext under Chromium's autoplay policy starts in
|
||||
// `suspended` state when no recent user gesture is in scope —
|
||||
// attachTrack fires from a LiveKit event, not the call-start click,
|
||||
// so we can't rely on the gesture crossing the async boundary. Kick
|
||||
// resume() immediately and retry on any statechange so a later
|
||||
// suspension (window backgrounding, device change) doesn't leave
|
||||
// the remote peer silent permanently.
|
||||
const tryResume = () => {
|
||||
if (ctx.state === 'suspended') {
|
||||
void ctx.resume().catch(() => {
|
||||
/* ignore — will retry on next statechange */
|
||||
});
|
||||
}
|
||||
};
|
||||
tryResume();
|
||||
ctx.addEventListener('statechange', tryResume);
|
||||
const source = ctx.createMediaElementSource(audio);
|
||||
const gain = ctx.createGain();
|
||||
// Start silent; the caller (CallContext) applies the correct effective
|
||||
@@ -46,11 +62,14 @@ export function createPipeline(
|
||||
gain.gain.value = 0;
|
||||
source.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
// createMediaElementSource diverts the element's direct output through
|
||||
// the audio graph. Muting the element is then a double-guard — if the
|
||||
// diversion ever fails (older WebKit), the element stays silent instead
|
||||
// of bypassing the gain chain entirely.
|
||||
audio.muted = true;
|
||||
// Do NOT set `audio.muted = true` here. Chromium gates the
|
||||
// media-element's internal sample production behind the muted flag,
|
||||
// and that gate sits *before* the MediaElementAudioSourceNode tap —
|
||||
// a muted element feeds zero samples into the WebAudio graph, which
|
||||
// silences the peer even though createMediaElementSource already
|
||||
// diverts the element's direct playback path. The diversion itself
|
||||
// is sufficient to stop the element from double-playing to the
|
||||
// default output; explicit muting is the bug.
|
||||
const pipeline: RemoteAudioPipeline = {
|
||||
trackSid: info.trackSid,
|
||||
participantId: info.participantId,
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
// Frontend side of the native system-audio pipeline. Pairs with the Rust
|
||||
// `screen_audio` module: it opens a Tauri Channel, receives interleaved
|
||||
// f32 stereo samples at 48kHz (base64-encoded), and surfaces them as a
|
||||
// real `MediaStream` that LiveKit can publish as a `ScreenShareAudio`
|
||||
// track. An AudioWorklet does the heavy lifting so the render thread is
|
||||
// never the bottleneck — the main thread just pushes decoded samples
|
||||
// across a port; the worklet copies them into its output buffer which
|
||||
// feeds a `MediaStreamDestination`.
|
||||
// System-audio loopback. Under Electron this is renderer-driven: we
|
||||
// ask main for the primary screen's capturer id, then call
|
||||
// getUserMedia with Chromium's `chromeMediaSource: 'desktop'`
|
||||
// constraint to obtain the OS-mixer MediaStream directly. The whole
|
||||
// Tauri WASAPI + AudioWorklet base64 pipeline is gone.
|
||||
//
|
||||
// Windows-only right now. On other platforms `startSystemAudioCapture`
|
||||
// throws `SystemAudioUnavailable` and the caller is expected to fall
|
||||
// back to the browser's getDisplayMedia path.
|
||||
// Windows-only in practice (loopback audio is a Windows feature of
|
||||
// Chromium's desktop source). On other platforms `startSystemAudioCapture`
|
||||
// throws `SystemAudioUnavailable`; callers are expected to fall back
|
||||
// to the standard getDisplayMedia flow.
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
export interface SystemAudioHandle {
|
||||
/** Rust-side capture id. Pass to the Rust stop command via `stop()`. */
|
||||
/** Monotonic id, used by callers to correlate stop() with start. */
|
||||
captureId: number;
|
||||
/** MediaStream carrying a single audio track at 48kHz stereo. */
|
||||
/** MediaStream with a single audio track carrying the OS mixer. */
|
||||
stream: MediaStream;
|
||||
/** Teardown — stops the Rust thread, closes the AudioContext, ends the
|
||||
* MediaStreamDestination track. Idempotent. */
|
||||
/** Teardown — stops the MediaStreamTrack. Idempotent. */
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Thrown when the platform can't deliver native system-audio (non-Tauri
|
||||
* runtime, non-Windows host, WebAudio unavailable, COM init failure). */
|
||||
export class SystemAudioUnavailable extends Error {
|
||||
constructor(reason: string) {
|
||||
super('system audio unavailable: ' + reason);
|
||||
@@ -32,211 +27,58 @@ export class SystemAudioUnavailable extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
interface AudioFramePayload {
|
||||
captureId: number;
|
||||
sampleRate: number;
|
||||
channels: number;
|
||||
samplesBase64: string;
|
||||
}
|
||||
|
||||
// AudioWorklet source embedded as a string. The worklet keeps a pair of
|
||||
// ring buffers (one per channel) that the main thread appends to as
|
||||
// samples arrive. `process()` drains the ring buffers into the output
|
||||
// blocks; an underrun emits silence instead of propagating the stall
|
||||
// upwards (a glitch is better than a freeze for LiveKit's Opus encoder).
|
||||
//
|
||||
// The worklet runs at AudioContext sample rate, which we pin to 48kHz via
|
||||
// the AudioContext constructor. That matches what the Rust side already
|
||||
// resamples to, so no further rate conversion is needed here.
|
||||
const WORKLET_SOURCE = `
|
||||
class LoopbackAudioProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
// Ring buffer sized for latency, not for "never drop". 300ms hard cap,
|
||||
// 80ms target — we aim for ~one WASAPI packet of headroom above the
|
||||
// render quantum and drop excess whenever the producer gets ahead.
|
||||
// Keeping the target small is the difference between "feels live" and
|
||||
// "laggy" for screen-share audio.
|
||||
this.bufferSize = 48000 * 0.3 | 0;
|
||||
this.targetFrames = 48000 * 0.08 | 0;
|
||||
this.bufL = new Float32Array(this.bufferSize);
|
||||
this.bufR = new Float32Array(this.bufferSize);
|
||||
this.writePos = 0;
|
||||
this.readPos = 0;
|
||||
this.available = 0;
|
||||
this.port.onmessage = (e) => {
|
||||
const { left, right } = e.data;
|
||||
const len = left.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
this.bufL[this.writePos] = left[i];
|
||||
this.bufR[this.writePos] = right[i];
|
||||
this.writePos = (this.writePos + 1) % this.bufferSize;
|
||||
if (this.available < this.bufferSize) {
|
||||
this.available++;
|
||||
} else {
|
||||
// Buffer full — advance the read cursor to keep writing.
|
||||
this.readPos = (this.readPos + 1) % this.bufferSize;
|
||||
}
|
||||
}
|
||||
// Hard cap: if we're this far behind the producer, skip ahead to
|
||||
// the target latency instead of playing out minutes of stale audio.
|
||||
// Happens on: AudioContext resume after suspend, tab throttle
|
||||
// recovery, any hiccup that left samples piling up.
|
||||
if (this.available > this.targetFrames * 3) {
|
||||
const drop = this.available - this.targetFrames;
|
||||
this.readPos = (this.readPos + drop) % this.bufferSize;
|
||||
this.available -= drop;
|
||||
}
|
||||
};
|
||||
}
|
||||
process(_inputs, outputs) {
|
||||
const output = outputs[0];
|
||||
if (!output || output.length === 0) return true;
|
||||
const out0 = output[0];
|
||||
const out1 = output[1] || output[0];
|
||||
const n = out0.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (this.available > 0) {
|
||||
out0[i] = this.bufL[this.readPos];
|
||||
if (out1 !== out0) out1[i] = this.bufR[this.readPos];
|
||||
this.readPos = (this.readPos + 1) % this.bufferSize;
|
||||
this.available--;
|
||||
} else {
|
||||
out0[i] = 0;
|
||||
if (out1 !== out0) out1[i] = 0;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('screen-audio-loopback', LoopbackAudioProcessor);
|
||||
`;
|
||||
|
||||
let workletModuleUrl: string | null = null;
|
||||
function getWorkletModuleUrl(): string {
|
||||
if (workletModuleUrl) return workletModuleUrl;
|
||||
const blob = new Blob([WORKLET_SOURCE], { type: 'application/javascript' });
|
||||
workletModuleUrl = URL.createObjectURL(blob);
|
||||
return workletModuleUrl;
|
||||
interface ChromiumAudioConstraint {
|
||||
mandatory: {
|
||||
chromeMediaSource: 'desktop';
|
||||
chromeMediaSourceId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function startSystemAudioCapture(): Promise<SystemAudioHandle> {
|
||||
if (!isTauriRuntime()) {
|
||||
throw new SystemAudioUnavailable('not a tauri runtime');
|
||||
throw new SystemAudioUnavailable('not an electron runtime');
|
||||
}
|
||||
const AudioCtor: typeof AudioContext | undefined =
|
||||
typeof window !== 'undefined'
|
||||
? (window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
||||
.webkitAudioContext)
|
||||
: undefined;
|
||||
if (!AudioCtor) {
|
||||
throw new SystemAudioUnavailable('WebAudio unavailable');
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices) {
|
||||
throw new SystemAudioUnavailable('mediaDevices unavailable');
|
||||
}
|
||||
|
||||
// Pin to 48kHz so the worklet's input rate matches the Rust-side
|
||||
// output rate. If the OS forces a different rate the constructor
|
||||
// throws on some browsers; we catch and surface as Unavailable so the
|
||||
// caller can fall back.
|
||||
let ctx: AudioContext;
|
||||
try {
|
||||
ctx = new AudioCtor({ sampleRate: 48000, latencyHint: 'interactive' });
|
||||
} catch (err: unknown) {
|
||||
throw new SystemAudioUnavailable(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
const resolved = await window.electronAPI.resolveLoopbackSource();
|
||||
if (!resolved) {
|
||||
throw new SystemAudioUnavailable('no screen source available');
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.audioWorklet.addModule(getWorkletModuleUrl());
|
||||
} catch (err: unknown) {
|
||||
await ctx.close().catch(() => undefined);
|
||||
throw new SystemAudioUnavailable(
|
||||
'audioWorklet load failed: ' +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
|
||||
const node = new AudioWorkletNode(ctx, 'screen-audio-loopback', {
|
||||
numberOfInputs: 0,
|
||||
numberOfOutputs: 1,
|
||||
outputChannelCount: [2],
|
||||
});
|
||||
const dest = ctx.createMediaStreamDestination();
|
||||
node.connect(dest);
|
||||
|
||||
// Kick the AudioContext out of `suspended` before any samples arrive —
|
||||
// the share is triggered from a user click so autoplay policy allows
|
||||
// this, and an un-resumed context would buffer everything the Rust
|
||||
// side produces until the context eventually runs, giving seconds of
|
||||
// initial latency.
|
||||
if (ctx.state !== 'running') {
|
||||
try {
|
||||
await ctx.resume();
|
||||
} catch (err: unknown) {
|
||||
console.warn('system-audio ctx.resume failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
const { Channel, invoke } = await import('@tauri-apps/api/core');
|
||||
const channel = new Channel<AudioFramePayload>();
|
||||
|
||||
channel.onmessage = (frame: AudioFramePayload) => {
|
||||
const bytes = base64ToBytes(frame.samplesBase64);
|
||||
// Re-view the bytes as f32 little-endian. The byteLength is always
|
||||
// a multiple of 8 (f32 stereo pairs) — if not, drop the trailing
|
||||
// partial frame rather than risk a truncation artifact.
|
||||
const sampleCount = Math.floor(bytes.byteLength / 4);
|
||||
if (sampleCount < 2) return;
|
||||
const floats = new Float32Array(
|
||||
bytes.buffer,
|
||||
bytes.byteOffset,
|
||||
sampleCount,
|
||||
);
|
||||
// Interleaved L/R → deinterleaved for the worklet. Copying out of
|
||||
// the base64 view also ensures the Float32Arrays we postMessage are
|
||||
// owned (the underlying buffer is about to be garbage-collected).
|
||||
const frames = floats.length >> 1;
|
||||
const left = new Float32Array(frames);
|
||||
const right = new Float32Array(frames);
|
||||
for (let i = 0; i < frames; i++) {
|
||||
left[i] = floats[i * 2] ?? 0;
|
||||
right[i] = floats[i * 2 + 1] ?? 0;
|
||||
}
|
||||
// Transfer the buffers so postMessage is zero-copy.
|
||||
node.port.postMessage(
|
||||
{ left, right },
|
||||
[left.buffer, right.buffer],
|
||||
);
|
||||
const audioConstraint: ChromiumAudioConstraint = {
|
||||
mandatory: {
|
||||
chromeMediaSource: 'desktop',
|
||||
chromeMediaSourceId: resolved.sourceId,
|
||||
},
|
||||
};
|
||||
|
||||
let captureId: number;
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
captureId = await invoke<number>('start_system_audio_capture', { channel });
|
||||
// Cast: the Chromium `mandatory` constraint is non-standard and
|
||||
// not covered by lib.dom.d.ts typings.
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: audioConstraint as unknown as MediaTrackConstraints,
|
||||
video: false,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
node.disconnect();
|
||||
await ctx.close().catch(() => undefined);
|
||||
throw new SystemAudioUnavailable(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
|
||||
const stream = dest.stream;
|
||||
const tracks = stream.getAudioTracks();
|
||||
if (tracks.length === 0) {
|
||||
for (const t of stream.getTracks()) t.stop();
|
||||
throw new SystemAudioUnavailable('no audio track in returned stream');
|
||||
}
|
||||
|
||||
const captureId = Date.now();
|
||||
let stopped = false;
|
||||
const stop = async (): Promise<void> => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
try {
|
||||
await invoke('stop_system_audio_capture', { captureId });
|
||||
} catch (err: unknown) {
|
||||
console.warn('stop_system_audio_capture failed', err);
|
||||
}
|
||||
try {
|
||||
node.disconnect();
|
||||
} catch {
|
||||
/* already disconnected */
|
||||
}
|
||||
for (const track of stream.getTracks()) {
|
||||
try {
|
||||
track.stop();
|
||||
@@ -244,17 +86,7 @@ export async function startSystemAudioCapture(): Promise<SystemAudioHandle> {
|
||||
/* already stopped */
|
||||
}
|
||||
}
|
||||
await ctx.close().catch(() => undefined);
|
||||
};
|
||||
|
||||
return { captureId, stream, stop };
|
||||
}
|
||||
|
||||
function base64ToBytes(b64: string): Uint8Array {
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) {
|
||||
bytes[i] = bin.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
// Frontend side of the native screen-capture pipeline. Starts a Rust-side
|
||||
// capture thread via `start_screen_capture` and streams JPEG frames back
|
||||
// through a Tauri Channel. Each frame is decoded into an ImageBitmap,
|
||||
// drawn onto an offscreen canvas, and the canvas' captureStream() is
|
||||
// returned as a MediaStream that LiveKit can publishTrack() directly —
|
||||
// no OS/browser screen picker is involved.
|
||||
//
|
||||
// Video-only: system audio would require WASAPI / ScreenCaptureKit hooks
|
||||
// that xcap doesn't provide. Callers that request shared audio must
|
||||
// either fall back to the browser picker or accept video-without-audio.
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
export interface NativeCaptureHandle {
|
||||
/** Rust-side capture id. Pass to `stopNativeCapture` to tear down. */
|
||||
captureId: number;
|
||||
/** MediaStream fed by a canvas that's drawing each incoming frame. */
|
||||
stream: MediaStream;
|
||||
/** Cleanup — stops the Rust thread, closes channels, revokes the canvas
|
||||
* stream. Idempotent. */
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface FramePayload {
|
||||
captureId: number;
|
||||
width: number;
|
||||
height: number;
|
||||
jpegBase64: string;
|
||||
}
|
||||
|
||||
/** Returned when the runtime can't support native capture (no Tauri, no
|
||||
* WebAudio, source vanished between enumeration and start, etc.). The
|
||||
* caller is expected to fall back to the browser's getDisplayMedia path. */
|
||||
export class NativeCaptureUnavailable extends Error {
|
||||
constructor(reason: string) {
|
||||
super('native capture unavailable: ' + reason);
|
||||
this.name = 'NativeCaptureUnavailable';
|
||||
}
|
||||
}
|
||||
|
||||
export async function startNativeCapture(opts: {
|
||||
sourceId: string;
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
fps: number;
|
||||
}): Promise<NativeCaptureHandle> {
|
||||
if (!isTauriRuntime()) {
|
||||
throw new NativeCaptureUnavailable('not a tauri runtime');
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = opts.maxWidth;
|
||||
canvas.height = opts.maxHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new NativeCaptureUnavailable('canvas 2d context unavailable');
|
||||
}
|
||||
|
||||
// Track whether we got the first frame so we can fail fast if Rust
|
||||
// reports "found" but then produces no output (e.g. screen was locked).
|
||||
let firstFrameResolved = false;
|
||||
let firstFrameResolve!: () => void;
|
||||
let firstFrameReject!: (err: Error) => void;
|
||||
const firstFramePromise = new Promise<void>((resolve, reject) => {
|
||||
firstFrameResolve = resolve;
|
||||
firstFrameReject = reject;
|
||||
});
|
||||
|
||||
const { Channel, invoke } = await import('@tauri-apps/api/core');
|
||||
const channel = new Channel<FramePayload>();
|
||||
|
||||
// Latest-wins frame queue: if the JS side falls behind the Rust producer,
|
||||
// we drop stale frames rather than queue them. Keeps memory flat and
|
||||
// latency sensible for live screenshare.
|
||||
let pendingFrame: FramePayload | null = null;
|
||||
let decoding = false;
|
||||
|
||||
const drainQueue = async () => {
|
||||
if (decoding) return;
|
||||
decoding = true;
|
||||
try {
|
||||
while (pendingFrame) {
|
||||
const frame = pendingFrame;
|
||||
pendingFrame = null;
|
||||
const bytes = base64ToBytes(frame.jpegBase64);
|
||||
// Uint8Array's buffer type is `ArrayBufferLike` (could be a
|
||||
// SharedArrayBuffer in theory); Blob wants plain ArrayBuffer.
|
||||
// Pass the underlying buffer explicitly so the type narrows.
|
||||
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: 'image/jpeg' });
|
||||
let bitmap: ImageBitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err: unknown) {
|
||||
console.warn('createImageBitmap failed', err);
|
||||
continue;
|
||||
}
|
||||
if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) {
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
}
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
bitmap.close();
|
||||
if (!firstFrameResolved) {
|
||||
firstFrameResolved = true;
|
||||
firstFrameResolve();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
decoding = false;
|
||||
}
|
||||
};
|
||||
|
||||
channel.onmessage = (frame: FramePayload) => {
|
||||
pendingFrame = frame;
|
||||
void drainQueue();
|
||||
};
|
||||
|
||||
let captureId: number;
|
||||
try {
|
||||
captureId = await invoke<number>('start_screen_capture', {
|
||||
sourceId: opts.sourceId,
|
||||
maxWidth: opts.maxWidth,
|
||||
maxHeight: opts.maxHeight,
|
||||
fps: opts.fps,
|
||||
channel,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
throw new NativeCaptureUnavailable(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
|
||||
// Bound the wait: the capture thread may fail silently on some sources
|
||||
// (locked screens, protected windows). Fall back to getDisplayMedia in
|
||||
// that case rather than hang the user.
|
||||
const firstFrameTimeout = window.setTimeout(() => {
|
||||
firstFrameReject(new Error('first frame timed out (3s)'));
|
||||
}, 3000);
|
||||
try {
|
||||
await firstFramePromise;
|
||||
} catch (err: unknown) {
|
||||
window.clearTimeout(firstFrameTimeout);
|
||||
try {
|
||||
await invoke('stop_screen_capture', { captureId });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new NativeCaptureUnavailable(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
window.clearTimeout(firstFrameTimeout);
|
||||
|
||||
const stream = canvas.captureStream(opts.fps);
|
||||
|
||||
let stopped = false;
|
||||
const stop = async (): Promise<void> => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
try {
|
||||
await invoke('stop_screen_capture', { captureId });
|
||||
} catch (err: unknown) {
|
||||
console.warn('stop_screen_capture failed', err);
|
||||
}
|
||||
for (const track of stream.getTracks()) {
|
||||
try {
|
||||
track.stop();
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { captureId, stream, stop };
|
||||
}
|
||||
|
||||
function base64ToBytes(b64: string): Uint8Array {
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) {
|
||||
bytes[i] = bin.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
@@ -29,6 +29,16 @@ export interface ScreenShareSettings {
|
||||
// Linux setups). If the browser ignores the `audio: true` request we
|
||||
// silently fall through to a video-only share.
|
||||
includeSystemAudio: boolean;
|
||||
// While system audio is being shared, mute the local playback of remote
|
||||
// call audio to prevent peers from hearing themselves echoed back via the
|
||||
// loopback capture. Defaults to true — Chromium's loopback grant in
|
||||
// Electron *should* exclude the app's own render output, but the
|
||||
// process-tree exclusion isn't always watertight (WebView2/Chromium audio
|
||||
// sessions can render in process owners outside the tree). The user can
|
||||
// opt out (e.g. when they route call audio to a separate output device
|
||||
// with setSinkId, in which case the default-render-endpoint capture
|
||||
// never sees it).
|
||||
duckRemoteAudioWhileSharing: boolean;
|
||||
}
|
||||
|
||||
const DEFAULTS: ScreenShareSettings = {
|
||||
@@ -36,6 +46,11 @@ const DEFAULTS: ScreenShareSettings = {
|
||||
displaySurface: null,
|
||||
framerateOverride: null,
|
||||
includeSystemAudio: false,
|
||||
// Default off — we use Electron's `loopback` (not `loopbackWithMute`)
|
||||
// so the user keeps local audio while sharing. Auto-ducking remote
|
||||
// mic audio for echo prevention also kills the user's ability to
|
||||
// hear peers, which most users don't want. Opt-in only.
|
||||
duckRemoteAudioWhileSharing: false,
|
||||
};
|
||||
|
||||
export interface PresetParams {
|
||||
@@ -131,6 +146,10 @@ function read(): ScreenShareSettings {
|
||||
typeof parsed.includeSystemAudio === 'boolean'
|
||||
? parsed.includeSystemAudio
|
||||
: DEFAULTS.includeSystemAudio,
|
||||
duckRemoteAudioWhileSharing:
|
||||
typeof parsed.duckRemoteAudioWhileSharing === 'boolean'
|
||||
? parsed.duckRemoteAudioWhileSharing
|
||||
: DEFAULTS.duckRemoteAudioWhileSharing,
|
||||
};
|
||||
return cached;
|
||||
} catch {
|
||||
|
||||
@@ -1,150 +1,78 @@
|
||||
// Frontend wrapper for the Rust `enumerate_screen_sources` command. Falls
|
||||
// back to an empty list outside the Tauri runtime so a browser-only dev
|
||||
// build (pnpm vite:dev in Chrome without Tauri) degrades gracefully to
|
||||
// "nothing to show" rather than throwing.
|
||||
// Frontend wrapper for the main-process screen-source enumerator.
|
||||
// Pre-migration this called into a Rust command that captured JPEG
|
||||
// thumbnails via xcap; Electron's desktopCapturer returns thumbnails
|
||||
// inline as data URLs so there's no binary/base64 dual-path to juggle.
|
||||
//
|
||||
// The return shape here matches the ipc-types `ScreenSource` contract.
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
export type ScreenSourceKind = 'screen' | 'window';
|
||||
|
||||
export interface ScreenSource {
|
||||
/** Chromium-format source id ("screen:<id>:0" / "window:<hwnd>:0"). */
|
||||
/** Chromium desktopCapturer id; feed unchanged to getUserMedia's
|
||||
* chromeMediaSourceId constraint when capturing this source. */
|
||||
id: string;
|
||||
name: string;
|
||||
kind: ScreenSourceKind;
|
||||
/** Base64-encoded JPEG without a data-URL prefix. Null when capture failed.
|
||||
* Kept under `thumbnailPng` key for rollout stability — the server-side
|
||||
* format switched from PNG to JPEG for payload size, but the field name
|
||||
* preserves the wire contract during the transition. */
|
||||
thumbnailPng: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Thumbnail as a ready-to-use data URL (image/png). Null when
|
||||
* desktopCapturer returned an empty buffer. */
|
||||
thumbnailDataUrl: string | null;
|
||||
/** App icon as a data URL for window sources; null for screens. */
|
||||
iconDataUrl: string | null;
|
||||
displayId: number | null;
|
||||
}
|
||||
|
||||
// Fast, metadata-only list. The picker uses this first so names show up
|
||||
// immediately; thumbnails stream in via captureScreenSourceThumbnail below.
|
||||
export async function listScreenSources(): Promise<ScreenSource[]> {
|
||||
if (!isTauriRuntime()) return [];
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const raw = await invoke<ScreenSource[]>('list_screen_sources');
|
||||
return raw ?? [];
|
||||
return await window.electronAPI.getScreenSources();
|
||||
} catch (err: unknown) {
|
||||
console.warn('list_screen_sources failed', err);
|
||||
console.warn('getScreenSources failed', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Single-source thumbnail capture (legacy base64 variant). Callers should
|
||||
// prefer `captureScreenSourceThumbnailBytes` below — it ships raw JPEG
|
||||
// bytes over IPC so the main thread avoids both the base64 decode AND
|
||||
// the JSON parse overhead of a long string result. Kept for fallback.
|
||||
// Single-source high-res refresh. Re-queries desktopCapturer at 640x360
|
||||
// so a detail view looks crisp without paying the full enumeration cost
|
||||
// more than once per hover-debounce.
|
||||
export async function captureScreenSourceThumbnail(
|
||||
sourceId: string,
|
||||
): Promise<string | null> {
|
||||
if (!isTauriRuntime()) return null;
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const result = await invoke<string | null>('capture_screen_source_thumbnail', {
|
||||
sourceId,
|
||||
});
|
||||
return result ?? null;
|
||||
return await window.electronAPI.getScreenThumbnail(sourceId);
|
||||
} catch (err: unknown) {
|
||||
console.warn('capture_screen_source_thumbnail failed', { sourceId, err });
|
||||
console.warn('getScreenThumbnail failed', { sourceId, err });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Thumbnail fetch with a two-tier fallback:
|
||||
// 1. binary-IPC path (`..._bytes`) — ArrayBuffer over Tauri's raw channel
|
||||
// 2. base64 path (legacy) — same command minus the ArrayBuffer wrapper
|
||||
//
|
||||
// The binary path can come through in several shapes depending on the
|
||||
// Tauri / WebView2 version combo: a real ArrayBuffer, a Uint8Array, or
|
||||
// occasionally a plain number[] when the response got re-serialised.
|
||||
// We normalise all three into an ArrayBuffer before handing it to Blob.
|
||||
// If the binary path returns nothing usable we retry once on the base64
|
||||
// command — keeps thumbnails visible while the binary contract settles.
|
||||
let warnedBinaryShape = false;
|
||||
// Legacy name retained for call-sites that expected a Blob. desktopCapturer
|
||||
// already gives us a data URL — callers that need a Blob can fetch() the
|
||||
// URL. This helper keeps the old signature so nothing breaks during the
|
||||
// migration.
|
||||
export async function captureScreenSourceThumbnailBytes(
|
||||
sourceId: string,
|
||||
): Promise<Blob | null> {
|
||||
if (!isTauriRuntime()) return null;
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
|
||||
// ---- Tier 1: binary IPC ---------------------------------------------
|
||||
const url = await captureScreenSourceThumbnail(sourceId);
|
||||
if (!url) return null;
|
||||
try {
|
||||
const result = await invoke<ArrayBuffer | Uint8Array | number[] | null>(
|
||||
'capture_screen_source_thumbnail_bytes',
|
||||
{ sourceId },
|
||||
);
|
||||
let bytes: Uint8Array | null = null;
|
||||
if (result instanceof ArrayBuffer) {
|
||||
bytes = new Uint8Array(result);
|
||||
} else if (result instanceof Uint8Array) {
|
||||
bytes = result;
|
||||
} else if (Array.isArray(result) && result.length > 0) {
|
||||
bytes = new Uint8Array(result);
|
||||
} else if (result && typeof result === 'object') {
|
||||
// One-time diagnostic so we can see the unexpected shape in the
|
||||
// console if WebView2 de-serialises the Response body into a bag
|
||||
// of properties instead of a transferable binary buffer.
|
||||
if (!warnedBinaryShape) {
|
||||
warnedBinaryShape = true;
|
||||
console.warn(
|
||||
'capture_screen_source_thumbnail_bytes: unexpected shape, falling back',
|
||||
result,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (bytes && bytes.byteLength > 0) {
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
return new Blob([copy.buffer], { type: 'image/jpeg' });
|
||||
}
|
||||
const res = await fetch(url);
|
||||
return await res.blob();
|
||||
} catch (err: unknown) {
|
||||
console.warn('binary thumbnail path threw, trying base64 fallback', {
|
||||
sourceId,
|
||||
err,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Tier 2: base64 fallback ----------------------------------------
|
||||
try {
|
||||
const b64 = await invoke<string | null>('capture_screen_source_thumbnail', {
|
||||
sourceId,
|
||||
});
|
||||
if (!b64) return null;
|
||||
const bin = atob(b64);
|
||||
const fallbackBytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) fallbackBytes[i] = bin.charCodeAt(i);
|
||||
return new Blob([fallbackBytes.buffer], { type: 'image/jpeg' });
|
||||
} catch (err: unknown) {
|
||||
console.warn('base64 thumbnail fallback failed', { sourceId, err });
|
||||
console.warn('thumbnail fetch failed', { sourceId, err });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy single-shot variant. Captures everything serially on the Rust side
|
||||
// before returning. Prefer listScreenSources + captureScreenSourceThumbnail
|
||||
// for user-facing flows — they feel 5–10× more responsive in practice.
|
||||
// Legacy single-shot API — now just delegates to listScreenSources.
|
||||
export async function enumerateScreenSources(): Promise<ScreenSource[]> {
|
||||
if (!isTauriRuntime()) return [];
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const raw = await invoke<ScreenSource[]>('enumerate_screen_sources');
|
||||
return raw ?? [];
|
||||
} catch (err: unknown) {
|
||||
console.warn('enumerate_screen_sources failed', err);
|
||||
return [];
|
||||
}
|
||||
return listScreenSources();
|
||||
}
|
||||
|
||||
// Data-URL helper — picker tiles bind `src={thumbnailDataUrl(src)}` so the
|
||||
// thumbnail bytes never leave the component's render pass. Rust encodes
|
||||
// JPEG now (smaller payload, faster decode); the mime type here must
|
||||
// match or the <img> element silently fails to paint.
|
||||
// Picker tiles bind `src={thumbnailDataUrl(src)}`; we already receive a
|
||||
// data URL so this is just a pass-through for API compatibility.
|
||||
export function thumbnailDataUrl(src: ScreenSource): string | null {
|
||||
if (!src.thumbnailPng) return null;
|
||||
return 'data:image/jpeg;base64,' + src.thumbnailPng;
|
||||
return src.thumbnailDataUrl;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { base64FromBytes, bytesFromBase64, type SecretStore } from '@chat-app/shared/auth';
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
import { makeSecureFileStore, migrateLocalStorageToVault } from './secureFileStore';
|
||||
import {
|
||||
makeStrongholdStore,
|
||||
migrateLocalStorageToStronghold,
|
||||
} from './strongholdStore';
|
||||
|
||||
// Two-tier SecretStore:
|
||||
// - Tauri runtime: encrypted single-file vault in `appLocalDataDir`
|
||||
// (`secureFileStore` — XSalsa20-Poly1305 + Argon2id KDF). Survives app
|
||||
// - Electron runtime: safeStorage-backed store in `<userData>`
|
||||
// (`strongholdStore`). DPAPI on Windows / Keychain on macOS /
|
||||
// libsecret on Linux seals the per-user blob. Survives app
|
||||
// reinstalls when the OS preserves the data dir.
|
||||
// - Web / pre-auth: plain localStorage (legacy fallback).
|
||||
//
|
||||
@@ -38,20 +42,20 @@ export async function setSecretStoreUser(userId: string | null): Promise<void> {
|
||||
activeUserId = userId;
|
||||
|
||||
if (userId && isTauriRuntime()) {
|
||||
const fileStore = makeSecureFileStore(userId);
|
||||
const store = makeStrongholdStore(userId);
|
||||
try {
|
||||
// Probe write/read to confirm the vault is usable on this machine.
|
||||
// If anything throws (perm denied, disk full, KDF error), fall back to
|
||||
// localStorage so the rest of the app keeps working.
|
||||
await fileStore.getSecret('__probe');
|
||||
activeBackend = fileStore;
|
||||
// Probe read to confirm the store is usable on this machine. If
|
||||
// anything throws, fall back to localStorage so the rest of the
|
||||
// app keeps working.
|
||||
await store.getSecret('__probe');
|
||||
activeBackend = store;
|
||||
try {
|
||||
await migrateLocalStorageToVault(userId, PREFIX);
|
||||
await migrateLocalStorageToStronghold(userId, PREFIX);
|
||||
} catch (err: unknown) {
|
||||
console.warn('vault migration failed', err);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn('secure file vault init failed — falling back to localStorage', err);
|
||||
console.warn('secure store init failed — falling back to localStorage', err);
|
||||
activeBackend = localStore;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
import type { SecretStore } from '@chat-app/shared/auth';
|
||||
import { exists, mkdir, readFile, rename, writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { appLocalDataDir } from '@tauri-apps/api/path';
|
||||
// sumo variant ships crypto_pwhash (Argon2id). Standard `libsodium-wrappers`
|
||||
// is the compact build without Argon2 — vault KDF would error otherwise.
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
import { pwhashArgon2id } from './nativeCryptoOps';
|
||||
|
||||
// Encrypted single-file SecretStore for Tauri. Replaces the flaky
|
||||
// tauri-plugin-stronghold implementation.
|
||||
//
|
||||
// File layout (binary, little-endian):
|
||||
// bytes 0..7 magic: ASCII "CHATVLT1"
|
||||
// bytes 8..23 salt for KDF (16 bytes)
|
||||
// bytes 24..47 XSalsa20-Poly1305 nonce (24 bytes)
|
||||
// bytes 48.. secretbox(plaintext_json, key, nonce)
|
||||
//
|
||||
// `plaintext_json` is a UTF-8 JSON object { [key: string]: base64url(value) }.
|
||||
//
|
||||
// Key derivation: Argon2id (libsodium MODERATE ops/mem) over a passphrase
|
||||
// derived from the authenticated user-id + a constant. Same userId on the
|
||||
// same machine after re-install ⇒ same key ⇒ vault recovers automatically.
|
||||
//
|
||||
// Atomic writes: serialised vault is first written to `<file>.tmp` then
|
||||
// renamed onto `<file>` so an interrupted write never corrupts the existing
|
||||
// vault.
|
||||
|
||||
// Per-user vault filename so multiple accounts on the same machine each get
|
||||
// their own file (and Argon2 derives a different key per user, so cross-user
|
||||
// decrypt is also blocked even if filenames collided).
|
||||
async function vaultFileName(userId: string): Promise<string> {
|
||||
const enc = new TextEncoder();
|
||||
const buf = await crypto.subtle.digest('SHA-256', enc.encode('chatapp-vault-name:' + userId));
|
||||
const hex = Array.from(new Uint8Array(buf))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
return 'chatapp-vault-' + hex.slice(0, 16) + '.bin';
|
||||
}
|
||||
const MAGIC = new TextEncoder().encode('CHATVLT1'); // 8 bytes
|
||||
const SALT_LEN = 16;
|
||||
const NONCE_LEN = 24;
|
||||
const KEY_LEN = 32;
|
||||
|
||||
interface VaultState {
|
||||
path: string;
|
||||
tmpPath: string;
|
||||
key: Uint8Array; // derived encryption key
|
||||
data: Map<string, Uint8Array>;
|
||||
}
|
||||
|
||||
let initPromise: Promise<VaultState> | null = null;
|
||||
let vault: VaultState | null = null;
|
||||
let initializedFor: string | null = null;
|
||||
|
||||
async function ensureSodium(): Promise<typeof sodium> {
|
||||
await sodium.ready;
|
||||
return sodium;
|
||||
}
|
||||
|
||||
function joinPath(dir: string, name: string): string {
|
||||
const sep = dir.endsWith('/') || dir.endsWith('\\') ? '' : '/';
|
||||
return dir + sep + name;
|
||||
}
|
||||
|
||||
function b64url(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function unb64url(s: string): Uint8Array {
|
||||
let str = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (str.length % 4) str += '=';
|
||||
const bin = atob(str);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function deriveKey(userId: string, salt: Uint8Array, _s: typeof sodium): Promise<Uint8Array> {
|
||||
const passphrase = 'chatapp-vault-v1:' + userId;
|
||||
return pwhashArgon2id({
|
||||
password: passphrase,
|
||||
salt,
|
||||
outLen: KEY_LEN,
|
||||
preset: 'moderate',
|
||||
});
|
||||
}
|
||||
|
||||
async function loadOrCreateVault(userId: string): Promise<VaultState> {
|
||||
const s = await ensureSodium();
|
||||
const dir = await appLocalDataDir();
|
||||
const fileName = await vaultFileName(userId);
|
||||
const path = joinPath(dir, fileName);
|
||||
const tmpPath = path + '.tmp';
|
||||
|
||||
// First-run: AppLocalData dir may not exist yet. `mkdir(recursive)` is
|
||||
// idempotent on macOS/Linux, but we need to surface genuine permission
|
||||
// errors (silent catch masked a previous bug where the dir was never
|
||||
// created and every subsequent writeFile failed with ENOENT).
|
||||
const dirExists = await exists(dir).catch(() => false);
|
||||
if (!dirExists) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const fileExists = await exists(path).catch(() => false);
|
||||
if (!fileExists) {
|
||||
const salt = s.randombytes_buf(SALT_LEN);
|
||||
const key = await deriveKey(userId, salt, s);
|
||||
const state: VaultState = { path, tmpPath, key, data: new Map() };
|
||||
await persist(state, salt, s);
|
||||
return state;
|
||||
}
|
||||
|
||||
const raw = await readFile(path);
|
||||
if (raw.length < MAGIC.length + SALT_LEN + NONCE_LEN + 1) {
|
||||
throw new Error('vault file too short');
|
||||
}
|
||||
for (let i = 0; i < MAGIC.length; i++) {
|
||||
if (raw[i] !== MAGIC[i]) throw new Error('vault magic mismatch');
|
||||
}
|
||||
const salt = raw.slice(MAGIC.length, MAGIC.length + SALT_LEN);
|
||||
const nonce = raw.slice(MAGIC.length + SALT_LEN, MAGIC.length + SALT_LEN + NONCE_LEN);
|
||||
const ciphertext = raw.slice(MAGIC.length + SALT_LEN + NONCE_LEN);
|
||||
|
||||
const key = await deriveKey(userId, salt, s);
|
||||
let plain: Uint8Array;
|
||||
try {
|
||||
plain = s.crypto_secretbox_open_easy(ciphertext, nonce, key);
|
||||
} catch (err: unknown) {
|
||||
throw new Error(
|
||||
'vault decrypt failed (wrong user / corrupted file): ' +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
const json = new TextDecoder().decode(plain) || '{}';
|
||||
const obj = JSON.parse(json) as Record<string, string>;
|
||||
const data = new Map<string, Uint8Array>();
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
try {
|
||||
data.set(k, unb64url(v));
|
||||
} catch {
|
||||
/* skip malformed entries */
|
||||
}
|
||||
}
|
||||
return { path, tmpPath, key, data };
|
||||
}
|
||||
|
||||
async function persist(state: VaultState, salt: Uint8Array, s: typeof sodium): Promise<void> {
|
||||
const obj: Record<string, string> = {};
|
||||
for (const [k, v] of state.data) obj[k] = b64url(v);
|
||||
const plain = new TextEncoder().encode(JSON.stringify(obj));
|
||||
const nonce = s.randombytes_buf(NONCE_LEN);
|
||||
const ciphertext = s.crypto_secretbox_easy(plain, nonce, state.key);
|
||||
|
||||
const out = new Uint8Array(MAGIC.length + SALT_LEN + NONCE_LEN + ciphertext.length);
|
||||
out.set(MAGIC, 0);
|
||||
out.set(salt, MAGIC.length);
|
||||
out.set(nonce, MAGIC.length + SALT_LEN);
|
||||
out.set(ciphertext, MAGIC.length + SALT_LEN + NONCE_LEN);
|
||||
|
||||
// Atomic write: tmp → rename. `rename` on the same filesystem is atomic
|
||||
// on macOS, Linux, and Windows (NTFS).
|
||||
await writeFile(state.tmpPath, out);
|
||||
await rename(state.tmpPath, state.path);
|
||||
}
|
||||
|
||||
// Re-derives the salt by reading the existing file header so persist() can
|
||||
// keep using the same KDF salt across writes (we don't rotate KDF on every
|
||||
// save — only on initial vault creation).
|
||||
async function readSalt(state: VaultState): Promise<Uint8Array> {
|
||||
const raw = await readFile(state.path);
|
||||
return raw.slice(MAGIC.length, MAGIC.length + SALT_LEN);
|
||||
}
|
||||
|
||||
async function ensureInit(userId: string): Promise<VaultState> {
|
||||
if (initializedFor === userId && vault) return vault;
|
||||
if (initPromise) return initPromise;
|
||||
initPromise = loadOrCreateVault(userId)
|
||||
.then((v) => {
|
||||
vault = v;
|
||||
initializedFor = userId;
|
||||
return v;
|
||||
})
|
||||
.finally(() => {
|
||||
initPromise = null;
|
||||
});
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
export function makeSecureFileStore(userId: string): SecretStore {
|
||||
return {
|
||||
async getSecret(key: string): Promise<Uint8Array | null> {
|
||||
const v = await ensureInit(userId);
|
||||
const found = v.data.get(key);
|
||||
return found ? new Uint8Array(found) : null;
|
||||
},
|
||||
async setSecret(key: string, value: Uint8Array): Promise<void> {
|
||||
const v = await ensureInit(userId);
|
||||
v.data.set(key, new Uint8Array(value));
|
||||
const s = await ensureSodium();
|
||||
const salt = await readSalt(v);
|
||||
await persist(v, salt, s);
|
||||
},
|
||||
async removeSecret(key: string): Promise<void> {
|
||||
const v = await ensureInit(userId);
|
||||
v.data.delete(key);
|
||||
const s = await ensureSodium();
|
||||
const salt = await readSalt(v);
|
||||
await persist(v, salt, s);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Migrates legacy localStorage entries (chatapp.secret:*) into the encrypted
|
||||
// vault on first init. Idempotent — checks for marker key.
|
||||
export async function migrateLocalStorageToVault(
|
||||
userId: string,
|
||||
prefix: string,
|
||||
): Promise<void> {
|
||||
const v = await ensureInit(userId);
|
||||
if (v.data.has('__migrated_from_localstorage')) return;
|
||||
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const fullKey = window.localStorage.key(i);
|
||||
if (!fullKey || !fullKey.startsWith(prefix)) continue;
|
||||
const raw = window.localStorage.getItem(fullKey);
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const decoded = Uint8Array.from(atob(raw), (c) => c.charCodeAt(0));
|
||||
const shortKey = fullKey.slice(prefix.length);
|
||||
v.data.set(shortKey, decoded);
|
||||
} catch {
|
||||
/* skip malformed */
|
||||
}
|
||||
}
|
||||
v.data.set('__migrated_from_localstorage', new Uint8Array([1]));
|
||||
const s = await ensureSodium();
|
||||
const salt = await readSalt(v);
|
||||
await persist(v, salt, s);
|
||||
}
|
||||
@@ -1,91 +1,91 @@
|
||||
import type { SecretStore } from '@chat-app/shared/auth';
|
||||
import { appLocalDataDir } from '@tauri-apps/api/path';
|
||||
import { type Client, type Store, Stronghold } from '@tauri-apps/plugin-stronghold';
|
||||
|
||||
// Stronghold-backed SecretStore. Vault file lives in Tauri's
|
||||
// `appLocalDataDir/chatapp.stronghold` and survives app re-installs (the
|
||||
// directory is preserved by the OS on macOS/Windows/Linux unless the user
|
||||
// manually removes it). Vault password is derived from the Supabase user-id
|
||||
// so the same user re-installing the app on the same machine recovers their
|
||||
// device key automatically.
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
const VAULT_NAME = 'chatapp.stronghold';
|
||||
const CLIENT_NAME = 'chatapp';
|
||||
// Secret store backed by Electron's safeStorage (DPAPI / Keychain /
|
||||
// libsecret) via the main-process secure-store IPC. Opens a per-user
|
||||
// handle on first access and caches it for the lifetime of the session.
|
||||
//
|
||||
// Values are Uint8Array at the SecretStore interface level; we encode
|
||||
// them as base64 on the wire since the preload bridge is stringly-typed.
|
||||
|
||||
let strongholdRef: Stronghold | null = null;
|
||||
let storeRef: Store | null = null;
|
||||
let initPromise: Promise<void> | null = null;
|
||||
let initializedFor: string | null = null;
|
||||
let handlePromise: Promise<string | null> | null = null;
|
||||
let openedFor: string | null = null;
|
||||
|
||||
async function derivePassword(userId: string): Promise<string> {
|
||||
const enc = new TextEncoder();
|
||||
const buf = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
enc.encode('chatapp-stronghold-v1:' + userId),
|
||||
);
|
||||
return Array.from(new Uint8Array(buf))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
async function ensureOpen(userId: string): Promise<string | null> {
|
||||
if (openedFor === userId && handlePromise) return handlePromise;
|
||||
if (!isTauriRuntime()) {
|
||||
handlePromise = Promise.resolve(null);
|
||||
return handlePromise;
|
||||
}
|
||||
openedFor = userId;
|
||||
handlePromise = (async (): Promise<string | null> => {
|
||||
try {
|
||||
const res = await window.electronAPI.secureStoreOpen({ userId });
|
||||
if (!res.encrypted) {
|
||||
console.warn(
|
||||
'secure-store: safeStorage unavailable, using plaintext fallback',
|
||||
);
|
||||
}
|
||||
return res.handle;
|
||||
} catch (err: unknown) {
|
||||
console.warn('secure-store open failed', err);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return handlePromise;
|
||||
}
|
||||
|
||||
async function ensureInit(userId: string): Promise<void> {
|
||||
if (initializedFor === userId && storeRef) return;
|
||||
if (initPromise) return initPromise;
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s);
|
||||
}
|
||||
|
||||
initPromise = (async () => {
|
||||
const dir = await appLocalDataDir();
|
||||
const sep = dir.endsWith('/') || dir.endsWith('\\') ? '' : '/';
|
||||
const vaultPath = dir + sep + VAULT_NAME;
|
||||
const password = await derivePassword(userId);
|
||||
|
||||
strongholdRef = await Stronghold.load(vaultPath, password);
|
||||
let client: Client;
|
||||
try {
|
||||
client = await strongholdRef.loadClient(CLIENT_NAME);
|
||||
} catch {
|
||||
client = await strongholdRef.createClient(CLIENT_NAME);
|
||||
}
|
||||
storeRef = client.getStore();
|
||||
initializedFor = userId;
|
||||
})().finally(() => {
|
||||
initPromise = null;
|
||||
});
|
||||
return initPromise;
|
||||
function base64ToBytes(value: string): Uint8Array {
|
||||
const bin = atob(value);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function makeStrongholdStore(userId: string): SecretStore {
|
||||
return {
|
||||
async getSecret(key: string): Promise<Uint8Array | null> {
|
||||
await ensureInit(userId);
|
||||
const val = await storeRef!.get(key);
|
||||
if (!val) return null;
|
||||
return val instanceof Uint8Array ? val : new Uint8Array(val);
|
||||
const handle = await ensureOpen(userId);
|
||||
if (!handle) return null;
|
||||
const raw = await window.electronAPI.secureStoreGet(handle, key);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return base64ToBytes(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async setSecret(key: string, value: Uint8Array): Promise<void> {
|
||||
await ensureInit(userId);
|
||||
await storeRef!.insert(key, Array.from(value));
|
||||
await strongholdRef!.save();
|
||||
const handle = await ensureOpen(userId);
|
||||
if (!handle) return;
|
||||
await window.electronAPI.secureStoreSet(handle, key, bytesToBase64(value));
|
||||
},
|
||||
async removeSecret(key: string): Promise<void> {
|
||||
await ensureInit(userId);
|
||||
await storeRef!.remove(key);
|
||||
await strongholdRef!.save();
|
||||
const handle = await ensureOpen(userId);
|
||||
if (!handle) return;
|
||||
await window.electronAPI.secureStoreRemove(handle, key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// One-time migration: copies any keys we find in localStorage (the legacy
|
||||
// dev store) into Stronghold so a user who upgrades from a localStorage-only
|
||||
// build doesn't lose their device key. Safe to call multiple times — no-op
|
||||
// once the marker key is present.
|
||||
// Back-compat migration helper. Legacy localStorage entries under the
|
||||
// caller-supplied prefix are copied into the safeStorage-backed store on
|
||||
// first run. A marker key prevents repeated work across launches.
|
||||
export async function migrateLocalStorageToStronghold(
|
||||
userId: string,
|
||||
prefix: string,
|
||||
): Promise<void> {
|
||||
await ensureInit(userId);
|
||||
if (!storeRef) return;
|
||||
const handle = await ensureOpen(userId);
|
||||
if (!handle) return;
|
||||
const markerKey = '__migrated_from_localstorage';
|
||||
const already = await storeRef.get(markerKey);
|
||||
const already = await window.electronAPI.secureStoreGet(handle, markerKey);
|
||||
if (already) return;
|
||||
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
@@ -93,14 +93,9 @@ export async function migrateLocalStorageToStronghold(
|
||||
if (!fullKey || !fullKey.startsWith(prefix)) continue;
|
||||
const raw = window.localStorage.getItem(fullKey);
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const decoded = Uint8Array.from(atob(raw), (c) => c.charCodeAt(0));
|
||||
const shortKey = fullKey.slice(prefix.length);
|
||||
await storeRef.insert(shortKey, Array.from(decoded));
|
||||
} catch {
|
||||
// Skip malformed entries.
|
||||
}
|
||||
const shortKey = fullKey.slice(prefix.length);
|
||||
// Legacy payloads are already base64 — store as-is.
|
||||
await window.electronAPI.secureStoreSet(handle, shortKey, raw);
|
||||
}
|
||||
await storeRef.insert(markerKey, [1]);
|
||||
if (strongholdRef) await strongholdRef.save();
|
||||
await window.electronAPI.secureStoreSet(handle, markerKey, '1');
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { emit } from '@tauri-apps/api/event';
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
// Pushes the current aggregate unread count to the Rust-side tray listener.
|
||||
// Rust mirrors it into the tray tooltip + macOS dock badge. No-op in the
|
||||
// browser/dev preview where the Tauri runtime isn't present.
|
||||
// Pushes the current aggregate unread count to main, which updates the
|
||||
// Tray tooltip and (on Windows) the taskbar overlay icon. No-op
|
||||
// outside the Electron runtime (e.g. browser dev preview) so no guards
|
||||
// needed at call-sites.
|
||||
export async function updateTrayUnread(count: number): Promise<void> {
|
||||
if (!isTauriRuntime()) return;
|
||||
try {
|
||||
await emit('tray-unread-update', { count: Math.max(0, Math.floor(count)) });
|
||||
await window.electronAPI.setTrayUnread(Math.max(0, Math.floor(count)));
|
||||
} catch (err: unknown) {
|
||||
console.warn('updateTrayUnread failed', err);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// Hook that runs SpeechRecognition on the local mic when live-captions are
|
||||
// enabled and a Room is connected. Each interim/final result is broadcast as
|
||||
// a `caption`-typed message via the LiveKit DataChannel so peers can render
|
||||
// it. Recognition stops cleanly when the call ends or the toggle flips off.
|
||||
|
||||
import type { Room } from 'livekit-client';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import {
|
||||
type LiveCaptionsSettings,
|
||||
getLiveCaptionsSettings,
|
||||
getSpeechRecognitionCtor,
|
||||
type SpeechRecognitionEventLike,
|
||||
type SpeechRecognitionLike,
|
||||
subscribeLiveCaptionsSettings,
|
||||
} from './liveCaptions';
|
||||
|
||||
interface Args {
|
||||
room: Room | null;
|
||||
/** True while we're connected and want captions to flow. */
|
||||
active: boolean;
|
||||
/** Callback fired locally for our own captions so the overlay can show
|
||||
* them without going through the SFU round-trip. */
|
||||
onLocalCaption: (text: string, final: boolean) => void;
|
||||
}
|
||||
|
||||
export function useLiveCaptions({ room, active, onLocalCaption }: Args): void {
|
||||
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
|
||||
const settingsRef = useRef<LiveCaptionsSettings>(getLiveCaptionsSettings());
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeLiveCaptionsSettings((s) => {
|
||||
settingsRef.current = s;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const Ctor = getSpeechRecognitionCtor();
|
||||
if (!Ctor) return; // unsupported runtime
|
||||
if (!active || !room) return;
|
||||
if (!getLiveCaptionsSettings().enabled) return;
|
||||
|
||||
const send = (text: string, final: boolean) => {
|
||||
onLocalCaption(text, final);
|
||||
try {
|
||||
const payload = new TextEncoder().encode(
|
||||
JSON.stringify({ type: 'caption', captionText: text, captionFinal: final }),
|
||||
);
|
||||
// Reliable channel — captions are infrequent enough to afford it,
|
||||
// and dropping interims looks worse than slight lag.
|
||||
void room.localParticipant.publishData(payload, { reliable: true });
|
||||
} catch {
|
||||
/* ignore — best-effort */
|
||||
}
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
const r = new Ctor();
|
||||
r.continuous = true;
|
||||
r.interimResults = true;
|
||||
const lang = settingsRef.current.lang ?? navigator.language ?? 'de-DE';
|
||||
r.lang = lang;
|
||||
r.onresult = (e: SpeechRecognitionEventLike) => {
|
||||
// Pull whichever results arrived since last fire. Interim fires
|
||||
// many times per second; the final one is sticky and persists.
|
||||
for (let i = e.resultIndex; i < e.results.length; i++) {
|
||||
const result = e.results[i];
|
||||
if (!result || result.length === 0) continue;
|
||||
const alt = result[0];
|
||||
if (!alt) continue;
|
||||
const transcript = alt.transcript.trim();
|
||||
if (!transcript) continue;
|
||||
send(transcript, result.isFinal);
|
||||
}
|
||||
};
|
||||
r.onerror = () => {
|
||||
// Recoverable: stop + retry on next effect cycle. `not-allowed` and
|
||||
// `service-not-allowed` are permission-permanent — bail.
|
||||
try {
|
||||
r.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
r.onend = () => {
|
||||
// SpeechRecognition tends to auto-stop after silence — if we still
|
||||
// want captions, restart it. Guard against tear-down race.
|
||||
if (recognitionRef.current === r && getLiveCaptionsSettings().enabled) {
|
||||
try {
|
||||
r.start();
|
||||
} catch {
|
||||
/* already running or browser refused */
|
||||
}
|
||||
}
|
||||
};
|
||||
try {
|
||||
r.start();
|
||||
recognitionRef.current = r;
|
||||
} catch {
|
||||
// Some browsers throw when start() is called too soon after a
|
||||
// previous abort — wait a tick and retry.
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
r.start();
|
||||
recognitionRef.current = r;
|
||||
} catch {
|
||||
/* give up */
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
};
|
||||
|
||||
start();
|
||||
|
||||
const unsub = subscribeLiveCaptionsSettings((s) => {
|
||||
const cur = recognitionRef.current;
|
||||
if (!s.enabled && cur) {
|
||||
recognitionRef.current = null;
|
||||
try {
|
||||
cur.abort();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} else if (s.enabled && !cur) {
|
||||
start();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
const cur = recognitionRef.current;
|
||||
recognitionRef.current = null;
|
||||
if (cur) {
|
||||
try {
|
||||
cur.abort();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [active, room, onLocalCaption]);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export interface AggregatedReaction {
|
||||
export interface UseMessageReactionsResult {
|
||||
byMessage: Map<string, AggregatedReaction[]>;
|
||||
toggle: (messageId: string, emoji: string) => Promise<void>;
|
||||
voteExclusive: (messageId: string, emoji: string, exclusiveEmojis: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
// Batch-fetches reactions for the given message ids + subscribes to the
|
||||
@@ -106,5 +107,27 @@ export function useMessageReactions(
|
||||
[byMessage, myId, refresh],
|
||||
);
|
||||
|
||||
return { byMessage, toggle };
|
||||
const voteExclusive = useCallback(
|
||||
async (messageId: string, emoji: string, exclusiveEmojis: string[]) => {
|
||||
if (!myId) return;
|
||||
const allowed = new Set(exclusiveEmojis);
|
||||
const current = (byMessage.get(messageId) ?? []).filter((reaction) =>
|
||||
allowed.has(reaction.emoji),
|
||||
);
|
||||
const selectedMine = current.some((reaction) => reaction.emoji === emoji && reaction.mine);
|
||||
|
||||
for (const reaction of current) {
|
||||
if (reaction.mine) {
|
||||
await removeReaction(supabase, messageId, reaction.emoji);
|
||||
}
|
||||
}
|
||||
if (!selectedMine) {
|
||||
await addReaction(supabase, messageId, emoji);
|
||||
}
|
||||
await refresh();
|
||||
},
|
||||
[byMessage, myId, refresh],
|
||||
);
|
||||
|
||||
return { byMessage, toggle, voteExclusive };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createPeerPresenceChannelName,
|
||||
getEffectivePresenceState,
|
||||
PRESENCE_DEVICE_STALE_MS,
|
||||
} from './presence';
|
||||
|
||||
describe('getEffectivePresenceState', () => {
|
||||
const now = Date.parse('2026-04-24T12:00:00.000Z');
|
||||
|
||||
it('keeps a live profile state when a device checked in recently', () => {
|
||||
const seenAt = new Date(now - PRESENCE_DEVICE_STALE_MS + 1_000).toISOString();
|
||||
|
||||
expect(getEffectivePresenceState('online', seenAt, now)).toBe('online');
|
||||
expect(getEffectivePresenceState('idle', seenAt, now)).toBe('idle');
|
||||
expect(getEffectivePresenceState('dnd', seenAt, now)).toBe('dnd');
|
||||
});
|
||||
|
||||
it('falls back to offline when the latest device check-in is stale', () => {
|
||||
const seenAt = new Date(now - PRESENCE_DEVICE_STALE_MS - 1_000).toISOString();
|
||||
|
||||
expect(getEffectivePresenceState('online', seenAt, now)).toBe('offline');
|
||||
});
|
||||
|
||||
it('respects explicit offline and invisible states', () => {
|
||||
const seenAt = new Date(now).toISOString();
|
||||
|
||||
expect(getEffectivePresenceState('offline', seenAt, now)).toBe('offline');
|
||||
expect(getEffectivePresenceState('invisible', seenAt, now)).toBe('invisible');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPeerPresenceChannelName', () => {
|
||||
it('keeps simultaneous subscriptions for the same user isolated', () => {
|
||||
const userId = '48c2959f-1a21-4de4-a69b-7481a3e68fbd';
|
||||
|
||||
expect(createPeerPresenceChannelName(userId, 'header')).not.toBe(
|
||||
createPeerPresenceChannelName(userId, 'profile-card'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { PresenceState } from '@chat-app/shared/supabase';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
createPeerPresenceChannelName,
|
||||
getEffectivePresenceState,
|
||||
PRESENCE_HEARTBEAT_MS,
|
||||
} from './presence';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
export interface PeerPresence {
|
||||
@@ -8,56 +13,92 @@ export interface PeerPresence {
|
||||
statusMessage: string | null;
|
||||
}
|
||||
|
||||
// Subscribe to a single peer's presence_state + status_message via Supabase
|
||||
// realtime. Returns null until the first row arrives, or when userId is
|
||||
// undefined.
|
||||
let peerPresenceSubscriptionCounter = 0;
|
||||
|
||||
function nextPeerPresenceSubscriptionId(): string {
|
||||
peerPresenceSubscriptionCounter += 1;
|
||||
return String(peerPresenceSubscriptionCounter);
|
||||
}
|
||||
|
||||
// Subscribe to a single peer's stored presence via Supabase realtime, then
|
||||
// treat it as live only while one of their devices has checked in recently.
|
||||
// Returns null until the first profile row arrives, or when userId is undefined.
|
||||
export function usePeerPresence(userId: string | undefined): PeerPresence | null {
|
||||
const [presence, setPresence] = useState<PeerPresence | null>(null);
|
||||
const [profilePresence, setProfilePresence] = useState<PeerPresence | null>(null);
|
||||
const [latestDeviceSeenAt, setLatestDeviceSeenAt] = useState<string | null>(null);
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
setPresence(null);
|
||||
setProfilePresence(null);
|
||||
setLatestDeviceSeenAt(null);
|
||||
return;
|
||||
}
|
||||
const peerUserId = userId;
|
||||
let cancelled = false;
|
||||
|
||||
async function refreshLatestDeviceSeenAt() {
|
||||
const { data, error } = await supabase
|
||||
.from('devices')
|
||||
.select('last_seen_at')
|
||||
.eq('user_id', peerUserId)
|
||||
.order('last_seen_at', { ascending: false })
|
||||
.limit(1);
|
||||
if (cancelled) return;
|
||||
if (error) {
|
||||
console.warn('peer latest device lookup failed', error);
|
||||
return;
|
||||
}
|
||||
setLatestDeviceSeenAt(data?.[0]?.last_seen_at ?? null);
|
||||
setNowMs(Date.now());
|
||||
}
|
||||
|
||||
void supabase
|
||||
.from('profiles')
|
||||
.select('presence_state, status_message')
|
||||
.eq('user_id', userId)
|
||||
.eq('user_id', peerUserId)
|
||||
.maybeSingle()
|
||||
.then(({ data }) => {
|
||||
if (cancelled || !data) return;
|
||||
setPresence({
|
||||
setProfilePresence({
|
||||
state: (data.presence_state as PresenceState | null) ?? 'offline',
|
||||
statusMessage: data.status_message ?? null,
|
||||
});
|
||||
});
|
||||
void refreshLatestDeviceSeenAt();
|
||||
const heartbeatPoll = window.setInterval(
|
||||
() => void refreshLatestDeviceSeenAt(),
|
||||
PRESENCE_HEARTBEAT_MS,
|
||||
);
|
||||
|
||||
const channelName = createPeerPresenceChannelName(
|
||||
peerUserId,
|
||||
nextPeerPresenceSubscriptionId(),
|
||||
);
|
||||
const channel = supabase
|
||||
.channel('peer-presence:' + userId)
|
||||
.channel(channelName)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'profiles',
|
||||
filter: 'user_id=eq.' + userId,
|
||||
filter: 'user_id=eq.' + peerUserId,
|
||||
},
|
||||
(payload: { new: Record<string, unknown> }) => {
|
||||
const nextState = payload.new['presence_state'];
|
||||
const nextMsg = payload.new['status_message'];
|
||||
setPresence((prev) => {
|
||||
setProfilePresence((prev) => {
|
||||
const state =
|
||||
typeof nextState === 'string'
|
||||
? (nextState as PresenceState)
|
||||
: prev?.state ?? 'offline';
|
||||
: (prev?.state ?? 'offline');
|
||||
const statusMessage =
|
||||
nextMsg === null
|
||||
? null
|
||||
: typeof nextMsg === 'string'
|
||||
? nextMsg
|
||||
: prev?.statusMessage ?? null;
|
||||
: (prev?.statusMessage ?? null);
|
||||
return { state, statusMessage };
|
||||
});
|
||||
},
|
||||
@@ -66,9 +107,15 @@ export function usePeerPresence(userId: string | undefined): PeerPresence | null
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(heartbeatPoll);
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}, [userId]);
|
||||
|
||||
return presence;
|
||||
if (!profilePresence) return null;
|
||||
|
||||
return {
|
||||
state: getEffectivePresenceState(profilePresence.state, latestDeviceSeenAt, nowMs),
|
||||
statusMessage: profilePresence.statusMessage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,6 +25,12 @@ export interface VoiceHotkeyBinding {
|
||||
export interface VoiceHotkeys {
|
||||
mute: VoiceHotkeyBinding;
|
||||
deafen: VoiceHotkeyBinding;
|
||||
/** Hang up the active call. Discord uses no default — easy to mis-fire. */
|
||||
hangup: VoiceHotkeyBinding;
|
||||
/** Toggle outgoing screen share. */
|
||||
screenShare: VoiceHotkeyBinding;
|
||||
/** Toggle outgoing camera. */
|
||||
video: VoiceHotkeyBinding;
|
||||
}
|
||||
|
||||
export type VoiceHotkeyKind = keyof VoiceHotkeys;
|
||||
@@ -46,6 +52,30 @@ const DEFAULTS: VoiceHotkeys = {
|
||||
alt: false,
|
||||
enabled: false,
|
||||
},
|
||||
hangup: {
|
||||
key: 'KeyH',
|
||||
keyLabel: 'Ctrl+Shift+H',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
alt: false,
|
||||
enabled: false,
|
||||
},
|
||||
screenShare: {
|
||||
key: 'KeyE',
|
||||
keyLabel: 'Ctrl+Shift+E',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
alt: false,
|
||||
enabled: false,
|
||||
},
|
||||
video: {
|
||||
key: 'KeyV',
|
||||
keyLabel: 'Ctrl+Shift+V',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
alt: false,
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
type Listener = (s: VoiceHotkeys) => void;
|
||||
@@ -78,6 +108,9 @@ function read(): VoiceHotkeys {
|
||||
cached = {
|
||||
mute: validateBinding(parsed.mute, DEFAULTS.mute),
|
||||
deafen: validateBinding(parsed.deafen, DEFAULTS.deafen),
|
||||
hangup: validateBinding(parsed.hangup, DEFAULTS.hangup),
|
||||
screenShare: validateBinding(parsed.screenShare, DEFAULTS.screenShare),
|
||||
video: validateBinding(parsed.video, DEFAULTS.video),
|
||||
};
|
||||
return cached;
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Window-focus tracking. Under Electron the renderer's `window` matches
|
||||
// a single BrowserWindow, and Chromium's focus/blur events fire when
|
||||
// that window gains/loses OS-foreground status, so DOM events are
|
||||
// authoritative — no IPC bridge needed.
|
||||
//
|
||||
// One synchronous getter + one subscriber so callers can mirror the
|
||||
// value into a ref for fast-path reads inside realtime callbacks.
|
||||
|
||||
type Listener = (focused: boolean) => void;
|
||||
|
||||
// Optimistic default — if we don't know yet, assume focused so the
|
||||
// first few events after app start behave like "user is here" rather
|
||||
// than spamming sounds during the init window.
|
||||
let cached = true;
|
||||
let initialized = false;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
function emit(next: boolean): void {
|
||||
if (next === cached) return;
|
||||
cached = next;
|
||||
for (const l of listeners) {
|
||||
try {
|
||||
l(next);
|
||||
} catch (err: unknown) {
|
||||
console.warn('windowFocus listener threw', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureInit(): void {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
if (typeof window === 'undefined') return;
|
||||
cached = typeof document !== 'undefined' ? document.hasFocus() : true;
|
||||
window.addEventListener('focus', () => emit(true));
|
||||
window.addEventListener('blur', () => emit(false));
|
||||
}
|
||||
|
||||
ensureInit();
|
||||
|
||||
export function getIsWindowFocused(): boolean {
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function subscribeWindowFocus(l: Listener): () => void {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Renderer-side wrapper for the OS-level window fullscreen flag. Replaces
|
||||
// the Tauri call `getCurrentWindow().setFullscreen(...)` from
|
||||
// `@tauri-apps/api/window`. We route through the Electron preload bridge
|
||||
// to `BrowserWindow.setFullScreen()` in the main process so the Windows
|
||||
// taskbar / macOS menubar stay covered while in cinema mode.
|
||||
//
|
||||
// Mirrors the shape of `lib/autoStart.ts` — runtime guard against the
|
||||
// Electron preload marker, swallow errors so renderer logic never breaks
|
||||
// when the host is not the desktop app (e.g. web build / Storybook).
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
export async function setWindowFullscreen(enabled: boolean): Promise<void> {
|
||||
if (!isTauriRuntime()) return;
|
||||
try {
|
||||
await window.electronAPI.setFullscreen(enabled);
|
||||
} catch (err: unknown) {
|
||||
// setFullscreen can reject if the window is minimised or focus is
|
||||
// gone — both recoverable noise, matches the Tauri callsite which
|
||||
// also `.catch(() => undefined)`s the promise.
|
||||
console.warn('setWindowFullscreen failed', err);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user