fix: recover inert macos webview startup
This commit is contained in:
@@ -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.
|
||||
|
||||
44
index.html
44
index.html
@@ -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>
|
||||
|
||||
10
src/components/FrontendReadyMarker.tsx
Normal file
10
src/components/FrontendReadyMarker.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useEffect } from 'react'
|
||||
import { markFrontendReady } from '@/utils/frontendReady'
|
||||
|
||||
export function FrontendReadyMarker() {
|
||||
useEffect(() => {
|
||||
markFrontendReady()
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
48
src/utils/frontendReady.test.ts
Normal file
48
src/utils/frontendReady.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
74
src/utils/frontendReady.ts
Normal file
74
src/utils/frontendReady.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user