fix: recover inert macos webview startup

This commit is contained in:
lucaronin
2026-05-01 04:47:41 +02:00
parent 0df5c7882d
commit 1c010ddc22
7 changed files with 209 additions and 2 deletions

View File

@@ -214,7 +214,7 @@ The main Tauri window derives its minimum width from the visible panes instead o
The main Tauri window also persists its last normal size and screen position in the app config directory as `window-state.json`. The state stores logical window points, while `window_state.rs` migrates older physical-pixel state on read so Retina and non-Retina launches restore the same user-facing bounds. On startup, the restored frame applies only to the main window and clamps to the currently available monitor work areas, so stale coordinates from a disconnected display fall back to a visible placement. Maximized, fullscreen, minimized, and detached note-window frames are not written as the restore baseline.
Tauri setup keeps launch-time filesystem and subprocess work off the window creation critical path. Legacy `~/Laputa` housekeeping and the initial persisted-vault MCP bridge sync run on named background threads, so large legacy vaults, stale active-vault paths, or slow process startup cannot beachball the macOS app before React mounts. React still resyncs the bridge from `useVaultSwitcher` after the persisted selection loads, and no selected vault stops the bridge.
Tauri setup keeps launch-time filesystem and subprocess work off the window creation critical path. Legacy `~/Laputa` housekeeping and the initial persisted-vault MCP bridge sync run on named background threads, so large legacy vaults, stale active-vault paths, or slow process startup cannot beachball the macOS app before React mounts. React still resyncs the bridge from `useVaultSwitcher` after the persisted selection loads, and no selected vault stops the bridge. The HTML bootstrap also installs a Tauri-only one-shot watchdog: React reports readiness from an effect after the root commits, and if that readiness signal never arrives the WebView reloads once instead of leaving macOS users in an inert rendered shell.
Linux uses custom React-rendered window chrome instead of the native Tauri menu bar. `setup_linux_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that the desktop native menus use. The native app menu keeps macOS-only Services/Hide entries off Windows and Linux, while cross-platform custom items such as Check for Updates emit Tolaria command IDs and show visible updater feedback.
When Tolaria is launched from a Linux AppImage, `run()` also applies AppImage-only WebKitGTK startup safeguards without changing native package installs. It injects `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` independently unless the user already set either variable, and on Wayland sessions it re-execs once with the first available system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The rendering overrides keep AppImage WebViews from blanking after accelerated compositing/DMA-BUF failures, while the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER` before GTK/WebKit create the display.

View File

@@ -50,6 +50,50 @@
}
})();
</script>
<script>
(function () {
var readyEventName = 'tolaria:frontend-ready';
var reloadAttemptKey = 'tolaria:startup-reload-attempted';
var startupTimeoutMs = 10000;
var isTauri = '__TAURI__' in window || '__TAURI_INTERNALS__' in window;
if (!isTauri) return;
function hasReloadAttempted() {
try {
return sessionStorage.getItem(reloadAttemptKey) === '1';
} catch (err) {
return true;
}
}
function markReloadAttempted() {
try {
sessionStorage.setItem(reloadAttemptKey, '1');
return true;
} catch (err) {
return false;
}
}
function clearReloadAttempt() {
try {
sessionStorage.removeItem(reloadAttemptKey);
} catch (err) {
// Storage can be unavailable in hardened WebView/privacy modes.
}
}
window.addEventListener(readyEventName, clearReloadAttempt, { once: true });
window.setTimeout(function () {
if (window.__tolariaFrontendReady === true) return;
if (hasReloadAttempted()) return;
if (!markReloadAttempted()) return;
window.location.reload();
}, startupTimeoutMs);
})();
</script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

View File

@@ -0,0 +1,10 @@
import { useEffect } from 'react'
import { markFrontendReady } from '@/utils/frontendReady'
export function FrontendReadyMarker() {
useEffect(() => {
markFrontendReady()
}, [])
return null
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createElement, type ReactNode } from 'react'
import { Children, createElement, isValidElement, type ReactNode } from 'react'
type ReactRootErrorInfo = { componentStack?: string }
type ReactRootOptions = {
@@ -80,11 +80,30 @@ function rootOptions(): ReactRootOptions {
return options
}
function renderedTree(): ReactNode {
const tree = mocks.render.mock.calls[0]?.[0]
if (!tree) throw new Error('React root was not rendered')
return tree
}
function hasElementTypeName(node: ReactNode, name: string): boolean {
if (!isValidElement<{ children?: ReactNode }>(node)) return false
const typeName = typeof node.type === 'function' ? node.type.name : ''
if (typeName === name) return true
return Children.toArray(node.props.children).some((child) =>
hasElementTypeName(child, name),
)
}
describe('main entrypoint', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
document.body.innerHTML = '<div id="root"></div>'
window.__tolariaFrontendReady = false
sessionStorage.clear()
})
it('captures React root errors through Sentry with component stack context', async () => {
@@ -101,6 +120,7 @@ describe('main entrypoint', () => {
)
const error = new Error('Maximum update depth exceeded')
window.__tolariaFrontendReady = true
rootOptions().onCaughtError?.(error, { componentStack: '\n in App' })
expect(mocks.sentryHandler).toHaveBeenCalledWith(error, { componentStack: '\n in App' })
@@ -110,11 +130,18 @@ describe('main entrypoint', () => {
await importEntrypoint()
const error = new Error('recoverable render error')
window.__tolariaFrontendReady = true
rootOptions().onRecoverableError?.(error, {})
expect(mocks.sentryHandler).toHaveBeenCalledWith(error, { componentStack: '' })
})
it('mounts a frontend readiness marker after the app shell', async () => {
await importEntrypoint()
expect(hasElementTypeName(renderedTree(), 'FrontendReadyMarker')).toBe(true)
})
it('prevents browser navigation for file drags and still lets app drop handlers run', async () => {
await importEntrypoint()

View File

@@ -4,6 +4,7 @@ import { createRoot } from 'react-dom/client'
import { TooltipProvider } from '@/components/ui/tooltip'
import './index.css'
import App from './App.tsx'
import { FrontendReadyMarker } from './components/FrontendReadyMarker'
import { LinuxTitlebar } from './components/LinuxTitlebar'
import { applyStoredThemeMode } from './lib/themeMode'
import {
@@ -17,6 +18,7 @@ import {
type AppCommandShortcutEventOptions,
} from './hooks/appCommandCatalog'
import { shouldUseLinuxWindowChrome } from './utils/platform'
import { reloadFrontendOnceIfStartupFailed } from './utils/frontendReady'
const EDITOR_DROP_SELECTOR = '.editor__blocknote-container'
@@ -113,6 +115,7 @@ function captureReactRootError(
errorInfo: { componentStack?: string },
): void {
sentryReactErrorHandler(error, { componentStack: errorInfo.componentStack ?? '' })
reloadFrontendOnceIfStartupFailed()
}
createRoot(document.getElementById('root')!, {
@@ -124,6 +127,7 @@ createRoot(document.getElementById('root')!, {
<TooltipProvider>
<LinuxTitlebar />
<App />
<FrontendReadyMarker />
</TooltipProvider>
</StrictMode>,
)

View File

@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
FRONTEND_READY_EVENT_NAME,
STARTUP_RELOAD_ATTEMPT_KEY,
markFrontendReady,
reloadFrontendOnceIfStartupFailed,
} from './frontendReady'
describe('frontend readiness recovery', () => {
beforeEach(() => {
window.__tolariaFrontendReady = false
sessionStorage.clear()
})
it('marks the frontend ready and clears a pending startup reload', () => {
const onReady = vi.fn()
window.addEventListener(FRONTEND_READY_EVENT_NAME, onReady, { once: true })
sessionStorage.setItem(STARTUP_RELOAD_ATTEMPT_KEY, '1')
markFrontendReady()
expect(window.__tolariaFrontendReady).toBe(true)
expect(sessionStorage.getItem(STARTUP_RELOAD_ATTEMPT_KEY)).toBeNull()
expect(onReady).toHaveBeenCalledOnce()
})
it('reloads once when React reports a startup error before readiness', () => {
const reload = vi.fn()
const firstAttempt = reloadFrontendOnceIfStartupFailed({ reload })
const secondAttempt = reloadFrontendOnceIfStartupFailed({ reload })
expect(firstAttempt).toBe(true)
expect(secondAttempt).toBe(false)
expect(reload).toHaveBeenCalledOnce()
expect(sessionStorage.getItem(STARTUP_RELOAD_ATTEMPT_KEY)).toBe('1')
})
it('does not reload after the frontend has reported readiness', () => {
const reload = vi.fn()
markFrontendReady()
const didReload = reloadFrontendOnceIfStartupFailed({ reload })
expect(didReload).toBe(false)
expect(reload).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,74 @@
export const FRONTEND_READY_EVENT_NAME = 'tolaria:frontend-ready'
export const STARTUP_RELOAD_ATTEMPT_KEY = 'tolaria:startup-reload-attempted'
declare global {
interface Window {
__tolariaFrontendReady?: boolean
}
}
type FrontendReadyOptions = {
storage?: Storage
win?: Window
}
type StartupReloadOptions = FrontendReadyOptions & {
reload?: () => void
}
function getSessionStorage(win: Window): Storage | null {
try {
return win.sessionStorage
} catch {
return null
}
}
function removeSessionItem(storage: Storage | null, key: string): void {
try {
storage?.removeItem(key)
} catch {
// Storage can be unavailable in hardened WebView/privacy modes.
}
}
function readSessionItem(storage: Storage | null, key: string): string | null {
try {
return storage?.getItem(key) ?? null
} catch {
return null
}
}
function writeSessionItem(storage: Storage | null, key: string, value: string): boolean {
try {
storage?.setItem(key, value)
return storage !== null
} catch {
return false
}
}
export function markFrontendReady(options: FrontendReadyOptions = {}): void {
const win = options.win ?? window
const storage = options.storage ?? getSessionStorage(win)
win.__tolariaFrontendReady = true
removeSessionItem(storage, STARTUP_RELOAD_ATTEMPT_KEY)
win.dispatchEvent(new Event(FRONTEND_READY_EVENT_NAME))
}
export function reloadFrontendOnceIfStartupFailed(
options: StartupReloadOptions = {},
): boolean {
const win = options.win ?? window
const storage = options.storage ?? getSessionStorage(win)
if (win.__tolariaFrontendReady === true) return false
if (readSessionItem(storage, STARTUP_RELOAD_ATTEMPT_KEY) === '1') return false
if (!writeSessionItem(storage, STARTUP_RELOAD_ATTEMPT_KEY, '1')) return false
const reload = options.reload ?? (() => win.location.reload())
reload()
return true
}