17749f7cc7
iOS and Android now run the same Web-URL clip flow as desktop. Paste
an article URL, the native side opens a full-screen WKWebView /
WebView with the same Chrome UA + fingerprint mask + "Saving to
Readest" overlay as the desktop hidden window, waits for load +
settle, captures `document.documentElement.outerHTML` via the
platform's `evaluateJavaScript`, and returns it through the existing
`convertToEpub` pipeline.
JS surface stays `invoke('clip_url', { url, options })` — no changes
in `library/page.tsx` or `send/page.tsx`. The platform branch lives
entirely in `clip_url.rs`.
Why not Tauri's `Window::add_child`
`add_child` is gated `#[cfg(any(test, all(desktop, feature =
"unstable")))]` in tauri 2.10. No public API for attaching a second
webview to the main window on mobile, so the clip flow can't be a
`#[cfg(mobile)]` branch of the existing `WebviewWindowBuilder` shape
— it needs native code. Extend `tauri-plugin-native-bridge` rather
than create a separate plugin: the Swift / Kotlin scaffolding +
Tauri IPC are already there.
Layout
- `src-tauri/src/clip_url.rs` — desktop branch unchanged; new
`#[cfg(mobile)]` `clip_url` command routes through
`app.native_bridge().clip_url(request)`. Shared `ClipOptions`
struct exposes its fields `pub` so the mobile branch can map into
the plugin's `ClipUrlRequest`.
- `plugins/tauri-plugin-native-bridge/src/models.rs` — `ClipUrlRequest`
+ `ClipUrlResponse` mirroring `ClipOptions` field-for-field so the
payload travels untouched from JS through to Swift/Kotlin.
- `plugins/tauri-plugin-native-bridge/src/{desktop,mobile}.rs` — desktop
returns an error (desktop has its own path); mobile dispatches via
`run_mobile_plugin("clip_url", payload)`.
- `ios/Sources/ClipUrlController.swift` — `UIViewController` hosting
`WKWebView` with the loading overlay drawn as native UIKit views
(not an injected user script, so the page's own hydration can't
wipe the spinner). 30 s hard timeout + 3 s settle window after
`didFinish`, same as desktop. Fingerprint mask injected as
`WKUserScript` at `.atDocumentStart`.
- `android/src/main/java/ClipUrlController.kt` — full-screen Dialog
hosting a `WebView`, mirrors the iOS controller's behaviour. JSON-
decodes the `evaluateJavascript` callback (raw return value is a
JSON-encoded string).
- `NativeBridgePlugin.{swift,kt}` — new `clip_url` method that parses
args via `invoke.parseArgs`, presents the controller, resolves the
invoke with `{ html }` on success or `invoke.reject` on failure.
Same rejection vocabulary as desktop (`"Invalid URL"`, `"Page took
too long to load"`, etc.) so the calling JS doesn't need a
platform branch.
- `build.rs` — adds `clip_url` to the plugin's `COMMANDS` array.
Notes
- The Swift overlay reserves the iOS safe-area-edge-to-edge so notch /
Dynamic Island devices don't see the underlying app peek through
during the brief capture window.
- The Android overlay's spinner tint follows the foreground theme
colour at 85 % alpha — same idea as the iOS controller.
- `WKWebView`'s JS keeps running while the controller is presented;
no off-screen / `isHidden` trick that would let iOS throttle the
page mid-capture.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
327 lines
12 KiB
Swift
327 lines
12 KiB
Swift
import UIKit
|
|
import WebKit
|
|
import os
|
|
|
|
private let clipLogger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ClipUrl")
|
|
|
|
/// Args decoded from the JS `invoke('clip_url', { ... })` payload.
|
|
/// Mirrors `ClipOptions` in `clip_url.rs` field-for-field (camelCase
|
|
/// JSON, all-but-`url` optional). The defaults below match the Rust
|
|
/// `ClipOptions::*()` accessors — English / dark-palette — so a
|
|
/// caller that omits a field still renders sensibly.
|
|
class ClipUrlArgs: Decodable {
|
|
let url: String
|
|
let windowTitle: String?
|
|
let overlayTitle: String?
|
|
let loadingStatus: String?
|
|
let capturingStatus: String?
|
|
let savedTitle: String?
|
|
let background: String?
|
|
let foreground: String?
|
|
|
|
// Resolved getters with the same fallbacks the Rust impl uses.
|
|
var resolvedOverlayTitle: String { overlayTitle ?? "Saving to Readest" }
|
|
var resolvedLoadingStatus: String { loadingStatus ?? "Loading article…" }
|
|
var resolvedCapturingStatus: String { capturingStatus ?? "Capturing article…" }
|
|
var resolvedBackground: String { background ?? "#1f2024" }
|
|
var resolvedForeground: String { foreground ?? "#f5f5f7" }
|
|
}
|
|
|
|
/// Errors surfaced to the JS caller through `invoke.reject`. Strings
|
|
/// match the desktop `clip_url` error vocabulary so callers handling
|
|
/// the rejection don't need a separate mobile branch.
|
|
enum ClipUrlError: Error {
|
|
case invalidUrl
|
|
case loadFailed(String)
|
|
case timedOut
|
|
|
|
var message: String {
|
|
switch self {
|
|
case .invalidUrl: return "Invalid URL"
|
|
case .loadFailed(let detail): return "Could not fetch this page: \(detail)"
|
|
case .timedOut: return "Page took too long to load"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Full-screen view controller that loads `options.url` in a WKWebView,
|
|
/// shows a deliberate "Saving…" overlay over the article render so the
|
|
/// user sees expected progress (not a website flashing by), and once
|
|
/// `webView(_:didFinish:)` fires + a 3 s settle window passes — long
|
|
/// enough for in-flight lazy-loaders / Cloudflare-style JS challenges
|
|
/// to resolve — captures `document.documentElement.outerHTML` and
|
|
/// hands it back via the completion handler.
|
|
///
|
|
/// Mirrors the desktop `clip_url` flow shape: same Chrome UA, same
|
|
/// fingerprint mask, same overlay (rendered via Swift here instead of
|
|
/// an inline JS user-script — Swift gives us a proper UIView spinner
|
|
/// that doesn't get wiped by the page's own hydration).
|
|
final class ClipUrlController: UIViewController, WKNavigationDelegate {
|
|
|
|
// Real Chrome UA. Tauri's default reports Safari/WKWebView, which
|
|
// sites with aggressive bot detection cross-check against
|
|
// `navigator.*` fingerprints and reject. Same string as the desktop
|
|
// `BROWSER_UA` constant in `clip_url.rs`.
|
|
static let browserUserAgent =
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
|
|
|
// Total budget from load-start to outerHTML capture. Same as desktop.
|
|
static let hardTimeoutSeconds: TimeInterval = 30
|
|
// Settle delay after `didFinish` so IntersectionObserver-based lazy
|
|
// loaders fire for content already in the viewport.
|
|
static let loadSettleSeconds: TimeInterval = 3
|
|
|
|
private let args: ClipUrlArgs
|
|
private let completion: (Result<String, ClipUrlError>) -> Void
|
|
|
|
private var webView: WKWebView!
|
|
private var overlayView: UIView!
|
|
private var statusLabel: UILabel!
|
|
private var didFinishOrFail = false
|
|
private var captureFired = false
|
|
private var timeoutWorkItem: DispatchWorkItem?
|
|
|
|
init(args: ClipUrlArgs, completion: @escaping (Result<String, ClipUrlError>) -> Void) {
|
|
self.args = args
|
|
self.completion = completion
|
|
super.init(nibName: nil, bundle: nil)
|
|
// Modal full-screen so the overlay covers the chrome and the app
|
|
// behind it doesn't peek through during the brief capture window.
|
|
self.modalPresentationStyle = .fullScreen
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) is not supported for ClipUrlController")
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
setUpWebView()
|
|
setUpOverlay()
|
|
startCapture()
|
|
}
|
|
|
|
// MARK: - Setup
|
|
|
|
private func setUpWebView() {
|
|
let config = WKWebViewConfiguration()
|
|
// Inject the same fingerprint-mask script the desktop flow uses,
|
|
// before any page script runs, so `navigator.webdriver` and the
|
|
// window.chrome shape look real to first-party detection code.
|
|
let mask = WKUserScript(
|
|
source: ClipUrlController.fingerprintMaskScript(),
|
|
injectionTime: .atDocumentStart,
|
|
forMainFrameOnly: false
|
|
)
|
|
config.userContentController.addUserScript(mask)
|
|
|
|
config.allowsInlineMediaPlayback = true
|
|
config.mediaTypesRequiringUserActionForPlayback = .all
|
|
// Suppress text selection / link previews — purely cosmetic; the
|
|
// overlay covers the page anyway. Defensive, in case the spinner
|
|
// catches a stray tap.
|
|
config.preferences.javaScriptCanOpenWindowsAutomatically = false
|
|
|
|
let wv = WKWebView(frame: .zero, configuration: config)
|
|
wv.customUserAgent = ClipUrlController.browserUserAgent
|
|
wv.navigationDelegate = self
|
|
wv.allowsBackForwardNavigationGestures = false
|
|
wv.isOpaque = true
|
|
wv.backgroundColor = UIColor(hexString: args.resolvedBackground) ?? .black
|
|
wv.scrollView.backgroundColor = wv.backgroundColor
|
|
wv.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(wv)
|
|
NSLayoutConstraint.activate([
|
|
wv.topAnchor.constraint(equalTo: view.topAnchor),
|
|
wv.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
wv.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
wv.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
])
|
|
self.webView = wv
|
|
}
|
|
|
|
private func setUpOverlay() {
|
|
let bg = UIColor(hexString: args.resolvedBackground) ?? .black
|
|
let fg = UIColor(hexString: args.resolvedForeground) ?? .white
|
|
view.backgroundColor = bg
|
|
|
|
let overlay = UIView()
|
|
overlay.backgroundColor = bg
|
|
overlay.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(overlay)
|
|
NSLayoutConstraint.activate([
|
|
overlay.topAnchor.constraint(equalTo: view.topAnchor),
|
|
overlay.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
overlay.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
overlay.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
])
|
|
|
|
let spinner = UIActivityIndicatorView(style: .large)
|
|
spinner.color = fg.withAlphaComponent(0.85)
|
|
spinner.translatesAutoresizingMaskIntoConstraints = false
|
|
spinner.startAnimating()
|
|
overlay.addSubview(spinner)
|
|
|
|
let title = UILabel()
|
|
title.text = args.resolvedOverlayTitle
|
|
title.textColor = fg
|
|
title.font = UIFont.systemFont(ofSize: 15, weight: .semibold)
|
|
title.textAlignment = .center
|
|
title.translatesAutoresizingMaskIntoConstraints = false
|
|
overlay.addSubview(title)
|
|
|
|
let status = UILabel()
|
|
status.text = args.resolvedLoadingStatus
|
|
status.textColor = fg.withAlphaComponent(0.7)
|
|
status.font = UIFont.systemFont(ofSize: 13)
|
|
status.textAlignment = .center
|
|
status.numberOfLines = 1
|
|
status.lineBreakMode = .byTruncatingTail
|
|
status.translatesAutoresizingMaskIntoConstraints = false
|
|
overlay.addSubview(status)
|
|
self.statusLabel = status
|
|
|
|
NSLayoutConstraint.activate([
|
|
spinner.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
|
|
spinner.centerYAnchor.constraint(equalTo: overlay.centerYAnchor, constant: -28),
|
|
title.topAnchor.constraint(equalTo: spinner.bottomAnchor, constant: 14),
|
|
title.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
|
|
title.leadingAnchor.constraint(greaterThanOrEqualTo: overlay.leadingAnchor, constant: 24),
|
|
title.trailingAnchor.constraint(lessThanOrEqualTo: overlay.trailingAnchor, constant: -24),
|
|
status.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
|
|
status.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
|
|
status.leadingAnchor.constraint(greaterThanOrEqualTo: overlay.leadingAnchor, constant: 24),
|
|
status.trailingAnchor.constraint(lessThanOrEqualTo: overlay.trailingAnchor, constant: -24),
|
|
])
|
|
self.overlayView = overlay
|
|
}
|
|
|
|
// MARK: - Capture flow
|
|
|
|
private func startCapture() {
|
|
guard let url = URL(string: args.url),
|
|
let scheme = url.scheme?.lowercased(),
|
|
scheme == "http" || scheme == "https"
|
|
else {
|
|
finish(.failure(.invalidUrl))
|
|
return
|
|
}
|
|
|
|
var req = URLRequest(url: url)
|
|
req.setValue(ClipUrlController.browserUserAgent, forHTTPHeaderField: "User-Agent")
|
|
webView.load(req)
|
|
|
|
// Hard timeout — fires even if `didFinish` never does (SPA, redirect
|
|
// chain, JS challenge that never resolves). Same 30 s budget as the
|
|
// desktop flow.
|
|
let work = DispatchWorkItem { [weak self] in
|
|
guard let self = self, !self.captureFired else { return }
|
|
clipLogger.warning("clip_url: hard timeout after \(ClipUrlController.hardTimeoutSeconds, privacy: .public)s")
|
|
self.finish(.failure(.timedOut))
|
|
}
|
|
timeoutWorkItem = work
|
|
DispatchQueue.main.asyncAfter(
|
|
deadline: .now() + ClipUrlController.hardTimeoutSeconds, execute: work)
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
guard !didFinishOrFail else { return }
|
|
didFinishOrFail = true
|
|
statusLabel.text = args.resolvedCapturingStatus
|
|
// Settle then capture. Matches the 3 s post-load delay in the
|
|
// desktop init script — long enough for in-flight asset fetches
|
|
// and lazy-load IntersectionObservers to fire.
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + ClipUrlController.loadSettleSeconds) {
|
|
[weak self] in
|
|
self?.captureOuterHtml()
|
|
}
|
|
}
|
|
|
|
func webView(
|
|
_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error
|
|
) {
|
|
if didFinishOrFail { return }
|
|
didFinishOrFail = true
|
|
finish(.failure(.loadFailed(error.localizedDescription)))
|
|
}
|
|
|
|
func webView(
|
|
_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!,
|
|
withError error: Error
|
|
) {
|
|
if didFinishOrFail { return }
|
|
didFinishOrFail = true
|
|
finish(.failure(.loadFailed(error.localizedDescription)))
|
|
}
|
|
|
|
private func captureOuterHtml() {
|
|
guard !captureFired else { return }
|
|
captureFired = true
|
|
webView.evaluateJavaScript("document.documentElement.outerHTML") { [weak self] result, error in
|
|
guard let self = self else { return }
|
|
if let html = result as? String, !html.isEmpty {
|
|
clipLogger.log("clip_url: captured \(html.count, privacy: .public) chars")
|
|
self.finish(.success(html))
|
|
} else {
|
|
let detail = error?.localizedDescription ?? "empty HTML"
|
|
self.finish(.failure(.loadFailed(detail)))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func finish(_ result: Result<String, ClipUrlError>) {
|
|
timeoutWorkItem?.cancel()
|
|
timeoutWorkItem = nil
|
|
// Stop the WebView before dismissing — otherwise its JS keeps
|
|
// running in the background until ARC tears it down, which on
|
|
// older devices can stutter the dismiss animation.
|
|
webView?.stopLoading()
|
|
webView?.navigationDelegate = nil
|
|
|
|
dismiss(animated: true) { [completion] in
|
|
completion(result)
|
|
}
|
|
}
|
|
|
|
// MARK: - Inline JS scripts
|
|
|
|
/// Same shape as `fingerprint_mask_script()` in `clip_url.rs`. Clears
|
|
/// the `navigator.webdriver` / window.chrome / navigator.languages
|
|
/// signals that obvious bot-detection scripts probe.
|
|
static func fingerprintMaskScript() -> String {
|
|
return """
|
|
(function() {
|
|
try {
|
|
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
|
} catch (e) {}
|
|
try {
|
|
if (!window.chrome) { window.chrome = { runtime: {} }; }
|
|
} catch (e) {}
|
|
try {
|
|
if (navigator.languages && navigator.languages.length === 0) {
|
|
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
|
|
}
|
|
} catch (e) {}
|
|
})();
|
|
"""
|
|
}
|
|
}
|
|
|
|
// MARK: - UIColor hex helper
|
|
|
|
private extension UIColor {
|
|
/// Parse `#rrggbb` (optionally without the `#`) into a UIColor.
|
|
/// Mirrors the Rust-side `parse_hex_color` semantics: returns nil for
|
|
/// anything malformed so the caller falls back to its own default.
|
|
convenience init?(hexString: String) {
|
|
var hex = hexString.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if hex.hasPrefix("#") { hex = String(hex.dropFirst()) }
|
|
guard hex.count == 6, let v = UInt32(hex, radix: 16) else { return nil }
|
|
let r = CGFloat((v >> 16) & 0xff) / 255.0
|
|
let g = CGFloat((v >> 8) & 0xff) / 255.0
|
|
let b = CGFloat(v & 0xff) / 255.0
|
|
self.init(red: r, green: g, blue: b, alpha: 1.0)
|
|
}
|
|
}
|