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;