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
253 changes: 253 additions & 0 deletions .builder-init.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
# Builder Init — single-app-pivot

## Current File Structure

```
ios/
├── Shared/
│ ├── Config.swift — App-wide constants (backendBaseURL = "https://piper.workers.dev")
│ ├── CookieManager.swift — Uses AppGroupStorage (UserDefaults suiteName: "group.com.piper.app")
│ └── Models.swift — ConnectionState, ExtractedContent, SaveResponse
├── Piper/
│ ├── PiperApp.swift — @main, creates CookieManager() and passes to ContentView
│ ├── ContentView.swift — Shows connect/connected states, no "Pipe Article" button yet
│ ├── XLoginView.swift — WKWebView login sheet, detects x.com/home redirect
│ └── Piper.entitlements — Contains com.apple.security.application-groups
├── PiperShareExtension/
│ ├── ShareViewController.swift — UIKit orchestrator: cookies → extract → POST → clipboard
│ ├── PiperShareExtension.entitlements — App Group entitlement
│ ├── readability.js — Mozilla Readability (real, not stub)
│ └── Services/
│ ├── ContentExtractor.swift — WKWebView + readability.js extraction
│ └── PiperAPIClient.swift — HTTP POST /save client
├── PiperShareExtensionTests/
│ ├── ContentExtractorTests.swift — imports @testable PiperShareExtension
│ ├── PiperAPIClientTests.swift — imports @testable PiperShareExtension
│ ├── ShareViewControllerTests.swift — imports @testable PiperShareExtension (DELETE)
│ └── ConfigTests.swift — imports @testable PiperShareExtension
└── PiperTests/
├── CookieManagerTests.swift — imports @testable Piper, uses InMemoryStorage mock
├── ContentViewTests.swift — imports @testable Piper
└── LoginDetectionTests.swift — imports @testable Piper
```

Note: No Xcode project file (.xcodeproj) is present in the repo — the project is managed
separately (likely created manually in Xcode). The repo only contains the Swift source files.
This means ALL Xcode project changes (target membership, build phases) must be done via
direct file edits to the source tree. The absence of a .xcodeproj means tests are likely
run via an Xcode project that exists only on developer machines.

## What Needs to Change

### Files to Modify

1. **ios/Shared/CookieManager.swift**
- Remove `AppGroupStorage` class (uses `UserDefaults(suiteName:)` which requires entitlements)
- Replace with `StandardStorage` that wraps `UserDefaults.standard`
- Update `convenience init()` to use `StandardStorage()` instead of `AppGroupStorage()`
- Update comment: "App Group" → "standard UserDefaults"
- The `CookieStorage` protocol and `CookieManager` class remain structurally identical
- CookieManagerTests already uses `InMemoryStorage` mock — no test changes needed for
the storage-switch itself, but a new test verifying StandardStorage uses UserDefaults.standard
is required per spec

2. **ios/Piper/ContentView.swift**
- Add "Pipe Article" button in the `.connected` case
- Add a `pipelineController` dependency (injected)
- Add state for showing PipeView sheet: `@State private var showingPipeView = false`
- Add state for clipboard URL and error messages
- The connected section currently shows: "You're all set. Use the share sheet to pipe articles."
→ Replace with "Pipe Article" button + disconnect button
- Wire to PipeView sheet

3. **ios/PiperTests/CookieManagerTests.swift**
- Add Test 1: verify that `CookieManager()` (convenience init) uses standard UserDefaults,
not an App Group. This likely tests that keys are stored under `UserDefaults.standard`

### Files to Move (content copy + import update)

4. **ios/PiperShareExtension/Services/ContentExtractor.swift → ios/Piper/Services/ContentExtractor.swift**
- Change `@testable import PiperShareExtension` → `@testable import Piper` in tests
- Change error message: "readability.js resource is missing from the extension bundle" →
"readability.js resource is missing from the app bundle"
- `Bundle(for: ContentExtractor.self)` will now resolve to the Piper app bundle

5. **ios/PiperShareExtension/Services/PiperAPIClient.swift → ios/Piper/Services/PiperAPIClient.swift**
- No content changes needed (uses Config.backendBaseURL from Shared/)

6. **ios/PiperShareExtension/readability.js → ios/Piper/Resources/readability.js**
- Pure file move, no content changes

7. **ios/PiperShareExtensionTests/ContentExtractorTests.swift → ios/PiperTests/ContentExtractorTests.swift**
- Change `@testable import PiperShareExtension` → `@testable import Piper`

8. **ios/PiperShareExtensionTests/PiperAPIClientTests.swift → ios/PiperTests/PiperAPIClientTests.swift**
- Change `@testable import PiperShareExtension` → `@testable import Piper`

