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
41 changes: 41 additions & 0 deletions docs/retros/ios-login.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Retro: ios-login

**Date**: 2026-03-05
**PR**: #3
**Cycles**: 1/3
**Outcome**: PASS

## Findings

| Category | Severity | Description |
|---|---|---|
| — | — | No findings. Build passed in a single cycle with no reviewer corrections required. |

## What the builder missed

Nothing. The builder completed the feature in one clean cycle. The PR diff matches the acceptance criteria exactly:

- `CookieManager` is the sole read/write point for App Group cookies, with a `CookieStorage` protocol enabling full test isolation without touching `UserDefaults`.
- `ContentView` and `XLoginView` access no network or storage directly.
- All 8 `CookieManagerTests`, 3 `LoginDetectionTests`, and 3 `ContentViewTests` were delivered on the first pass, each mapping 1:1 to a spec test case.
- Domain filtering (allow `.x.com` and `.twitter.com`, reject everything else) is present and tested.
- Corrupt-data guard in `loadCookies()` returns an empty array without crashing.
- Layer comments (`// View layer`, `// Services layer`, `// Models layer`) in each file match the architecture defined in `docs/IOS.md`.
- `Piper.entitlements` wires the `group.com.piper.app` App Group.
- Cancel path surfaces to the caller (`.cancelled` result), satisfying Belief #6 (no silent failures).

## What the reviewer caught correctly

There was no reviewer correction cycle. The clean pass demonstrates that:

- The spec's acceptance criteria were concrete and complete enough to fully drive the implementation.
- The `CookieStorage` protocol abstraction was the right design choice: it removed the need for any `UserDefaults` mocking in tests and eliminated a common iOS testing pain point that often produces second-cycle findings.
- The `isLoginSuccess(url:)` function being a pure predicate on `Coordinator` made it trivially testable without a live `WKWebView`, which would have required UI testing infrastructure not available in unit tests.

## Harness improvements

None required. The spec was precise, the layer rules are clearly documented in `docs/IOS.md`, and the builder followed both without prompting.

## Actions

_(none)_
96 changes: 96 additions & 0 deletions ios/Piper/ContentView.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
}
10 changes: 10 additions & 0 deletions ios/Piper/Piper.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>
11 changes: 11 additions & 0 deletions ios/Piper/PiperApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// PiperApp.swift — App entry point
import SwiftUI

@main
struct PiperApp: App {
var body: some Scene {
WindowGroup {
ContentView(cookieManager: CookieManager())
}
}
}
100 changes: 100 additions & 0 deletions ios/Piper/XLoginView.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
62 changes: 62 additions & 0 deletions ios/PiperTests/ContentViewTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading