Skip to content

Commit c2afd5b

Browse files
authored
Merge pull request #7 from m6un/agent/single-app-pivot
feat: single-app-pivot
2 parents c384cd7 + cbfa92b commit c2afd5b

20 files changed

Lines changed: 865 additions & 502 deletions

.builder-init.md

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
# Builder Init — single-app-pivot
2+
3+
## Current File Structure
4+
5+
```
6+
ios/
7+
├── Shared/
8+
│ ├── Config.swift — App-wide constants (backendBaseURL = "https://piper.workers.dev")
9+
│ ├── CookieManager.swift — Uses AppGroupStorage (UserDefaults suiteName: "group.com.piper.app")
10+
│ └── Models.swift — ConnectionState, ExtractedContent, SaveResponse
11+
├── Piper/
12+
│ ├── PiperApp.swift — @main, creates CookieManager() and passes to ContentView
13+
│ ├── ContentView.swift — Shows connect/connected states, no "Pipe Article" button yet
14+
│ ├── XLoginView.swift — WKWebView login sheet, detects x.com/home redirect
15+
│ └── Piper.entitlements — Contains com.apple.security.application-groups
16+
├── PiperShareExtension/
17+
│ ├── ShareViewController.swift — UIKit orchestrator: cookies → extract → POST → clipboard
18+
│ ├── PiperShareExtension.entitlements — App Group entitlement
19+
│ ├── readability.js — Mozilla Readability (real, not stub)
20+
│ └── Services/
21+
│ ├── ContentExtractor.swift — WKWebView + readability.js extraction
22+
│ └── PiperAPIClient.swift — HTTP POST /save client
23+
├── PiperShareExtensionTests/
24+
│ ├── ContentExtractorTests.swift — imports @testable PiperShareExtension
25+
│ ├── PiperAPIClientTests.swift — imports @testable PiperShareExtension
26+
│ ├── ShareViewControllerTests.swift — imports @testable PiperShareExtension (DELETE)
27+
│ └── ConfigTests.swift — imports @testable PiperShareExtension
28+
└── PiperTests/
29+
├── CookieManagerTests.swift — imports @testable Piper, uses InMemoryStorage mock
30+
├── ContentViewTests.swift — imports @testable Piper
31+
└── LoginDetectionTests.swift — imports @testable Piper
32+
```
33+
34+
Note: No Xcode project file (.xcodeproj) is present in the repo — the project is managed
35+
separately (likely created manually in Xcode). The repo only contains the Swift source files.
36+
This means ALL Xcode project changes (target membership, build phases) must be done via
37+
direct file edits to the source tree. The absence of a .xcodeproj means tests are likely
38+
run via an Xcode project that exists only on developer machines.
39+
40+
## What Needs to Change
41+
42+
### Files to Modify
43+
44+
1. **ios/Shared/CookieManager.swift**
45+
- Remove `AppGroupStorage` class (uses `UserDefaults(suiteName:)` which requires entitlements)
46+
- Replace with `StandardStorage` that wraps `UserDefaults.standard`
47+
- Update `convenience init()` to use `StandardStorage()` instead of `AppGroupStorage()`
48+
- Update comment: "App Group" → "standard UserDefaults"
49+
- The `CookieStorage` protocol and `CookieManager` class remain structurally identical
50+
- CookieManagerTests already uses `InMemoryStorage` mock — no test changes needed for
51+
the storage-switch itself, but a new test verifying StandardStorage uses UserDefaults.standard
52+
is required per spec
53+
54+
2. **ios/Piper/ContentView.swift**
55+
- Add "Pipe Article" button in the `.connected` case
56+
- Add a `pipelineController` dependency (injected)
57+
- Add state for showing PipeView sheet: `@State private var showingPipeView = false`
58+
- Add state for clipboard URL and error messages
59+
- The connected section currently shows: "You're all set. Use the share sheet to pipe articles."
60+
→ Replace with "Pipe Article" button + disconnect button
61+
- Wire to PipeView sheet
62+
63+
3. **ios/PiperTests/CookieManagerTests.swift**
64+
- Add Test 1: verify that `CookieManager()` (convenience init) uses standard UserDefaults,
65+
not an App Group. This likely tests that keys are stored under `UserDefaults.standard`
66+
67+
### Files to Move (content copy + import update)
68+
69+
4. **ios/PiperShareExtension/Services/ContentExtractor.swift → ios/Piper/Services/ContentExtractor.swift**
70+
- Change `@testable import PiperShareExtension``@testable import Piper` in tests
71+
- Change error message: "readability.js resource is missing from the extension bundle" →
72+
"readability.js resource is missing from the app bundle"
73+
- `Bundle(for: ContentExtractor.self)` will now resolve to the Piper app bundle
74+
75+
5. **ios/PiperShareExtension/Services/PiperAPIClient.swift → ios/Piper/Services/PiperAPIClient.swift**
76+
- No content changes needed (uses Config.backendBaseURL from Shared/)
77+
78+
6. **ios/PiperShareExtension/readability.js → ios/Piper/Resources/readability.js**
79+
- Pure file move, no content changes
80+
81+
7. **ios/PiperShareExtensionTests/ContentExtractorTests.swift → ios/PiperTests/ContentExtractorTests.swift**
82+
- Change `@testable import PiperShareExtension``@testable import Piper`
83+
84+
8. **ios/PiperShareExtensionTests/PiperAPIClientTests.swift → ios/PiperTests/PiperAPIClientTests.swift**
85+
- Change `@testable import PiperShareExtension``@testable import Piper`
86+
87+
9. **ios/PiperShareExtensionTests/ConfigTests.swift → ios/PiperTests/ConfigTests.swift**
88+
- Change `@testable import PiperShareExtension``@testable import Piper`
89+
90+
### Files to Create
91+
92+
10. **ios/Piper/Services/PipelineController.swift**
93+
- Protocol-based dependencies: `ContentExtracting`, `PiperAPIClientProtocol`, `CookieManager`
94+
- Method: `pipe(urlString: String, completion: @escaping (Result<String, Error>) -> Void)`
95+
- Error cases: notLoggedIn, invalidURL, extractionFailed(Error), saveFailed(Error)
96+
- Orchestration: check cookies → validate URL → extract → save → return result URL string
97+
- No UI dependencies — pure service class
98+
- Should be async-friendly (completion handlers)
99+
100+
11. **ios/Piper/PipeView.swift**
101+
- SwiftUI view showing extraction progress/result
102+
- Receives a URL string from clipboard
103+
- Uses `PipelineController` to run the pipe flow
104+
- Shows: loading spinner → success ("Saved — paste into Instapaper") or error
105+
- No direct network or storage access
106+
107+
12. **ios/PiperTests/PipelineControllerTests.swift**
108+
- Test 1: No cookies → returns notLoggedIn error
109+
- Test 2: Happy path → valid cookies + mock extractor + mock API → returns success URL
110+
- Test 3: Extraction failure → returns extraction error
111+
- Test 4: API failure → extraction succeeds + API fails → returns save error
112+
- Test 5: Invalid URL → returns invalidURL error
113+
114+
### Files to Delete
115+
116+
13. **ios/PiperShareExtension/** (entire directory)
117+
- ShareViewController.swift
118+
- PiperShareExtension.entitlements
119+
- readability.js
120+
- Services/ContentExtractor.swift
121+
- Services/PiperAPIClient.swift
122+
123+
14. **ios/PiperShareExtensionTests/** (entire directory)
124+
- ContentExtractorTests.swift (moved to PiperTests)
125+
- PiperAPIClientTests.swift (moved to PiperTests)
126+
- ShareViewControllerTests.swift (deleted — no replacement, tests covered by PipelineControllerTests)
127+
- ConfigTests.swift (moved to PiperTests)
128+
129+
15. **ios/Piper/Piper.entitlements** (delete — App Group no longer needed)
130+
131+
## Key Insights About the Repo
132+
133+
### No .xcodeproj in Repo
134+
The `ios/` directory does NOT contain a `.xcodeproj` file. The Xcode project is managed
135+
separately. This means:
136+
- We cannot edit target membership via pbxproj
137+
- All changes are to source files only
138+
- The spec says "readability.js is bundled as a resource in the app target" — this is a
139+
Xcode project setting that must be done manually in Xcode. We can only place the file
140+
in the right directory.
141+
- Tests reference `@testable import Piper` and `@testable import PiperShareExtension`
142+
these target names are defined in the Xcode project
143+
144+
### CookieStorage Protocol Design
145+
The `CookieStorage` protocol in CookieManager.swift is excellent for testability. The only
146+
change needed is replacing `AppGroupStorage` (which uses `UserDefaults(suiteName:)`) with
147+
a new `StandardStorage` class that uses `UserDefaults.standard`. The `CookieManager` class
148+
itself stays unchanged.
149+
150+
### ShareViewController Orchestration Logic → PipelineController
151+
`ShareViewController` orchestrates: cookieManager.hasCookies → extractSharedURL →
152+
contentExtractor.extract → apiClient.save → pasteboard.string = url → showSuccess.
153+
154+
`PipelineController` should mirror this logic but:
155+
- Take a URL string as input (from UIPasteboard in the app) instead of NSExtensionContext
156+
- Be fully testable with injected mocks
157+
- Return results via completion handlers or async/await
158+
159+
### UIPasteboardProtocol
160+
The `UIPasteboardProtocol` is currently defined in `ShareViewController.swift`. When that
161+
file is deleted, this protocol needs a new home. It could go in:
162+
- `ios/Piper/Services/PipelineController.swift` (since the controller reads clipboard)
163+
- Or a new small file
164+
165+
Actually, looking more carefully at the spec: PipelineController takes a URL string as
166+
input (the view reads from clipboard and passes it). So the view is responsible for reading
167+
the clipboard, PipelineController just processes the URL. UIPasteboardProtocol may not be
168+
needed in PipelineController at all — the view reads UIPasteboard.general.string and passes
169+
it to PipelineController.
170+
171+
### ContentExtractor Bundle
172+
`ContentExtractor` uses `Bundle(for: ContentExtractor.self)` as the default bundle. When
173+
moved from PiperShareExtension to Piper, this will automatically resolve to the Piper app
174+
bundle, which is correct. The error message "missing from the extension bundle" needs
175+
updating to "missing from the app bundle".
176+
177+
### Config.swift Location
178+
`Config.swift` is in `ios/Shared/` (not in PiperShareExtension). The spec says "Backend
179+
URL is a single constant via Config.swift" — this is already the case. No change needed
180+
here except ensuring the file stays in Shared/ and is accessible to the Piper target.
181+
182+
### Test Target Names
183+
- `PiperTests` imports `@testable import Piper` — these tests compile against the Piper target
184+
- `PiperShareExtensionTests` imports `@testable import PiperShareExtension` — must be updated
185+
to `@testable import Piper` when moved
186+
187+
### InMemoryStorage Duplication
188+
`InMemoryStorage` is defined in both:
189+
- `ios/PiperTests/CookieManagerTests.swift` (as `final class InMemoryStorage: CookieStorage`)
190+
- `ios/PiperShareExtensionTests/ShareViewControllerTests.swift` (private version)
191+
192+
The `LoginDetectionTests.swift` and `ContentViewTests.swift` reference `InMemoryStorage`
193+
without defining it — they rely on the definition in `CookieManagerTests.swift` being
194+
compiled in the same test target. The moved tests (ContentExtractorTests, etc.) that come
195+
from PiperShareExtension do NOT use InMemoryStorage, so no conflict there.
196+
197+
## Potential Issues and Gotchas
198+
199+
1. **No .xcodeproj in repo**: Cannot modify target membership. The spec's "Files to
200+
create" and "Files to move" are file-system operations only. The developer must manually
201+
update Xcode project settings (add files to targets, add readability.js to Copy Bundle
202+
Resources). The SETUP.md will need updating to reflect the new single-target setup.
203+
204+
2. **readability.js bundle lookup**: After the move, `ContentExtractor` looks for
205+
`readability.js` via `bundle.url(forResource: "readability", withExtension: "js")`.
206+
This will work IF Xcode adds readability.js to the Piper target's "Copy Bundle Resources"
207+
phase. The file must be at `ios/Piper/Resources/readability.js` on disk.
208+
209+
3. **PiperApp.swift currently passes CookieManager() to ContentView**. After the pivot,
210+
ContentView also needs a PipelineController. The PiperApp.swift will need to create a
211+
PipelineController and pass it to ContentView (or ContentView can create it internally).
212+
213+
4. **ShareViewControllerTests.swift tests the UIKit ShareViewController** — this entire
214+
file is deleted (not moved). Coverage is replaced by PipelineControllerTests.swift which
215+
tests the equivalent orchestration logic at the service level.
216+
217+
5. **UIPasteboardProtocol** is defined in ShareViewController.swift. When that file is
218+
deleted, the protocol disappears. If PipelineController tests need to mock clipboard
219+
writing, UIPasteboardProtocol needs to be re-homed. However, per the spec, the clipboard
220+
WRITE (copying result URL) could happen either in PipelineController or in the view.
221+
For testability, PipelineController should just return the URL string, and the view
222+
copies it to the clipboard. This avoids needing UIPasteboardProtocol in the service layer.
223+
224+
6. **AppGroupStorage fatalError**: The current `AppGroupStorage.init()` calls `fatalError`
225+
if the UserDefaults suite can't be opened. Replacing with `StandardStorage` (wrapping
226+
`UserDefaults.standard`) eliminates this crash risk entirely.
227+
228+
7. **Piper.entitlements**: Currently in `ios/Piper/Piper.entitlements`. The spec says to
229+
delete it. Since there's no .xcodeproj to edit, we only delete the file. The developer
230+
must also remove the entitlements file reference from the Xcode project settings for
231+
the Piper target.
232+
233+
8. **SETUP.md needs updating**: The existing SETUP.md describes the two-target setup with
234+
App Group. After the pivot, it should describe the single-target setup. However, the
235+
spec doesn't list SETUP.md as a file to modify — this may be out of scope or handled
236+
separately.
237+
238+
## Build Order (Implementation Sequence)
239+
240+
1. Modify CookieManager.swift (AppGroupStorage → StandardStorage)
241+
2. Create ios/Piper/Services/ directory structure
242+
3. Copy ContentExtractor.swift to ios/Piper/Services/ (update bundle error message)
243+
4. Copy PiperAPIClient.swift to ios/Piper/Services/
244+
5. Create ios/Piper/Resources/ directory and move readability.js
245+
6. Create ios/Piper/Services/PipelineController.swift
246+
7. Create ios/Piper/PipeView.swift
247+
8. Modify ios/Piper/ContentView.swift (add Pipe Article button + PipeView sheet)
248+
9. Move PiperShareExtensionTests/* → PiperTests/ (updating imports)
249+
10. Create ios/PiperTests/PipelineControllerTests.swift
250+
11. Update CookieManagerTests.swift (add StandardStorage test)
251+
12. Delete PiperShareExtension/ and PiperShareExtensionTests/ directories
252+
13. Delete ios/Piper/Piper.entitlements
253+
14. Update SETUP.md if required

.github/scripts/lint-ios-layers.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ while IFS= read -r file; do
1414
done < <(find "$IOS" -name "*View.swift" 2>/dev/null)
1515

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

0 commit comments

Comments
 (0)