Files
keevault/docs/superpowers/plans/2026-09-19-autofill-extension.md
krissandClaude Sonnet 5 ee675471f5 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
2026-09-19 19:03:22 +02:00

22 KiB

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

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

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

// 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
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):

xcodebuild -project MyPass.xcodeproj -scheme MyPass -destination 'generic/platform=iOS Simulator' build 2>&1 | grep -i "error:"

Expected: no output.

  • Step 3: Commit
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
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)

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