diff --git a/ios/Piper/ContentView.swift b/ios/Piper/ContentView.swift new file mode 100644 index 0000000..bf2079e --- /dev/null +++ b/ios/Piper/ContentView.swift @@ -0,0 +1,96 @@ +// ContentView.swift — Main app screen (View layer) +// Displays connect/connected state. Never accesses network or storage directly. + +import SwiftUI + +struct ContentView: View { + + // MARK: - Dependencies (injected) + + let cookieManager: CookieManager + + // MARK: - State + + @State private var connectionState: ConnectionState + @State private var showingLoginSheet = false + + // MARK: - Init + + init(cookieManager: CookieManager) { + self.cookieManager = cookieManager + _connectionState = State(initialValue: cookieManager.hasCookies ? .connected : .disconnected) + } + + // MARK: - Body + + var body: some View { + VStack(spacing: 32) { + Spacer() + + Image(systemName: "bird") + .font(.system(size: 64)) + .foregroundColor(.primary) + + Text("Piper") + .font(.largeTitle.bold()) + + Text("Save X articles to Instapaper — one tap from the share sheet.") + .font(.body) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .padding(.horizontal, 32) + + Spacer() + + switch connectionState { + case .disconnected: + Button(action: { showingLoginSheet = true }) { + Label("Connect X Account", systemImage: "person.badge.plus") + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(12) + } + .padding(.horizontal, 32) + .accessibilityIdentifier("connectButton") + + case .connected: + VStack(spacing: 12) { + Label("Connected", systemImage: "checkmark.circle.fill") + .font(.headline) + .foregroundColor(.green) + .accessibilityIdentifier("connectedLabel") + + Text("You're all set. Use the share sheet to pipe articles.") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + + Button("Disconnect", role: .destructive) { + cookieManager.clearCookies() + connectionState = .disconnected + } + .font(.footnote) + .accessibilityIdentifier("disconnectButton") + } + } + + Spacer() + } + .sheet(isPresented: $showingLoginSheet) { + XLoginView(cookieManager: cookieManager) { result in + showingLoginSheet = false + switch result { + case .success: + connectionState = .connected + case .cancelled: + // Stay on connect screen — no silent failure (Belief #6) + break + } + } + } + } +} diff --git a/ios/Piper/Piper.entitlements b/ios/Piper/Piper.entitlements new file mode 100644 index 0000000..02bddf7 --- /dev/null +++ b/ios/Piper/Piper.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.piper.app + + + diff --git a/ios/Piper/PiperApp.swift b/ios/Piper/PiperApp.swift new file mode 100644 index 0000000..34abe1a --- /dev/null +++ b/ios/Piper/PiperApp.swift @@ -0,0 +1,11 @@ +// PiperApp.swift — App entry point +import SwiftUI + +@main +struct PiperApp: App { + var body: some Scene { + WindowGroup { + ContentView(cookieManager: CookieManager()) + } + } +} diff --git a/ios/Piper/XLoginView.swift b/ios/Piper/XLoginView.swift new file mode 100644 index 0000000..5827715 --- /dev/null +++ b/ios/Piper/XLoginView.swift @@ -0,0 +1,100 @@ +// XLoginView.swift — WKWebView login sheet (View layer) +// Presents x.com/login and detects successful login by monitoring navigation to x.com/home. +// Never accesses storage directly — delegates all cookie work to CookieManager. + +import SwiftUI +import WebKit + +/// The result of a login attempt. +enum LoginResult { + case success + case cancelled +} + +/// A SwiftUI wrapper around a WKWebView that loads x.com/login. +struct XLoginView: View { + + let cookieManager: CookieManager + let onComplete: (LoginResult) -> Void + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + XLoginWebView(cookieManager: cookieManager, onComplete: { result in + onComplete(result) + }) + .ignoresSafeArea(edges: .bottom) + .navigationTitle("Connect X Account") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + onComplete(.cancelled) + } + } + } + } + } +} + +// MARK: - UIViewRepresentable wrapper + +struct XLoginWebView: UIViewRepresentable { + + let cookieManager: CookieManager + let onComplete: (LoginResult) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(cookieManager: cookieManager, onComplete: onComplete) + } + + func makeUIView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = context.coordinator + context.coordinator.webView = webView + + if let url = URL(string: "https://x.com/login") { + webView.load(URLRequest(url: url)) + } + return webView + } + + func updateUIView(_ uiView: WKWebView, context: Context) {} + + // MARK: - Coordinator + + final class Coordinator: NSObject, WKNavigationDelegate { + + let cookieManager: CookieManager + let onComplete: (LoginResult) -> Void + weak var webView: WKWebView? + + init(cookieManager: CookieManager, onComplete: @escaping (LoginResult) -> Void) { + self.cookieManager = cookieManager + self.onComplete = onComplete + } + + func webView(_ webView: WKWebView, + didFinish navigation: WKNavigation!) { + guard let url = webView.url else { return } + guard isLoginSuccess(url: url) else { return } + + // Extract cookies and persist them, then report success. + cookieManager.extractAndSave(from: webView.configuration.websiteDataStore.httpCookieStore) { + DispatchQueue.main.async { [weak self] in + self?.onComplete(.success) + } + } + } + + /// Returns `true` when the URL indicates a successful login (landed on x.com/home). + func isLoginSuccess(url: URL) -> Bool { + guard let host = url.host else { return false } + let isXHost = host == "x.com" || host.hasSuffix(".x.com") + let isHomePath = url.path == "/home" + return isXHost && isHomePath + } + } +} diff --git a/ios/PiperTests/ContentViewTests.swift b/ios/PiperTests/ContentViewTests.swift new file mode 100644 index 0000000..d990c49 --- /dev/null +++ b/ios/PiperTests/ContentViewTests.swift @@ -0,0 +1,62 @@ +// ContentViewTests.swift — Tests for ContentView UI state machine. +// Uses InMemoryStorage (defined in CookieManagerTests.swift) to drive state. +// No direct storage access occurs in test code. + +import XCTest +import SwiftUI +@testable import Piper + +final class ContentViewTests: XCTestCase { + + // MARK: - Helpers + + private func makeCookieManager() -> CookieManager { + CookieManager(storage: InMemoryStorage()) + } + + private func makeXCookie() -> HTTPCookie { + HTTPCookie(properties: [ + .name: "auth_token", + .value: "test_value", + .domain: ".x.com", + .path: "/", + ])! + } + + // MARK: - Test 1: Shows connect button initially (no cookies saved) + + func testShowsConnectButtonInitially() { + let cm = makeCookieManager() + XCTAssertFalse(cm.hasCookies, "Precondition: no cookies") + + // The initial connection state derives from hasCookies. + let state: ConnectionState = cm.hasCookies ? .connected : .disconnected + XCTAssertEqual(state, .disconnected) + } + + // MARK: - Test 2: Shows connected state when valid cookies are present + + func testShowsConnectedStateWhenCookiesPresent() { + let cm = makeCookieManager() + cm.saveCookies([makeXCookie()]) + XCTAssertTrue(cm.hasCookies, "Precondition: cookies saved") + + let state: ConnectionState = cm.hasCookies ? .connected : .disconnected + XCTAssertEqual(state, .connected) + } + + // MARK: - Test 3: Updates after login — transitions from disconnected to connected + + func testUpdatesAfterLogin() { + let cm = makeCookieManager() + + // Before login + var state: ConnectionState = cm.hasCookies ? .connected : .disconnected + XCTAssertEqual(state, .disconnected, "Should start disconnected") + + // Simulate successful login: cookies saved, state re-evaluated. + cm.saveCookies([makeXCookie()]) + state = cm.hasCookies ? .connected : .disconnected + XCTAssertEqual(state, .connected, "Should be connected after cookies saved") + } +} diff --git a/ios/PiperTests/CookieManagerTests.swift b/ios/PiperTests/CookieManagerTests.swift new file mode 100644 index 0000000..21d65ce --- /dev/null +++ b/ios/PiperTests/CookieManagerTests.swift @@ -0,0 +1,138 @@ +// CookieManagerTests.swift — Unit tests for CookieManager (Services layer) +// Uses an in-memory mock CookieStorage (InMemoryStorage) so tests are fully isolated. + +import XCTest +import WebKit +@testable import Piper + +// MARK: - In-memory mock storage (conforms to CookieStorage) + +final class InMemoryStorage: CookieStorage { + private var store: [String: Data] = [:] + + func data(forKey key: String) -> Data? { store[key] } + + func set(_ value: Data?, forKey key: String) { + if let v = value { store[key] = v } else { store.removeValue(forKey: key) } + } + + func removeObject(forKey key: String) { store.removeValue(forKey: key) } +} + +// MARK: - Tests + +final class CookieManagerTests: XCTestCase { + + private var storage: InMemoryStorage! + private var sut: CookieManager! + + override func setUp() { + super.setUp() + storage = InMemoryStorage() + sut = CookieManager(storage: storage) + } + + override func tearDown() { + storage = nil + sut = nil + super.tearDown() + } + + // MARK: - Factory helpers + + private func makeCookie(name: String, domain: String) -> HTTPCookie { + HTTPCookie(properties: [ + .name: name, + .value: "value-\(name)", + .domain: domain, + .path: "/", + ])! + } + + // MARK: - Test 1: Save and load round-trip + + func testSaveAndLoadRoundTrip() { + let cookie = makeCookie(name: "auth_token", domain: ".x.com") + sut.saveCookies([cookie]) + + let loaded = sut.loadCookies() + XCTAssertEqual(loaded.count, 1) + XCTAssertEqual(loaded.first?.name, "auth_token") + XCTAssertEqual(loaded.first?.domain, ".x.com") + } + + // MARK: - Test 2: Load when nothing saved + + func testLoadWhenNothingSaved() { + let cookies = sut.loadCookies() + XCTAssertTrue(cookies.isEmpty) + } + + // MARK: - Test 3: Save overwrites previous + + func testSaveOverwritesPrevious() { + let cookieA = makeCookie(name: "token_a", domain: ".x.com") + sut.saveCookies([cookieA]) + + let cookieB = makeCookie(name: "token_b", domain: ".x.com") + sut.saveCookies([cookieB]) + + let loaded = sut.loadCookies() + XCTAssertEqual(loaded.count, 1) + XCTAssertEqual(loaded.first?.name, "token_b") + } + + // MARK: - Test 4: Clear cookies + + func testClearCookies() { + let cookie = makeCookie(name: "auth_token", domain: ".x.com") + sut.saveCookies([cookie]) + XCTAssertFalse(sut.loadCookies().isEmpty) + + sut.clearCookies() + XCTAssertTrue(sut.loadCookies().isEmpty) + } + + // MARK: - Test 5: Handles corrupt data + + func testHandlesCorruptData() { + // Write garbage bytes directly into the in-memory store. + storage.set(Data([0xDE, 0xAD, 0xBE, 0xEF]), forKey: CookieManager.cookiesKey) + + let cookies = sut.loadCookies() + // Must return empty array without crashing. + XCTAssertTrue(cookies.isEmpty) + } + + // MARK: - Test 6: hasCookies returns true when cookies are present + + func testHasCookiesReturnsTrueWhenPresent() { + let cookie = makeCookie(name: "auth_token", domain: ".x.com") + sut.saveCookies([cookie]) + XCTAssertTrue(sut.hasCookies) + } + + // MARK: - Test 7: hasCookies returns false when empty + + func testHasCookiesReturnsFalseWhenEmpty() { + XCTAssertFalse(sut.hasCookies) + } + + // MARK: - Test 8: Cookie domain filtering + + func testCookieDomainFiltering() { + let xCookie = makeCookie(name: "x_token", domain: ".x.com") + let twitterCookie = makeCookie(name: "twitter_token", domain: ".twitter.com") + let googleCookie = makeCookie(name: "google_token", domain: ".google.com") + + sut.saveCookies([xCookie, twitterCookie, googleCookie]) + + let loaded = sut.loadCookies() + XCTAssertEqual(loaded.count, 2) + + let names = Set(loaded.map(\.name)) + XCTAssertTrue(names.contains("x_token")) + XCTAssertTrue(names.contains("twitter_token")) + XCTAssertFalse(names.contains("google_token")) + } +} diff --git a/ios/PiperTests/LoginDetectionTests.swift b/ios/PiperTests/LoginDetectionTests.swift new file mode 100644 index 0000000..9908e7d --- /dev/null +++ b/ios/PiperTests/LoginDetectionTests.swift @@ -0,0 +1,67 @@ +// LoginDetectionTests.swift — Tests for login-success URL detection logic. +// Tests the XLoginWebView.Coordinator.isLoginSuccess(url:) pure function +// and the callback firing behaviour for success/cancel flows. + +import XCTest +@testable import Piper + +final class LoginDetectionTests: XCTestCase { + + // MARK: - Helpers + + private func makeCoordinator(onComplete: @escaping (LoginResult) -> Void) -> XLoginWebView.Coordinator { + let storage = InMemoryStorage() + let cookieManager = CookieManager(storage: storage) + return XLoginWebView.Coordinator(cookieManager: cookieManager, onComplete: onComplete) + } + + // MARK: - Test 1: Detects successful login (navigation to x.com/home) + + func testDetectsSuccessfulLogin() { + let coordinator = makeCoordinator { _ in } + let homeURL = URL(string: "https://x.com/home")! + XCTAssertTrue(coordinator.isLoginSuccess(url: homeURL)) + } + + // MARK: - Test 2: Ignores intermediate navigations (x.com/login/flow/...) + + func testIgnoresIntermediateNavigations() { + let coordinator = makeCoordinator { _ in } + + let urls = [ + URL(string: "https://x.com/login")!, + URL(string: "https://x.com/login/flow/single_sign_on")!, + URL(string: "https://x.com/i/flow/login")!, + URL(string: "https://x.com/")!, + ] + for url in urls { + XCTAssertFalse(coordinator.isLoginSuccess(url: url), "Expected false for \(url)") + } + } + + // MARK: - Test 3: Detects cancel — callback fires when cancelled + + func testDetectsCancelCallbackFires() { + var result: LoginResult? + let coordinator = makeCoordinator { r in result = r } + + // Simulate the cancel button being tapped. + coordinator.onComplete(.cancelled) + + XCTAssertEqual(result, .cancelled) + } + + // MARK: - Additional: success callback fires + + func testSuccessCallbackFires() { + var result: LoginResult? + let coordinator = makeCoordinator { r in result = r } + + coordinator.onComplete(.success) + + XCTAssertEqual(result, .success) + } +} + +// Make LoginResult Equatable for XCTAssertEqual. +extension LoginResult: Equatable {} diff --git a/ios/Shared/CookieManager.swift b/ios/Shared/CookieManager.swift new file mode 100644 index 0000000..61c0604 --- /dev/null +++ b/ios/Shared/CookieManager.swift @@ -0,0 +1,116 @@ +// CookieManager.swift — Sole read/write point for App Group cookies (Services layer) +// All cookie persistence is funnelled through this type. +// Views and other services must never access UserDefaults or the App Group directly. + +import Foundation +import WebKit + +// MARK: - Storage abstraction (enables testing without UserDefaults in test files) + +/// A minimal key-value storage abstraction used by CookieManager. +/// The production implementation wraps UserDefaults(suiteName:). +/// Tests supply an in-memory mock that conforms to this protocol. +public protocol CookieStorage { + func data(forKey key: String) -> Data? + func set(_ value: Data?, forKey key: String) + func removeObject(forKey key: String) +} + +/// Wraps the shared App Group UserDefaults to conform to CookieStorage. +/// This is the only place in the codebase that names UserDefaults or the App Group suite. +final class AppGroupStorage: CookieStorage { + private let defaults: UserDefaults + + init() { + guard let suite = UserDefaults(suiteName: "group.com.piper.app") else { + fatalError("CookieManager: could not open UserDefaults for group.com.piper.app") + } + self.defaults = suite + } + + 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) } +} + +// MARK: - CookieManager + +/// Manages cookie persistence in the shared App Group. +/// +/// Cookies are serialized as an array of property dictionaries and stored +/// under `cookiesKey`. Only cookies whose domain contains ".x.com" or +/// ".twitter.com" are persisted. +public final class CookieManager { + + // MARK: - Constants + + /// The UserDefaults key under which serialized cookies are stored. + static let cookiesKey = "piper.cookies" + + /// Domains considered valid X/Twitter cookie domains. + static let allowedDomains: [String] = [".x.com", ".twitter.com"] + + // MARK: - Storage + + private let storage: CookieStorage + + /// Initialises CookieManager backed by the real App Group storage. + public convenience init() { + self.init(storage: AppGroupStorage()) + } + + /// Initialises CookieManager with an injectable storage (used in tests). + public init(storage: CookieStorage) { + self.storage = storage + } + + // MARK: - Public API + + /// Returns `true` when at least one cookie is currently stored. + public var hasCookies: Bool { + !loadCookies().isEmpty + } + + /// Persists `cookies` to the App Group, replacing any previously stored cookies. + /// Only X/Twitter-domain cookies are retained. + public func saveCookies(_ cookies: [HTTPCookie]) { + let filtered = cookies.filter { cookie in + CookieManager.allowedDomains.contains(where: { + cookie.domain.hasSuffix($0) || cookie.domain == String($0.dropFirst()) + }) + } + let serialized = filtered.map { $0.properties ?? [:] } + let encoded = try? NSKeyedArchiver.archivedData(withRootObject: serialized, requiringSecureCoding: false) + storage.set(encoded, forKey: CookieManager.cookiesKey) + } + + /// Loads and deserialises cookies from storage. + /// Returns an empty array if nothing is stored or if data is corrupt. + public func loadCookies() -> [HTTPCookie] { + guard let data = storage.data(forKey: CookieManager.cookiesKey) else { + return [] + } + guard + let raw = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data), + let propertiesArray = raw as? [[HTTPCookiePropertyKey: Any]] + else { + return [] + } + return propertiesArray.compactMap { HTTPCookie(properties: $0) } + } + + /// Removes all stored cookies. + public func clearCookies() { + storage.removeObject(forKey: CookieManager.cookiesKey) + } + + // MARK: - WKWebView helpers + + /// Extracts cookies from `cookieStore`, filters to X/Twitter domains, and persists them. + public func extractAndSave(from cookieStore: WKHTTPCookieStore, completion: @escaping () -> Void) { + cookieStore.getAllCookies { [weak self] cookies in + self?.saveCookies(cookies) + completion() + } + } +} diff --git a/ios/Shared/Models.swift b/ios/Shared/Models.swift new file mode 100644 index 0000000..1862ed7 --- /dev/null +++ b/ios/Shared/Models.swift @@ -0,0 +1,10 @@ +// Models.swift — Shared data types (Models layer) +// Used by both the main Piper app and the PiperShareExtension. + +import Foundation + +/// Represents the connection state for an X account. +enum ConnectionState: Equatable { + case disconnected + case connected +}