feat(iOS): support open file with Readest in Files App, closes #2334 (#2705)

This commit is contained in:
Huang Xin
2025-12-13 20:28:03 +08:00
committed by GitHub
parent 730fadb834
commit c1530cc5c4
10 changed files with 261 additions and 16 deletions
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
</dict>
</plist>
+161
View File
@@ -19,6 +19,8 @@
<string>EPUB Document</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSItemContentTypes</key>
<array>
<string>org.idpf.epub-container</string>
@@ -30,6 +32,8 @@
<string>PDF Document</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSItemContentTypes</key>
<array>
<string>com.adobe.pdf</string>
@@ -41,6 +45,8 @@
<string>FB2 Document</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSItemContentTypes</key>
<array>
<string>com.readest.fb2</string>
@@ -52,6 +58,8 @@
<string>CBZ Archive</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSItemContentTypes</key>
<array>
<string>com.readest.cbz</string>
@@ -63,6 +71,8 @@
<string>MOBI Document</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSItemContentTypes</key>
<array>
<string>org.mobipocket.mobi</string>
@@ -74,6 +84,8 @@
<string>AZW Document</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSItemContentTypes</key>
<array>
<string>com.amazon.azw</string>
@@ -86,6 +98,8 @@
<string>Text File</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSItemContentTypes</key>
<array>
<string>public.plain-text</string>
@@ -93,6 +107,153 @@
</dict>
</array>
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>org.idpf.epub-container</string>
<key>UTTypeDescription</key>
<string>EPUB Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.composite-content</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>epub</string>
</array>
<key>public.mime-type</key>
<string>application/epub+zip</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>com.adobe.pdf</string>
<key>UTTypeDescription</key>
<string>PDF Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.composite-content</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>pdf</string>
</array>
<key>public.mime-type</key>
<string>application/pdf</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>com.readest.fb2</string>
<key>UTTypeDescription</key>
<string>FB2 Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.xml</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>fb2</string>
</array>
<key>public.mime-type</key>
<string>application/xml</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>com.readest.cbz</string>
<key>UTTypeDescription</key>
<string>CBZ Archive</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.archive</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>cbz</string>
</array>
<key>public.mime-type</key>
<string>application/x-cbz</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>org.mobipocket.mobi</string>
<key>UTTypeDescription</key>
<string>MOBI Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>mobi</string>
</array>
<key>public.mime-type</key>
<string>application/x-mobipocket-ebook</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>com.amazon.azw</string>
<key>UTTypeDescription</key>
<string>AZW Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>azw</string>
<string>azw3</string>
</array>
<key>public.mime-type</key>
<string>application/vnd.amazon.ebook</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>public.plain-text</string>
<key>UTTypeDescription</key>
<string>Text File</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.content</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>txt</string>
</array>
<key>public.mime-type</key>
<string>text/plain</string>
</dict>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
@@ -53,6 +53,11 @@ class SetScreenBrightnessRequestArgs: Decodable {
let brightness: Float?
}
class CopyUriToPathRequestArgs: Decodable {
let uri: String?
let dst: String?
}
struct InitializeRequest: Decodable {
let publicKey: String?
}
@@ -816,6 +821,59 @@ class NativeBridgePlugin: Plugin {
}
invoke.resolve(["success": true])
}
@objc public func copy_uri_to_path(_ invoke: Invoke) {
guard let args = try? invoke.parseArgs(CopyUriToPathRequestArgs.self) else {
return invoke.reject("Failed to parse arguments")
}
guard let uriString = args.uri, let dstPath = args.dst else {
return invoke.reject("URI and destination path must be provided")
}
guard let uri = URL(string: uriString) else {
return invoke.reject("Invalid URI")
}
let fileManager = FileManager.default
let dstURL = URL(fileURLWithPath: dstPath)
do {
let didStartAccessing = uri.startAccessingSecurityScopedResource()
defer {
if didStartAccessing {
uri.stopAccessingSecurityScopedResource()
}
}
var shouldCopy = false
if fileManager.fileExists(atPath: dstURL.path) {
let srcAttributes = try fileManager.attributesOfItem(atPath: uri.path)
let dstAttributes = try fileManager.attributesOfItem(atPath: dstURL.path)
let srcModDate = srcAttributes[.modificationDate] as? Date ?? Date.distantPast
let dstModDate = dstAttributes[.modificationDate] as? Date ?? Date.distantPast
if srcModDate > dstModDate {
try fileManager.removeItem(at: dstURL)
shouldCopy = true
} else {
shouldCopy = false
}
} else {
shouldCopy = true
}
if shouldCopy {
try fileManager.copyItem(at: uri, to: dstURL)
}
invoke.resolve(["success": true])
} catch {
invoke.reject("Failed to copy file: \(error.localizedDescription)")
}
}
}
@_cdecl("init_plugin_native_bridge")
@@ -72,6 +72,7 @@
},
"iOS": {
"developmentTeam": "J5W48D69VR",
"infoPlist": "./Info-ios.plist",
"minimumSystemVersion": "14.0"
},
"fileAssociations": [
+1 -1
View File
@@ -323,7 +323,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
};
const handleOpenWithBooks = async (appService: AppService, library: Book[]) => {
const openWithFiles = (await parseOpenWithFiles()) || [];
const openWithFiles = (await parseOpenWithFiles(appService)) || [];
if (openWithFiles.length > 0) {
return await processOpenWithFiles(appService, openWithFiles, library);
@@ -178,7 +178,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
}
dismissBook(bookKey);
if (bookKeys.filter((key) => key !== bookKey).length == 0) {
const openWithFiles = (await parseOpenWithFiles()) || [];
const openWithFiles = (await parseOpenWithFiles(appService)) || [];
if (appService?.hasWindow) {
if (openWithFiles.length > 0) {
tauriHandleOnCloseWindow(handleCloseBooks);
+9 -4
View File
@@ -1,4 +1,5 @@
import { isWebAppPlatform, hasCli } from '@/services/environment';
import { AppService } from '@/types/system';
import { getCurrent } from '@tauri-apps/plugin-deep-link';
declare global {
@@ -35,14 +36,18 @@ const parseCLIOpenWithFiles = async () => {
return files;
};
const parseIntentOpenWithFiles = async () => {
const parseIntentOpenWithFiles = async (appService: AppService | null) => {
const urls = await getCurrent();
if (urls && urls.length > 0) {
console.log('Intent Open with URL:', urls);
return urls
.map((url) => {
if (url.startsWith('file://')) {
return decodeURI(url.replace('file://', ''));
if (appService?.isIOSApp) {
return decodeURI(url);
} else {
return decodeURI(url.replace('file://', ''));
}
} else if (url.startsWith('content://')) {
return url;
} else {
@@ -55,7 +60,7 @@ const parseIntentOpenWithFiles = async () => {
return null;
};
export const parseOpenWithFiles = async () => {
export const parseOpenWithFiles = async (appService: AppService | null) => {
if (isWebAppPlatform()) return [];
let files = parseWindowOpenWithFiles();
@@ -63,7 +68,7 @@ export const parseOpenWithFiles = async () => {
files = await parseCLIOpenWithFiles();
}
if (!files || files.length === 0) {
files = await parseIntentOpenWithFiles();
files = await parseIntentOpenWithFiles(appService);
}
return files;
};
+1 -1
View File
@@ -1,6 +1,6 @@
const DEFAULT_SHORTCUTS = {
onSwitchSideBar: ['ctrl+Tab', 'opt+Tab', 'alt+Tab'],
onToggleSideBar: ['s', 'F9'],
onToggleSideBar: ['s'],
onToggleNotebook: ['n'],
onShowSearchBar: ['ctrl+f', 'cmd+f'],
onToggleScrollMode: ['shift+j'],
+19 -8
View File
@@ -36,7 +36,11 @@ export function useOpenWithBooks() {
const filePaths = [];
for (let url of urls) {
if (url.startsWith('file://')) {
url = decodeURI(url.replace('file://', ''));
if (appService?.isIOSApp) {
url = decodeURI(url);
} else {
url = decodeURI(url.replace('file://', ''));
}
}
if (!/^(https?:|data:|blob:)/i.test(url)) {
filePaths.push(url);
@@ -73,20 +77,27 @@ export function useOpenWithBooks() {
if (listenedOpenWithBooks.current) return;
listenedOpenWithBooks.current = true;
const unlistenDeeplink = getCurrentWindow().listen('single-instance', ({ event, payload }) => {
console.log('Received deep link:', event, payload);
const { args } = payload as SingleInstancePayload;
if (args?.[1]) {
handleOpenWithFileUrl([args[1]]);
}
});
// For Windows/Linux deep link and macOS open-file event
const unlistenDeeplink = getCurrentWindow().listen<SingleInstancePayload>(
'single-instance',
({ event, payload }) => {
console.log('Received deep link:', event, payload);
const { args } = payload;
if (args?.[1]) {
handleOpenWithFileUrl([args[1]]);
}
},
);
// For Android "Share to Readest" intent
let unlistenSharedIntent: Promise<PluginListener> | null = null;
// FIXME: register/unregister plugin listeniner on iOS might cause app freeze for unknown reason
// so we only register it on Android for now to support "Shared to Readest" feature
if (appService?.isAndroidApp) {
unlistenSharedIntent = initializeListeners();
}
// iOS Open with URL event
const listenOpenWithFiles = async () => {
return await onOpenUrl((urls) => {
handleOpenWithFileUrl(urls);
@@ -194,7 +194,7 @@ export const nativeFileSystem: FileSystem = {
let fname = name || getFilename(fp);
if (isValidURL(path)) {
return await new RemoteFile(path, fname).open();
} else if (isContentURI(path)) {
} else if (isContentURI(path) || (isFileURI(path) && OS_TYPE === 'ios')) {
fname = await basename(path);
if (path.includes('com.android.externalstorage')) {
// If the URI is from shared internal storage (like /storage/emulated/0),
@@ -202,6 +202,7 @@ export const nativeFileSystem: FileSystem = {
return await new NativeFile(fp, fname, baseDir ? baseDir : null).open();
} else {
// Otherwise, for content:// URIs (e.g. from MediaStore, Drive, or third-party apps),
// or file:// URIs is security scoped resource in iOS (e.g. from Files app),
// we cannot access the file directly — so we copy it to a temporary cache location.
const prefix = await this.getPrefix('Cache');
const dst = await join(prefix, fname);