forked from akai/readest
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 =
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user