From 4b2c5f93abff3cd9a011acbe399c0980eaacf4bf Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 3 Jul 2026 04:41:26 +0900 Subject: [PATCH] fix(window): keep Linux window opaque so it can't turn invisible (#3682) (#4904) On Linux the window was created fully transparent to draw rounded corners (#1982), but on WebKitGTK a transparent window composites as transparent whenever its web process is too busy to repaint damaged regions (for example during a library backup). Interacting with the app then makes it appear to turn invisible, showing the desktop through the window. Make the window opaque everywhere: the main window in lib.rs and the reader/extra windows in nav.ts. Drop the rounded-window treatment (hasRoundedWindow=false) so no rounded 1px border floats on the now square opaque window, and give the Linux loading placeholders a solid background. An opaque window retains its last painted frame instead of going invisible; the tradeoff is square corners on Linux. Co-authored-by: Claude Opus 4.8 (1M context) --- apps/readest-app/src-tauri/src/lib.rs | 11 ++- .../native-app-service-window.test.ts | 94 +++++++++++++++++++ .../src/__tests__/utils/nav-window.test.ts | 58 ++++++++++++ apps/readest-app/src/app/library/page.tsx | 2 +- .../src/app/reader/components/Reader.tsx | 2 +- .../src/services/nativeAppService.ts | 5 +- apps/readest-app/src/utils/nav.ts | 8 +- 7 files changed, 172 insertions(+), 8 deletions(-) create mode 100644 apps/readest-app/src/__tests__/services/native-app-service-window.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/nav-window.test.ts diff --git a/apps/readest-app/src-tauri/src/lib.rs b/apps/readest-app/src-tauri/src/lib.rs index ea21cfc6..32208b8d 100644 --- a/apps/readest-app/src-tauri/src/lib.rs +++ b/apps/readest-app/src-tauri/src/lib.rs @@ -591,9 +591,14 @@ pub fn run() { } #[cfg(target_os = "linux")] { - builder = builder - .transparent(true) - .background_color(tauri::window::Color(0, 0, 0, 0)); + // Keep the window opaque on Linux. A transparent WebKitGTK + // window (previously used to draw rounded corners, #1982) + // composites as fully transparent whenever its web process is + // too busy to repaint damaged regions (e.g. during a library + // backup), so the app "turns invisible" on any interaction + // (#3682). An opaque window instead retains its last painted + // frame, at the cost of square corners. + builder = builder.transparent(false); } builder diff --git a/apps/readest-app/src/__tests__/services/native-app-service-window.test.ts b/apps/readest-app/src/__tests__/services/native-app-service-window.test.ts new file mode 100644 index 00000000..226bf60d --- /dev/null +++ b/apps/readest-app/src/__tests__/services/native-app-service-window.test.ts @@ -0,0 +1,94 @@ +import { describe, test, expect, vi } from 'vitest'; + +const osTypeMock = vi.fn().mockReturnValue('macos'); + +vi.mock('@tauri-apps/plugin-os', () => ({ + type: () => osTypeMock(), +})); + +vi.mock('@tauri-apps/plugin-fs', () => ({ + exists: vi.fn().mockResolvedValue(false), + mkdir: vi.fn().mockResolvedValue(undefined), + readTextFile: vi.fn().mockResolvedValue(''), + readFile: vi.fn().mockResolvedValue(new Uint8Array()), + writeTextFile: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readDir: vi.fn().mockResolvedValue([]), + remove: vi.fn().mockResolvedValue(undefined), + copyFile: vi.fn().mockResolvedValue(undefined), + stat: vi.fn().mockResolvedValue({ size: 0 }), + BaseDirectory: {}, +})); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn().mockResolvedValue(undefined), + convertFileSrc: (p: string) => `asset://${p}`, +})); + +vi.mock('@tauri-apps/plugin-dialog', () => ({ + open: vi.fn().mockResolvedValue(null), + save: vi.fn().mockResolvedValue(null), + ask: vi.fn().mockResolvedValue(true), +})); + +vi.mock('@tauri-apps/api/path', () => ({ + join: (...parts: string[]) => Promise.resolve(parts.join('/')), + basename: (p: string) => Promise.resolve(p.split('/').pop() ?? p), + appDataDir: () => Promise.resolve('/tmp/app-data'), + appConfigDir: () => Promise.resolve('/tmp/app-config'), + appCacheDir: () => Promise.resolve('/tmp/app-cache'), + appLogDir: () => Promise.resolve('/tmp/app-log'), + tempDir: () => Promise.resolve('/tmp'), +})); + +vi.mock('@/utils/bridge', () => ({ + copyURIToPath: vi.fn().mockResolvedValue({ path: '' }), + getStorefrontRegionCode: vi.fn().mockResolvedValue({ regionCode: null }), +})); + +vi.mock('@/utils/file', () => ({ + NativeFile: class {}, + RemoteFile: class {}, +})); + +vi.mock('@/utils/files', () => ({ + copyFiles: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/services/settingsService', () => ({ + getDefaultViewSettings: vi.fn().mockReturnValue({}), + loadSettings: vi.fn().mockResolvedValue({ migrationVersion: 99999999 }), + saveSettings: vi.fn().mockResolvedValue(undefined), +})); + +async function loadServiceWithOS(os: 'macos' | 'windows' | 'linux' | 'ios' | 'android') { + osTypeMock.mockReturnValue(os); + vi.resetModules(); + const mod = await import('@/services/nativeAppService'); + return new mod.NativeAppService(); +} + +// Regression (#3682): the Linux window used to be created fully transparent to +// draw rounded corners (#1982). On WebKitGTK a transparent window whose web +// process is busy (e.g. during a library backup) fails to repaint damaged +// regions on interaction, so the whole window composites as transparent — the +// app "turns invisible". The window is now opaque, which means it can no longer +// present a rounded, transparent frame, so `hasRoundedWindow` must be false on +// every desktop platform. +describe('NativeAppService rounded-window capability', () => { + test('Linux does not use a rounded (transparent) window', async () => { + const service = await loadServiceWithOS('linux'); + expect(service.isLinuxApp).toBe(true); + expect(service.hasRoundedWindow).toBe(false); + }); + + test('macOS does not use a rounded (transparent) window', async () => { + const service = await loadServiceWithOS('macos'); + expect(service.hasRoundedWindow).toBe(false); + }); + + test('Windows does not use a rounded (transparent) window', async () => { + const service = await loadServiceWithOS('windows'); + expect(service.hasRoundedWindow).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/nav-window.test.ts b/apps/readest-app/src/__tests__/utils/nav-window.test.ts new file mode 100644 index 00000000..35cca648 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/nav-window.test.ts @@ -0,0 +1,58 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import type { AppService } from '@/types/system'; + +const webviewWindowCtor = vi.fn(); + +vi.mock('@tauri-apps/api/webviewWindow', () => ({ + WebviewWindow: class { + constructor(label: string, options: Record) { + webviewWindowCtor(label, options); + } + once() {} + show() {} + }, +})); + +vi.mock('@tauri-apps/api/window', () => ({ + getCurrentWindow: () => ({ label: 'main' }), + ScrollBarStyle: {}, +})); + +vi.mock('@/services/environment', () => ({ + isTauriAppPlatform: () => true, + isWebAppPlatform: () => false, + isPWA: () => false, +})); + +import { showReaderWindow } from '@/utils/nav'; + +const makeAppService = (os: 'macos' | 'windows' | 'linux'): AppService => + ({ + isMacOSApp: os === 'macos', + isWindowsApp: os === 'windows', + isLinuxApp: os === 'linux', + osPlatform: os, + }) as unknown as AppService; + +// Regression (#3682): reader/extra windows opened via nav.ts must also be +// opaque on Linux — a transparent WebKitGTK window goes invisible when the web +// process is busy. Only macOS (native decorations) stays non-transparent by +// design; Windows keeps its existing behavior. +describe('nav.ts window transparency', () => { + beforeEach(() => { + webviewWindowCtor.mockClear(); + }); + + test('Linux reader window is not transparent', () => { + showReaderWindow(makeAppService('linux'), ['book-1']); + expect(webviewWindowCtor).toHaveBeenCalledTimes(1); + const options = webviewWindowCtor.mock.calls[0]![1] as Record; + expect(options['transparent']).toBe(false); + }); + + test('macOS reader window is not transparent (native decorations)', () => { + showReaderWindow(makeAppService('macos'), ['book-1']); + const options = webviewWindowCtor.mock.calls[0]![1] as Record; + expect(options['transparent']).toBe(false); + }); +}); diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index 2f13ec45..a225fb0f 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -1393,7 +1393,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP }; if (!appService || !insets || checkOpenWithBooks || checkLastOpenBooks) { - return
; + return
; } const showBookshelf = libraryLoaded || libraryBooks.length > 0; diff --git a/apps/readest-app/src/app/reader/components/Reader.tsx b/apps/readest-app/src/app/reader/components/Reader.tsx index 273e62f8..3974e936 100644 --- a/apps/readest-app/src/app/reader/components/Reader.tsx +++ b/apps/readest-app/src/app/reader/components/Reader.tsx @@ -165,7 +165,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
) : ( -
+
); }; diff --git a/apps/readest-app/src/services/nativeAppService.ts b/apps/readest-app/src/services/nativeAppService.ts index 515943ac..36f20de4 100644 --- a/apps/readest-app/src/services/nativeAppService.ts +++ b/apps/readest-app/src/services/nativeAppService.ts @@ -557,7 +557,10 @@ export class NativeAppService extends BaseAppService { override hasWindow = !(OS_TYPE === 'ios' || OS_TYPE === 'android'); override hasWindowBar = !(OS_TYPE === 'ios' || OS_TYPE === 'android'); override hasContextMenu = !(OS_TYPE === 'ios' || OS_TYPE === 'android'); - override hasRoundedWindow = OS_TYPE === 'linux'; + // No desktop platform draws a rounded, transparent window anymore: the Linux + // window is opaque with square corners to avoid the WebKitGTK "turns + // invisible while busy" bug (#3682). + override hasRoundedWindow = false; override hasSafeAreaInset = OS_TYPE === 'ios' || OS_TYPE === 'android'; override hasHaptics = OS_TYPE === 'ios' || OS_TYPE === 'android'; override hasUpdater = diff --git a/apps/readest-app/src/utils/nav.ts b/apps/readest-app/src/utils/nav.ts index 10e8db16..0b4e29a8 100644 --- a/apps/readest-app/src/utils/nav.ts +++ b/apps/readest-app/src/utils/nav.ts @@ -18,7 +18,9 @@ const createReaderWindow = (appService: AppService, url: string) => { resizable: true, title: appService.isMacOSApp ? '' : 'Readest', decorations: !!appService.isMacOSApp, - transparent: !appService.isMacOSApp, + // Linux stays opaque: a transparent WebKitGTK window turns invisible when + // its web process is busy (#3682). macOS uses native decorations instead. + transparent: !appService.isMacOSApp && !appService.isLinuxApp, shadow: appService.isMacOSApp ? undefined : true, titleBarStyle: appService.isMacOSApp ? 'overlay' : undefined, // Enum ScrollBarStyle is exported as type by tauri, so it cannot be used directly. @@ -74,7 +76,9 @@ export const ensureMainLibraryWindow = async (appService: AppService) => { resizable: true, title: appService.isMacOSApp ? '' : 'Readest', decorations: !!appService.isMacOSApp, - transparent: !appService.isMacOSApp, + // Linux stays opaque: a transparent WebKitGTK window turns invisible when + // its web process is busy (#3682). macOS uses native decorations instead. + transparent: !appService.isMacOSApp && !appService.isLinuxApp, shadow: appService.isMacOSApp ? undefined : true, titleBarStyle: appService.isMacOSApp ? 'overlay' : undefined, scrollBarStyle: (appService.osPlatform === 'windows'