diff --git a/.claude/scripts/run-verify.sh b/.claude/scripts/run-verify.sh index d3b592d..da08b41 100755 --- a/.claude/scripts/run-verify.sh +++ b/.claude/scripts/run-verify.sh @@ -53,12 +53,13 @@ if [ -d "ios" ]; then # On VPS (Linux) this step is skipped automatically. if command -v xcodebuild &>/dev/null; then XCODE_PROJECT="ios/Piper/Piper.xcodeproj" - # Pick the first available iPhone simulator. - SIM_DEST=$(xcodebuild -project "$XCODE_PROJECT" -scheme Piper \ + # Pick the first available iPhone simulator (name only for reliable matching). + SIM_NAME=$(xcodebuild -project "$XCODE_PROJECT" -scheme Piper \ -showdestinations 2>/dev/null \ | grep 'platform:iOS Simulator.*iPhone' \ | head -1 \ - | sed 's/.*{ //' | sed 's/ }.*//') || true + | sed 's/.*name://' | sed 's/ }.*//') || true + SIM_DEST="${SIM_NAME:+platform=iOS Simulator,name=$SIM_NAME}" if [ -n "$SIM_DEST" ]; then echo "▶ ios: xcodebuild build (${SIM_DEST})" diff --git a/ios/Piper/Piper/ExtractionWebView.swift b/ios/Piper/Piper/ExtractionWebView.swift new file mode 100644 index 0000000..02b2c87 --- /dev/null +++ b/ios/Piper/Piper/ExtractionWebView.swift @@ -0,0 +1,191 @@ +// ExtractionWebView.swift — UIViewRepresentable for content extraction (View layer) +// Hosts a WKWebView in the view hierarchy so iOS grants process assertions. +// Injects cookies, loads the target URL, runs readability.js, and returns ExtractedContent. + +import SwiftUI +import WebKit + +/// UIViewRepresentable that extracts article content from a URL. +/// Must be the primary rendered content (not hidden or zero-sized) for iOS to grant +/// WKWebView process assertions on real devices. +struct ExtractionWebView: UIViewRepresentable { + + let url: URL + let cookies: [HTTPCookie] + let bundle: Bundle + let onResult: (Result) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(url: url, cookies: cookies, bundle: bundle, onResult: onResult) + } + + func makeUIView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + // Request desktop content mode to prevent X.com from redirecting to + // twitter:// deep links, which WKWebView can't load (error 102). + let pagePrefs = WKWebpagePreferences() + pagePrefs.preferredContentMode = .desktop + config.defaultWebpagePreferences = pagePrefs + + let webView = WKWebView(frame: .zero, configuration: config) + webView.navigationDelegate = context.coordinator + // Desktop Safari UA — X.com serves web content instead of app redirects. + webView.customUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15" + + // Inject cookies before loading. + let cookieStore = config.websiteDataStore.httpCookieStore + let group = DispatchGroup() + for cookie in cookies { + group.enter() + cookieStore.setCookie(cookie) { group.leave() } + } + group.notify(queue: .main) { + webView.load(URLRequest(url: url)) + } + + return webView + } + + func updateUIView(_ uiView: WKWebView, context: Context) {} + + // MARK: - Coordinator + + final class Coordinator: NSObject, WKNavigationDelegate { + + private let url: URL + private let cookies: [HTTPCookie] + private let bundle: Bundle + private let onResult: (Result) -> Void + private var hasCompleted = false + private var retryCount = 0 + private let maxRetries = 2 + private var extractionAttempts = 0 + private let maxExtractionAttempts = 5 + + init(url: URL, cookies: [HTTPCookie], bundle: Bundle, + onResult: @escaping (Result) -> Void) { + self.url = url + self.cookies = cookies + self.bundle = bundle + self.onResult = onResult + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + guard !hasCompleted else { return } + // X.com is a React SPA — content renders via JS after didFinish. + // Wait for the SPA to hydrate before running readability.js. + scheduleExtraction(webView: webView, delay: 2.0) + } + + func webView(_ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + decisionHandler(ContentExtractor.navigationPolicy(for: navigationAction.request.url)) + } + + func webView(_ webView: WKWebView, + didFail navigation: WKNavigation!, + withError error: Error) { + handleFailure(webView: webView, error: error) + } + + func webView(_ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error) { + handleFailure(webView: webView, error: error) + } + + // MARK: - Private + + private func handleFailure(webView: WKWebView, error: Error) { + guard !hasCompleted else { return } + let nsError = error as NSError + if nsError.domain == "WebKitErrorDomain" && nsError.code == 102 && retryCount < maxRetries { + retryCount += 1 + webView.load(URLRequest(url: url)) + return + } + finish(.failure(error)) + } + + private func scheduleExtraction(webView: WKWebView, delay: TimeInterval) { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self, !self.hasCompleted else { return } + self.extractionAttempts += 1 + self.injectReadabilityAndExtract(webView: webView) + } + } + + private func injectReadabilityAndExtract(webView: WKWebView) { + guard let jsURL = bundle.url(forResource: "readability", withExtension: "js"), + let jsSource = try? String(contentsOf: jsURL, encoding: .utf8) else { + finish(.failure(ContentExtractionError.bundleResourceMissing)) + return + } + + let script = """ + (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(script) { [weak self] result, error in + guard let self, !self.hasCompleted else { return } + + if let error { + self.finish(.failure(ContentExtractionError.javascriptExecutionFailed(error.localizedDescription))) + return + } + + guard let jsonString = result as? String, + let data = jsonString.data(using: .utf8) else { + self.finish(.failure(ContentExtractionError.unexpectedResultType)) + return + } + + do { + let parsed = try JSONSerialization.jsonObject(with: data) as? [String: String] + if let errorMsg = parsed?["error"] { + if errorMsg == "null" { + // SPA might not have rendered yet — retry + if self.extractionAttempts < self.maxExtractionAttempts { + self.scheduleExtraction(webView: webView, delay: 1.5) + return + } + self.finish(.failure(ContentExtractionError.readabilityReturnedNull)) + } else { + self.finish(.failure(ContentExtractionError.javascriptExecutionFailed(errorMsg))) + } + return + } + let title = parsed?["title"] ?? "" + let content = parsed?["content"] ?? "" + + // If content is thin and title is empty, SPA probably hasn't + // rendered yet — retry before accepting. + if title.isEmpty && content.count < 500 && self.extractionAttempts < self.maxExtractionAttempts { + self.scheduleExtraction(webView: webView, delay: 1.5) + return + } + + self.finish(.success(ExtractedContent(title: title, content: content))) + } catch { + self.finish(.failure(error)) + } + } + } + + private func finish(_ result: Result) { + guard !hasCompleted else { return } + hasCompleted = true + DispatchQueue.main.async { self.onResult(result) } + } + } +} diff --git a/ios/Piper/Piper/PipeView.swift b/ios/Piper/Piper/PipeView.swift index da6676d..af0ac39 100644 --- a/ios/Piper/Piper/PipeView.swift +++ b/ios/Piper/Piper/PipeView.swift @@ -10,7 +10,8 @@ import UIKit /// The UI state of the pipe operation. private enum PipeState: Equatable { case idle - case running + case extracting(URL, [HTTPCookie]) + case saving case success(String) // the UUID URL case failure(String) // the error message } @@ -34,60 +35,72 @@ struct PipeView: View { // MARK: - Body var body: some View { - VStack(spacing: 24) { - Spacer() - - switch pipeState { - case .idle: - idleContent + NavigationStack { + contentForState + .ignoresSafeArea(edges: .bottom) + .navigationTitle("Pipe Article") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + .accessibilityIdentifier("pipeDoneButton") + } + } + } + .onAppear { startPipe() } + } - case .running: - runningContent + // MARK: - Content + + @ViewBuilder + private var contentForState: some View { + switch pipeState { + case .extracting(let url, let cookies): + // WKWebView is the primary content — must be rendered for iOS to + // grant process assertions on real devices. Matches XLoginView pattern. + ExtractionWebView( + url: url, + cookies: cookies, + bundle: Bundle(for: ContentExtractor.self), + onResult: { result in handleExtractionResult(result) } + ) + .overlay { + progressOverlay(text: "Piping article…") + } - case .success(let url): - successContent(url: url) + case .idle: + progressOverlay(text: "Preparing…") - case .failure(let message): - failureContent(message: message) - } + case .saving: + progressOverlay(text: "Saving…") - Spacer() + case .success(let url): + successContent(url: url) - Button("Done") { dismiss() } - .font(.footnote) - .foregroundColor(.secondary) - .padding(.bottom, 16) - .accessibilityIdentifier("pipeDoneButton") + case .failure(let message): + failureContent(message: message) } - .padding(.horizontal, 32) - .onAppear { startPipe() } } // MARK: - State Views - private var idleContent: some View { + private func progressOverlay(text: String) -> some View { VStack(spacing: 12) { ProgressView() .accessibilityIdentifier("pipeActivityIndicator") - Text("Preparing…") - .font(.subheadline) - .foregroundColor(.secondary) - } - } - - private var runningContent: some View { - VStack(spacing: 12) { - ProgressView() - .accessibilityIdentifier("pipeActivityIndicator") - Text("Piping article…") + Text(text) .font(.subheadline) .foregroundColor(.secondary) .accessibilityIdentifier("pipeStatusLabel") } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.regularMaterial) } private func successContent(url: String) -> some View { VStack(spacing: 16) { + Spacer() + Image(systemName: "checkmark.circle.fill") .font(.system(size: 48)) .foregroundColor(.green) @@ -101,11 +114,16 @@ struct PipeView: View { .font(.caption) .foregroundColor(.secondary) .multilineTextAlignment(.center) + + Spacer() } + .padding(.horizontal, 32) } private func failureContent(message: String) -> some View { VStack(spacing: 16) { + Spacer() + Image(systemName: "xmark.circle.fill") .font(.system(size: 48)) .foregroundColor(.red) @@ -115,13 +133,15 @@ struct PipeView: View { .multilineTextAlignment(.center) .foregroundColor(.primary) .accessibilityIdentifier("pipeErrorLabel") + + Spacer() } + .padding(.horizontal, 32) } // MARK: - Pipeline private func startPipe() { - // Read URL from clipboard. let clipboardString = UIPasteboard.general.string ?? "" guard !clipboardString.isEmpty, URL(string: clipboardString) != nil else { @@ -129,20 +149,36 @@ struct PipeView: View { return } - pipeState = .running + do { + let (url, cookies) = try pipeline.validate(urlString: clipboardString) + pipeState = .extracting(url, cookies) + } catch { + pipeState = .failure(error.localizedDescription) + } + } - Task { - do { - let resultURL = try await pipeline.pipe(urlString: clipboardString) - await MainActor.run { - UIPasteboard.general.string = resultURL - pipeState = .success(resultURL) - } - } catch { - await MainActor.run { - pipeState = .failure(error.localizedDescription) + private func handleExtractionResult(_ result: Result) { + switch result { + case .success(let extracted): + pipeState = .saving + Task { + do { + let resultURL = try await pipeline.save( + title: extracted.title, + content: extracted.content + ) + await MainActor.run { + UIPasteboard.general.string = resultURL + pipeState = .success(resultURL) + } + } catch { + await MainActor.run { + pipeState = .failure(error.localizedDescription) + } } } + case .failure(let error): + pipeState = .failure(error.localizedDescription) } } } diff --git a/ios/Piper/Piper/Services/ContentExtractor.swift b/ios/Piper/Piper/Services/ContentExtractor.swift index 8b86d45..7b88a1d 100644 --- a/ios/Piper/Piper/Services/ContentExtractor.swift +++ b/ios/Piper/Piper/Services/ContentExtractor.swift @@ -83,11 +83,14 @@ public final class ContentExtractor: NSObject, ContentExtracting, WKNavigationDe self.pendingCookies = cookies self.retryCount = 0 - // Create a fresh WKWebView configuration with a non-persistent data store. let config = WKWebViewConfiguration() - config.websiteDataStore = WKWebsiteDataStore.nonPersistent() + let pagePrefs = WKWebpagePreferences() + pagePrefs.preferredContentMode = .desktop + config.defaultWebpagePreferences = pagePrefs + let wv = WKWebView(frame: .zero, configuration: config) wv.navigationDelegate = self + wv.customUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15" self.webView = wv // Inject cookies before loading the page. @@ -109,6 +112,18 @@ public final class ContentExtractor: NSObject, ContentExtracting, WKNavigationDe injectReadabilityAndExtract(webView: webView) } + public func webView(_ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + decisionHandler(Self.navigationPolicy(for: navigationAction.request.url)) + } + + /// Returns `.cancel` for non-HTTP(S) URLs (e.g. twitter://) to prevent frame-load interruptions. + static func navigationPolicy(for url: URL?) -> WKNavigationActionPolicy { + guard let scheme = url?.scheme?.lowercased() else { return .allow } + return (scheme == "http" || scheme == "https") ? .allow : .cancel + } + public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { @@ -198,8 +213,9 @@ public final class ContentExtractor: NSObject, ContentExtracting, WKNavigationDe private func shouldRetryForFrameLoadInterruption(error: Error) -> Bool { let nsError = error as NSError - return nsError.domain == WKErrorDomain - && nsError.code == WKError.Code.frameLoadInterruptedByPolicyChange.rawValue + // WebKitErrorFrameLoadInterruptedByPolicyChange = 102 + return nsError.domain == "WebKitErrorDomain" + && nsError.code == 102 } private func retryLoadAfterInterruption() { diff --git a/ios/Piper/Piper/Services/PipelineController.swift b/ios/Piper/Piper/Services/PipelineController.swift index c364634..2495074 100644 --- a/ios/Piper/Piper/Services/PipelineController.swift +++ b/ios/Piper/Piper/Services/PipelineController.swift @@ -84,48 +84,49 @@ public final class PipelineController { // MARK: - Public API - /// Runs the full pipe flow for the given URL string. - /// - /// - Parameter urlString: A raw URL string (e.g. from UIPasteboard). - /// - Returns: The UUID URL string returned by the backend. - /// - Throws: `PipelineError` on any failure — never fails silently. - public func pipe(urlString: String) async throws -> String { - // Step 1: Validate cookies — must be logged in. + /// Validates cookies and URL, returns the parsed URL and cookies for extraction. + /// Call this before showing ExtractionWebView. + public func validate(urlString: String) throws -> (url: URL, cookies: [HTTPCookie]) { guard cookieProvider.hasCookies else { throw PipelineError.notLoggedIn } - - // Step 2: Validate URL — must be parseable. guard let url = URL(string: urlString), url.scheme != nil, url.host != nil else { throw PipelineError.invalidURL } + return (url, cookieProvider.loadCookies()) + } - let cookies = cookieProvider.loadCookies() - - // Step 3: Extract content. - let extracted: ExtractedContent = try await withCheckedThrowingContinuation { continuation in - extractor.extract(from: url, cookies: cookies) { result in + /// Saves extracted content to the backend. Returns the UUID URL. + public func save(title: String, content: String) async throws -> String { + let resultURL: String = try await withCheckedThrowingContinuation { continuation in + apiClient.save(title: title, content: content) { result in switch result { - case .success(let content): - continuation.resume(returning: content) + case .success(let url): + continuation.resume(returning: url) case .failure(let error): - continuation.resume(throwing: PipelineError.extractionFailed(error.localizedDescription)) + continuation.resume(throwing: PipelineError.saveFailed(error.localizedDescription)) } } } + return resultURL + } - // Step 4: Save to backend. - let resultURL: String = try await withCheckedThrowingContinuation { continuation in - apiClient.save(title: extracted.title, content: extracted.content) { result in + /// Runs the full pipe flow for the given URL string. + /// Used by tests and as a convenience — production flow uses validate() + ExtractionWebView + save(). + public func pipe(urlString: String) async throws -> String { + let (url, cookies) = try validate(urlString: urlString) + + let extracted: ExtractedContent = try await withCheckedThrowingContinuation { continuation in + extractor.extract(from: url, cookies: cookies) { result in switch result { - case .success(let url): - continuation.resume(returning: url) + case .success(let content): + continuation.resume(returning: content) case .failure(let error): - continuation.resume(throwing: PipelineError.saveFailed(error.localizedDescription)) + continuation.resume(throwing: PipelineError.extractionFailed(error.localizedDescription)) } } } - return resultURL + return try await save(title: extracted.title, content: extracted.content) } } diff --git a/ios/Piper/Piper/Shared/Config.swift b/ios/Piper/Piper/Shared/Config.swift index 36b71cd..beb29a3 100644 --- a/ios/Piper/Piper/Shared/Config.swift +++ b/ios/Piper/Piper/Shared/Config.swift @@ -8,5 +8,5 @@ import Foundation public enum Config { /// The base URL of the Piper Cloudflare Worker backend. /// No trailing slash. - public static let backendBaseURL = "https://piper.workers.dev" + public static let backendBaseURL = "https://piper-backend.instapiper.workers.dev" } diff --git a/ios/Piper/Piper/Shared/CookieManager.swift b/ios/Piper/Piper/Shared/CookieManager.swift index 03d3749..bc0ffa8 100644 --- a/ios/Piper/Piper/Shared/CookieManager.swift +++ b/ios/Piper/Piper/Shared/CookieManager.swift @@ -25,6 +25,8 @@ final class StandardStorage: CookieStorage { self.defaults = UserDefaults.standard } + nonisolated deinit {} + func data(forKey key: String) -> Data? { defaults.data(forKey: key) } func set(_ value: Data?, forKey key: String) { defaults.set(value, forKey: key) } func removeObject(forKey key: String) { defaults.removeObject(forKey: key) } @@ -61,6 +63,10 @@ public final class CookieManager { self.storage = storage } + // Explicit nonisolated deinit prevents executor-based deallocation crash + // when the module uses SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor. + nonisolated deinit {} + // MARK: - Public API /// Returns `true` when at least one cookie is currently stored. diff --git a/ios/Piper/PiperTests/ContentExtractorTests.swift b/ios/Piper/PiperTests/ContentExtractorTests.swift index e92bb0a..dd07cdb 100644 --- a/ios/Piper/PiperTests/ContentExtractorTests.swift +++ b/ios/Piper/PiperTests/ContentExtractorTests.swift @@ -42,6 +42,7 @@ final class MockContentExtractor: ContentExtracting { // MARK: - Tests +@MainActor final class ContentExtractorTests: XCTestCase { private var sut: MockContentExtractor! @@ -148,6 +149,23 @@ final class ContentExtractorTests: XCTestCase { } waitForExpectations(timeout: 1) } + // MARK: - Test 5: Navigation policy blocks non-HTTP(S) schemes + + func testNavigationPolicyBlocksNonHTTPSchemes() { + let twitter = URL(string: "twitter://timeline")! + XCTAssertEqual(ContentExtractor.navigationPolicy(for: twitter), .cancel) + + let tel = URL(string: "tel:+1234567890")! + XCTAssertEqual(ContentExtractor.navigationPolicy(for: tel), .cancel) + + let https = URL(string: "https://x.com/article")! + XCTAssertEqual(ContentExtractor.navigationPolicy(for: https), .allow) + + let http = URL(string: "http://example.com")! + XCTAssertEqual(ContentExtractor.navigationPolicy(for: http), .allow) + + XCTAssertEqual(ContentExtractor.navigationPolicy(for: nil), .allow) + } } // MARK: - ContentExtractionError conformance check (compile-time) diff --git a/ios/Piper/PiperTests/ContentViewTests.swift b/ios/Piper/PiperTests/ContentViewTests.swift index d990c49..dc2e410 100644 --- a/ios/Piper/PiperTests/ContentViewTests.swift +++ b/ios/Piper/PiperTests/ContentViewTests.swift @@ -6,6 +6,7 @@ import XCTest import SwiftUI @testable import Piper +@MainActor final class ContentViewTests: XCTestCase { // MARK: - Helpers diff --git a/ios/Piper/PiperTests/CookieManagerTests.swift b/ios/Piper/PiperTests/CookieManagerTests.swift index 52ea16f..5adf202 100644 --- a/ios/Piper/PiperTests/CookieManagerTests.swift +++ b/ios/Piper/PiperTests/CookieManagerTests.swift @@ -22,6 +22,7 @@ final class InMemoryStorage: CookieStorage { // MARK: - Tests +@MainActor final class CookieManagerTests: XCTestCase { private var storage: InMemoryStorage! diff --git a/ios/Piper/PiperTests/LoginDetectionTests.swift b/ios/Piper/PiperTests/LoginDetectionTests.swift index 1c4d8c8..739db5d 100644 --- a/ios/Piper/PiperTests/LoginDetectionTests.swift +++ b/ios/Piper/PiperTests/LoginDetectionTests.swift @@ -5,6 +5,7 @@ import XCTest @testable import Piper +@MainActor final class LoginDetectionTests: XCTestCase { // MARK: - Helpers diff --git a/ios/Piper/PiperTests/PipelineControllerTests.swift b/ios/Piper/PiperTests/PipelineControllerTests.swift index 899aedc..bc33619 100644 --- a/ios/Piper/PiperTests/PipelineControllerTests.swift +++ b/ios/Piper/PiperTests/PipelineControllerTests.swift @@ -54,6 +54,7 @@ private final class MockAPIClient: PiperAPIClientProtocol { // MARK: - Tests +@MainActor final class PipelineControllerTests: XCTestCase { private let validURL = "https://x.com/some/article" diff --git a/ios/Piper/PiperTests/PiperAPIClientTests.swift b/ios/Piper/PiperTests/PiperAPIClientTests.swift index 9c5eda6..b69b0b4 100644 --- a/ios/Piper/PiperTests/PiperAPIClientTests.swift +++ b/ios/Piper/PiperTests/PiperAPIClientTests.swift @@ -42,6 +42,7 @@ private func makeHTTPResponse(statusCode: Int) -> HTTPURLResponse { // MARK: - Tests +@MainActor final class PiperAPIClientTests: XCTestCase { private var session: MockURLSession!