From 10a223b0e2a27826220a2bf5cf8e3d8ff1fd3f50 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 29 May 2026 01:09:39 +0800 Subject: [PATCH] fix(export): export uses save dialog on Windows to avoid share UI freeze (#4343) (#4346) Windows WebView2's native share UI, invoked via tauri-plugin-sharekit, synchronously blocks the Tauri command thread waiting on ShareCompleted/ShareCanceled callbacks. When the user dismisses the picker without those callbacks firing, the app freezes and has to be killed from Task Manager. Skip the native share path on Windows and fall through to the save dialog, mirroring Linux. --- .../services/native-app-service-share.test.ts | 110 ++++++++++++++++++ apps/readest-app/src/services/appService.ts | 1 + .../src/services/nativeAppService.ts | 9 +- apps/readest-app/src/types/system.ts | 1 + 4 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 apps/readest-app/src/__tests__/services/native-app-service-share.test.ts diff --git a/apps/readest-app/src/__tests__/services/native-app-service-share.test.ts b/apps/readest-app/src/__tests__/services/native-app-service-share.test.ts new file mode 100644 index 00000000..1bb8ee09 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/native-app-service-share.test.ts @@ -0,0 +1,110 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +const osTypeMock = vi.fn().mockReturnValue('macos'); +const writeTextFileMock = vi.fn().mockResolvedValue(undefined); +const writeFileMock = vi.fn().mockResolvedValue(undefined); +const saveDialogMock = vi.fn().mockResolvedValue('/tmp/exported.md'); +const shareFileMock = vi.fn().mockResolvedValue(undefined); + +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: (...args: unknown[]) => writeTextFileMock(...args), + writeFile: (...args: unknown[]) => writeFileMock(...args), + 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: (...args: unknown[]) => saveDialogMock(...args), + 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('@choochmeque/tauri-plugin-sharekit-api', () => ({ + shareFile: (...args: unknown[]) => shareFileMock(...args), +})); + +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(); +} + +describe('NativeAppService.saveFile share gating', () => { + beforeEach(() => { + writeTextFileMock.mockClear(); + writeFileMock.mockClear(); + saveDialogMock.mockClear(); + shareFileMock.mockClear(); + }); + + test('uses native share on macOS when share=true', async () => { + const service = await loadServiceWithOS('macos'); + await service.saveFile('notes.md', 'hello', { share: true, mimeType: 'text/markdown' }); + expect(shareFileMock).toHaveBeenCalledTimes(1); + expect(saveDialogMock).not.toHaveBeenCalled(); + }); + + // Regression: on Windows the sharekit plugin's share UI blocks the main + // thread waiting on cancel/complete callbacks that may never fire, freezing + // the app. See issue #4343. Windows must fall through to the save dialog. + test('falls through to save dialog on Windows when share=true', async () => { + const service = await loadServiceWithOS('windows'); + await service.saveFile('notes.md', 'hello', { share: true, mimeType: 'text/markdown' }); + expect(shareFileMock).not.toHaveBeenCalled(); + expect(saveDialogMock).toHaveBeenCalledTimes(1); + }); + + test('falls through to save dialog on Linux when share=true', async () => { + const service = await loadServiceWithOS('linux'); + await service.saveFile('notes.md', 'hello', { share: true, mimeType: 'text/markdown' }); + expect(shareFileMock).not.toHaveBeenCalled(); + expect(saveDialogMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index 415e81c3..a08c2550 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -42,6 +42,7 @@ export abstract class BaseAppService implements AppService { isAppDataSandbox = false; isAndroidApp = false; isIOSApp = false; + isWindowsApp = false; isMobileApp = false; isPortableApp = false; isDesktopApp = false; diff --git a/apps/readest-app/src/services/nativeAppService.ts b/apps/readest-app/src/services/nativeAppService.ts index d1802f9b..1ad061a2 100644 --- a/apps/readest-app/src/services/nativeAppService.ts +++ b/apps/readest-app/src/services/nativeAppService.ts @@ -444,6 +444,7 @@ export class NativeAppService extends BaseAppService { override isIOSApp = OS_TYPE === 'ios'; override isMacOSApp = OS_TYPE === 'macos'; override isLinuxApp = OS_TYPE === 'linux'; + override isWindowsApp = OS_TYPE === 'windows'; override isMobileApp = ['android', 'ios'].includes(OS_TYPE); override isDesktopApp = ['macos', 'windows', 'linux'].includes(OS_TYPE); override isAppImage = Boolean(window.__READEST_IS_APPIMAGE); @@ -642,8 +643,12 @@ export class NativeAppService extends BaseAppService { ): Promise { try { const ext = filename.split('.').pop() || ''; - // Linux desktop has no system share sheet; always fall through to saveDialog. - const wantShare = !this.isLinuxApp && (this.isIOSApp || options?.share); + // Linux desktop has no system share sheet; Windows WebView2's native + // share UI (via tauri-plugin-sharekit) blocks the main thread waiting + // on complete/cancel callbacks that may never fire when the user + // dismisses the picker, freezing the app (issue #4343). Both fall + // through to saveDialog instead. + const wantShare = !this.isLinuxApp && !this.isWindowsApp && (this.isIOSApp || options?.share); if (wantShare) { let shareablePath = options?.filePath; if (!shareablePath) { diff --git a/apps/readest-app/src/types/system.ts b/apps/readest-app/src/types/system.ts index 4a06e71e..c4903f4b 100644 --- a/apps/readest-app/src/types/system.ts +++ b/apps/readest-app/src/types/system.ts @@ -89,6 +89,7 @@ export interface AppService { isIOSApp: boolean; isMacOSApp: boolean; isLinuxApp: boolean; + isWindowsApp: boolean; isPortableApp: boolean; isDesktopApp: boolean; isAppImage: boolean;