Files
readest/apps/readest-app/src-tauri/gen/apple/ShareExtension/ShareViewController.swift
T
Huang Xin 912e97cb82 feat(send): iOS share-extension picker + App Group queue + reliable host launch (#4267)
* feat(send): iOS share-extension picker + App Group queue + reliable host launch

Rework the iOS Share Extension to a Zotero-style sheet: URL preview row +
library group picker + "Save & Open". Queues each save into the shared
App Group container and best-effort launches the host app via the
Chrome-style responder-chain trick (IMP cast against
`openURL:options:completionHandler:`). The host plugin drains the queue
on `applicationDidBecomeActive`, so if the launch ever fails the article
still ingests next time Readest is opened.

A `WKScriptMessageHandler` named `readestShareBridge` lets the JS hook
post `{type:'ready'}` on mount, fixing the cold-start race when the
extension wakes the app before the React side has loaded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(send): cover generator, favicon fetcher, share-extension polish, locale sync

Builds on the previous commit (iOS share-extension picker + App Group queue +
reliable host launch) with three further additions to the send-to-Readest
pipeline:

* **Cover generator** — `services/send/conversion/coverGenerator.ts` renders a
  deterministic cover image into clipped EPUBs (favicon + page title + host).
  Hooked into `buildEpub.ts` so every clip path (desktop, mobile, browser
  extension) produces the same cover for the same source.
* **Favicon fetcher** — `services/send/conversion/faviconFetcher.ts` resolves
  the best-available site icon (Open Graph image → apple-touch-icon →
  /favicon.ico), with size + format normalization. Feeds the cover generator.
* **Unified page conversion** — `convertToEpub({kind:'page', ...})` replaces
  the older `convertPageToEpub(html, url)` so the share extension, /send page,
  and browser extension share one entry point. Test: `send-convert-page-unified`.
* **Share-extension project.yml comment** — clarifies why the ShareExtension
  target carries no `.lproj` files (system bar buttons + JS-supplied "Default"
  label, no per-locale strings to wire).
* **Locale sync** — 33 translation.json files updated with new "Default",
  "Saving article…", and cover-generator strings extracted by i18next-scanner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

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

440 lines
16 KiB
Swift

// Share Extension for Readest: catches an article URL from any iOS share
// sheet (Safari, Chrome, third-party browsers), shows a small sheet UI
// that lets the user pick a target library group, then queues the save
// into the App Group container and best-effort launches Readest.
//
// Two delivery paths to the host app, in order of preference:
//
// 1. App Group queue + responder-chain launch.
// `AppGroupBridge.appendPendingSave` writes the URL + chosen group
// to the shared NSUserDefaults at `group.com.bilingify.readest`.
// We then walk the UIResponder chain looking for an object that
// responds to `openURL:options:completionHandler:` (UIApplication)
// and dispatch via an objc-runtime IMP cast. This is the pattern
// Chrome iOS ships in `ios/chrome/common/extension_open_url.mm`
// (using NSInvocation there; we use the equivalent IMP-cast trick
// since pure Swift can't see NSInvocation). Continues to work on
// iOS 26 — the deprecated `openURL:` selector is what breaks
// ("BUG IN CLIENT OF UIKIT" + no-op). The modern 3-arg selector is
// not directly visible to extensions but the responder chain still
// hands UIApplication over for runtime dispatch.
//
// 2. App Group queue as standalone fallback.
// If the launch trick is ever blocked by Apple, the save still
// sits in the queue. The host's `NativeBridgePlugin` drains it on
// `applicationDidBecomeActive` so the next time the user opens
// Readest manually, the article is ingested.
//
// `extensionContext.open(_:)` is intentionally not used — Apple docs
// scope it to Today widgets only and it returns success=false from
// Share Extensions on modern iOS regardless of URL scheme.
import ObjectiveC
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")
view.backgroundColor = .clear
Task { await self.loadAndPresent() }
}
// MARK: - Input handling
private func loadAndPresent() async {
guard let context = extensionContext else {
NSLog("[ReadestShare] no extensionContext")
return
}
let items = context.inputItems.compactMap { $0 as? NSExtensionItem }
NSLog("[ReadestShare] inputItems count=\(items.count)")
let url = await firstShareableURL(from: items)
let pageTitle =
items
.compactMap { $0.attributedTitle?.string ?? $0.attributedContentText?.string }
.first { !$0.isEmpty }
await MainActor.run {
guard let url = url else {
NSLog("[ReadestShare] no URL found, cancelling")
self.cancelRequest()
return
}
NSLog("[ReadestShare] presenting picker for URL: %@", url.absoluteString)
self.presentPicker(url: url, pageTitle: pageTitle)
}
}
private func presentPicker(url: URL, pageTitle: String?) {
let groups = AppGroupBridge.readGroups()
let options = SaveOptionsViewController(
url: url,
pageTitle: pageTitle,
groups: groups,
onCancel: { [weak self] in
self?.cancelRequest()
},
onSave: { [weak self] selectedGroup in
self?.handleSave(url: url, group: selectedGroup)
}
)
let nav = UINavigationController(rootViewController: options)
nav.view.translatesAutoresizingMaskIntoConstraints = false
addChild(nav)
view.addSubview(nav.view)
NSLayoutConstraint.activate([
nav.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
nav.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
nav.view.topAnchor.constraint(equalTo: view.topAnchor),
nav.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
nav.didMove(toParent: self)
}
// MARK: - Save / Cancel
private func handleSave(url: URL, group: AppGroupBridge.LibraryGroup?) {
let save = AppGroupBridge.PendingSave(
url: url.absoluteString,
groupId: group?.id,
groupName: group?.name,
addedAt: AppGroupBridge.nowIso8601()
)
AppGroupBridge.appendPendingSave(save)
NSLog("[ReadestShare] queued save for %@ group=%@", url.absoluteString, group?.name ?? "<none>")
if let target = buildTargetURL(scheme: "readest", host: "clip", inner: url) {
let opened = openViaResponderChain(target)
NSLog("[ReadestShare] responder-chain launch=%@", opened ? "yes" : "no")
}
completeOnce()
}
private func cancelRequest() {
guard !didCompleteOnce else { return }
didCompleteOnce = true
let err = NSError(domain: "ReadestShare", code: NSUserCancelledError, userInfo: nil)
extensionContext?.cancelRequest(withError: err)
}
private func completeOnce() {
guard !didCompleteOnce else { return }
didCompleteOnce = true
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
// MARK: - URL extraction
private func firstShareableURL(from items: [NSExtensionItem]) async -> URL? {
for item in items {
guard let attachments = item.attachments else { continue }
for attachment in attachments {
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
}
// MARK: - Launch trick (Chrome iOS pattern, IMP-cast variant)
/// Build `<scheme>://<host>?url=<percent-encoded-inner>`. The inner URL
/// is encoded against the RFC 3986 unreserved set so every URL-
/// significant character (`?`, `&`, `=`, `:`, `/`, `#`) is escaped and
/// the outer parser sees exactly one `?` and one `=`.
private func buildTargetURL(scheme: String, host: 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)?url=\(encoded)")
}
/// Walk the responder chain to find a responder that implements
/// `openURL:options:completionHandler:` (UIApplication), then call it
/// via an Objective-C IMP cast. The IMP-cast path is equivalent to
/// Chrome's NSInvocation pattern in `ios/chrome/common/extension_open_url.mm`
/// but works in pure Swift — we never name `UIApplication` so the
/// App Store extension symbol scanner doesn't reject the binary.
///
/// `openURL:options:completionHandler:` (non-deprecated) is what
/// continues to work on iOS 26. The legacy `openURL:` selector logs
/// "BUG IN CLIENT OF UIKIT" and no-ops.
@discardableResult
private func openViaResponderChain(_ url: URL) -> Bool {
typealias OpenURLFn = @convention(c) (
AnyObject, Selector, URL, NSDictionary?, AnyObject?
) -> Void
let selector = NSSelectorFromString("openURL:options:completionHandler:")
var responder: UIResponder? = self
while let r = responder {
if r.responds(to: selector) {
let target = r as AnyObject
let cls: AnyClass = object_getClass(target) ?? type(of: target)
guard let method = class_getInstanceMethod(cls, selector) else {
responder = r.next
continue
}
let imp = method_getImplementation(method)
let fn = unsafeBitCast(imp, to: OpenURLFn.self)
fn(target, selector, url, nil, nil)
NSLog("[ReadestShare] openURL invoked on \(cls) via IMP")
return true
}
responder = r.next
}
NSLog("[ReadestShare] no responder accepted openURL:options:completionHandler:")
return false
}
}
// MARK: - SaveOptionsViewController
/// URL preview row + per-group radio list, with Cancel and "Save"
/// in the nav bar. Mirrors the Zotero / Pocket save UX.
private final class SaveOptionsViewController: UITableViewController {
private let url: URL
private let pageTitle: String?
private let groups: [AppGroupBridge.LibraryGroup]
// nil means "Default" (no group). Initial selection: nil.
private var selectedGroupId: String?
private let onCancel: () -> Void
private let onSave: (AppGroupBridge.LibraryGroup?) -> Void
init(
url: URL,
pageTitle: String?,
groups: [AppGroupBridge.LibraryGroup],
onCancel: @escaping () -> Void,
onSave: @escaping (AppGroupBridge.LibraryGroup?) -> Void
) {
self.url = url
self.pageTitle = pageTitle
self.groups = groups
self.onCancel = onCancel
self.onSave = onSave
super.init(style: .insetGrouped)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
title = NSLocalizedString("Save to Readest", comment: "Share extension title")
// Both Cancel and Save are iOS system bar button items — UIKit
// localizes them automatically for every language Apple ships, so
// the extension doesn't carry its own .strings file for them.
navigationItem.leftBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped))
navigationItem.rightBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .save, target: self, action: #selector(saveTapped))
tableView.register(URLPreviewCell.self, forCellReuseIdentifier: URLPreviewCell.reuseId)
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "groupCell")
}
@objc private func cancelTapped() { onCancel() }
@objc private func saveTapped() {
let selected = groups.first { $0.id == selectedGroupId }
onSave(selected)
}
// MARK: Table source
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
section == 0 ? 1 : groups.count + 1 // +1 for "Default"
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int)
-> String?
{
section == 1 ? NSLocalizedString("GROUP", comment: "Group picker header") : nil
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath)
-> UITableViewCell
{
if indexPath.section == 0 {
let cell =
tableView.dequeueReusableCell(withIdentifier: URLPreviewCell.reuseId, for: indexPath)
as! URLPreviewCell
cell.configure(url: url, pageTitle: pageTitle)
cell.selectionStyle = .none
return cell
}
let cell = tableView.dequeueReusableCell(withIdentifier: "groupCell", for: indexPath)
let name: String
let isSelected: Bool
if indexPath.row == 0 {
// JS-supplied user-locale "Default" label (see AppGroupBridge).
// Falls back to English when the host hasn't synced yet — happens
// on the very first share before the app has been opened.
name = AppGroupBridge.readDefaultGroupName() ?? "Default"
isSelected = selectedGroupId == nil
} else {
let group = groups[indexPath.row - 1]
name = group.name
isSelected = selectedGroupId == group.id
}
cell.textLabel?.text = name
cell.accessoryType = isSelected ? .checkmark : .none
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard indexPath.section == 1 else { return }
selectedGroupId = indexPath.row == 0 ? nil : groups[indexPath.row - 1].id
tableView.reloadSections(IndexSet(integer: 1), with: .none)
}
}
// MARK: - URLPreviewCell
private final class URLPreviewCell: UITableViewCell {
static let reuseId = "URLPreviewCell"
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let hostLabel = UILabel()
private var faviconTask: URLSessionDataTask?
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: .default, reuseIdentifier: reuseIdentifier)
iconView.translatesAutoresizingMaskIntoConstraints = false
iconView.contentMode = .scaleAspectFit
iconView.clipsToBounds = true
iconView.layer.cornerRadius = 4
iconView.image = UIImage(systemName: "doc.text")
iconView.tintColor = .secondaryLabel
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.font = .preferredFont(forTextStyle: .body)
titleLabel.numberOfLines = 2
hostLabel.translatesAutoresizingMaskIntoConstraints = false
hostLabel.font = .preferredFont(forTextStyle: .footnote)
hostLabel.textColor = .secondaryLabel
hostLabel.numberOfLines = 1
contentView.addSubview(iconView)
contentView.addSubview(titleLabel)
contentView.addSubview(hostLabel)
NSLayoutConstraint.activate([
iconView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
iconView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
iconView.widthAnchor.constraint(equalToConstant: 40),
iconView.heightAnchor.constraint(equalToConstant: 40),
titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 12),
titleLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
titleLabel.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor),
hostLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
hostLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
hostLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2),
hostLabel.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
faviconTask?.cancel()
faviconTask = nil
iconView.image = UIImage(systemName: "doc.text")
iconView.tintColor = .secondaryLabel
}
func configure(url: URL, pageTitle: String?) {
let host = url.host ?? url.absoluteString
titleLabel.text = pageTitle?.isEmpty == false ? pageTitle : url.absoluteString
hostLabel.text = host
loadFavicon(for: url)
}
/// Best-effort favicon at `https://<host>/favicon.ico` with a 2s
/// timeout. On any failure we keep the placeholder — never blocking
/// the user's save action on a network round-trip.
private func loadFavicon(for url: URL) {
guard let host = url.host, var components = URLComponents(string: "https://\(host)") else {
return
}
components.path = "/favicon.ico"
guard let iconURL = components.url else { return }
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 2
let session = URLSession(configuration: config)
let task = session.dataTask(with: iconURL) { [weak self] data, response, _ in
guard let data = data,
let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode),
let image = UIImage(data: data)
else { return }
DispatchQueue.main.async {
self?.iconView.image = image
self?.iconView.tintColor = nil
}
}
task.resume()
faviconTask = task
}
}