Files
readest/apps/readest-app/src/services/webAppService.ts
T
Huang Xin 295a588988 feat(share): route annotation exports through the system share sheet (#4107)
Adds a `share` flag and `sharePosition` to `saveFile` across the app
services. On iOS/Android/macOS/Windows the annotation export now calls
the sharekit `shareFile` (writing the markdown/txt to `$TEMP` first when
no `filePath` is provided), so users get the system "Share via…" sheet
that drops the export into Mail, Notes, Messages, etc. Linux desktop
keeps the existing save dialog, since sharekit has no Linux backend.

On the web, `saveFile` now prefers `navigator.share({ files })` when the
browser advertises support via `canShare`. AbortError (user dismissed)
is treated as a deliberate "don't share" choice; any other rejection
(e.g., Chrome desktop's `NotAllowedError` despite a positive `canShare`)
falls through to the `<a download>` fallback so a save still happens.

Also fixes the macOS share popover anchoring: `preferredEdge: 'top'`
maps to `NSMaxYEdge`, which is the rect's bottom edge in WKWebView's
flipped coords, so the picker rendered below the trigger button. The
annotations export only got away with it because its dialog has no room
below — macOS auto-flipped above. Switching to `preferredEdge: 'bottom'`
(`NSMinYEdge` → top edge in flipped coords) anchors the popover above
the button consistently. Adds `$TEMP/**/*` to the Tauri fs capabilities
so the writable temp share file is permitted.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:50:08 +02:00

419 lines
14 KiB
TypeScript

import { FileSystem, BaseDir, AppPlatform, ResolvedPath, FileItem } from '@/types/system';
import { DatabaseOpts, DatabaseService } from '@/types/database';
import { SchemaType } from '@/services/database/migrate';
import { getOSPlatform, isValidURL } from '@/utils/misc';
import { isSafariBrowser } from '@/utils/ua';
import { RemoteFile } from '@/utils/file';
import { isPWA } from './environment';
import { BaseAppService } from './appService';
import {
DATA_SUBDIR,
LOCAL_BOOKS_SUBDIR,
LOCAL_DICTIONARIES_SUBDIR,
LOCAL_FONTS_SUBDIR,
LOCAL_IMAGES_SUBDIR,
} from './constants';
const basePrefix = async () => '';
const resolvePath = (path: string, base: BaseDir): ResolvedPath => {
switch (base) {
case 'Data':
return { baseDir: 0, basePrefix, fp: `${DATA_SUBDIR}/${path}`, base };
case 'Books':
return { baseDir: 0, basePrefix, fp: `${LOCAL_BOOKS_SUBDIR}/${path}`, base };
case 'Fonts':
return { baseDir: 0, basePrefix, fp: `${LOCAL_FONTS_SUBDIR}/${path}`, base };
case 'Images':
return { baseDir: 0, basePrefix, fp: `${LOCAL_IMAGES_SUBDIR}/${path}`, base };
case 'Dictionaries':
return { baseDir: 0, basePrefix, fp: `${LOCAL_DICTIONARIES_SUBDIR}/${path}`, base };
case 'None':
return { baseDir: 0, basePrefix, fp: path, base };
default:
return { baseDir: 0, basePrefix, fp: `${base}/${path}`, base };
}
};
const dbName = 'AppFileSystem';
const dbVersion = 1;
async function openIndexedDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, dbVersion);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('files')) {
db.createObjectStore('files', { keyPath: 'path' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
const indexedDBFileSystem: FileSystem = {
resolvePath,
async getPrefix(base: BaseDir) {
const { basePrefix, fp } = this.resolvePath('', base);
const basePath = await basePrefix();
const prefix = fp ? (basePath ? `${basePath}/${fp}` : fp) : basePath;
return prefix.replace(/\/+$/, '');
},
getURL(path: string) {
if (isValidURL(path)) {
return path;
} else {
return URL.createObjectURL(new Blob([path]));
}
},
async getBlobURL(path: string, base: BaseDir) {
try {
const content = await this.readFile(path, base, 'binary');
return URL.createObjectURL(new Blob([content]));
} catch {
return path;
}
},
async getImageURL(path: string) {
return await this.getBlobURL(path, 'None');
},
async openFile(path: string, base: BaseDir, filename?: string) {
if (isValidURL(path)) {
return await new RemoteFile(path, filename).open();
} else {
const content = await this.readFile(path, base, 'binary');
return new File([content], filename || path);
}
},
async copyFile(srcPath: string, srcBase: BaseDir, dstPath: string, dstBase: BaseDir) {
const { fp: srcFp } = this.resolvePath(srcPath, srcBase);
const { fp: dstFp } = this.resolvePath(dstPath, dstBase);
const db = await openIndexedDB();
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction('files', 'readwrite');
const store = transaction.objectStore('files');
const getRequest = store.get(srcFp);
getRequest.onsuccess = () => {
const data = getRequest.result;
if (data) {
store.put({ path: dstFp, content: data.content });
resolve();
} else {
reject(new Error(`File not found: ${srcFp}`));
}
};
getRequest.onerror = () => reject(getRequest.error);
});
},
async readFile(path: string, base: BaseDir, mode: 'text' | 'binary') {
const { fp } = this.resolvePath(path, base);
const db = await openIndexedDB();
return new Promise<string | ArrayBuffer>((resolve, reject) => {
const transaction = db.transaction('files', 'readonly');
const store = transaction.objectStore('files');
const request = store.get(fp);
request.onsuccess = async () => {
if (request.result) {
const content = request.result.content;
if (mode === 'text') resolve(content);
else {
if (content instanceof Blob) {
const arrayBuffer = await content.arrayBuffer();
resolve(arrayBuffer);
} else if (content instanceof ArrayBuffer) {
resolve(content);
} else if (typeof content === 'string') {
resolve(new TextEncoder().encode(content).buffer as ArrayBuffer);
} else {
reject(new Error('Unsupported content type in IndexedDB'));
}
}
} else {
reject(new Error(`File not found: ${fp}`));
}
};
request.onerror = () => reject(request.error);
});
},
async writeFile(path: string, base: BaseDir, content: string | ArrayBuffer | File) {
const { fp } = this.resolvePath(path, base);
const db = await openIndexedDB();
if (content instanceof File) {
content = await content.arrayBuffer();
}
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction('files', 'readwrite');
const store = transaction.objectStore('files');
store.put({ path: fp, content });
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
},
async removeFile(path: string, base: BaseDir) {
const { fp } = this.resolvePath(path, base);
const db = await openIndexedDB();
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction('files', 'readwrite');
const store = transaction.objectStore('files');
store.delete(fp);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
},
async createDir(path: string, base: BaseDir) {
return await this.writeFile(path, base, '');
},
async removeDir(path: string, base: BaseDir) {
const { fp } = this.resolvePath(path, base);
const db = await openIndexedDB();
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction('files', 'readwrite');
const store = transaction.objectStore('files');
const request = store.getAll();
request.onsuccess = () => {
const files = request.result as { path: string }[];
files.forEach((file) => {
if (file.path.startsWith(fp)) {
store.delete(file.path);
}
});
};
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
},
async readDir(path: string, base: BaseDir) {
const { fp } = this.resolvePath(path, base);
const prefix = fp.endsWith('/') ? fp : `${fp}/`;
const db = await openIndexedDB();
return new Promise<FileItem[]>((resolve, reject) => {
const transaction = db.transaction('files', 'readonly');
const store = transaction.objectStore('files');
const request = store.getAll();
request.onsuccess = () => {
const files = request.result as { path: string; content: string | ArrayBuffer | Blob }[];
resolve(
files
.filter((file) => file.path.startsWith(prefix))
.map((file) => ({
path: file.path.slice(prefix.length),
size:
file.content instanceof Blob
? file.content.size
: typeof file.content === 'string'
? file.content.length
: file.content instanceof ArrayBuffer
? file.content.byteLength
: 0,
})),
);
};
request.onerror = () => reject(request.error);
});
},
async exists(path: string, base: BaseDir) {
const { fp } = this.resolvePath(path, base);
const db = await openIndexedDB();
return new Promise<boolean>((resolve, reject) => {
const transaction = db.transaction('files', 'readonly');
const store = transaction.objectStore('files');
const request = store.get(fp);
request.onsuccess = () => resolve(!!request.result);
request.onerror = () => reject(request.error);
});
},
async stats(path: string, base: BaseDir) {
const { fp } = this.resolvePath(path, base);
const db = await openIndexedDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction('files', 'readonly');
const store = transaction.objectStore('files');
const request = store.get(fp);
request.onsuccess = () => {
const result = request.result;
if (result) {
const content = result.content;
const size =
content instanceof Blob
? content.size
: typeof content === 'string'
? content.length
: content instanceof ArrayBuffer
? content.byteLength
: 0;
resolve({
isFile: true,
isDirectory: false,
size,
mtime: null,
atime: null,
birthtime: null,
});
} else {
reject(new Error(`File not found: ${fp}`));
}
};
request.onerror = () => reject(request.error);
});
},
};
export class WebAppService extends BaseAppService {
fs = indexedDBFileSystem;
override isMobile = ['android', 'ios'].includes(getOSPlatform());
override appPlatform = 'web' as AppPlatform;
override supportsCanvasContext2DFilter = !isSafariBrowser();
override hasSafeAreaInset = isPWA();
override async init() {
await this.loadSettings();
await this.prepareBooksDir();
await this.runMigrations();
}
override async runMigrations() {
try {
const settings = await this.loadSettings();
const lastMigrationVersion = settings.migrationVersion || 0;
await super.runMigrations(lastMigrationVersion);
if (lastMigrationVersion < this.CURRENT_MIGRATION_VERSION) {
await this.saveSettings({
...settings,
migrationVersion: this.CURRENT_MIGRATION_VERSION,
});
}
} catch (error) {
console.error('Failed to run migrations:', error);
}
}
override resolvePath(fp: string, base: BaseDir): ResolvedPath {
return this.fs.resolvePath(fp, base);
}
async setCustomRootDir() {
// No-op in web environment
}
async selectDirectory(): Promise<string> {
throw new Error('selectDirectory is not supported in browser');
}
async selectFiles(): Promise<string[]> {
throw new Error('selectFiles is not supported in browser');
}
async saveFile(
filename: string,
content: string | ArrayBuffer,
options?: {
filePath?: string;
mimeType?: string;
share?: boolean;
// Web ignores `sharePosition` — `navigator.share()` anchors itself
// natively to the calling element on Safari / iOS.
sharePosition?: { x: number; y: number; preferredEdge?: 'top' | 'bottom' | 'left' | 'right' };
},
): Promise<boolean> {
const mimeType = options?.mimeType || 'application/octet-stream';
if (
options?.share &&
typeof navigator !== 'undefined' &&
typeof navigator.share === 'function'
) {
let shareData: ShareData | null = null;
try {
const file = new File([content], filename, { type: mimeType });
const candidate: ShareData = { files: [file], title: filename };
if (typeof navigator.canShare !== 'function' || navigator.canShare(candidate)) {
shareData = candidate;
}
} catch (error) {
// File constructor unavailable or rejected the input; fall through to download.
console.warn('Failed to build share file; falling back to download:', error);
}
if (shareData) {
try {
await navigator.share(shareData);
return true;
} catch (error) {
// AbortError = user dismissed the sheet; respect that as an explicit
// "don't share" choice. Any other error (e.g., NotAllowedError on
// desktop Chrome where canShare lies about file support) means the
// share never happened — fall through to the download fallback.
if ((error as DOMException)?.name === 'AbortError') {
return true;
}
console.warn('navigator.share failed; falling back to download:', error);
}
}
}
try {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
return true;
} catch (error) {
console.error('Failed to save file:', error);
return false;
}
}
async ask(message: string): Promise<boolean> {
return window.confirm(message);
}
async openDatabase(
schema: SchemaType,
path: string,
base: BaseDir,
opts?: DatabaseOpts,
): Promise<DatabaseService> {
const fullPath = await this.resolveFilePath(path, base);
// OPFS `getFileHandle` rejects names containing path separators, and the
// Turso WASM connector passes the whole string as a single OPFS handle
// name without traversing directories. Flatten to a safe single segment.
const opfsName = fullPath.replace(/[/\\]+/g, '_').replace(/^_+/, '');
const { WebDatabaseService } = await import('./database/webDatabaseService');
const db = await WebDatabaseService.open(opfsName, opts);
const { migrate } = await import('./database/migrate');
const { getMigrations } = await import('./database/migrations');
await migrate(db, getMigrations(schema));
return db;
}
}