Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions ios/PiperShareExtension/PiperShareExtension.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.piper.app</string>
</array>
</dict>
</plist>
186 changes: 186 additions & 0 deletions ios/PiperShareExtension/Services/ContentExtractor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// ContentExtractor.swift — Extracts article content from a URL (Services layer)
// Loads a URL in a hidden WKWebView with injected cookies, then runs readability.js
// to pull out {title, content}. Never accessed by Views directly.

import Foundation
import WebKit

// MARK: - Errors

/// Errors that can occur during content extraction.
public enum ContentExtractionError: Error, LocalizedError {
case readabilityReturnedNull
case javascriptExecutionFailed(String)
case unexpectedResultType
case bundleResourceMissing

public var errorDescription: String? {
switch self {
case .readabilityReturnedNull:
return "Couldn't extract article — try opening in Safari first"
case .javascriptExecutionFailed(let detail):
return "JavaScript error: \(detail)"
case .unexpectedResultType:
return "Unexpected result from content extraction"
case .bundleResourceMissing:
return "readability.js resource is missing from the extension bundle"
}
}
}

// MARK: - Protocol (enables mocking in tests)

/// Abstracts content extraction so tests can inject a mock.
public protocol ContentExtracting {
func extract(from url: URL,
cookies: [HTTPCookie],
completion: @escaping (Result<ExtractedContent, Error>) -> Void)
}

// MARK: - ContentExtractor

/// Loads a URL in a hidden WKWebView, injects readability.js, and extracts
/// article {title, content}.
///
/// - Cookie injection: cookies are pushed into WKHTTPCookieStore before navigation.
/// - readability.js: bundled as a resource in the PiperShareExtension target.
/// - Memory: WKWebView is released as soon as extraction completes.
public final class ContentExtractor: NSObject, ContentExtracting, WKNavigationDelegate {

// MARK: - Private state

private var webView: WKWebView?
private var completion: ((Result<ExtractedContent, Error>) -> Void)?
private var pendingURL: URL?
private var pendingCookies: [HTTPCookie] = []
private let bundle: Bundle

/// Designated initialiser.
/// - Parameter bundle: The bundle from which readability.js is loaded.
/// Defaults to the extension's own bundle.
public init(bundle: Bundle = Bundle(for: ContentExtractor.self)) {
self.bundle = bundle
super.init()
}

// MARK: - ContentExtracting

/// Loads `url` with `cookies` injected, then extracts content.
/// The completion block is always called exactly once on the main thread.
public func extract(from url: URL,
cookies: [HTTPCookie],
completion: @escaping (Result<ExtractedContent, Error>) -> Void) {
// Guard: readability.js must be present.
guard bundle.url(forResource: "readability", withExtension: "js") != nil else {
DispatchQueue.main.async { completion(.failure(ContentExtractionError.bundleResourceMissing)) }
return
}

self.completion = completion
self.pendingURL = url
self.pendingCookies = cookies

// Create a fresh WKWebView configuration with a non-persistent data store.
let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.nonPersistent()
let wv = WKWebView(frame: .zero, configuration: config)
wv.navigationDelegate = self
self.webView = wv

// Inject cookies before loading the page.
let cookieStore = config.websiteDataStore.httpCookieStore
let group = DispatchGroup()
for cookie in cookies {
group.enter()
cookieStore.setCookie(cookie) { group.leave() }
}
group.notify(queue: .main) { [weak self] in
guard let self = self, let url = self.pendingURL else { return }
self.webView?.load(URLRequest(url: url))
}
}

// MARK: - WKNavigationDelegate

public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
injectReadabilityAndExtract(webView: webView)
}

public func webView(_ webView: WKWebView,
didFail navigation: WKNavigation!,
withError error: Error) {
finish(with: .failure(error))
}

public func webView(_ webView: WKWebView,
didFailProvisionalNavigation navigation: WKNavigation!,
withError error: Error) {
finish(with: .failure(error))
}

// MARK: - Private

private func injectReadabilityAndExtract(webView: WKWebView) {
// Load readability.js source from bundle.
guard let jsURL = bundle.url(forResource: "readability", withExtension: "js"),
let jsSource = try? String(contentsOf: jsURL, encoding: .utf8) else {
finish(with: .failure(ContentExtractionError.bundleResourceMissing))
return
}

// Build a script that injects Readability, runs parse(), and returns JSON.
let extractionScript = """
(function() {
\(jsSource)
try {
var article = new Readability(document).parse();
if (!article) { return JSON.stringify({error: "null"}); }
return JSON.stringify({title: article.title || "", content: article.content || ""});
} catch(e) {
return JSON.stringify({error: e.toString()});
}
})();
"""

webView.evaluateJavaScript(extractionScript) { [weak self] result, error in
guard let self = self else { return }

if let error = error {
self.finish(with: .failure(ContentExtractionError.javascriptExecutionFailed(error.localizedDescription)))
return
}

guard let jsonString = result as? String,
let data = jsonString.data(using: .utf8) else {
self.finish(with: .failure(ContentExtractionError.unexpectedResultType))
return
}

do {
let parsed = try JSONSerialization.jsonObject(with: data) as? [String: String]
if let errorMsg = parsed?["error"] {
if errorMsg == "null" {
self.finish(with: .failure(ContentExtractionError.readabilityReturnedNull))
} else {
self.finish(with: .failure(ContentExtractionError.javascriptExecutionFailed(errorMsg)))
}
return
}
let title = parsed?["title"] ?? ""
let content = parsed?["content"] ?? ""
self.finish(with: .success(ExtractedContent(title: title, content: content)))
} catch {
self.finish(with: .failure(error))
}
}
}

