docs: add corrected implementation plan for AutoFill extension UI

Supersedes Tasks 18-20 of the original plan with fixed bundle
identifiers (org.antiloop222, not com.christophevila), the correct
AutoFill/ folder name (renamed from autofill/ during Phase 2), and a
real bug fix found before implementation: the original Task 18 named
its view controller class differently from what the extension's
storyboard actually instantiates, which would have silently left the
default boilerplate UI running instead of any real code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYqycDFsynHH9VnnK7LNSf
This commit is contained in:
2026-09-19 19:03:22 +02:00
co-authored by Claude Sonnet 5
parent e6136a3f63
commit ee675471f5
@@ -0,0 +1,489 @@
# AutoFill Extension UI Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the actual AutoFill Credential Provider extension UI — biometric/password unlock, and a credential list filtered by the requesting app/website — completing Phase 5 (the last phase) of MyPass's original implementation plan.
**Architecture:** The `AutoFill` app-extension target (already wired up: App Group, Keychain Sharing, `MyPassCore` linked — see `docs/superpowers/plans/2026-05-21-mypass.md` Task 9) gets its own `ASCredentialProviderViewController` subclass that hosts a small SwiftUI view hierarchy (`ExtensionRootView` → unlock screen or `CredentialListView`), backed by its own `ExtensionViewModel`. It reuses `MyPassCore`'s `VaultSession`, `KeychainStore`, `FileBookmarkService` (app target), `BiometricAuthService` (app target), and `CredentialMatcher` directly — no new `MyPassCore` logic needed for this phase.
**Tech Stack:** Swift 5.9+, SwiftUI hosted inside `AuthenticationServices.ASCredentialProviderViewController` via `UIHostingController`, same `MyPassCore` package and app-target services as the rest of the project.
**Spec:** `docs/superpowers/specs/2026-05-21-mypass-design.md` — see "AutoFill Credential Provider Extension" section (runtime flow, onboarding, URL matching, extension constraints). Original task-by-task source for this phase is `docs/superpowers/plans/2026-05-21-mypass.md` Tasks 18-20 — this plan supersedes those three tasks with corrected identifiers, the correct extension folder name, and a storyboard-wiring fix (see below). Do not re-run Tasks 18-20 from that file; this plan replaces them.
## Global Constraints
- **Bundle identifier prefix is `org.antiloop222`, not `com.christophevila`** (the original Tasks 18-20 text predates the September 2026 rebrand — see `docs/superpowers/specs/2026-05-21-mypass-design.md`'s Architecture section). `KeychainStore(accessGroup:)` must be constructed with `"org.antiloop222.mypass"`.
- **The extension's folder is `AutoFill/`, not `AutoFillExtension/`** (the target itself was renamed from Xcode's wizard-generated `autofill` to `AutoFill` during Phase 2 — see `docs/superpowers/plans/2026-05-21-mypass.md` Task 9's history). All file paths below are under `AutoFill/`.
- **Storyboard class-name compatibility (a real bug found before this plan's tasks were written):** the extension's entry point is `AutoFill/Base.lproj/MainInterface.storyboard`, whose scene has `customClass="CredentialProviderViewController"` — the system instantiates a view controller by that exact class name at launch. The original Task 18 named its class `AutoFillViewController`, which the storyboard would never actually instantiate, silently leaving the default boilerplate ("Return Example Password" button, wired to methods that no longer exist) as the only thing the user ever sees. This plan's Task 18 keeps the class named `CredentialProviderViewController` to match the storyboard, and Task 18 also strips the storyboard's now-unused static button/navigation-bar/actions so nothing dangling points at removed selectors.
- **Extension memory/lifecycle constraint (from the spec):** the KDBX file is parsed fresh on each extension invocation — no persistent cache inside the extension process.
---
## Task 18: CredentialProviderViewController (extension entry point)
**Files:**
- Create: `AutoFill/CredentialProviderViewController.swift` (replaces the boilerplate content of the existing file of the same name — same class name, new implementation)
- Modify: `AutoFill/Base.lproj/MainInterface.storyboard` (strip the unused static button/nav-bar/actions; keep the scene and its `customClass`)
**Interfaces:**
- Consumes: `VaultSession`, `KeychainStore`, `CredentialMatcher.filter(entries:for:)` (all `MyPassCore`, existing), `FileBookmarkService`, `BiometricAuthService` (app target, existing, from `docs/superpowers/plans/2026-05-21-mypass.md` Tasks 10-11).
- Produces: `ExtensionRootView(session:serviceIdentifiers:bookmarkService:keychainStore:biometricService:onSelect:onCancel:)` — used by Task 19 (not yet defined; this task references it, Task 19 defines it, matching the existing project's own established pattern of forward-referencing a not-yet-created type across adjacent tasks — see `docs/superpowers/plans/2026-05-21-mypass.md` Task 18's own note "Build (will fail until ExtensionRootView is created in Task 19)").
- [ ] **Step 1: Write CredentialProviderViewController**
```swift
// AutoFill/CredentialProviderViewController.swift
import AuthenticationServices
import SwiftUI
import MyPassCore
final class CredentialProviderViewController: ASCredentialProviderViewController {
private let session = VaultSession()
private let bookmarkService = FileBookmarkService()
private let keychainStore = KeychainStore(accessGroup: "org.antiloop222.mypass")
private let biometricService = BiometricAuthService()
// Called when the user selects MyPass from the QuickType bar.
override func prepareCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier]) {
let ids = serviceIdentifiers.map(\.identifier)
showUI(serviceIdentifiers: ids)
}
// Called for inline QuickType suggestion (no UI shown).
override func provideCredentialWithoutUserInteraction(for credentialIdentity: ASPasswordCredentialIdentity) {
Task {
do {
try await unlockSilently()
let all = session.allEntries()
if let entry = all.first(where: { $0.id.uuidString == credentialIdentity.recordIdentifier }) {
let credential = ASPasswordCredential(user: entry.username, password: entry.password.reveal())
self.extensionContext.completeRequest(withSelectedCredential: credential, completionHandler: nil)
} else {
self.extensionContext.cancelRequest(withError: ASExtensionError(.credentialIdentityNotFound))
}
} catch {
self.extensionContext.cancelRequest(withError: ASExtensionError(.userInteractionRequired))
}
}
}
private func unlockSilently() async throws {
guard session.isLocked else { return }
let password = try keychainStore.load(for: "masterPassword")
let url = try bookmarkService.resolveURL()
defer { bookmarkService.stopAccess(url: url) }
try session.unlock(url: url, password: password)
}
private func showUI(serviceIdentifiers: [String]) {
let rootView = ExtensionRootView(
session: session,
serviceIdentifiers: serviceIdentifiers,
bookmarkService: bookmarkService,
keychainStore: keychainStore,
biometricService: biometricService,
onSelect: { [weak self] entry in
let credential = ASPasswordCredential(
user: entry.username,
password: entry.password.reveal()
)
self?.extensionContext.completeRequest(withSelectedCredential: credential, completionHandler: nil)
},
onCancel: { [weak self] in
self?.extensionContext.cancelRequest(withError: ASExtensionError(.userCanceled))
}
)
let host = UIHostingController(rootView: rootView)
addChild(host)
view.addSubview(host.view)
host.view.frame = view.bounds
host.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
host.didMove(toParent: self)
}
}
```
- [ ] **Step 2: Strip the storyboard's unused static UI**
Open `AutoFill/Base.lproj/MainInterface.storyboard` in a text editor (not Xcode's Interface Builder, to make an exact, reviewable diff). Replace the `<viewController>` element's contents so it has an empty view (no navigation bar, no button, no `<connections>` blocks referencing `cancel:`/`passwordSelected:`), while keeping the same `id`, `customClass="CredentialProviderViewController"`, and the scene/document structure exactly as-is otherwise. The full corrected file:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14092" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="Xki-Si-B7m">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14081.1"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Credential Provider View Controller-->
<scene sceneID="Uma-9u-xWV">
<objects>
<viewController id="Xki-Si-B7m" customClass="CredentialProviderViewController" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="BuU-Ak-iZz">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<viewLayoutGuide key="safeArea" id="Ky8-vK-JVj"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="RwB-HB-TSk" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
```
- [ ] **Step 3: Build (expected to fail until Task 19 creates ExtensionRootView)**
```bash
xcodebuild -project MyPass.xcodeproj -scheme AutoFill -destination 'generic/platform=iOS Simulator' build 2>&1 | grep -i "error:"
```
Expected: exactly one error, `cannot find 'ExtensionRootView' in scope` in `AutoFill/CredentialProviderViewController.swift`. No other errors.
- [ ] **Step 4: Commit**
```bash
git add AutoFill/CredentialProviderViewController.swift AutoFill/Base.lproj/MainInterface.storyboard
git commit -m "feat: add CredentialProviderViewController skeleton, strip unused storyboard UI"
```
---
## Task 19: ExtensionUnlockView + ExtensionRootView + ExtensionViewModel
**Files:**
- Create: `AutoFill/Views/ExtensionUnlockView.swift`
**Interfaces:**
- Consumes: `VaultSession`, `Entry`, `KDBXError` (`MyPassCore`), `FileBookmarkService`, `BiometricAuthService` (app target).
- Produces: `ExtensionRootView` (used by Task 18, already committed — this task makes Task 18's forward reference resolve), `CredentialListView(vm:)` — used by Task 20 (not yet defined; same forward-reference pattern as Task 18 → 19).
- [ ] **Step 1: Write ExtensionUnlockView, ExtensionRootView, and ExtensionViewModel**
```swift
// AutoFill/Views/ExtensionUnlockView.swift
import SwiftUI
import MyPassCore
/// Root view for the extension -- shows unlock screen or credential list depending on state.
struct ExtensionRootView: View {
@StateObject private var vm: ExtensionViewModel
init(
session: VaultSession,
serviceIdentifiers: [String],
bookmarkService: FileBookmarkService,
keychainStore: KeychainStore,
biometricService: BiometricAuthService,
onSelect: @escaping (Entry) -> Void,
onCancel: @escaping () -> Void
) {
_vm = StateObject(wrappedValue: ExtensionViewModel(
session: session,
serviceIdentifiers: serviceIdentifiers,
bookmarkService: bookmarkService,
keychainStore: keychainStore,
biometricService: biometricService,
onSelect: onSelect,
onCancel: onCancel
))
}
var body: some View {
NavigationStack {
SwiftUI.Group {
if vm.isLocked {
extensionUnlockView
} else {
CredentialListView(vm: vm)
}
}
.navigationTitle("MyPass")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel", action: vm.cancel)
}
}
}
.task { await vm.tryBiometricUnlock() }
}
private var extensionUnlockView: some View {
VStack(spacing: 24) {
Spacer()
Image(systemName: "lock.shield.fill").font(.system(size: 48)).foregroundStyle(.tint)
Text("Vault Locked").font(.headline)
if vm.canUseBiometrics {
Button(action: { Task { await vm.tryBiometricUnlock() } }) {
Label("Use Face ID / Touch ID", systemImage: "faceid")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
SecureField("Master Password", text: $vm.password)
.textFieldStyle(.roundedBorder)
.onSubmit { Task { await vm.unlockWithPassword() } }
Button("Unlock") { Task { await vm.unlockWithPassword() } }
.buttonStyle(.bordered)
.disabled(vm.password.isEmpty || vm.isUnlocking)
if let msg = vm.errorMessage {
Text(msg).foregroundStyle(.red).font(.caption).multilineTextAlignment(.center)
}
if !vm.hasVault {
Link(destination: URL(string: "mypass://unlock")!) {
Label("Open MyPass to set up vault", systemImage: "arrow.up.right")
.font(.caption)
}
}
Spacer()
}
.padding(24)
}
}
@MainActor
final class ExtensionViewModel: ObservableObject {
@Published var password: String = ""
@Published var errorMessage: String?
@Published var isUnlocking: Bool = false
let session: VaultSession
let serviceIdentifiers: [String]
private let bookmarkService: FileBookmarkService
private let keychainStore: KeychainStore
private let biometricService: BiometricAuthService
private let onSelect: (Entry) -> Void
private let onCancel: () -> Void
init(
session: VaultSession,
serviceIdentifiers: [String],
bookmarkService: FileBookmarkService,
keychainStore: KeychainStore,
biometricService: BiometricAuthService,
onSelect: @escaping (Entry) -> Void,
onCancel: @escaping () -> Void
) {
self.session = session
self.serviceIdentifiers = serviceIdentifiers
self.bookmarkService = bookmarkService
self.keychainStore = keychainStore
self.biometricService = biometricService
self.onSelect = onSelect
self.onCancel = onCancel
}
var isLocked: Bool { session.isLocked }
var hasVault: Bool { bookmarkService.hasBookmark }
var canUseBiometrics: Bool { biometricService.isAvailable && keychainStore.exists(for: "masterPassword") }
var suggestedEntries: [Entry] {
CredentialMatcher.filter(entries: session.allEntries(), for: serviceIdentifiers).suggested
}
var allEntries: [Entry] {
CredentialMatcher.filter(entries: session.allEntries(), for: serviceIdentifiers).all
}
func select(_ entry: Entry) { onSelect(entry) }
func cancel() { onCancel() }
func tryBiometricUnlock() async {
guard canUseBiometrics, session.isLocked else { return }
isUnlocking = true
do {
try await biometricService.authenticate(reason: "Unlock MyPass")
try performUnlockFromKeychain()
} catch {
// Silently fail -- user can type password
}
isUnlocking = false
}
func unlockWithPassword() async {
isUnlocking = true
errorMessage = nil
do {
let url = try bookmarkService.resolveURL()
defer { bookmarkService.stopAccess(url: url) }
try session.unlock(url: url, password: password)
try keychainStore.save(password: password, for: "masterPassword")
password = ""
} catch {
errorMessage = error.localizedDescription
}
isUnlocking = false
}
private func performUnlockFromKeychain() throws {
let pw = try keychainStore.load(for: "masterPassword")
let url = try bookmarkService.resolveURL()
defer { bookmarkService.stopAccess(url: url) }
try session.unlock(url: url, password: pw)
}
}
```
Note: unlike the original Tasks 18-20 draft, `unlockWithPassword()`'s `catch` clause here uses the generic `error.localizedDescription` for ALL errors (not a special-cased `catch KDBXError.invalidPassword`), matching the fix already applied to the main app's `UnlockViewModel` (see `MyPass/ViewModels/UnlockViewModel.swift` and `MyPassCore/Sources/MyPassCore/KDBX/KDBXError.swift`'s `LocalizedError` conformance) -- `KDBXError` already produces a readable message for every case, so there is no need to special-case `.invalidPassword` here either.
- [ ] **Step 2: Build (expected to fail until Task 20 creates CredentialListView)**
```bash
xcodebuild -project MyPass.xcodeproj -scheme AutoFill -destination 'generic/platform=iOS Simulator' build 2>&1 | grep -i "error:"
```
Expected: exactly one error, `cannot find 'CredentialListView' in scope` in `AutoFill/Views/ExtensionUnlockView.swift`. No other errors (in particular, Task 18's `ExtensionRootView` forward-reference error should be gone now).
- [ ] **Step 3: Commit**
```bash
git add AutoFill/Views/ExtensionUnlockView.swift
git commit -m "feat: add ExtensionRootView and ExtensionViewModel"
```
---
## Task 20: CredentialListView (extension)
**Files:**
- Create: `AutoFill/Views/CredentialListView.swift`
**Interfaces:**
- Consumes: `ExtensionViewModel` (Task 19, already committed).
- Produces: nothing further consumed by later tasks -- this is the last task in the phase.
- [ ] **Step 1: Write CredentialListView**
```swift
// AutoFill/Views/CredentialListView.swift
import SwiftUI
import MyPassCore
struct CredentialListView: View {
@ObservedObject var vm: ExtensionViewModel
@State private var searchQuery: String = ""
private var displayedSuggested: [Entry] {
searchQuery.isEmpty ? vm.suggestedEntries : []
}
private var displayedAll: [Entry] {
let entries = searchQuery.isEmpty ? vm.allEntries : vm.session.allEntries()
guard !searchQuery.isEmpty else { return entries }
let q = searchQuery.lowercased()
return entries.filter {
$0.title.lowercased().contains(q)
|| $0.username.lowercased().contains(q)
|| $0.url.lowercased().contains(q)
}
}
var body: some View {
List {
if !displayedSuggested.isEmpty {
Section("Suggested") {
ForEach(displayedSuggested) { entry in
CredentialRow(entry: entry) { vm.select(entry) }
}
}
}
Section(displayedSuggested.isEmpty ? "" : "All Entries") {
if displayedAll.isEmpty {
Text("No entries found").foregroundStyle(.secondary)
} else {
ForEach(displayedAll) { entry in
CredentialRow(entry: entry) { vm.select(entry) }
}
}
}
}
.searchable(text: $searchQuery, prompt: "Search…")
}
}
private struct CredentialRow: View {
let entry: Entry
let onSelect: () -> Void
var body: some View {
Button(action: onSelect) {
VStack(alignment: .leading, spacing: 2) {
Text(entry.title).font(.body).foregroundStyle(.primary)
Text(entry.username).font(.caption).foregroundStyle(.secondary)
if !entry.url.isEmpty {
Text(entry.url).font(.caption2).foregroundStyle(.tertiary)
}
}
}
}
}
```
- [ ] **Step 2: Build -- should now succeed with zero errors**
```bash
xcodebuild -project MyPass.xcodeproj -scheme AutoFill -destination 'generic/platform=iOS Simulator' build 2>&1 | grep -i "error:"
```
Expected: no output (no errors). Also confirm the main app scheme still builds clean (this phase doesn't touch it, but the extension links against the same `MyPassCore` target, so a sanity check is cheap):
```bash
xcodebuild -project MyPass.xcodeproj -scheme MyPass -destination 'generic/platform=iOS Simulator' build 2>&1 | grep -i "error:"
```
Expected: no output.
- [ ] **Step 3: Commit**
```bash
git add AutoFill/Views/CredentialListView.swift
git commit -m "feat: add CredentialListView for AutoFill extension"
```
---
## Task 21: Final verification
**Files:** none (verification only).
- [ ] **Step 1: Build both the AutoFill extension scheme and the main app scheme clean**
```bash
xcodebuild -project MyPass.xcodeproj -scheme AutoFill -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -5
xcodebuild -project MyPass.xcodeproj -scheme MyPass -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -5
```
Expected: `** BUILD SUCCEEDED **` for both.
- [ ] **Step 2: Manual smoke test in the Simulator**
Per the spec's own onboarding note, the extension must be manually enabled first: **Settings → Passwords → AutoFill Passwords → MyPass** (toggle on) on the simulator. Then:
- Open Safari (or another app with a login form) and navigate to a site/field that matches a known entry's URL
- Tap the field -- the QuickType bar should show MyPass's icon
- Tap it -- the extension UI should appear, locked (unless a recent main-app unlock already populated the shared Keychain token)
- Unlock with password (or Face ID, if biometrics are set up and enabled for this simulator)
- Verify "Suggested" section shows entries matching the site's domain, "All Entries" shows the rest, and search narrows both
- Tap an entry -- the field in the host app should be filled and the extension should dismiss
- Tap Cancel from the unlock screen -- the extension should dismiss without filling anything
- [ ] **Step 3: Final commit (if the smoke test surfaced fixes)**
```bash
git add -A
git commit -m "fix: address issues found in AutoFill extension manual verification"
```
(Skip this step if the smoke test found nothing to fix.)