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.
This commit is contained in:
Huang Xin
2026-05-29 01:09:39 +08:00
committed by GitHub
parent 18c2115cc1
commit 10a223b0e2
4 changed files with 119 additions and 2 deletions
@@ -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);
});
});
@@ -42,6 +42,7 @@ export abstract class BaseAppService implements AppService {
isAppDataSandbox = false;
isAndroidApp = false;
isIOSApp = false;
isWindowsApp = false;
isMobileApp = false;
isPortableApp = false;
isDesktopApp = false;
@@ -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<boolean> {
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) {
+1
View File
@@ -89,6 +89,7 @@ export interface AppService {
isIOSApp: boolean;
isMacOSApp: boolean;
isLinuxApp: boolean;
isWindowsApp: boolean;
isPortableApp: boolean;
isDesktopApp: boolean;
isAppImage: boolean;