diff --git a/apps/readest-app/src-tauri/Info-ios.plist b/apps/readest-app/src-tauri/Info-ios.plist new file mode 100644 index 00000000..d679effa --- /dev/null +++ b/apps/readest-app/src-tauri/Info-ios.plist @@ -0,0 +1,8 @@ + + + + + LSSupportsOpeningDocumentsInPlace + + + diff --git a/apps/readest-app/src-tauri/Info.plist b/apps/readest-app/src-tauri/Info.plist index 30554cb8..bc00a3e0 100644 --- a/apps/readest-app/src-tauri/Info.plist +++ b/apps/readest-app/src-tauri/Info.plist @@ -19,6 +19,8 @@ EPUB Document LSHandlerRank Alternate + CFBundleTypeRole + Viewer LSItemContentTypes org.idpf.epub-container @@ -30,6 +32,8 @@ PDF Document LSHandlerRank Alternate + CFBundleTypeRole + Viewer LSItemContentTypes com.adobe.pdf @@ -41,6 +45,8 @@ FB2 Document LSHandlerRank Alternate + CFBundleTypeRole + Viewer LSItemContentTypes com.readest.fb2 @@ -52,6 +58,8 @@ CBZ Archive LSHandlerRank Alternate + CFBundleTypeRole + Viewer LSItemContentTypes com.readest.cbz @@ -63,6 +71,8 @@ MOBI Document LSHandlerRank Alternate + CFBundleTypeRole + Viewer LSItemContentTypes org.mobipocket.mobi @@ -74,6 +84,8 @@ AZW Document LSHandlerRank Alternate + CFBundleTypeRole + Viewer LSItemContentTypes com.amazon.azw @@ -86,6 +98,8 @@ Text File LSHandlerRank Alternate + CFBundleTypeRole + Viewer LSItemContentTypes public.plain-text @@ -93,6 +107,153 @@ + UTImportedTypeDeclarations + + + UTTypeIdentifier + org.idpf.epub-container + UTTypeDescription + EPUB Document + UTTypeConformsTo + + public.data + public.composite-content + + UTTypeTagSpecification + + public.filename-extension + + epub + + public.mime-type + application/epub+zip + + + + + UTTypeIdentifier + com.adobe.pdf + UTTypeDescription + PDF Document + UTTypeConformsTo + + public.data + public.composite-content + + UTTypeTagSpecification + + public.filename-extension + + pdf + + public.mime-type + application/pdf + + + + + UTTypeIdentifier + com.readest.fb2 + UTTypeDescription + FB2 Document + UTTypeConformsTo + + public.xml + + UTTypeTagSpecification + + public.filename-extension + + fb2 + + public.mime-type + application/xml + + + + + UTTypeIdentifier + com.readest.cbz + UTTypeDescription + CBZ Archive + UTTypeConformsTo + + public.archive + + UTTypeTagSpecification + + public.filename-extension + + cbz + + public.mime-type + application/x-cbz + + + + + UTTypeIdentifier + org.mobipocket.mobi + UTTypeDescription + MOBI Document + UTTypeConformsTo + + public.data + + UTTypeTagSpecification + + public.filename-extension + + mobi + + public.mime-type + application/x-mobipocket-ebook + + + + + UTTypeIdentifier + com.amazon.azw + UTTypeDescription + AZW Document + UTTypeConformsTo + + public.data + + UTTypeTagSpecification + + public.filename-extension + + azw + azw3 + + public.mime-type + application/vnd.amazon.ebook + + + + + UTTypeIdentifier + public.plain-text + UTTypeDescription + Text File + UTTypeConformsTo + + public.data + public.content + + UTTypeTagSpecification + + public.filename-extension + + txt + + public.mime-type + text/plain + + + + UTExportedTypeDeclarations diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift index 78457c23..ba4fc6d4 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift @@ -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") diff --git a/apps/readest-app/src-tauri/tauri.conf.json b/apps/readest-app/src-tauri/tauri.conf.json index 9403d353..31de56ca 100644 --- a/apps/readest-app/src-tauri/tauri.conf.json +++ b/apps/readest-app/src-tauri/tauri.conf.json @@ -72,6 +72,7 @@ }, "iOS": { "developmentTeam": "J5W48D69VR", + "infoPlist": "./Info-ios.plist", "minimumSystemVersion": "14.0" }, "fileAssociations": [ diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index 54ece2a4..ae0b73cb 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -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); diff --git a/apps/readest-app/src/app/reader/components/ReaderContent.tsx b/apps/readest-app/src/app/reader/components/ReaderContent.tsx index 8772b2a4..b9aa4c3c 100644 --- a/apps/readest-app/src/app/reader/components/ReaderContent.tsx +++ b/apps/readest-app/src/app/reader/components/ReaderContent.tsx @@ -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); diff --git a/apps/readest-app/src/helpers/openWith.ts b/apps/readest-app/src/helpers/openWith.ts index c8319057..47bcc34c 100644 --- a/apps/readest-app/src/helpers/openWith.ts +++ b/apps/readest-app/src/helpers/openWith.ts @@ -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; }; diff --git a/apps/readest-app/src/helpers/shortcuts.ts b/apps/readest-app/src/helpers/shortcuts.ts index eb2b89bc..ac6a2d3b 100644 --- a/apps/readest-app/src/helpers/shortcuts.ts +++ b/apps/readest-app/src/helpers/shortcuts.ts @@ -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'], diff --git a/apps/readest-app/src/hooks/useOpenWithBooks.ts b/apps/readest-app/src/hooks/useOpenWithBooks.ts index 7086ff75..055e82a9 100644 --- a/apps/readest-app/src/hooks/useOpenWithBooks.ts +++ b/apps/readest-app/src/hooks/useOpenWithBooks.ts @@ -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( + '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 | 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); diff --git a/apps/readest-app/src/services/nativeAppService.ts b/apps/readest-app/src/services/nativeAppService.ts index 0a642df2..471acadd 100644 --- a/apps/readest-app/src/services/nativeAppService.ts +++ b/apps/readest-app/src/services/nativeAppService.ts @@ -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);