Files
readest/apps/readest-app/src-tauri/gen/apple/ShareExtension/ShareViewController.swift
T
Huang Xin 62b5ed8138 feat(send): handle shared URLs from system share sheets (iOS + Android) (#4256)
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>
2026-05-21 18:50:33 +02:00

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:")
}
}