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
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