feat(library): send book file from bookshelf selection popup (#4402)

* feat(library): send book file from bookshelf selection popup

Adds a Send button to the bottom popup that appears when one or more
books are selected on the bookshelf. Hands the actual book file
(epub/pdf/...) to the OS share sheet via tauri-plugin-sharekit
(UIActivityViewController on iOS, Intent.ACTION_SEND on Android,
NSSharingServicePicker on macOS), so users can fire the file off to
Mail / Messages / WeChat / AirDrop / etc.

This is intentionally distinct from the per-item context-menu "Share
Book", which uploads the book to the readest backend and generates a
public link. "Send" is offline file egress; "Share Book" is remote
collaboration. They share zero infra.

Resolution rules mirror bookContent.resolveBookContentSource: managed
copy under Books/<hash>/ first, then the device-local in-place import
path. Cloud-only books warn rather than silently no-op.

Path is handed to shareFile via options.filePath. Without that,
saveFile() falls back to writing a temp copy under BaseDirectory.Temp,
which on Android resolves to /data/local/tmp/ — the app sandbox has
no write permission there and the call fails with EACCES ("failed to
open file at path: /data/local/tmp/...epub Permission denied (os error
13)"). Passing the absolute path also avoids re-buffering the entire
epub/pdf into memory.

On macOS the NSSharingServicePicker is anchored to the selected
book's cover rather than to the Send button — the user's visual
focus is on the cover they just tapped, not on the bottom toolbar.
BookshelfItem stamps a data-book-hash attribute on its root div so
the Send handler can locate the cell via querySelector and pass its
rect through saveFile's sharePosition option. preferredEdge='bottom'
maps to NSMinYEdge, so the popover renders above the cover (and only
auto-flips below when there's no room above). iOS / Android share
sheets are modal and ignore sharePosition, so the same code is a
no-op there.

The button is hidden on Linux (no system share sheet), Windows
(WebView2 share UI deadlocks the main thread, see #4343), and web
browsers (no "send file to <app>" affordance for arbitrary downloads).

* fix(share): don't fall back to saveDialog when shareFile is cancelled

The native saveFile({ share: true }) path used to swallow any error
from sharekit's shareFile() and fall through to saveDialog. The plugin
treats user cancellation the same as a failure (it rejects with
'Share cancelled' on Android when the user dismisses the share sheet),
so cancelling a share popped up an unwanted 'Save As...' dialog right
after the user explicitly chose not to share.

Mirror what webAppService already does for the navigator.share()
AbortError path: once we entered the share branch, return true
regardless of whether the share completed or was cancelled. The
saveDialog path is now reserved for Linux/Windows desktop, which never
hit the share branch in the first place (wantShare gates them out).

If a future caller wants 'try share, fall back to save on hard
failure', that decision belongs to the caller — saveFile shouldn't
silently override an explicit share intent.
This commit is contained in:
loveheaven
2026-06-02 22:09:29 +08:00
committed by GitHub
parent 4abbc0254c
commit fe853554a9
4 changed files with 175 additions and 3 deletions
@@ -45,6 +45,9 @@ import {
resolveEffectiveSecondarySort,
} from '../utils/libraryUtils';
import { eventDispatcher } from '@/utils/event';
import { getLocalBookFilename } from '@/utils/book';
import { MIMETYPES, EXTS } from '@/libs/document';
import { makeSafeFilename } from '@/utils/misc';
import { useSpatialNavigation } from '../hooks/useSpatialNavigation';
import Alert from '@/components/Alert';
@@ -425,6 +428,114 @@ const Bookshelf: React.FC<BookshelfProps> = ({
setShowStatusAlert(true);
};
const sendSelectedBook = async () => {
// "Send" hands the actual book file (epub/pdf/...) to the OS share
// sheet (UIActivityViewController on iOS, Intent.ACTION_SEND on
// Android, NSSharingServicePicker on macOS) so the user can fire it
// off to Mail / Messages / WeChat / AirDrop / etc. Backed by
// tauri-plugin-sharekit via appService.saveFile({ share: true }).
//
// This is intentionally distinct from the per-item "Share Book"
// context menu, which uploads the book to the readest backend and
// generates a public link. "Send" is offline file egress; "Share
// Book" is remote collaboration. They share zero infra.
//
// Linux has no system share sheet, and Windows is intentionally
// disabled (issue #4343 — WebView2's native share UI blocks the main
// thread waiting on cancel/complete callbacks that may never fire).
// We hide the button entirely on those platforms (see sendEnabled
// in the JSX) so users don't see an action that can't be honoured.
const ids = getSelectedBooks();
if (ids.length !== 1) return;
const book = filteredBooks.find((b) => b.hash === ids[0]);
if (!book || !appService) return;
// Anchor the macOS share popover to the selected book's cover, not
// to the Send button — the user just tapped/clicked the book, so
// their visual focus is on the cover. We look the cover up via the
// `data-book-hash` attribute that BookshelfItem stamps on its root
// div. The rect must be captured *before* setShowSelectModeActions
// tears the popup down (the bookshelf itself stays mounted, but we
// still want to grab it up front to keep the share-call site
// simple). preferredEdge='bottom' maps to NSMinYEdge, which in
// WKWebView's flipped coord space is the rect's top edge, so the
// popover renders above the cover (and only auto-flips below when
// there's no room above). On iOS / Android the share sheet is modal
// and ignores sharePosition, so this work is harmless there.
const coverEl = document.querySelector<HTMLElement>(`[data-book-hash="${book.hash}"]`);
const anchorRect = coverEl?.getBoundingClientRect();
const sharePosition = anchorRect
? {
x: anchorRect.left + anchorRect.width / 2,
y: anchorRect.top + anchorRect.height / 2,
preferredEdge: 'bottom' as const,
}
: undefined;
setShowSelectModeActions(false);
handleSetSelectMode(false);
try {
// Resolve the file the same way bookContent.resolveBookContentSource
// does, but via the public AppService surface (the underlying `fs`
// is protected): managed copy under Books/<hash>/ first, then the
// device-local in-place import path. Cloud-only books or remote
// URL books can't be shared without first downloading them.
const managedPath = getLocalBookFilename(book);
let path: string;
let base: 'Books' | 'None';
if (await appService.exists(managedPath, 'Books')) {
path = managedPath;
base = 'Books';
} else if (book.filePath && (await appService.exists(book.filePath, 'None'))) {
path = book.filePath;
base = 'None';
} else {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: _('Book file is not available locally'),
timeout: 2500,
});
return;
}
const ext = EXTS[book.format] ?? 'bin';
const mimeType = MIMETYPES[book.format]?.[0] ?? 'application/octet-stream';
const baseName = makeSafeFilename(book.sourceTitle || book.title || book.hash);
const shareFilename = `${baseName}.${ext}`;
// Native (Tauri) only — the Share button is hidden on web because
// browsers can't surface a real "share to <app>" sheet for an
// arbitrary local file. Hand the already-on-disk file straight to
// the OS share sheet via `options.filePath`. Without it,
// saveFile() falls back to writing a temp copy under
// BaseDirectory.Temp, which on Android resolves to
// /data/local/tmp/ — the app sandbox has no write permission
// there and the call fails with EACCES ("failed to open file at
// path: /data/local/tmp/...epub Permission denied (os error
// 13)"). Passing the absolute path also avoids re-buffering the
// whole epub/pdf into memory just to have saveFile write it back
// to disk.
const absoluteFilePath = await appService.resolveFilePath(path, base);
// saveFile's binary branch wants an ArrayBuffer; with `filePath`
// set the content arg is ignored on the native share path, but
// we still pass an empty buffer so the type-checker is happy.
await appService.saveFile(shareFilename, new ArrayBuffer(0), {
share: true,
mimeType,
filePath: absoluteFilePath,
sharePosition,
});
} catch (err) {
console.error('Failed to send book file:', err);
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to send book'),
timeout: 2500,
});
}
};
const updateBooksStatus = async (status: ReadingStatus | undefined) => {
const selectedIds = getSelectedBooks();
const booksToUpdate: Book[] = [];
@@ -688,10 +799,22 @@ const Bookshelf: React.FC<BookshelfProps> = ({
<SelectModeActions
selectedBooks={selectedBooks}
safeAreaBottom={safeAreaInsets?.bottom || 0}
// Native send targets: iOS, Android, macOS — route through
// tauri-plugin-sharekit (UIActivityViewController /
// Intent.ACTION_SEND / NSSharingServicePicker). Linux has no
// system share sheet, Windows WebView2 share UI is disabled
// upstream (issue #4343 — deadlocks the main thread), and web
// browsers don't expose a real "send file to <app>" sheet, so
// the button is hidden on those platforms.
sendEnabled={
!!appService &&
(appService.isIOSApp || appService.isAndroidApp || appService.isMacOSApp)
}
onOpen={openSelectedBooks}
onGroup={groupSelectedBooks}
onDetails={openBookDetails}
onStatus={showStatusSelection}
onSend={sendSelectedBook}
onDelete={deleteSelectedBooks}
onCancel={() => handleSetSelectMode(false)}
/>
@@ -427,6 +427,14 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
}
};
// Tag the rendered DOM with the book/group identity so feature code
// (e.g. the Send action's macOS share-popover anchor) can locate the
// exact bookshelf cell the user is acting on without threading refs
// through every parent. Books carry their content-hash; groups carry
// their full group name.
const itemDataAttrs =
'format' in item ? { 'data-book-hash': item.hash } : { 'data-group-name': item.name };
return (
<div className={clsx(mode === 'grid' ? 'h-full' : 'sm:hover:bg-base-300/50 px-4 sm:px-6')}>
<div
@@ -445,6 +453,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
transition: 'transform 0.2s',
}}
onKeyDown={handleKeyDown}
{...itemDataAttrs}
{...handlers}
>
<div className='flex h-full flex-col justify-end'>
@@ -6,6 +6,7 @@ import {
MdInfoOutline,
MdCheckCircleOutline,
} from 'react-icons/md';
import { IoShareSocialOutline } from 'react-icons/io5';
import { LuFolderPlus } from 'react-icons/lu';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
import { useTranslation } from '@/hooks/useTranslation';
@@ -14,10 +15,21 @@ import { isMd5 } from '@/utils/md5';
interface SelectModeActionsProps {
selectedBooks: string[];
safeAreaBottom: number;
// When false (Linux desktop, Windows desktop, web) the Send button is
// hidden entirely — those platforms can't surface a system share sheet
// so the affordance would be misleading. Note: this is *file send* (hands
// the book file to the OS share sheet), distinct from "Share Book" in
// the per-item context menu, which generates a remote share link.
sendEnabled?: boolean;
onOpen: () => void;
onGroup: () => void;
onDetails: () => void;
onStatus: () => void;
// The macOS / iPad share popover is anchored to the selected book's
// cover (located via its data-book-hash attribute), not to this
// button — the user's visual focus is on the cover they just tapped.
// On iOS / Android the share sheet is modal and ignores position.
onSend: () => void;
onDelete: () => void;
onCancel: () => void;
}
@@ -25,10 +37,12 @@ interface SelectModeActionsProps {
const SelectModeActions: React.FC<SelectModeActionsProps> = ({
selectedBooks,
safeAreaBottom,
sendEnabled = true,
onOpen,
onGroup,
onDetails,
onStatus,
onSend,
onDelete,
onCancel,
}) => {
@@ -96,11 +110,30 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
<MdInfoOutline />
<div>{_('Details')}</div>
</button>
{sendEnabled && (
<button
onClick={onSend}
className={clsx(
'flex flex-col items-center justify-center gap-1',
// Wraps to the start of the second row on narrow viewports.
'max-[500px]:col-start-1',
(!hasSingleSelection || !hasValidBooks) && 'btn-disabled opacity-50',
)}
>
<IoShareSocialOutline />
<div>{_('Send')}</div>
</button>
)}
<button
onClick={onDelete}
className={clsx(
'flex flex-col items-center justify-center gap-1',
'max-[500px]:col-start-2',
// Without Send (Linux/Windows/web), Delete needs an explicit
// col-start-2 so the wrapped row {Delete, Cancel} stays centred
// under the 4-col grid. With Send present, the layout is
// {Send, Delete, Cancel} starting at col-start-1, so Delete
// naturally lands in col-start-2 without an override.
!sendEnabled && 'max-[500px]:col-start-2',
!hasSelection && 'btn-disabled opacity-50',
)}
>
@@ -667,10 +667,17 @@ export class NativeAppService extends BaseAppService {
// WebView's top-left corner.
...(options?.sharePosition ? { position: options.sharePosition } : {}),
});
return true;
} catch (error) {
console.error('shareFile failed; falling back to saveDialog:', error);
// The plugin throws on user cancellation (e.g. dismissing the
// Android share sheet returns "Share cancelled"). That's not a
// failure — the user explicitly chose not to share, so we must
// NOT fall back to saveDialog and pop a "Save As..." prompt.
// Same goes for any other share error: the caller asked for a
// share sheet, fulfilled or not, the saveDialog flow is a
// completely different user intent.
console.warn('shareFile did not complete:', error);
}
return true;
}
const filePath = await saveDialog({