9. **ios/PiperShareExtensionTests/ConfigTests.swift → ios/PiperTests/ConfigTests.swift**
- Change `@testable import PiperShareExtension` → `@testable import Piper`

### Files to Create

10. **ios/Piper/Services/PipelineController.swift**
- Protocol-based dependencies: `ContentExtracting`, `PiperAPIClientProtocol`, `CookieManager`
- Method: `pipe(urlString: String, completion: @escaping (Result<String, Error>) -> Void)`
- Error cases: notLoggedIn, invalidURL, extractionFailed(Error), saveFailed(Error)
- Orchestration: check cookies → validate URL → extract → save → return result URL string
- No UI dependencies — pure service class
- Should be async-friendly (completion handlers)

11. **ios/Piper/PipeView.swift**
- SwiftUI view showing extraction progress/result
- Receives a URL string from clipboard
- Uses `PipelineController` to run the pipe flow
- Shows: loading spinner → success ("Saved — paste into Instapaper") or error
- No direct network or storage access

12. **ios/PiperTests/PipelineControllerTests.swift**
- Test 1: No cookies → returns notLoggedIn error
- Test 2: Happy path → valid cookies + mock extractor + mock API → returns success URL
- Test 3: Extraction failure → returns extraction error
- Test 4: API failure → extraction succeeds + API fails → returns save error
- Test 5: Invalid URL → returns invalidURL error

### Files to Delete