/// Calls the completion handler once and tears down the WKWebView.
private func finish(with result: Result<ExtractedContent, Error>) {
let block = completion
completion = nil
webView?.navigationDelegate = nil
webView = nil
DispatchQueue.main.async { block?(result) }
}
}
140 changes: 140 additions & 0 deletions ios/PiperShareExtension/Services/PiperAPIClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// PiperAPIClient.swift — Piper backend API client (Services layer)
// POSTs extracted content to the /save endpoint and returns the UUID URL.
// Never called by Views directly. Uses Config.backendBaseURL as the sole URL source.

import Foundation

// MARK: - Errors

/// Errors that can occur when communicating with the Piper backend.
public enum PiperAPIError: Error, LocalizedError {
case httpError(statusCode: Int, message: String)
case parseError
case networkError(Error)

public var errorDescription: String? {
switch self {
case .httpError(_, let message):
return "Failed to save: \(message)"
case .parseError:
return "Failed to parse server response"
case .networkError(let underlying):
return "Network error: \(underlying.localizedDescription)"
}
}
}

// MARK: - Protocol (enables mocking in tests)

/// Abstracts API calls so tests can inject a mock without hitting the network.
public protocol PiperAPIClientProtocol {
func save(title: String,
content: String,
completion: @escaping (Result<String, Error>) -> Void)
}

// MARK: - URLSession abstraction (enables test injection)

/// A minimal URLSession-like protocol so tests can inject a mock session.
public protocol URLSessionProtocol {
func dataTask(with request: URLRequest,
completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTaskProtocol
}

public protocol URLSessionDataTaskProtocol {
func resume()
}

extension URLSession: URLSessionProtocol {
public func dataTask(with request: URLRequest,
completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTaskProtocol {
return (dataTask(with: request, completionHandler: completionHandler) as URLSessionDataTask)
}
}

extension URLSessionDataTask: URLSessionDataTaskProtocol {}

// MARK: - PiperAPIClient

/// Sends article content to the Piper Cloudflare Worker /save endpoint.
///
/// The backend URL is taken from `Config.backendBaseURL` — never hardcoded here.
public final class PiperAPIClient: PiperAPIClientProtocol {

// MARK: - Dependencies

private let session: URLSessionProtocol

/// Designated initialiser.
/// - Parameter session: URLSession (or mock) to use for network calls.
public init(session: URLSessionProtocol = URLSession.shared) {
self.session = session
}

// MARK: - PiperAPIClientProtocol

/// POSTs `{title, content}` to the /save endpoint and returns the UUID URL on success.
/// The completion block is called on an arbitrary queue — callers must dispatch to main if needed.
public func save(title: String,
content: String,
completion: @escaping (Result<String, Error>) -> Void) {
// Build the URL from the single source of truth.
let urlString = Config.backendBaseURL + "/save"
guard let url = URL(string: urlString) else {
completion(.failure(PiperAPIError.networkError(
NSError(domain: "PiperAPIClient", code: -1,
userInfo: [NSLocalizedDescriptionKey: "Invalid backend URL"]))))
return
}

// Encode the request body.
let body: [String: String] = ["title": title, "content": content]
guard let bodyData = try? JSONSerialization.data(withJSONObject: body) else {
completion(.failure(PiperAPIError.parseError))
return
}

var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = bodyData

let task = session.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(PiperAPIError.networkError(error)))
return
}

guard let httpResponse = response as? HTTPURLResponse else {
completion(.failure(PiperAPIError.parseError))
return
}

guard (200..<300).contains(httpResponse.statusCode) else {
// Try to surface server-provided message.
var serverMessage = "please try again"
if let data = data,
let text = String(data: data, encoding: .utf8),
!text.isEmpty {
serverMessage = text
}
completion(.failure(PiperAPIError.httpError(statusCode: httpResponse.statusCode,
message: serverMessage)))
return
}

guard let data = data else {
completion(.failure(PiperAPIError.parseError))
return
}

do {
let decoded = try JSONDecoder().decode(SaveResponse.self, from: data)
completion(.success(decoded.url))
} catch {
completion(.failure(PiperAPIError.parseError))
}
}
task.resume()
}
}
Loading