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) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-07-03 04:41:26 +09:00
committed by GitHub
parent 84c5a9dae6
commit 4b2c5f93ab
7 changed files with 172 additions and 8 deletions
+8 -3
View File
@@ -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
@@ -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);
});
});
@@ -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<string, unknown>) {
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<string, unknown>;
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<string, unknown>;
expect(options['transparent']).toBe(false);
});
});
+1 -1
View File
@@ -1393,7 +1393,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
};
if (!appService || !insets || checkOpenWithBooks || checkLastOpenBooks) {
return <div className={clsx('full-height', !appService?.isLinuxApp && 'bg-base-200')} />;
return <div className='full-height bg-base-200' />;
}
const showBookshelf = libraryLoaded || libraryBooks.length > 0;
@@ -165,7 +165,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
</Suspense>
</div>
) : (
<div className={clsx('full-height', !appService?.isLinuxApp && 'bg-base-100')}></div>
<div className='full-height bg-base-100'></div>
);
};
@@ -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 =
+6 -2
View File
@@ -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'