62b5ed8138
Users can now tap "Share → Readest" in Safari, Chrome, or any other browser on iOS / Android and the article URL flows through the same clip-and-import pipeline the in-app "From Web URL" entry uses. Android `MainActivity.handleIncomingIntent` already routed file shares via `ACTION_SEND` + `EXTRA_STREAM`. Extend it to also pick up URL shares via `ACTION_SEND` + `EXTRA_TEXT`: parse the first http(s) token out of the text payload and dispatch it on the existing `shared-intent` event channel. No new event channel needed — `useAppUrlIngress` already listens and re-broadcasts as `app-incoming-url`. The existing `<intent-filter>` for `ACTION_SEND` with `*/*` MIME type already accepts `text/plain` from browsers — no manifest change required. iOS `gen/apple/` gains a new ShareExtension target. The extension's `ShareViewController` extracts a URL from `NSExtensionContext.inputItems` (prefers `public.url`, falls back to first http(s) token in `public.plain-text`) and forwards it to the main app as `readest://clip?url=<encoded>` via the responder-chain `openURL:` selector — the standard share-extension trick used by Pocket, Instapaper, Matter, etc. `project.yml` adds the ShareExtension target and switches the main app's Info.plist / entitlements references to `INFOPLIST_FILE` / `CODE_SIGN_ENTITLEMENTS` build settings instead of xcodegen's `info:` / `entitlements:` blocks. That way the hand-tuned `Readest_iOS/Info.plist` (CFBundleDocumentTypes, UTExportedTypeDeclarations, locales, CFBundleURLTypes for readest://, applesignin, associated-domains for Universal Links) is treated as an opaque input — xcodegen won't regenerate it. JS New `useClipUrlIngress` hook subscribes to `app-incoming-url`, unwraps `readest://clip?url=<encoded>` into the inner URL (the iOS forwarding path), filters out file URIs and annotation deep links, and runs each remaining http(s) URL through `clip_url` → `convertToEpubWithWorker` → `ingestFile` — the same path `/send` uses. Mounted alongside `useOpenWithBooks` and `useOpenAnnotationLink` in both `app/library/page.tsx` and `app/reader/page.tsx` so shares arriving while the user is reading still process. Notes - The PR targets `feat/send-clip-mobile` (PR #4252) since the share pipeline depends on `clip_url` being available on mobile. - iOS Share Extension built locally via xcodegen; the regenerated pbxproj is tracked because gen/apple is gitignored. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
216 lines
8.7 KiB
Swift
216 lines
8.7 KiB
Swift
// Share Extension for Readest: catches an article URL from any iOS share
|
|
// sheet (Safari, Chrome, third-party browsers) and forwards it to the
|
|
// main app via the existing `readest://` URL scheme. The main app's
|
|
// `tauri-plugin-deep-link` integration emits an `onOpenUrl` event,
|
|
// `useAppUrlIngress` re-broadcasts it as `app-incoming-url`, and
|
|
// `useClipUrlIngress` clips + ingests the article through the same
|
|
// pipeline the in-app "From Web URL" entry uses.
|
|
|
|
import UIKit
|
|
import UniformTypeIdentifiers
|
|
|
|
final class ShareViewController: UIViewController {
|
|
|
|
// Single-shot: avoid double-firing if iOS re-presents the extension.
|
|
private var didCompleteOnce = false
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
NSLog("[ReadestShare] viewDidLoad")
|
|
// Kick the work as early as possible — iOS 26 sometimes dismisses
|
|
// the extension before `viewDidAppear` fires when the activation
|
|
// rule matches a single URL exactly. Running from `viewDidLoad`
|
|
// gives us the longest possible window.
|
|
Task { await processInput() }
|
|
}
|
|
|
|
/// Walk the inputItems for a URL or text payload and forward it.
|
|
/// Cancels the extension if no URL was found.
|
|
private func processInput() async {
|
|
NSLog("[ReadestShare] processInput started")
|
|
guard let context = extensionContext else {
|
|
NSLog("[ReadestShare] no extensionContext, bailing")
|
|
return
|
|
}
|
|
let items = (context.inputItems.compactMap { $0 as? NSExtensionItem })
|
|
NSLog("[ReadestShare] inputItems count=\(items.count)")
|
|
|
|
let url = await firstShareableURL(from: items)
|
|
if let url = url {
|
|
NSLog("[ReadestShare] found URL: \(url.absoluteString)")
|
|
await openInMainApp(url: url)
|
|
} else {
|
|
NSLog("[ReadestShare] no URL found in any inputItem")
|
|
}
|
|
await MainActor.run {
|
|
if !self.didCompleteOnce {
|
|
self.didCompleteOnce = true
|
|
NSLog("[ReadestShare] completing extension request")
|
|
context.completeRequest(returningItems: [], completionHandler: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Probe attachments for the first usable URL. Prefers a real
|
|
/// `public.url` attachment; falls back to scanning a `public.plain-text`
|
|
/// payload for an http(s) substring (some apps share "Title\nURL").
|
|
private func firstShareableURL(from items: [NSExtensionItem]) async -> URL? {
|
|
for (itemIdx, item) in items.enumerated() {
|
|
guard let attachments = item.attachments else { continue }
|
|
NSLog("[ReadestShare] item[\(itemIdx)] has \(attachments.count) attachments")
|
|
for (attIdx, attachment) in attachments.enumerated() {
|
|
NSLog(
|
|
"[ReadestShare] attachment[\(attIdx)] types: \(attachment.registeredTypeIdentifiers)")
|
|
if attachment.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
|
|
if let url = try? await loadURL(from: attachment), Self.isHttp(url) {
|
|
return url
|
|
}
|
|
}
|
|
if attachment.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
|
|
if let text = try? await loadText(from: attachment),
|
|
let url = Self.extractHTTPURL(from: text)
|
|
{
|
|
return url
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func loadURL(from provider: NSItemProvider) async throws -> URL? {
|
|
let item = try await provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil)
|
|
if let url = item as? URL { return url }
|
|
if let str = item as? String { return URL(string: str) }
|
|
if let data = item as? Data,
|
|
let str = String(data: data, encoding: .utf8)
|
|
{
|
|
return URL(string: str)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func loadText(from provider: NSItemProvider) async throws -> String? {
|
|
let item = try await provider.loadItem(
|
|
forTypeIdentifier: UTType.plainText.identifier, options: nil)
|
|
if let text = item as? String { return text }
|
|
if let data = item as? Data { return String(data: data, encoding: .utf8) }
|
|
return nil
|
|
}
|
|
|
|
private static func isHttp(_ url: URL) -> Bool {
|
|
let scheme = url.scheme?.lowercased() ?? ""
|
|
return scheme == "http" || scheme == "https"
|
|
}
|
|
|
|
private static func extractHTTPURL(from text: String) -> URL? {
|
|
for token in text.split(whereSeparator: { $0.isWhitespace }) {
|
|
let s = String(token)
|
|
if s.hasPrefix("http://") || s.hasPrefix("https://") {
|
|
if let url = URL(string: s), isHttp(url) { return url }
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Open Readest with the article URL. Tries three paths in order:
|
|
///
|
|
/// 1. `extensionContext.open(_:)` against `readest://clip?url=...`.
|
|
/// Apple's sanctioned share-extension → host-app handoff for
|
|
/// custom URL schemes. The main app's `tauri-plugin-deep-link`
|
|
/// catches the `readest://` scheme.
|
|
/// 2. `extensionContext.open(_:)` against the Universal Link
|
|
/// `https://web.readest.com/clip?url=...`. Only works when the
|
|
/// web.readest.com AASA file claims `/clip` for the app —
|
|
/// currently it likely doesn't, but tried as a defensive
|
|
/// fallback in case (1) fails on some iOS version.
|
|
/// 3. Responder-chain `openURL:` trick. iOS 26 silently blocks
|
|
/// this even when `responds(to: selector)` returns true and
|
|
/// the responder is `UIApplication`, so it's purely a
|
|
/// last-ditch attempt for older iOS.
|
|
///
|
|
/// Inner URL gets RFC-3986 percent-encoded so the outer URL's
|
|
/// query parser sees exactly one `?` (separating outer query from
|
|
/// outer path) and exactly one `=` per param. URLComponents alone
|
|
/// is NOT enough — its `.urlQueryAllowed` set permits `=`, `?`,
|
|
/// `&` and leaves them unescaped, which silently breaks the deep-
|
|
/// link parse (the inner URL's first `?s=...` gets promoted to an
|
|
/// outer query param). All log messages use the `%@` format
|
|
/// specifier with the URL passed as an argument so `printf`'s
|
|
/// percent-spec parser doesn't try to interpret the percent-encoded
|
|
/// characters in the URL.
|
|
@MainActor
|
|
private func openInMainApp(url: URL) async {
|
|
// 1. Custom URL scheme via extensionContext.open — the modern
|
|
// sanctioned path.
|
|
if let target = buildTargetURL(scheme: "readest", host: "clip", inner: url) {
|
|
NSLog("[ReadestShare] trying custom scheme via extensionContext: %@", target.absoluteString)
|
|
if await openViaExtensionContext(target) {
|
|
NSLog("[ReadestShare] custom scheme open succeeded")
|
|
return
|
|
}
|
|
NSLog("[ReadestShare] custom scheme open failed")
|
|
}
|
|
|
|
// 2. Universal Link via extensionContext.open.
|
|
if let target = buildTargetURL(
|
|
scheme: "https", host: "web.readest.com", path: "/clip", inner: url)
|
|
{
|
|
NSLog("[ReadestShare] trying universal link: %@", target.absoluteString)
|
|
if await openViaExtensionContext(target) {
|
|
NSLog("[ReadestShare] universal link open succeeded")
|
|
return
|
|
}
|
|
NSLog("[ReadestShare] universal link open failed")
|
|
}
|
|
|
|
// 3. Responder-chain — usually blocked on iOS 26 but tried for
|
|
// completeness so older devices still get the handoff.
|
|
if let target = buildTargetURL(scheme: "readest", host: "clip", inner: url) {
|
|
NSLog("[ReadestShare] trying responder-chain: %@", target.absoluteString)
|
|
openViaResponderChain(target)
|
|
}
|
|
}
|
|
|
|
/// Build a target URL like `<scheme>://<host><path>?url=<inner>`.
|
|
/// Hand-encodes the inner URL against the RFC 3986 "unreserved" set
|
|
/// (alnum + `-._~`) so every URL-significant character — including
|
|
/// `?`, `&`, `=`, `:`, `/`, `#` — gets percent-encoded. See
|
|
/// `openInMainApp` for why URLComponents alone is insufficient.
|
|
private func buildTargetURL(scheme: String, host: String, path: String = "", inner: URL) -> URL?
|
|
{
|
|
let unreserved = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~"))
|
|
guard
|
|
let encoded = inner.absoluteString.addingPercentEncoding(withAllowedCharacters: unreserved)
|
|
else { return nil }
|
|
return URL(string: "\(scheme)://\(host)\(path)?url=\(encoded)")
|
|
}
|
|
|
|
@MainActor
|
|
private func openViaExtensionContext(_ url: URL) async -> Bool {
|
|
await withCheckedContinuation { continuation in
|
|
guard let ctx = extensionContext else {
|
|
continuation.resume(returning: false)
|
|
return
|
|
}
|
|
ctx.open(url, completionHandler: { success in
|
|
continuation.resume(returning: success)
|
|
})
|
|
}
|
|
}
|
|
|
|
private func openViaResponderChain(_ url: URL) {
|
|
var responder: UIResponder? = self
|
|
let selector = sel_registerName("openURL:")
|
|
while let r = responder {
|
|
if r.responds(to: selector) {
|
|
_ = r.perform(selector, with: url)
|
|
NSLog("[ReadestShare] responder-chain openURL: invoked on \(type(of: r))")
|
|
return
|
|
}
|
|
responder = r.next
|
|
}
|
|
NSLog("[ReadestShare] responder-chain found no responder for openURL:")
|
|
}
|
|
}
|