13. **ios/PiperShareExtension/** (entire directory)
- ShareViewController.swift
- PiperShareExtension.entitlements
- readability.js
- Services/ContentExtractor.swift
- Services/PiperAPIClient.swift

14. **ios/PiperShareExtensionTests/** (entire directory)
- ContentExtractorTests.swift (moved to PiperTests)
- PiperAPIClientTests.swift (moved to PiperTests)
- ShareViewControllerTests.swift (deleted — no replacement, tests covered by PipelineControllerTests)
- ConfigTests.swift (moved to PiperTests)

15. **ios/Piper/Piper.entitlements** (delete — App Group no longer needed)

## Key Insights About the Repo

### No .xcodeproj in Repo
The `ios/` directory does NOT contain a `.xcodeproj` file. The Xcode project is managed
separately. This means:
- We cannot edit target membership via pbxproj
- All changes are to source files only
- The spec says "readability.js is bundled as a resource in the app target" — this is a
Xcode project setting that must be done manually in Xcode. We can only place the file
in the right directory.
- Tests reference `@testable import Piper` and `@testable import PiperShareExtension` —
these target names are defined in the Xcode project

### CookieStorage Protocol Design
The `CookieStorage` protocol in CookieManager.swift is excellent for testability. The only
change needed is replacing `AppGroupStorage` (which uses `UserDefaults(suiteName:)`) with
a new `StandardStorage` class that uses `UserDefaults.standard`. The `CookieManager` class
itself stays unchanged.

### ShareViewController Orchestration Logic → PipelineController
`ShareViewController` orchestrates: cookieManager.hasCookies → extractSharedURL →
contentExtractor.extract → apiClient.save → pasteboard.string = url → showSuccess.

`PipelineController` should mirror this logic but:
- Take a URL string as input (from UIPasteboard in the app) instead of NSExtensionContext
- Be fully testable with injected mocks
- Return results via completion handlers or async/await

### UIPasteboardProtocol
The `UIPasteboardProtocol` is currently defined in `ShareViewController.swift`. When that
file is deleted, this protocol needs a new home. It could go in:
- `ios/Piper/Services/PipelineController.swift` (since the controller reads clipboard)
- Or a new small file

Actually, looking more carefully at the spec: PipelineController takes a URL string as
input (the view reads from clipboard and passes it). So the view is responsible for reading
the clipboard, PipelineController just processes the URL. UIPasteboardProtocol may not be
needed in PipelineController at all — the view reads UIPasteboard.general.string and passes
it to PipelineController.

### ContentExtractor Bundle
`ContentExtractor` uses `Bundle(for: ContentExtractor.self)` as the default bundle. When
moved from PiperShareExtension to Piper, this will automatically resolve to the Piper app
bundle, which is correct. The error message "missing from the extension bundle" needs
updating to "missing from the app bundle".

### Config.swift Location
`Config.swift` is in `ios/Shared/` (not in PiperShareExtension). The spec says "Backend
URL is a single constant via Config.swift" — this is already the case. No change needed
here except ensuring the file stays in Shared/ and is accessible to the Piper target.

### Test Target Names
- `PiperTests` imports `@testable import Piper` — these tests compile against the Piper target
- `PiperShareExtensionTests` imports `@testable import PiperShareExtension` — must be updated
to `@testable import Piper` when moved

### InMemoryStorage Duplication
`InMemoryStorage` is defined in both:
- `ios/PiperTests/CookieManagerTests.swift` (as `final class InMemoryStorage: CookieStorage`)
- `ios/PiperShareExtensionTests/ShareViewControllerTests.swift` (private version)

The `LoginDetectionTests.swift` and `ContentViewTests.swift` reference `InMemoryStorage`
without defining it — they rely on the definition in `CookieManagerTests.swift` being
compiled in the same test target. The moved tests (ContentExtractorTests, etc.) that come
from PiperShareExtension do NOT use InMemoryStorage, so no conflict there.

## Potential Issues and Gotchas

1. **No .xcodeproj in repo**: Cannot modify target membership. The spec's "Files to
create" and "Files to move" are file-system operations only. The developer must manually
update Xcode project settings (add files to targets, add readability.js to Copy Bundle
Resources). The SETUP.md will need updating to reflect the new single-target setup.

2. **readability.js bundle lookup**: After the move, `ContentExtractor` looks for
`readability.js` via `bundle.url(forResource: "readability", withExtension: "js")`.
This will work IF Xcode adds readability.js to the Piper target's "Copy Bundle Resources"
phase. The file must be at `ios/Piper/Resources/readability.js` on disk.

3. **PiperApp.swift currently passes CookieManager() to ContentView**. After the pivot,
ContentView also needs a PipelineController. The PiperApp.swift will need to create a
PipelineController and pass it to ContentView (or ContentView can create it internally).

4. **ShareViewControllerTests.swift tests the UIKit ShareViewController** — this entire
file is deleted (not moved). Coverage is replaced by PipelineControllerTests.swift which
tests the equivalent orchestration logic at the service level.

5. **UIPasteboardProtocol** is defined in ShareViewController.swift. When that file is
deleted, the protocol disappears. If PipelineController tests need to mock clipboard
writing, UIPasteboardProtocol needs to be re-homed. However, per the spec, the clipboard
WRITE (copying result URL) could happen either in PipelineController or in the view.
For testability, PipelineController should just return the URL string, and the view
copies it to the clipboard. This avoids needing UIPasteboardProtocol in the service layer.

6. **AppGroupStorage fatalError**: The current `AppGroupStorage.init()` calls `fatalError`
if the UserDefaults suite can't be opened. Replacing with `StandardStorage` (wrapping
`UserDefaults.standard`) eliminates this crash risk entirely.

7. **Piper.entitlements**: Currently in `ios/Piper/Piper.entitlements`. The spec says to
delete it. Since there's no .xcodeproj to edit, we only delete the file. The developer
must also remove the entitlements file reference from the Xcode project settings for
the Piper target.

8. **SETUP.md needs updating**: The existing SETUP.md describes the two-target setup with
App Group. After the pivot, it should describe the single-target setup. However, the
spec doesn't list SETUP.md as a file to modify — this may be out of scope or handled
separately.

## Build Order (Implementation Sequence)

1. Modify CookieManager.swift (AppGroupStorage → StandardStorage)
2. Create ios/Piper/Services/ directory structure
3. Copy ContentExtractor.swift to ios/Piper/Services/ (update bundle error message)
4. Copy PiperAPIClient.swift to ios/Piper/Services/
5. Create ios/Piper/Resources/ directory and move readability.js
6. Create ios/Piper/Services/PipelineController.swift
7. Create ios/Piper/PipeView.swift
8. Modify ios/Piper/ContentView.swift (add Pipe Article button + PipeView sheet)
9. Move PiperShareExtensionTests/* → PiperTests/ (updating imports)
10. Create ios/PiperTests/PipelineControllerTests.swift
11. Update CookieManagerTests.swift (add StandardStorage test)
12. Delete PiperShareExtension/ and PiperShareExtensionTests/ directories
13. Delete ios/Piper/Piper.entitlements
14. Update SETUP.md if required
2 changes: 2 additions & 0 deletions .github/scripts/lint-ios-layers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ while IFS= read -r file; do
done < <(find "$IOS" -name "*View.swift" 2>/dev/null)

# Rule 2: Only CookieManager.swift may reference UserDefaults or the App Group
# (Test files for CookieManager are excluded — they must verify storage behavior)
while IFS= read -r file; do
[ "$(basename "$file")" = "CookieManager.swift" ] && continue
[[ "$(basename "$file")" == *Tests.swift ]] && continue
if grep -nE "UserDefaults|group\.com\.piper\.app" "$file"; then
echo "FAIL: $(basename "$file") references App Group cookies — only CookieManager.swift may do this"
ERRORS=$((ERRORS + 1))
Expand Down
Loading