diff --git a/.gitignore b/.gitignore index 7ad97f72f..23c7e1093 100644 --- a/.gitignore +++ b/.gitignore @@ -40,9 +40,12 @@ dist-js src-tauri/target/ # Tauri Android build outputs src-tauri/gen/android/app/build/ +# Tauri Android release keystore +*.jks # Tauri-typegen cache .typecache # Worktrees -.worktrees \ No newline at end of file +.worktrees + diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index d42d2ed28..af842c605 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,6 +4,8 @@ fn main() { #[cfg(target_os = "linux")] unsafe { + use std::path::{Path, PathBuf}; + // Tao/Tauri Wayland decorations are don't respect server side decorations, forcing GTK onto X11/XWayland for now. // https://github.com/tauri-apps/tao/issues/1046 // https://github.com/tauri-apps/tauri/issues/11856 @@ -16,6 +18,62 @@ fn main() { if std::env::var_os("__NV_DISABLE_EXPLICIT_SYNC").is_none() { std::env::set_var("__NV_DISABLE_EXPLICIT_SYNC", "1"); } + + // WebKit2GTK can hit compositor/DMABUF bugs + // https://github.com/tauri-apps/tauri/issues/14424 + // https://github.com/tauri-apps/tauri/issues/9394 + if std::env::var_os("WEBKIT_DISABLE_COMPOSITING_MODE").is_none() { + std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); + } + if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() { + std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); + } + + // AppImage can fail to discover host GStreamer plugins/scanner. Probe + // common distro layouts, but don't override explicit user config. + // Not finding these plugings prevents Sable from launching correctly. + // Maybe there's a better way to do this? + let plugin_dirs = [ + "/usr/lib/gstreamer-1.0", + "/usr/lib64/gstreamer-1.0", + "/usr/local/lib/gstreamer-1.0", + "/usr/local/lib64/gstreamer-1.0", + "/usr/lib/x86_64-linux-gnu/gstreamer-1.0", + "/usr/lib/aarch64-linux-gnu/gstreamer-1.0", + "/run/host/usr/lib/gstreamer-1.0", + "/run/host/usr/lib64/gstreamer-1.0", + ]; + let resolved_plugin_dir = plugin_dirs.iter().find(|dir| Path::new(dir).exists()); + + if std::env::var_os("GST_PLUGIN_SYSTEM_PATH_1_0").is_none() { + if let Some(dir) = resolved_plugin_dir { + std::env::set_var("GST_PLUGIN_SYSTEM_PATH_1_0", dir); + } + } + if std::env::var_os("GST_PLUGIN_PATH_1_0").is_none() { + if let Some(dir) = resolved_plugin_dir { + std::env::set_var("GST_PLUGIN_PATH_1_0", dir); + } + } + if std::env::var_os("GST_PLUGIN_SCANNER").is_none() { + let mut scanner_candidates: Vec = vec![ + PathBuf::from("/usr/lib/gstreamer-1.0/gst-plugin-scanner"), + PathBuf::from("/usr/lib64/gstreamer-1.0/gst-plugin-scanner"), + PathBuf::from("/usr/libexec/gstreamer-1.0/gst-plugin-scanner"), + PathBuf::from("/usr/lib/x86_64-linux-gnu/gstreamer-1.0/gst-plugin-scanner"), + PathBuf::from("/usr/lib/aarch64-linux-gnu/gstreamer-1.0/gst-plugin-scanner"), + PathBuf::from("/run/host/usr/lib/gstreamer-1.0/gst-plugin-scanner"), + PathBuf::from("/run/host/usr/lib64/gstreamer-1.0/gst-plugin-scanner"), + ]; + + if let Some(path_env) = std::env::var_os("PATH") { + scanner_candidates.extend(std::env::split_paths(&path_env).map(|p| p.join("gst-plugin-scanner"))); + } + + if let Some(scanner) = scanner_candidates.iter().find(|path| path.exists()) { + std::env::set_var("GST_PLUGIN_SCANNER", scanner.as_os_str()); + } + } } app_lib::run(); diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index d20d8cd56..10931d8b2 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -197,6 +197,7 @@ export function ClientRoot({ children }: ClientRootProps) { const loadedUserIdRef = useRef(undefined); const syncStartTimeRef = useRef(performance.now()); const firstSyncReadyRef = useRef(false); + const loadingStateLoggedRef = useRef<'unknown' | 'true' | 'false'>('unknown'); const [loadState, loadMatrix, setLoadState] = useAsyncCallback( useCallback(async () => { @@ -227,7 +228,7 @@ export function ClientRoot({ children }: ClientRootProps) { const mx = loadState.status === AsyncStatus.Success ? loadState.data : undefined; - const [startState, startMatrix] = useAsyncCallback( + const [startState, startMatrix, setStartState] = useAsyncCallback( useCallback( (m) => startClient(m, { @@ -239,6 +240,19 @@ export function ClientRoot({ children }: ClientRootProps) { ) ); + useEffect(() => { + if (loadState.status === AsyncStatus.Loading) { + firstSyncReadyRef.current = false; + syncStartTimeRef.current = performance.now(); + } + if (loadState.status !== AsyncStatus.Success) { + setStartState((prev) => { + if (prev.status === AsyncStatus.Idle) return prev; + return { status: AsyncStatus.Idle }; + }); + } + }, [loadState.status, setStartState]); + useEffect(() => { if (!activeSession) return; if (loadedUserIdRef.current && loadedUserIdRef.current !== activeSession.userId) { @@ -289,6 +303,32 @@ export function ClientRoot({ children }: ClientRootProps) { } }, [loadState, loadMatrix]); + useSyncState( + mx, + useCallback((state: string) => { + if (isClientReady(state)) { + if (!firstSyncReadyRef.current) { + firstSyncReadyRef.current = true; + try { + Sentry.metrics.distribution( + 'sable.sync.time_to_ready_ms', + performance.now() - syncStartTimeRef.current + ); + } catch { + // no-op: never block loading gate on telemetry + } + } + setLoading(false); + } + }, []) + ); + + useEffect(() => { + const next = loading ? 'true' : 'false'; + if (loadingStateLoggedRef.current === next) return; + loadingStateLoggedRef.current = next; + }, [loading]); + useEffect(() => { if (mx && !mx.clientRunning) { startMatrix(mx); @@ -302,21 +342,12 @@ export function ClientRoot({ children }: ClientRootProps) { } }, [mx]); - useSyncState( - mx, - useCallback((state: string) => { - if (isClientReady(state)) { - if (!firstSyncReadyRef.current) { - firstSyncReadyRef.current = true; - Sentry.metrics.distribution( - 'sable.sync.time_to_ready_ms', - performance.now() - syncStartTimeRef.current - ); - } - setLoading(false); - } - }, []) - ); + useEffect(() => { + if (startState.status !== AsyncStatus.Success || !mx) return; + const syncState = mx.getSyncState(); + if (!isClientReady(syncState)) return; + setLoading(false); + }, [startState.status, mx]); // Set matrix client context: homeserver and sync type (not PII) useEffect(() => { diff --git a/src/app/pages/client/SyncStatus.tsx b/src/app/pages/client/SyncStatus.tsx index 04b6bed09..9dd077f6f 100644 --- a/src/app/pages/client/SyncStatus.tsx +++ b/src/app/pages/client/SyncStatus.tsx @@ -38,7 +38,7 @@ const isSyncStatusDemoEnabled = (): boolean => { export function SyncStatus({ mx }: SyncStatusProps) { const [stateData, setStateData] = useState({ - current: null, + current: mx.getSyncState(), previous: undefined, }); const [demoIndex, setDemoIndex] = useState(0); diff --git a/src/app/utils/mediaCache.ts b/src/app/utils/mediaCache.ts index 8e8f21976..1f013c768 100644 --- a/src/app/utils/mediaCache.ts +++ b/src/app/utils/mediaCache.ts @@ -10,11 +10,24 @@ async function openCache(): Promise { } } +function getCacheRequest(url: string): Request { + const isAbsoluteHttpUrl = /^https?:\/\//i.test(url); + if (!isAbsoluteHttpUrl) { + return new Request(`https://sable-media-cache.invalid/${encodeURIComponent(url)}`); + } + + try { + return new Request(url); + } catch { + return new Request(`https://sable-media-cache.invalid/${encodeURIComponent(url)}`); + } +} + export async function getFromMediaCache(url: string): Promise { const cache = await openCache(); if (!cache) return undefined; try { - const response = await cache.match(url); + const response = await cache.match(getCacheRequest(url)); if (!response) return undefined; return await response.blob(); } catch { @@ -43,7 +56,7 @@ export async function putInMediaCache(url: string, blob: Blob): Promise { if (!cache) return; try { await cache.put( - url, + getCacheRequest(url), new Response(blob, { headers: { 'Content-Type': blob.type || 'application/octet-stream' }, }) diff --git a/src/app/utils/mediaTransport.ts b/src/app/utils/mediaTransport.ts index e98964e5e..b80035fec 100644 --- a/src/app/utils/mediaTransport.ts +++ b/src/app/utils/mediaTransport.ts @@ -175,7 +175,8 @@ async function fetchMediaResponse( async function fetchMediaBlobInternal(url: string, options?: MediaTransportOptions): Promise { const cacheMode = options?.cache ?? 'default'; - const scopedCacheKey = getScopedMediaCacheKey(url, resolveSessionScope(options)); + const resolvedScope = resolveSessionScope(options); + const scopedCacheKey = getScopedMediaCacheKey(url, resolvedScope); if (cacheMode === 'default') { const cachedBlob = await getFromMediaCache(scopedCacheKey); diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 7206bd6ef..4f27fb6a7 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -476,13 +476,17 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): data?: ISyncStateData ) => { classicSyncCount += 1; - Sentry.metrics.count('sable.sync.cycle', 1, { attributes: { transport: 'classic', state } }); - debugLog.info('sync', `Classic sync state: ${state}`, { - state, - prevState: prevState ?? 'null', - syncNumber: classicSyncCount, - error: data?.error?.message, - }); + if (prevState !== state) { + Sentry.metrics.count('sable.sync.cycle', 1, { + attributes: { transport: 'classic', state }, + }); + debugLog.info('sync', `Classic sync state: ${state}`, { + state, + prevState: prevState ?? 'null', + syncNumber: classicSyncCount, + error: data?.error?.message, + }); + } if (state === SyncState.Error || state === SyncState.Reconnecting) { debugLog.warn('sync', `Classic sync problem: ${state}`, { state,