diff --git a/apps/readest-app/src-tauri/capabilities/default.json b/apps/readest-app/src-tauri/capabilities/default.json index 46ab50da..97ade28d 100644 --- a/apps/readest-app/src-tauri/capabilities/default.json +++ b/apps/readest-app/src-tauri/capabilities/default.json @@ -85,6 +85,9 @@ }, { "path": "**/last-book-cover.png" + }, + { + "path": "$TEMP/**/*" } ] }, diff --git a/apps/readest-app/src/__tests__/services/web-app-service.test.ts b/apps/readest-app/src/__tests__/services/web-app-service.test.ts index f2e6de6d..7b4c6947 100644 --- a/apps/readest-app/src/__tests__/services/web-app-service.test.ts +++ b/apps/readest-app/src/__tests__/services/web-app-service.test.ts @@ -242,6 +242,213 @@ describe('WebAppService', () => { const result = await service.saveFile('book.epub', 'content'); expect(result).toBe(false); }); + + test('uses navigator.share when share option is set and supported', async () => { + const shareSpy = vi.fn().mockResolvedValue(undefined); + const canShareSpy = vi.fn().mockReturnValue(true); + const originalShare = (navigator as unknown as { share?: unknown }).share; + const originalCanShare = (navigator as unknown as { canShare?: unknown }).canShare; + Object.defineProperty(navigator, 'share', { + value: shareSpy, + configurable: true, + writable: true, + }); + Object.defineProperty(navigator, 'canShare', { + value: canShareSpy, + configurable: true, + writable: true, + }); + const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL'); + + const result = await service.saveFile('annotations.md', '# notes', { + mimeType: 'text/markdown', + share: true, + }); + + expect(result).toBe(true); + expect(shareSpy).toHaveBeenCalledTimes(1); + const shareData = shareSpy.mock.calls[0]![0] as { files: File[]; title?: string }; + expect(shareData.files).toHaveLength(1); + expect(shareData.files[0]!.name).toBe('annotations.md'); + expect(shareData.files[0]!.type).toBe('text/markdown'); + expect(createObjectURLSpy).not.toHaveBeenCalled(); + + if (originalShare === undefined) { + delete (navigator as unknown as { share?: unknown }).share; + } else { + Object.defineProperty(navigator, 'share', { + value: originalShare, + configurable: true, + writable: true, + }); + } + if (originalCanShare === undefined) { + delete (navigator as unknown as { canShare?: unknown }).canShare; + } else { + Object.defineProperty(navigator, 'canShare', { + value: originalCanShare, + configurable: true, + writable: true, + }); + } + }); + + test('falls through to download when share=true but canShare rejects', async () => { + const shareSpy = vi.fn(); + const canShareSpy = vi.fn().mockReturnValue(false); + const originalShare = (navigator as unknown as { share?: unknown }).share; + const originalCanShare = (navigator as unknown as { canShare?: unknown }).canShare; + Object.defineProperty(navigator, 'share', { + value: shareSpy, + configurable: true, + writable: true, + }); + Object.defineProperty(navigator, 'canShare', { + value: canShareSpy, + configurable: true, + writable: true, + }); + + const clickSpy = vi.fn(); + vi.spyOn(document.body, 'appendChild').mockImplementation((node) => { + (node as HTMLAnchorElement).click = clickSpy; + return node; + }); + vi.spyOn(document.body, 'removeChild').mockImplementation((node) => node); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + + const result = await service.saveFile('annotations.md', '# notes', { + mimeType: 'text/markdown', + share: true, + }); + + expect(result).toBe(true); + expect(shareSpy).not.toHaveBeenCalled(); + expect(clickSpy).toHaveBeenCalledTimes(1); + + if (originalShare === undefined) { + delete (navigator as unknown as { share?: unknown }).share; + } else { + Object.defineProperty(navigator, 'share', { + value: originalShare, + configurable: true, + writable: true, + }); + } + if (originalCanShare === undefined) { + delete (navigator as unknown as { canShare?: unknown }).canShare; + } else { + Object.defineProperty(navigator, 'canShare', { + value: originalCanShare, + configurable: true, + writable: true, + }); + } + }); + + test('returns true without falling back when user dismisses share sheet', async () => { + const dismissError = new DOMException('share canceled', 'AbortError'); + const shareSpy = vi.fn().mockRejectedValue(dismissError); + const canShareSpy = vi.fn().mockReturnValue(true); + const originalShare = (navigator as unknown as { share?: unknown }).share; + const originalCanShare = (navigator as unknown as { canShare?: unknown }).canShare; + Object.defineProperty(navigator, 'share', { + value: shareSpy, + configurable: true, + writable: true, + }); + Object.defineProperty(navigator, 'canShare', { + value: canShareSpy, + configurable: true, + writable: true, + }); + const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL'); + + const result = await service.saveFile('annotations.md', '# notes', { + mimeType: 'text/markdown', + share: true, + }); + + expect(result).toBe(true); + expect(shareSpy).toHaveBeenCalledTimes(1); + expect(createObjectURLSpy).not.toHaveBeenCalled(); + + if (originalShare === undefined) { + delete (navigator as unknown as { share?: unknown }).share; + } else { + Object.defineProperty(navigator, 'share', { + value: originalShare, + configurable: true, + writable: true, + }); + } + if (originalCanShare === undefined) { + delete (navigator as unknown as { canShare?: unknown }).canShare; + } else { + Object.defineProperty(navigator, 'canShare', { + value: originalCanShare, + configurable: true, + writable: true, + }); + } + }); + + test('falls back to download when navigator.share rejects with non-AbortError', async () => { + // Chrome desktop sometimes reports canShare={files}=true but then rejects + // navigator.share with NotAllowedError; without a fallback nothing happens. + const shareSpy = vi + .fn() + .mockRejectedValue(new DOMException('Permission denied', 'NotAllowedError')); + const canShareSpy = vi.fn().mockReturnValue(true); + const originalShare = (navigator as unknown as { share?: unknown }).share; + const originalCanShare = (navigator as unknown as { canShare?: unknown }).canShare; + Object.defineProperty(navigator, 'share', { + value: shareSpy, + configurable: true, + writable: true, + }); + Object.defineProperty(navigator, 'canShare', { + value: canShareSpy, + configurable: true, + writable: true, + }); + + const clickSpy = vi.fn(); + vi.spyOn(document.body, 'appendChild').mockImplementation((node) => { + (node as HTMLAnchorElement).click = clickSpy; + return node; + }); + vi.spyOn(document.body, 'removeChild').mockImplementation((node) => node); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + + const result = await service.saveFile('annotations.md', '# notes', { + mimeType: 'text/markdown', + share: true, + }); + + expect(result).toBe(true); + expect(shareSpy).toHaveBeenCalledTimes(1); + expect(clickSpy).toHaveBeenCalledTimes(1); + + if (originalShare === undefined) { + delete (navigator as unknown as { share?: unknown }).share; + } else { + Object.defineProperty(navigator, 'share', { + value: originalShare, + configurable: true, + writable: true, + }); + } + if (originalCanShare === undefined) { + delete (navigator as unknown as { canShare?: unknown }).canShare; + } else { + Object.defineProperty(navigator, 'canShare', { + value: originalCanShare, + configurable: true, + writable: true, + }); + } + }); }); describe('fs.resolvePath basePrefix', () => { diff --git a/apps/readest-app/src/app/library/components/ShareBookDialog.tsx b/apps/readest-app/src/app/library/components/ShareBookDialog.tsx index af28df8b..8812ff7b 100644 --- a/apps/readest-app/src/app/library/components/ShareBookDialog.tsx +++ b/apps/readest-app/src/app/library/components/ShareBookDialog.tsx @@ -154,10 +154,21 @@ const ShareBookDialog: React.FC = ({ isOpen, book, cfi, on } }; - const handleNativeShare = async () => { + const handleNativeShare = async (e: React.MouseEvent) => { if (!created) return; const title = book.title; const url = created.url; + // Anchor the macOS / iPad share sheet to the trigger button rect so + // NSSharingServicePicker doesn't fall back to the WebView's top-left. + // `preferredEdge: 'bottom'` maps to NSMinYEdge — in the flipped WKWebView + // coord space that's the rect's top edge, which makes the popover appear + // above the button (matching the annotations export popover). + const rect = e.currentTarget.getBoundingClientRect(); + const sharePosition = { + x: rect.left + rect.width / 2, + y: rect.top, + preferredEdge: 'bottom' as const, + }; // Tauri (mobile + desktop windowed): try sharekit. If the import or // call throws, the plugin isn't usable on this platform — fall through @@ -167,7 +178,7 @@ const ShareBookDialog: React.FC = ({ isOpen, book, cfi, on let sharekitWorked = false; try { const { shareText } = await import('@choochmeque/tauri-plugin-sharekit-api'); - await shareText(`${title}\n${url}`); + await shareText(`${title}\n${url}`, { position: sharePosition }); sharekitWorked = true; } catch (err) { console.error('shareText failed; falling back:', err); @@ -357,7 +368,7 @@ const ShareBookDialog: React.FC = ({ isOpen, book, cfi, on