Files
keevault/docs/superpowers/plans/2026-05-21-mypass.md
T
2026-05-21 23:04:24 +02:00

85 KiB
Raw Blame History

MyPass 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 a KDBX-backed iOS/macOS password manager with biometric unlock and an AutoFill Credential Provider extension that fills credentials into every app on the device.

Architecture: A local Swift Package (MyPassCore) holds all vault logic (KDBX parsing, models, TOTP, Keychain). The main app and AutoFill Extension both link to it. A shared App Group exposes the KDBX file bookmark and Keychain credentials to both processes.

Tech Stack: Swift 5.9+, SwiftUI, KeePassKit (KDBX 3.1/4.0 parsing via SPM), CryptoKit (HMAC-SHA for TOTP), LocalAuthentication, AuthenticationServices, Security framework.


File Map

MyPassCore/                                         ← new local Swift Package
├── Package.swift
└── Sources/MyPassCore/
│   ├── Models/
│   │   ├── KDBXDatabase.swift
│   │   ├── DatabaseMetadata.swift
│   │   ├── Group.swift
│   │   ├── Entry.swift
│   │   ├── ProtectedString.swift
│   │   ├── CustomField.swift
│   │   ├── Attachment.swift
│   │   └── TOTPConfig.swift
│   ├── KDBX/
│   │   ├── KDBXDocument.swift
│   │   ├── KDBXMapper.swift
│   │   └── KDBXError.swift
│   ├── TOTP/
│   │   └── TOTPGenerator.swift
│   ├── Keychain/
│   │   ├── KeychainStore.swift
│   │   └── KeychainError.swift
│   ├── AutoFill/
│   │   └── CredentialMatcher.swift
│   └── Session/
│       └── VaultSession.swift
└── Tests/MyPassCoreTests/
    ├── ProtectedStringTests.swift
    ├── TOTPGeneratorTests.swift
    ├── CredentialMatcherTests.swift
    ├── KDBXDocumentTests.swift
    └── Fixtures/
        └── test.kdbx                               ← copied in Task 4

MyPass/                                             ← existing main app target
├── MyPassApp.swift                                 ← modify: remove SwiftData, add lifecycle
├── ContentView.swift                               ← replace: route Unlock ↔ GroupBrowser
├── Item.swift                                      ← delete
├── Services/
│   ├── FileBookmarkService.swift                   ← new
│   ├── BiometricAuthService.swift                  ← new
│   └── ClipboardService.swift                      ← new
├── ViewModels/
│   ├── UnlockViewModel.swift                       ← new
│   ├── VaultViewModel.swift                        ← new
│   └── EntryEditViewModel.swift                    ← new
└── Views/
    ├── UnlockView.swift                            ← new
    ├── GroupBrowserView.swift                      ← new
    ├── EntryDetailView.swift                       ← new
    ├── EntryEditView.swift                         ← new
    └── SearchView.swift                            ← new

AutoFillExtension/                                  ← new App Extension target
├── AutoFillViewController.swift
├── Info.plist
└── Views/
    ├── ExtensionUnlockView.swift
    └── CredentialListView.swift

Phase 1 — MyPassCore Package

Task 1: Create MyPassCore local Swift Package

Files:

  • Create: MyPassCore/Package.swift

  • Create directory tree: MyPassCore/Sources/MyPassCore/ subdirectories

  • Create directory tree: MyPassCore/Tests/MyPassCoreTests/Fixtures/

  • Step 1: Verify KeePassKit SPM availability

Open https://github.com/mstarke/KeePassKit. Confirm a Package.swift exists at the repository root. If yes, proceed. If no, see the Fallback section at the end of this task before writing Package.swift.

  • Step 2: Create directory structure
mkdir -p MyPassCore/Sources/MyPassCore/Models
mkdir -p MyPassCore/Sources/MyPassCore/KDBX
mkdir -p MyPassCore/Sources/MyPassCore/TOTP
mkdir -p MyPassCore/Sources/MyPassCore/Keychain
mkdir -p MyPassCore/Sources/MyPassCore/AutoFill
mkdir -p MyPassCore/Sources/MyPassCore/Session
mkdir -p MyPassCore/Tests/MyPassCoreTests/Fixtures
  • Step 3: Write Package.swift
// MyPassCore/Package.swift
// swift-tools-version: 5.9
import PackageDescription

let package = Package(
    name: "MyPassCore",
    platforms: [.iOS(.v17), .macOS(.v14)],
    products: [
        .library(name: "MyPassCore", targets: ["MyPassCore"]),
    ],
    dependencies: [
        .package(url: "https://github.com/mstarke/KeePassKit", branch: "master"),
    ],
    targets: [
        .target(
            name: "MyPassCore",
            dependencies: [
                .product(name: "KeePassKit", package: "KeePassKit"),
            ]
        ),
        .testTarget(
            name: "MyPassCoreTests",
            dependencies: ["MyPassCore"],
            resources: [.copy("Fixtures")]
        ),
    ]
)
  • Step 4: Add the package to the Xcode project

In Xcode: File → Add Package Dependencies... → Add Local... → select the MyPassCore/ folder → click Add Package. In the dialog, check MyPassCore as a dependency of the MyPass target. (The AutoFill extension will be added in Task 9.)

  • Step 5: Delete Item.swift

In Xcode's file navigator, right-click Item.swiftDeleteMove to Trash.

  • Step 6: Commit
git add MyPassCore/ MyPass/
git commit -m "feat: scaffold MyPassCore Swift Package"

Fallback — if KeePassKit has no Package.swift:

mkdir -p Vendor
git submodule add https://github.com/mstarke/KeePassKit Vendor/KeePassKit

In Package.swift replace the remote .package(url:branch:) with:

.package(path: "../Vendor/KeePassKit"),

If the submodule itself has no Package.swift, create one at Vendor/KeePassKit/Package.swift:

// swift-tools-version: 5.9
import PackageDescription
let package = Package(
    name: "KeePassKit",
    products: [.library(name: "KeePassKit", targets: ["KeePassKit"])],
    targets: [
        .target(
            name: "KeePassKit",
            path: "KeePassKit",
            publicHeadersPath: ".",
            cSettings: [.headerSearchPath(".")]
        )
    ]
)

Task 2: ProtectedString + tests

Files:

  • Create: MyPassCore/Sources/MyPassCore/Models/ProtectedString.swift

  • Create: MyPassCore/Tests/MyPassCoreTests/ProtectedStringTests.swift

  • Step 1: Write the failing test

// MyPassCore/Tests/MyPassCoreTests/ProtectedStringTests.swift
import XCTest
@testable import MyPassCore

final class ProtectedStringTests: XCTestCase {
    func test_reveal_returnsOriginalValue() {
        let ps = ProtectedString("hunter2", isProtected: true)
        XCTAssertEqual(ps.reveal(), "hunter2")
    }

    func test_description_isRedactedWhenProtected() {
        let ps = ProtectedString("secret", isProtected: true)
        XCTAssertEqual("\(ps)", "***")
    }

    func test_description_isPlainWhenNotProtected() {
        let ps = ProtectedString("visible", isProtected: false)
        XCTAssertEqual("\(ps)", "visible")
    }

    func test_equality_matchesByRevealedValue() {
        let a = ProtectedString("abc", isProtected: true)
        let b = ProtectedString("abc", isProtected: true)
        XCTAssertEqual(a, b)
    }

    func test_debugDescription_neverRevealSecret() {
        let ps = ProtectedString("topsecret", isProtected: true)
        XCTAssertFalse(ps.debugDescription.contains("topsecret"))
    }
}
  • Step 2: Run to confirm failure

In Xcode: Product → Test (or ⌘U). MyPassCoreTests fails because ProtectedString does not exist.

  • Step 3: Implement ProtectedString
// MyPassCore/Sources/MyPassCore/Models/ProtectedString.swift
import Foundation

public struct ProtectedString: Equatable {
    private let obfuscated: [UInt8]
    private let key: [UInt8]
    public let isProtected: Bool

    public init(_ value: String, isProtected: Bool = true) {
        self.isProtected = isProtected
        let bytes = Array(value.utf8)
        let k = (0 ..< bytes.count).map { _ in UInt8.random(in: 0 ... 255) }
        self.key = k
        self.obfuscated = zip(bytes, k).map { $0 ^ $1 }
    }

    public func reveal() -> String {
        let bytes = zip(obfuscated, key).map { $0 ^ $1 }
        return String(bytes: bytes, encoding: .utf8) ?? ""
    }

    public static func == (lhs: ProtectedString, rhs: ProtectedString) -> Bool {
        lhs.reveal() == rhs.reveal() && lhs.isProtected == rhs.isProtected
    }
}

extension ProtectedString: CustomStringConvertible {
    public var description: String { isProtected ? "***" : reveal() }
}

extension ProtectedString: CustomDebugStringConvertible {
    public var debugDescription: String { "ProtectedString(isProtected: \(isProtected))" }
}
  • Step 4: Run tests — all pass

⌘UProtectedStringTests passes (5 tests).

  • Step 5: Commit
git add MyPassCore/
git commit -m "feat: add ProtectedString with XOR obfuscation"

Task 3: Core data models

Files:

  • Create: MyPassCore/Sources/MyPassCore/Models/DatabaseMetadata.swift

  • Create: MyPassCore/Sources/MyPassCore/Models/KDBXDatabase.swift

  • Create: MyPassCore/Sources/MyPassCore/Models/TOTPConfig.swift

  • Create: MyPassCore/Sources/MyPassCore/Models/CustomField.swift

  • Create: MyPassCore/Sources/MyPassCore/Models/Attachment.swift

  • Create: MyPassCore/Sources/MyPassCore/Models/Entry.swift

  • Create: MyPassCore/Sources/MyPassCore/Models/Group.swift

  • Step 1: Write all model files

// MyPassCore/Sources/MyPassCore/Models/DatabaseMetadata.swift
import Foundation

public struct DatabaseMetadata: Equatable {
    public var name: String
    public var description: String
    public init(name: String, description: String = "") {
        self.name = name
        self.description = description
    }
}
// MyPassCore/Sources/MyPassCore/Models/TOTPConfig.swift
import Foundation

public enum TOTPAlgorithm: String, Equatable, CaseIterable {
    case sha1 = "SHA1"
    case sha256 = "SHA256"
    case sha512 = "SHA512"
}

public struct TOTPConfig: Equatable {
    public var secret: String
    public var period: Int
    public var digits: Int
    public var algorithm: TOTPAlgorithm

    public init(secret: String, period: Int = 30, digits: Int = 6, algorithm: TOTPAlgorithm = .sha1) {
        self.secret = secret
        self.period = period
        self.digits = digits
        self.algorithm = algorithm
    }

    /// Parses an otpauth://totp/ URI (standard KeePass TOTP storage format).
    public static func parse(from uri: String) -> TOTPConfig? {
        guard let url = URL(string: uri),
              url.scheme == "otpauth",
              url.host == "totp",
              let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
        else { return nil }
        let params = Dictionary(
            uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in
                item.value.map { (item.name, $0) }
            }
        )
        guard let secret = params["secret"] else { return nil }
        let period  = Int(params["period"]  ?? "30") ?? 30
        let digits  = Int(params["digits"]  ?? "6")  ?? 6
        let algo: TOTPAlgorithm
        switch params["algorithm"]?.uppercased() {
        case "SHA256": algo = .sha256
        case "SHA512": algo = .sha512
        default:       algo = .sha1
        }
        return TOTPConfig(secret: secret, period: period, digits: digits, algorithm: algo)
    }
}
// MyPassCore/Sources/MyPassCore/Models/CustomField.swift
import Foundation

public struct CustomField: Identifiable, Equatable {
    public var id: UUID
    public var key: String
    public var value: ProtectedString

    public init(id: UUID = UUID(), key: String, value: ProtectedString) {
        self.id = id
        self.key = key
        self.value = value
    }
}
// MyPassCore/Sources/MyPassCore/Models/Attachment.swift
import Foundation

public struct Attachment: Identifiable, Equatable {
    public var id: UUID
    public var name: String
    public var data: Data

    public init(id: UUID = UUID(), name: String, data: Data) {
        self.id = id
        self.name = name
        self.data = data
    }
}
// MyPassCore/Sources/MyPassCore/Models/Entry.swift
import Foundation

public struct Entry: Identifiable, Equatable, Hashable {
    public var id: UUID
    public var title: String
    public var username: String
    public var password: ProtectedString
    public var url: String
    public var notes: String
    public var customFields: [CustomField]
    public var attachments: [Attachment]
    public var totp: TOTPConfig?
    public var tags: [String]
    public var iconIndex: Int
    public var expiryDate: Date?
    public var creationDate: Date
    public var modificationDate: Date
    public var history: [Entry]

    public init(
        id: UUID = UUID(),
        title: String = "",
        username: String = "",
        password: ProtectedString = ProtectedString("", isProtected: true),
        url: String = "",
        notes: String = "",
        customFields: [CustomField] = [],
        attachments: [Attachment] = [],
        totp: TOTPConfig? = nil,
        tags: [String] = [],
        iconIndex: Int = 0,
        expiryDate: Date? = nil,
        creationDate: Date = Date(),
        modificationDate: Date = Date(),
        history: [Entry] = []
    ) {
        self.id = id
        self.title = title
        self.username = username
        self.password = password
        self.url = url
        self.notes = notes
        self.customFields = customFields
        self.attachments = attachments
        self.totp = totp
        self.tags = tags
        self.iconIndex = iconIndex
        self.expiryDate = expiryDate
        self.creationDate = creationDate
        self.modificationDate = modificationDate
        self.history = history
    }

    // Hashable by ID so SwiftUI List(selection:) works without requiring all fields to be Hashable.
    public func hash(into hasher: inout Hasher) { hasher.combine(id) }
    public static func == (lhs: Entry, rhs: Entry) -> Bool { lhs.id == rhs.id }
}
// MyPassCore/Sources/MyPassCore/Models/Group.swift
import Foundation

public struct Group: Identifiable, Equatable, Hashable {
    public var id: UUID
    public var name: String
    public var iconIndex: Int
    public var subgroups: [Group]
    public var entries: [Entry]

    public init(
        id: UUID = UUID(),
        name: String,
        iconIndex: Int = 0,
        subgroups: [Group] = [],
        entries: [Entry] = []
    ) {
        self.id = id
        self.name = name
        self.iconIndex = iconIndex
        self.subgroups = subgroups
        self.entries = entries
    }

    // Hashable by ID so SwiftUI List(selection:) works without hashing the full tree.
    public func hash(into hasher: inout Hasher) { hasher.combine(id) }
    public static func == (lhs: Group, rhs: Group) -> Bool { lhs.id == rhs.id }
}
// MyPassCore/Sources/MyPassCore/Models/KDBXDatabase.swift
import Foundation

public struct KDBXDatabase: Equatable {
    public var metadata: DatabaseMetadata
    public var root: Group

    public init(metadata: DatabaseMetadata, root: Group) {
        self.metadata = metadata
        self.root = root
    }
}
  • Step 2: Build to confirm it compiles

⌘B in Xcode. No errors expected (pure value types, no dependencies).

  • Step 3: Commit
git add MyPassCore/
git commit -m "feat: add core KDBX data models"

Task 4: KDBXError + KDBXDocument + KDBXMapper

Files:

  • Create: MyPassCore/Sources/MyPassCore/KDBX/KDBXError.swift

  • Create: MyPassCore/Sources/MyPassCore/KDBX/KDBXMapper.swift

  • Create: MyPassCore/Sources/MyPassCore/KDBX/KDBXDocument.swift

  • Create: MyPassCore/Tests/MyPassCoreTests/KDBXDocumentTests.swift

  • Copy: a KDBX test fixture to MyPassCore/Tests/MyPassCoreTests/Fixtures/test.kdbx

  • Step 1: Obtain a test KDBX fixture

Download a sample KDBX 4 database from https://keepass.info/help/kb/testfiles_stable.html (or copy Test Files/Format4.kdbx from the KeePassKit repository). Save it as:

MyPassCore/Tests/MyPassCoreTests/Fixtures/test.kdbx

Note the password for this file (typically master for KeePassKit test files).

  • Step 2: Write the failing test
// MyPassCore/Tests/MyPassCoreTests/KDBXDocumentTests.swift
import XCTest
@testable import MyPassCore

final class KDBXDocumentTests: XCTestCase {
    var fixtureURL: URL!

    override func setUp() {
        super.setUp()
        fixtureURL = Bundle.module.url(forResource: "test", withExtension: "kdbx", subdirectory: "Fixtures")!
    }

    func test_read_parsesRootGroup() throws {
        let doc = KDBXDocument(url: fixtureURL)
        let db = try doc.read(password: "master")
        XCTAssertFalse(db.root.name.isEmpty)
    }

    func test_read_wrongPassword_throwsInvalidPassword() throws {
        let doc = KDBXDocument(url: fixtureURL)
        XCTAssertThrowsError(try doc.read(password: "wrong")) { error in
            XCTAssertEqual(error as? KDBXError, .invalidPassword)
        }
    }

    func test_roundTrip_preservesEntryTitle() throws {
        let doc = KDBXDocument(url: fixtureURL)
        var db = try doc.read(password: "master")
        let newEntry = Entry(title: "RoundTripTest", username: "user", password: ProtectedString("pass"))
        db.root.entries.append(newEntry)
        let tmpURL = FileManager.default.temporaryDirectory.appendingPathComponent("roundtrip.kdbx")
        let tmpDoc = KDBXDocument(url: tmpURL)
        try tmpDoc.write(db, password: "master")
        let reloaded = try KDBXDocument(url: tmpURL).read(password: "master")
        XCTAssertTrue(reloaded.root.entries.contains { $0.title == "RoundTripTest" })
    }
}
  • Step 3: Run to confirm failure

⌘U → fails because KDBXDocument, KDBXError don't exist yet.

  • Step 4: Write KDBXError
// MyPassCore/Sources/MyPassCore/KDBX/KDBXError.swift
import Foundation

public enum KDBXError: Error, Equatable {
    case invalidPassword
    case fileNotFound
    case parseError(String)
    case writeError(String)
}
  • Step 5: Write KDBXMapper

Note: The following uses KeePassKit's public API. If property names differ from what you see in the library headers (e.g. childGroups vs groups), adjust to match. The KeePassKit header files are the source of truth.

// MyPassCore/Sources/MyPassCore/KDBX/KDBXMapper.swift
import Foundation
import KeePassKit

enum KDBXMapper {
    // MARK: KeePassKit → MyPassCore

    static func database(from tree: KPKTree) -> KDBXDatabase {
        let meta = DatabaseMetadata(
            name: tree.metaData?.databaseName ?? "",
            description: tree.metaData?.databaseDescription ?? ""
        )
        let root = group(from: tree.root ?? KPKGroup())
        return KDBXDatabase(metadata: meta, root: root)
    }

    static func group(from g: KPKGroup) -> Group {
        Group(
            id: uuid(from: g.uuid),
            name: g.name ?? "",
            iconIndex: Int(g.iconId),
            subgroups: (g.childGroups as? [KPKGroup] ?? []).map { group(from: $0) },
            entries: (g.childEntries as? [KPKEntry] ?? []).map { entry(from: $0) }
        )
    }

    static func entry(from e: KPKEntry) -> Entry {
        let customFields: [CustomField] = (e.customAttributes as? [KPKAttribute] ?? [])
            .filter { !reservedKeys.contains($0.key ?? "") }
            .map {
                CustomField(
                    id: UUID(),
                    key: $0.key ?? "",
                    value: ProtectedString($0.value ?? "", isProtected: $0.isProtected)
                )
            }

        let totpURI = (e.customAttributes as? [KPKAttribute] ?? [])
            .first { $0.key == "otp" }?.value
        let totpConfig = totpURI.flatMap { TOTPConfig.parse(from: $0) }

        let attachments: [Attachment] = (e.binaries as? [KPKBinary] ?? []).map {
            Attachment(id: UUID(), name: $0.name ?? "", data: $0.data ?? Data())
        }

        return Entry(
            id: uuid(from: e.uuid),
            title: e.title ?? "",
            username: e.username ?? "",
            password: ProtectedString(e.password ?? "", isProtected: true),
            url: e.url ?? "",
            notes: e.notes ?? "",
            customFields: customFields,
            attachments: attachments,
            totp: totpConfig,
            tags: [],
            iconIndex: Int(e.iconId),
            expiryDate: e.timeInfo?.expiryDate,
            creationDate: e.timeInfo?.creationDate ?? Date(),
            modificationDate: e.timeInfo?.modificationDate ?? Date(),
            history: []
        )
    }

    // MARK: MyPassCore → KeePassKit

    static func tree(from db: KDBXDatabase) -> KPKTree {
        let tree = KPKTree()
        tree.root = kpkGroup(from: db.root)
        tree.metaData?.databaseName = db.metadata.name
        tree.metaData?.databaseDescription = db.metadata.description
        return tree
    }

    static func kpkGroup(from g: Group) -> KPKGroup {
        let kpk = KPKGroup()
        kpk.name = g.name
        kpk.iconId = UInt32(g.iconIndex)
        for sub in g.subgroups {
            kpk.addGroup(kpkGroup(from: sub), undoManager: nil)
        }
        for e in g.entries {
            kpk.addEntry(kpkEntry(from: e), undoManager: nil)
        }
        return kpk
    }

    static func kpkEntry(from e: Entry) -> KPKEntry {
        let kpk = KPKEntry()
        kpk.title    = e.title
        kpk.username = e.username
        kpk.password = e.password.reveal()
        kpk.url      = e.url
        kpk.notes    = e.notes
        kpk.iconId   = UInt32(e.iconIndex)
        for field in e.customFields {
            let attr = KPKAttribute(key: field.key, value: field.value.reveal(), isProtected: field.value.isProtected)
            kpk.addCustomAttribute(attr)
        }
        for att in e.attachments {
            let bin = KPKBinary(named: att.name, with: att.data)
            kpk.addBinary(bin)
        }
        return kpk
    }

    // MARK: Helpers

    private static func uuid(from nsUUID: NSUUID?) -> UUID {
        guard let u = nsUUID else { return UUID() }
        return UUID(uuidString: u.uuidString) ?? UUID()
    }

    private static let reservedKeys: Set<String> = ["Title", "UserName", "Password", "URL", "Notes"]
}
  • Step 6: Write KDBXDocument
// MyPassCore/Sources/MyPassCore/KDBX/KDBXDocument.swift
import Foundation
import KeePassKit

public struct KDBXDocument {
    public let url: URL

    public init(url: URL) {
        self.url = url
    }

    public func read(password: String) throws -> KDBXDatabase {
        guard FileManager.default.fileExists(atPath: url.path) else {
            throw KDBXError.fileNotFound
        }
        let key = KPKCompositeKey()
        try key.addPasswordData(Data(password.utf8))
        do {
            let tree = try KPKTree(contentsOf: url, key: key)
            return KDBXMapper.database(from: tree)
        } catch let error as NSError {
            if error.domain == KPKErrorDomain || error.code == KPKErrorCode.incorrectKey.rawValue {
                throw KDBXError.invalidPassword
            }
            throw KDBXError.parseError(error.localizedDescription)
        }
    }

    public func write(_ database: KDBXDatabase, password: String) throws {
        let tree = KDBXMapper.tree(from: database)
        let key = KPKCompositeKey()
        try key.addPasswordData(Data(password.utf8))
        do {
            try tree.write(to: url, key: key)
        } catch {
            throw KDBXError.writeError(error.localizedDescription)
        }
    }
}
  • Step 7: Run tests — should pass

⌘U. Three KDBXDocumentTests pass. If test_read_wrongPassword_throwsInvalidPassword fails because the error domain constant is different, check KeePassKit's error constants and adjust the catch clause in KDBXDocument.read.

  • Step 8: Commit
git add MyPassCore/
git commit -m "feat: add KDBXDocument with KeePassKit-backed KDBX parsing"

Task 5: TOTPGenerator + tests

Files:

  • Create: MyPassCore/Sources/MyPassCore/TOTP/TOTPGenerator.swift

  • Create: MyPassCore/Tests/MyPassCoreTests/TOTPGeneratorTests.swift

  • Step 1: Write failing tests (RFC 6238 test vectors)

// MyPassCore/Tests/MyPassCoreTests/TOTPGeneratorTests.swift
import XCTest
@testable import MyPassCore

final class TOTPGeneratorTests: XCTestCase {
    // RFC 6238 Section 8 test vectors for SHA-1
    // secret = "12345678901234567890" (ASCII), base32 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
    let sha1Secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"

    func test_sha1_at59s() {
        let config = TOTPConfig(secret: sha1Secret, period: 30, digits: 8, algorithm: .sha1)
        let date = Date(timeIntervalSince1970: 59)
        XCTAssertEqual(TOTPGenerator.generate(config: config, at: date), "94287082")
    }

    func test_sha1_at1111111109s() {
        let config = TOTPConfig(secret: sha1Secret, period: 30, digits: 8, algorithm: .sha1)
        let date = Date(timeIntervalSince1970: 1111111109)
        XCTAssertEqual(TOTPGenerator.generate(config: config, at: date), "07081804")
    }

    func test_secondsRemaining_isWithinPeriod() {
        let config = TOTPConfig(secret: sha1Secret, period: 30)
        let remaining = TOTPGenerator.secondsRemaining(config: config, at: Date())
        XCTAssertGreaterThan(remaining, 0)
        XCTAssertLessThanOrEqual(remaining, 30)
    }

    func test_generate_defaultSixDigits() {
        let config = TOTPConfig(secret: sha1Secret)
        let code = TOTPGenerator.generate(config: config, at: Date())
        XCTAssertEqual(code.count, 6)
        XCTAssertNotNil(Int(code))
    }
}
  • Step 2: Run to confirm failure

⌘U → fails — TOTPGenerator not defined.

  • Step 3: Implement TOTPGenerator
// MyPassCore/Sources/MyPassCore/TOTP/TOTPGenerator.swift
import Foundation
import CryptoKit

public enum TOTPGenerator {
    public static func generate(config: TOTPConfig, at date: Date = Date()) -> String {
        let counter = UInt64(date.timeIntervalSince1970) / UInt64(config.period)
        let keyBytes = base32Decode(config.secret)
        let counterBytes = withUnsafeBytes(of: counter.bigEndian, Array.init)
        let symKey = SymmetricKey(data: keyBytes)
        let hmacBytes: [UInt8]
        switch config.algorithm {
        case .sha1:
            hmacBytes = Array(HMAC<Insecure.SHA1>.authenticationCode(for: counterBytes, using: symKey))
        case .sha256:
            hmacBytes = Array(HMAC<SHA256>.authenticationCode(for: counterBytes, using: symKey))
        case .sha512:
            hmacBytes = Array(HMAC<SHA512>.authenticationCode(for: counterBytes, using: symKey))
        }
        let offset = Int(hmacBytes[hmacBytes.count - 1] & 0x0f)
        let truncated = ((Int(hmacBytes[offset]) & 0x7f) << 24)
            | (Int(hmacBytes[offset + 1]) << 16)
            | (Int(hmacBytes[offset + 2]) << 8)
            |  Int(hmacBytes[offset + 3])
        let otp = truncated % Int(pow(10.0, Double(config.digits)))
        return String(format: "%0\(config.digits)d", otp)
    }

    public static func secondsRemaining(config: TOTPConfig, at date: Date = Date()) -> Int {
        let elapsed = Int(date.timeIntervalSince1970) % config.period
        return config.period - elapsed
    }

    private static func base32Decode(_ input: String) -> [UInt8] {
        let alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
        let s = input.uppercased().filter { alphabet.contains($0) }
        var result: [UInt8] = []
        var buffer = 0
        var bitsLeft = 0
        for char in s {
            guard let idx = alphabet.firstIndex(of: char) else { continue }
            buffer = (buffer << 5) | alphabet.distance(from: alphabet.startIndex, to: idx)
            bitsLeft += 5
            if bitsLeft >= 8 {
                bitsLeft -= 8
                result.append(UInt8((buffer >> bitsLeft) & 0xff))
            }
        }
        return result
    }
}
  • Step 4: Run tests — all pass

⌘U → 4 tests pass.

  • Step 5: Commit
git add MyPassCore/
git commit -m "feat: add TOTPGenerator (RFC 6238)"

Task 6: KeychainStore

Files:

  • Create: MyPassCore/Sources/MyPassCore/Keychain/KeychainError.swift
  • Create: MyPassCore/Sources/MyPassCore/Keychain/KeychainStore.swift

Keychain operations require a real device or simulator entitlements and cannot be unit-tested in a plain SPM test target. Correctness is verified via integration in Task 12 (UnlockView).

  • Step 1: Write KeychainError
// MyPassCore/Sources/MyPassCore/Keychain/KeychainError.swift
import Foundation

public enum KeychainError: Error, Equatable {
    case saveFailed(OSStatus)
    case loadFailed(OSStatus)
    case notFound
}
  • Step 2: Write KeychainStore
// MyPassCore/Sources/MyPassCore/Keychain/KeychainStore.swift
import Foundation
import Security

public struct KeychainStore {
    private let accessGroup: String
    private let service: String

    public init(accessGroup: String, service: String = "com.christophevila.mypass") {
        self.accessGroup = accessGroup
        self.service = service
    }

    public func save(password: String, for account: String) throws {
        let data = Data(password.utf8)
        var query: [CFString: Any] = [
            kSecClass:           kSecClassGenericPassword,
            kSecAttrService:     service,
            kSecAttrAccount:     account,
            kSecAttrAccessGroup: accessGroup,
            kSecAttrAccessible:  kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
            kSecValueData:       data,
        ]
        SecItemDelete(query as CFDictionary)
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else { throw KeychainError.saveFailed(status) }
    }

    public func load(for account: String) throws -> String {
        let query: [CFString: Any] = [
            kSecClass:           kSecClassGenericPassword,
            kSecAttrService:     service,
            kSecAttrAccount:     account,
            kSecAttrAccessGroup: accessGroup,
            kSecReturnData:      true,
            kSecMatchLimit:      kSecMatchLimitOne,
        ]
        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        guard status == errSecSuccess else {
            throw status == errSecItemNotFound ? KeychainError.notFound : KeychainError.loadFailed(status)
        }
        guard let data = result as? Data, let password = String(data: data, encoding: .utf8) else {
            throw KeychainError.loadFailed(errSecInvalidData)
        }
        return password
    }

    public func delete(for account: String) {
        let query: [CFString: Any] = [
            kSecClass:           kSecClassGenericPassword,
            kSecAttrService:     service,
            kSecAttrAccount:     account,
            kSecAttrAccessGroup: accessGroup,
        ]
        SecItemDelete(query as CFDictionary)
    }

    public func exists(for account: String) -> Bool {
        let query: [CFString: Any] = [
            kSecClass:       kSecClassGenericPassword,
            kSecAttrService: service,
            kSecAttrAccount: account,
            kSecAttrAccessGroup: accessGroup,
            kSecMatchLimit:  kSecMatchLimitOne,
        ]
        return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
    }
}
  • Step 3: Build to confirm compile

⌘B — no errors.

  • Step 4: Commit
git add MyPassCore/
git commit -m "feat: add KeychainStore"

Task 7: CredentialMatcher + tests

Files:

  • Create: MyPassCore/Sources/MyPassCore/AutoFill/CredentialMatcher.swift

  • Create: MyPassCore/Tests/MyPassCoreTests/CredentialMatcherTests.swift

  • Step 1: Write failing tests

// MyPassCore/Tests/MyPassCoreTests/CredentialMatcherTests.swift
import XCTest
@testable import MyPassCore

final class CredentialMatcherTests: XCTestCase {
    func makeEntry(url: String) -> Entry {
        Entry(title: "Test", url: url)
    }

    func test_exactURLMatch() {
        let e = makeEntry(url: "https://github.com/login")
        XCTAssertTrue(CredentialMatcher.matches(entry: e, serviceIdentifier: "https://github.com"))
    }

    func test_wwwStripped() {
        let e = makeEntry(url: "https://www.github.com")
        XCTAssertTrue(CredentialMatcher.matches(entry: e, serviceIdentifier: "https://github.com"))
    }

    func test_subdomainMatch() {
        let e = makeEntry(url: "https://api.github.com")
        XCTAssertTrue(CredentialMatcher.matches(entry: e, serviceIdentifier: "https://github.com"))
    }

    func test_differentDomain_noMatch() {
        let e = makeEntry(url: "https://gitlab.com")
        XCTAssertFalse(CredentialMatcher.matches(entry: e, serviceIdentifier: "https://github.com"))
    }

    func test_emptyURL_noMatch() {
        let e = makeEntry(url: "")
        XCTAssertFalse(CredentialMatcher.matches(entry: e, serviceIdentifier: "https://github.com"))
    }

    func test_filter_suggestedAndRest() {
        let entries = [
            makeEntry(url: "https://github.com"),
            makeEntry(url: "https://gitlab.com"),
            makeEntry(url: ""),
        ]
        let result = CredentialMatcher.filter(entries: entries, for: ["https://github.com"])
        XCTAssertEqual(result.suggested.count, 1)
        XCTAssertEqual(result.suggested[0].url, "https://github.com")
        XCTAssertEqual(result.all.count, 2)
    }
}
  • Step 2: Run to confirm failure

⌘U → fails — CredentialMatcher not defined.

  • Step 3: Implement CredentialMatcher
// MyPassCore/Sources/MyPassCore/AutoFill/CredentialMatcher.swift
import Foundation

public enum CredentialMatcher {
    public static func matches(entry: Entry, serviceIdentifier: String) -> Bool {
        guard !entry.url.isEmpty else { return false }
        guard let entryHost = host(from: entry.url),
              let serviceHost = host(from: serviceIdentifier)
        else {
            return entry.url.lowercased().contains(serviceIdentifier.lowercased())
        }
        return hostsMatch(entryHost, serviceHost)
    }

    public static func filter(
        entries: [Entry],
        for serviceIdentifiers: [String]
    ) -> (suggested: [Entry], all: [Entry]) {
        guard !serviceIdentifiers.isEmpty else { return ([], entries) }
        let suggested = entries.filter { entry in
            serviceIdentifiers.contains { matches(entry: entry, serviceIdentifier: $0) }
        }
        let suggestedIDs = Set(suggested.map(\.id))
        let rest = entries.filter { !suggestedIDs.contains($0.id) }
        return (suggested: suggested, all: rest)
    }

    private static func host(from urlString: String) -> String? {
        URL(string: urlString)?.host
    }

    private static func hostsMatch(_ a: String, _ b: String) -> Bool {
        let na = stripped(a)
        let nb = stripped(b)
        return na == nb
            || na.hasSuffix("." + nb)
            || nb.hasSuffix("." + na)
    }

    private static func stripped(_ host: String) -> String {
        host.hasPrefix("www.") ? String(host.dropFirst(4)).lowercased() : host.lowercased()
    }
}
  • Step 4: Run — 6 tests pass

⌘U.

  • Step 5: Commit
git add MyPassCore/
git commit -m "feat: add CredentialMatcher for AutoFill URL matching"

Task 8: VaultSession

Files:

  • Create: MyPassCore/Sources/MyPassCore/Session/VaultSession.swift

  • Step 1: Write VaultSession

// MyPassCore/Sources/MyPassCore/Session/VaultSession.swift
import Foundation
import Combine

@MainActor
public final class VaultSession: ObservableObject {
    @Published public private(set) var database: KDBXDatabase?
    @Published public private(set) var isLocked: Bool = true

    private var document: KDBXDocument?
    private var masterPassword: String = ""

    public init() {}

    public func unlock(url: URL, password: String) throws {
        let doc = KDBXDocument(url: url)
        let db  = try doc.read(password: password)
        document       = doc
        database       = db
        masterPassword = password
        isLocked       = false
    }

    public func lock() {
        masterPassword = ""
        database       = nil
        document       = nil
        isLocked       = true
    }

    public func save() throws {
        guard let db = database, let doc = document, !masterPassword.isEmpty else { return }
        try doc.write(db, password: masterPassword)
    }

    // MARK: Entry CRUD

    public func addEntry(_ entry: Entry, toGroupId groupId: UUID) throws {
        guard var db = database else { return }
        mutateGroup(id: groupId, in: &db.root) { $0.entries.append(entry) }
        database = db
        try save()
    }

    public func updateEntry(_ entry: Entry) throws {
        guard var db = database else { return }
        updateEntryInTree(entry, in: &db.root)
        database = db
        try save()
    }

    public func deleteEntry(id entryId: UUID, fromGroupId groupId: UUID) throws {
        guard var db = database else { return }
        mutateGroup(id: groupId, in: &db.root) { $0.entries.removeAll { $0.id == entryId } }
        database = db
        try save()
    }

    // MARK: Search

    public func allEntries() -> [Entry] {
        guard let db = database else { return [] }
        return flatEntries(in: db.root)
    }

    // MARK: Helpers

    private func mutateGroup(id: UUID, in group: inout Group, _ mutation: (inout Group) -> Void) {
        if group.id == id {
            mutation(&group)
            return
        }
        for i in group.subgroups.indices {
            mutateGroup(id: id, in: &group.subgroups[i], mutation)
        }
    }

    private func updateEntryInTree(_ entry: Entry, in group: inout Group) {
        if let idx = group.entries.firstIndex(where: { $0.id == entry.id }) {
            var updated = entry
            updated.modificationDate = Date()
            group.entries[idx] = updated
            return
        }
        for i in group.subgroups.indices {
            updateEntryInTree(entry, in: &group.subgroups[i])
        }
    }

    private func flatEntries(in group: Group) -> [Entry] {
        group.entries + group.subgroups.flatMap { flatEntries(in: $0) }
    }
}
  • Step 2: Build — no errors

⌘B.

  • Step 3: Commit
git add MyPassCore/
git commit -m "feat: add VaultSession (vault lifecycle and entry CRUD)"

Phase 2 — Xcode Project Setup

Task 9: App Group, Keychain Access Group, AutoFill Extension target

All steps in this task are done in Xcode's GUI. No code files are created — only project settings.

  • Step 1: Set the main app Bundle ID

Select the MyPass project → MyPass target → Signing & Capabilities. Set Bundle Identifier to com.christophevila.mypass.

  • Step 2: Add App Group to MyPass target

Still in Signing & Capabilities for MyPass: click + CapabilityApp Groups. Add group.com.christophevila.mypass.

  • Step 3: Add Keychain Sharing to MyPass target

Click + CapabilityKeychain Sharing. Add keychain group com.christophevila.mypass.

  • Step 4: Create the AutoFill Extension target

File → New → Target → AutoFill Credential Provider Extension. Set:

  • Product Name: AutoFillExtension
  • Bundle ID: com.christophevila.mypass.autofill
  • Language: Swift
  • Embed in: MyPass

Click Finish.

  • Step 5: Add App Group + Keychain Sharing to the extension target

Select the AutoFillExtension target → Signing & Capabilities. Add the same App Group (group.com.christophevila.mypass) and Keychain Sharing (com.christophevila.mypass) as in Steps 23.

  • Step 6: Add MyPassCore to the extension target

Select the AutoFillExtension target → General → Frameworks and Libraries. Click + → select MyPassCore.

  • Step 7: Remove SwiftData from MyPassApp.swift (prep)

Open MyPass/MyPassApp.swift. Remove the import SwiftData line and the .modelContainer(sharedModelContainer) modifier. The file should now be minimal:

import SwiftUI

@main
struct MyPassApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
  • Step 8: Build to confirm the project compiles

⌘B. Errors about Item no longer existing are expected and will be resolved in Task 17.

  • Step 9: Commit
git add MyPass/MyPassApp.swift
git commit -m "chore: configure App Group, Keychain Sharing, and AutoFill Extension target"

Phase 3 — App Services

Task 10: FileBookmarkService

Files:

  • Create: MyPass/Services/FileBookmarkService.swift

  • Step 1: Write FileBookmarkService

// MyPass/Services/FileBookmarkService.swift
import Foundation

/// Persists a security-scoped bookmark for the user's KDBX file in the shared App Group.
public final class FileBookmarkService {
    private static let key = "kdbxBookmark"
    private let defaults: UserDefaults

    public init(appGroup: String = "group.com.christophevila.mypass") {
        defaults = UserDefaults(suiteName: appGroup) ?? .standard
    }

    public func save(url: URL) throws {
        let data = try url.bookmarkData(
            options: .withSecurityScope,
            includingResourceValuesForKeys: nil,
            relativeTo: nil
        )
        defaults.set(data, forKey: Self.key)
    }

    /// Returns the resolved URL, starting security access. Caller must call `stopAccess(url:)` when done.
    public func resolveURL() throws -> URL {
        guard let data = defaults.data(forKey: Self.key) else { throw BookmarkError.notFound }
        var isStale = false
        let url = try URL(
            resolvingBookmarkData: data,
            options: .withSecurityScope,
            relativeTo: nil,
            bookmarkDataIsStale: &isStale
        )
        if isStale {
            let fresh = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil)
            defaults.set(fresh, forKey: Self.key)
        }
        guard url.startAccessingSecurityScopedResource() else { throw BookmarkError.accessDenied }
        return url
    }

    public func stopAccess(url: URL) {
        url.stopAccessingSecurityScopedResource()
    }

    public func clear() {
        defaults.removeObject(forKey: Self.key)
    }

    public var hasBookmark: Bool {
        defaults.data(forKey: Self.key) != nil
    }
}

public enum BookmarkError: Error {
    case notFound
    case accessDenied
}
  • Step 2: Build — no errors ⌘B.

  • Step 3: Commit

git add MyPass/Services/FileBookmarkService.swift
git commit -m "feat: add FileBookmarkService"

Task 11: BiometricAuthService + ClipboardService

Files:

  • Create: MyPass/Services/BiometricAuthService.swift

  • Create: MyPass/Services/ClipboardService.swift

  • Step 1: Write BiometricAuthService

// MyPass/Services/BiometricAuthService.swift
import LocalAuthentication
import Foundation

public final class BiometricAuthService {
    public var isAvailable: Bool {
        let ctx = LAContext()
        var error: NSError?
        return ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
    }

    /// Returns true on success. On failure, throws an error with a localized message.
    public func authenticate(reason: String) async throws {
        let ctx = LAContext()
        try await ctx.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason)
    }
}
  • Step 2: Write ClipboardService
// MyPass/Services/ClipboardService.swift
import Foundation
#if os(iOS)
import UIKit
#else
import AppKit
#endif

public enum ClipboardService {
    public static func copy(_ text: String, expiresAfter seconds: TimeInterval = 30) {
        #if os(iOS)
        UIPasteboard.general.setItems(
            [[UIPasteboard.typeAutomatic: text]],
            options: [.expirationDate: Date().addingTimeInterval(seconds)]
        )
        #else
        let pb = NSPasteboard.general
        pb.clearContents()
        pb.setString(text, forType: .string)
        let captured = text
        DispatchQueue.main.asyncAfter(deadline: .now() + seconds) {
            if pb.string(forType: .string) == captured { pb.clearContents() }
        }
        #endif
    }
}
  • Step 3: Build ⌘B.

  • Step 4: Commit

git add MyPass/Services/
git commit -m "feat: add BiometricAuthService and ClipboardService"

Phase 4 — App UI

Task 12: UnlockViewModel + UnlockView

Files:

  • Create: MyPass/ViewModels/UnlockViewModel.swift

  • Create: MyPass/Views/UnlockView.swift

  • Step 1: Write UnlockViewModel

// MyPass/ViewModels/UnlockViewModel.swift
import Foundation
import MyPassCore

@MainActor
final class UnlockViewModel: ObservableObject {
    @Published var password: String = ""
    @Published var errorMessage: String?
    @Published var isUnlocking: Bool = false
    @Published var showFilePicker: Bool = false

    private let session: VaultSession
    private let bookmarkService: FileBookmarkService
    private let keychainStore: KeychainStore
    private let biometricService: BiometricAuthService

    private let keychainAccount = "masterPassword"

    init(
        session: VaultSession,
        bookmarkService: FileBookmarkService = .init(),
        keychainStore: KeychainStore = .init(accessGroup: "com.christophevila.mypass"),
        biometricService: BiometricAuthService = .init()
    ) {
        self.session = session
        self.bookmarkService = bookmarkService
        self.keychainStore = keychainStore
        self.biometricService = biometricService
    }

    var canUseBiometrics: Bool {
        biometricService.isAvailable && keychainStore.exists(for: keychainAccount)
    }

    var hasVault: Bool { bookmarkService.hasBookmark }

    func unlockWithBiometrics() {
        Task {
            isUnlocking = true
            errorMessage = nil
            do {
                try await biometricService.authenticate(reason: "Unlock MyPass")
                let storedPassword = try keychainStore.load(for: keychainAccount)
                let url = try bookmarkService.resolveURL()
                defer { bookmarkService.stopAccess(url: url) }
                try session.unlock(url: url, password: storedPassword)
            } catch {
                errorMessage = error.localizedDescription
            }
            isUnlocking = false
        }
    }

    func unlockWithPassword() {
        Task {
            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: keychainAccount)
                password = ""
            } catch KDBXError.invalidPassword {
                errorMessage = "Incorrect password."
            } catch {
                errorMessage = error.localizedDescription
            }
            isUnlocking = false
        }
    }

    func openFile(url: URL) {
        do {
            try bookmarkService.save(url: url)
            unlockWithPassword()
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}
  • Step 2: Write UnlockView
// MyPass/Views/UnlockView.swift
import SwiftUI
import MyPassCore

struct UnlockView: View {
    @ObservedObject var vm: UnlockViewModel

    var body: some View {
        VStack(spacing: 24) {
            Spacer()
            Image(systemName: "lock.shield.fill")
                .font(.system(size: 64))
                .foregroundStyle(.tint)
            Text("MyPass")
                .font(.largeTitle.bold())

            if vm.hasVault {
                vaultUnlockSection
            } else {
                openFileSection
            }

            if let msg = vm.errorMessage {
                Text(msg)
                    .foregroundStyle(.red)
                    .font(.caption)
                    .multilineTextAlignment(.center)
            }
            Spacer()
        }
        .padding(32)
        .fileImporter(
            isPresented: $vm.showFilePicker,
            allowedContentTypes: [.init(filenameExtension: "kdbx")!],
            onCompletion: { result in
                if let url = try? result.get() { vm.openFile(url: url) }
            }
        )
    }

    @ViewBuilder
    private var vaultUnlockSection: some View {
        VStack(spacing: 16) {
            if vm.canUseBiometrics {
                Button(action: vm.unlockWithBiometrics) {
                    Label("Use Face ID / Touch ID", systemImage: "faceid")
                        .frame(maxWidth: .infinity)
                }
                .buttonStyle(.borderedProminent)
                .disabled(vm.isUnlocking)

                Text("or enter password")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }

            SecureField("Master Password", text: $vm.password)
                .textFieldStyle(.roundedBorder)
                .onSubmit(vm.unlockWithPassword)

            Button("Unlock", action: vm.unlockWithPassword)
                .buttonStyle(.bordered)
                .disabled(vm.password.isEmpty || vm.isUnlocking)

            Button("Choose different file…") { vm.showFilePicker = true }
                .font(.caption)
                .foregroundStyle(.secondary)
        }
    }

    @ViewBuilder
    private var openFileSection: some View {
        VStack(spacing: 16) {
            Text("No vault selected. Open a .kdbx file to get started.")
                .multilineTextAlignment(.center)
                .foregroundStyle(.secondary)
            Button(action: { vm.showFilePicker = true }) {
                Label("Open KDBX File…", systemImage: "doc.badge.plus")
                    .frame(maxWidth: .infinity)
            }
            .buttonStyle(.borderedProminent)
        }
    }
}
  • Step 3: Build ⌘B.

  • Step 4: Commit

git add MyPass/ViewModels/UnlockViewModel.swift MyPass/Views/UnlockView.swift
git commit -m "feat: add UnlockView with biometric and password unlock"

Task 13: VaultViewModel + GroupBrowserView

Files:

  • Create: MyPass/ViewModels/VaultViewModel.swift

  • Create: MyPass/Views/GroupBrowserView.swift

  • Step 1: Write VaultViewModel

// MyPass/ViewModels/VaultViewModel.swift
import Foundation
import MyPassCore

@MainActor
final class VaultViewModel: ObservableObject {
    @Published var searchQuery: String = ""
    @Published var errorMessage: String?

    let session: VaultSession

    init(session: VaultSession) {
        self.session = session
    }

    var filteredEntries: [Entry] {
        guard !searchQuery.isEmpty else { return [] }
        let q = searchQuery.lowercased()
        return session.allEntries().filter {
            $0.title.lowercased().contains(q)
            || $0.username.lowercased().contains(q)
            || $0.url.lowercased().contains(q)
        }
    }

    var isSearching: Bool { !searchQuery.isEmpty }

    func deleteEntry(_ entry: Entry, fromGroup group: Group) {
        Task {
            do { try session.deleteEntry(id: entry.id, fromGroupId: group.id) }
            catch { errorMessage = error.localizedDescription }
        }
    }
}
  • Step 2: Write GroupBrowserView
// MyPass/Views/GroupBrowserView.swift
import SwiftUI
import MyPassCore

struct GroupBrowserView: View {
    @ObservedObject var vm: VaultViewModel
    let group: Group

    @State private var selectedEntry: Entry?
    @State private var showAddEntry = false

    var body: some View {
        List {
            if vm.isSearching {
                searchResultsSection
            } else {
                groupTreeSection
            }
        }
        .navigationTitle(group.name)
        .searchable(text: $vm.searchQuery, prompt: "Search all entries…")
        .toolbar {
            #if os(iOS)
            ToolbarItem(placement: .navigationBarTrailing) { EditButton() }
            #endif
            ToolbarItem(placement: .primaryAction) {
                Button { showAddEntry = true } label: {
                    Image(systemName: "plus")
                }
            }
        }
        .sheet(isPresented: $showAddEntry) {
            let editVM = EntryEditViewModel(session: vm.session, groupId: group.id)
            EntryEditView(vm: editVM)
        }
        .alert("Error", isPresented: Binding(
            get: { vm.errorMessage != nil },
            set: { if !$0 { vm.errorMessage = nil } }
        )) {
            Button("OK", role: .cancel) { vm.errorMessage = nil }
        } message: {
            Text(vm.errorMessage ?? "")
        }
    }

    @ViewBuilder
    private var searchResultsSection: some View {
        ForEach(vm.filteredEntries) { entry in
            NavigationLink(destination: EntryDetailView(entry: entry, session: vm.session)) {
                EntryRow(entry: entry)
            }
        }
    }

    @ViewBuilder
    private var groupTreeSection: some View {
        if !group.subgroups.isEmpty {
            Section("Groups") {
                ForEach(group.subgroups) { sub in
                    NavigationLink(destination: GroupBrowserView(vm: vm, group: sub)) {
                        Label(sub.name, systemImage: "folder")
                    }
                }
            }
        }
        if !group.entries.isEmpty {
            Section("Entries") {
                ForEach(group.entries) { entry in
                    NavigationLink(destination: EntryDetailView(entry: entry, session: vm.session)) {
                        EntryRow(entry: entry)
                    }
                }
                .onDelete { offsets in
                    offsets.map { group.entries[$0] }.forEach { vm.deleteEntry($0, fromGroup: group) }
                }
            }
        }
    }
}

private struct EntryRow: View {
    let entry: Entry
    var body: some View {
        VStack(alignment: .leading, spacing: 2) {
            Text(entry.title).font(.body)
            Text(entry.username).font(.caption).foregroundStyle(.secondary)
        }
    }
}
  • Step 3: Build ⌘B.

  • Step 4: Commit

git add MyPass/ViewModels/VaultViewModel.swift MyPass/Views/GroupBrowserView.swift
git commit -m "feat: add GroupBrowserView with search and group navigation"

Task 14: EntryDetailView (with TOTP countdown)

Files:

  • Create: MyPass/Views/EntryDetailView.swift

  • Step 1: Write EntryDetailView

// MyPass/Views/EntryDetailView.swift
import SwiftUI
import MyPassCore

struct EntryDetailView: View {
    let entry: Entry
    let session: VaultSession

    @State private var showPassword = false
    @State private var showEditSheet = false

    var body: some View {
        List {
            credentialSection
            if !entry.url.isEmpty         { urlSection }
            if !entry.notes.isEmpty       { notesSection }
            if entry.totp != nil          { totpSection }
            if !entry.customFields.isEmpty { customFieldsSection }
            if !entry.attachments.isEmpty { attachmentsSection }
        }
        .navigationTitle(entry.title)
        .toolbar {
            ToolbarItem(placement: .primaryAction) {
                Button("Edit") { showEditSheet = true }
            }
        }
        .sheet(isPresented: $showEditSheet) {
            EntryEditView(vm: EntryEditViewModel(session: session, existing: entry))
        }
        #if os(macOS)
        .keyboardShortcut("e", modifiers: .command)
        #endif
    }

    private var credentialSection: some View {
        Section("Credentials") {
            FieldRow(label: "Username", value: entry.username, isCopyable: true)
            HStack {
                VStack(alignment: .leading, spacing: 2) {
                    Text("Password").font(.caption).foregroundStyle(.secondary)
                    Text(showPassword ? entry.password.reveal() : String(repeating: "•", count: 10))
                        .font(.body.monospaced())
                }
                Spacer()
                Button { showPassword.toggle() } label: {
                    Image(systemName: showPassword ? "eye.slash" : "eye")
                }
                .buttonStyle(.plain)
                Button { ClipboardService.copy(entry.password.reveal()) } label: {
                    Image(systemName: "doc.on.doc")
                }
                .buttonStyle(.plain)
            }
        }
    }

    private var urlSection: some View {
        Section("URL") {
            FieldRow(label: "URL", value: entry.url, isCopyable: true)
        }
    }

    private var notesSection: some View {
        Section("Notes") {
            Text(entry.notes)
                .font(.body)
        }
    }

    private var totpSection: some View {
        Section("One-Time Password") {
            if let config = entry.totp {
                TOTPRow(config: config)
            }
        }
    }

    private var customFieldsSection: some View {
        Section("Custom Fields") {
            ForEach(entry.customFields) { field in
                FieldRow(
                    label: field.key,
                    value: field.value.reveal(),
                    isCopyable: true,
                    isProtected: field.value.isProtected
                )
            }
        }
    }

    private var attachmentsSection: some View {
        Section("Attachments") {
            ForEach(entry.attachments) { att in
                Label(att.name, systemImage: "paperclip")
            }
        }
    }
}

private struct FieldRow: View {
    let label: String
    let value: String
    var isCopyable: Bool = false
    var isProtected: Bool = false
    @State private var revealed = false

    var body: some View {
        HStack {
            VStack(alignment: .leading, spacing: 2) {
                Text(label).font(.caption).foregroundStyle(.secondary)
                Text(isProtected && !revealed ? "••••••••" : value)
                    .font(.body)
            }
            Spacer()
            if isProtected {
                Button { revealed.toggle() } label: {
                    Image(systemName: revealed ? "eye.slash" : "eye")
                }.buttonStyle(.plain)
            }
            if isCopyable {
                Button { ClipboardService.copy(value) } label: {
                    Image(systemName: "doc.on.doc")
                }.buttonStyle(.plain)
            }
        }
    }
}

private struct TOTPRow: View {
    let config: TOTPConfig
    @State private var code: String = ""
    @State private var secondsLeft: Int = 30
    let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

    var body: some View {
        HStack {
            VStack(alignment: .leading, spacing: 2) {
                Text("TOTP").font(.caption).foregroundStyle(.secondary)
                Text(formattedCode).font(.title3.monospaced()).bold()
            }
            Spacer()
            ZStack {
                Circle()
                    .stroke(Color.secondary.opacity(0.2), lineWidth: 3)
                Circle()
                    .trim(from: 0, to: CGFloat(secondsLeft) / CGFloat(config.period))
                    .stroke(secondsLeft > 10 ? Color.green : Color.orange, lineWidth: 3)
                    .rotationEffect(.degrees(-90))
                    .animation(.linear(duration: 1), value: secondsLeft)
                Text("\(secondsLeft)").font(.caption2)
            }
            .frame(width: 32, height: 32)
            Button { ClipboardService.copy(code) } label: {
                Image(systemName: "doc.on.doc")
            }.buttonStyle(.plain)
        }
        .onReceive(timer) { _ in refresh() }
        .onAppear { refresh() }
    }

    private var formattedCode: String {
        guard code.count == 6 else { return code }
        return String(code.prefix(3)) + " " + String(code.suffix(3))
    }

    private func refresh() {
        code = TOTPGenerator.generate(config: config)
        secondsLeft = TOTPGenerator.secondsRemaining(config: config)
    }
}
  • Step 2: Build ⌘B.

  • Step 3: Commit

git add MyPass/Views/EntryDetailView.swift
git commit -m "feat: add EntryDetailView with TOTP countdown"

Task 15: EntryEditViewModel + EntryEditView

Files:

  • Create: MyPass/ViewModels/EntryEditViewModel.swift

  • Create: MyPass/Views/EntryEditView.swift

  • Step 1: Write EntryEditViewModel

// MyPass/ViewModels/EntryEditViewModel.swift
import Foundation
import MyPassCore

@MainActor
final class EntryEditViewModel: ObservableObject {
    @Published var title: String
    @Published var username: String
    @Published var password: String
    @Published var url: String
    @Published var notes: String
    @Published var customFields: [CustomField]
    @Published var errorMessage: String?
    @Published var isSaving: Bool = false

    private let session: VaultSession
    private let groupId: UUID
    private let existingEntry: Entry?

    init(session: VaultSession, groupId: UUID, existing: Entry? = nil) {
        self.session = session
        self.groupId = groupId
        self.existingEntry = existing
        title        = existing?.title ?? ""
        username     = existing?.username ?? ""
        password     = existing?.password.reveal() ?? ""
        url          = existing?.url ?? ""
        notes        = existing?.notes ?? ""
        customFields = existing?.customFields ?? []
    }

    var isEditing: Bool { existingEntry != nil }

    func save(dismiss: () -> Void) {
        Task {
            isSaving = true
            errorMessage = nil
            do {
                if var entry = existingEntry {
                    entry.title    = title
                    entry.username = username
                    entry.password = ProtectedString(password, isProtected: true)
                    entry.url      = url
                    entry.notes    = notes
                    entry.customFields = customFields
                    try session.updateEntry(entry)
                } else {
                    let entry = Entry(
                        title: title,
                        username: username,
                        password: ProtectedString(password, isProtected: true),
                        url: url,
                        notes: notes,
                        customFields: customFields
                    )
                    try session.addEntry(entry, toGroupId: groupId)
                }
                dismiss()
            } catch {
                errorMessage = error.localizedDescription
            }
            isSaving = false
        }
    }

    func addCustomField() {
        customFields.append(CustomField(key: "", value: ProtectedString("", isProtected: false)))
    }

    func removeCustomField(at offsets: IndexSet) {
        customFields.remove(atOffsets: offsets)
    }
}
  • Step 2: Write EntryEditView
// MyPass/Views/EntryEditView.swift
import SwiftUI
import MyPassCore

struct EntryEditView: View {
    @ObservedObject var vm: EntryEditViewModel
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        NavigationStack {
            Form {
                Section("Credentials") {
                    TextField("Title", text: $vm.title)
                    TextField("Username", text: $vm.username)
                        .textContentType(.username)
                        .autocorrectionDisabled()
                    SecureField("Password", text: $vm.password)
                        .textContentType(.password)
                    TextField("URL", text: $vm.url)
                        .textContentType(.URL)
                        .keyboardType(.URL)
                        .autocorrectionDisabled()
                }

                Section("Notes") {
                    TextEditor(text: $vm.notes)
                        .frame(minHeight: 80)
                }

                Section {
                    ForEach($vm.customFields) { $field in
                        HStack {
                            TextField("Key", text: $field.key)
                            Divider()
                            TextField("Value", text: Binding(
                                get: { field.value.reveal() },
                                set: { field.value = ProtectedString($0, isProtected: field.value.isProtected) }
                            ))
                        }
                    }
                    .onDelete(perform: vm.removeCustomField)
                    Button("Add Field", action: vm.addCustomField)
                } header: {
                    Text("Custom Fields")
                }

                if let msg = vm.errorMessage {
                    Section { Text(msg).foregroundStyle(.red) }
                }
            }
            .navigationTitle(vm.isEditing ? "Edit Entry" : "New Entry")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") { vm.save { dismiss() } }
                        .disabled(vm.title.isEmpty || vm.isSaving)
                }
            }
        }
    }
}
  • Step 3: Build ⌘B.

  • Step 4: Commit

git add MyPass/ViewModels/EntryEditViewModel.swift MyPass/Views/EntryEditView.swift
git commit -m "feat: add EntryEditView for add/edit entries"

Task 16: SearchView

Files:

  • Create: MyPass/Views/SearchView.swift

Note: Search is embedded directly in GroupBrowserView via .searchable. SearchView is a standalone view for displaying search results when the query is active — used in the macOS 3-column layout as the middle column when searching.

  • Step 1: Write SearchView
// MyPass/Views/SearchView.swift
import SwiftUI
import MyPassCore

struct SearchView: View {
    @ObservedObject var vm: VaultViewModel
    @Binding var selectedEntry: Entry?

    var body: some View {
        Group {
            if vm.filteredEntries.isEmpty {
                ContentUnavailableView.search(text: vm.searchQuery)
            } else {
                List(vm.filteredEntries, selection: $selectedEntry) { entry in
                    #if os(macOS)
                    EntryListRow(entry: entry).tag(entry)
                    #else
                    NavigationLink(destination: EntryDetailView(entry: entry, session: vm.session)) {
                        EntryListRow(entry: entry)
                    }
                    #endif
                }
            }
        }
    }
}

struct EntryListRow: View {
    let entry: Entry
    var body: some View {
        VStack(alignment: .leading, spacing: 2) {
            Text(entry.title).font(.body)
            Text(entry.username).font(.caption).foregroundStyle(.secondary)
            if !entry.url.isEmpty {
                Text(entry.url).font(.caption2).foregroundStyle(.tertiary)
            }
        }
    }
}
  • Step 2: Build ⌘B.

  • Step 3: Commit

git add MyPass/Views/SearchView.swift
git commit -m "feat: add SearchView"

Task 17: MyPassApp + ContentView (routing, lifecycle, macOS layout)

Files:

  • Modify: MyPass/MyPassApp.swift

  • Replace: MyPass/ContentView.swift

  • Step 1: Rewrite ContentView as the app router

// MyPass/ContentView.swift
import SwiftUI
import MyPassCore

struct ContentView: View {
    @StateObject private var session = VaultSession()

    var body: some View {
        Group {
            if session.isLocked {
                UnlockView(vm: UnlockViewModel(session: session))
            } else {
                vaultView
            }
        }
        .onReceive(
            NotificationCenter.default.publisher(for: sceneBackgroundNotification)
        ) { _ in
            session.lock()
        }
    }

    @ViewBuilder
    private var vaultView: some View {
        let vaultVM = VaultViewModel(session: session)
        #if os(macOS)
        MacVaultView(vm: vaultVM)
        #else
        NavigationStack {
            if let root = session.database?.root {
                GroupBrowserView(vm: vaultVM, group: root)
            }
        }
        #endif
    }

    private var sceneBackgroundNotification: Notification.Name {
        #if os(iOS)
        UIScene.didEnterBackgroundNotification
        #else
        NSApplication.didResignActiveNotification
        #endif
    }
}

// MARK: - macOS 3-column layout

#if os(macOS)
private struct MacVaultView: View {
    @ObservedObject var vm: VaultViewModel
    @State private var selectedGroup: Group?
    @State private var selectedEntry: Entry?
    @State private var showAddEntry = false

    private var activeGroup: Group? { selectedGroup ?? vm.session.database?.root }

    var body: some View {
        NavigationSplitView {
            GroupSidebarView(vm: vm, selectedGroup: $selectedGroup)
        } content: {
            if vm.isSearching {
                SearchView(vm: vm, selectedEntry: $selectedEntry)
            } else if let group = activeGroup {
                entryList(for: group)
            } else {
                Text("Select a group").foregroundStyle(.secondary)
            }
        } detail: {
            if let entry = selectedEntry {
                EntryDetailView(entry: entry, session: vm.session)
            } else {
                Text("Select an entry").foregroundStyle(.secondary)
            }
        }
        .searchable(text: $vm.searchQuery, prompt: "Search all entries…")
        .sheet(isPresented: $showAddEntry) {
            if let group = activeGroup {
                EntryEditView(vm: EntryEditViewModel(session: vm.session, groupId: group.id))
            }
        }
    }

    private func entryList(for group: Group) -> some View {
        List(group.entries, selection: $selectedEntry) { entry in
            EntryListRow(entry: entry).tag(entry)
        }
        .navigationTitle(group.name)
        .toolbar {
            ToolbarItem {
                Button { showAddEntry = true } label: { Image(systemName: "plus") }
            }
        }
    }
}

private struct GroupSidebarView: View {
    @ObservedObject var vm: VaultViewModel
    @Binding var selectedGroup: Group?

    var body: some View {
        List(selection: $selectedGroup) {
            if let root = vm.session.database?.root {
                GroupNode(group: root)
            }
        }
        .navigationTitle("Groups")
    }
}

private struct GroupNode: View {
    let group: Group
    var body: some View {
        if group.subgroups.isEmpty {
            Label(group.name, systemImage: "folder").tag(group)
        } else {
            DisclosureGroup {
                ForEach(group.subgroups) { sub in GroupNode(group: sub) }
            } label: {
                Label(group.name, systemImage: "folder").tag(group)
            }
        }
    }
}
#endif
  • Step 2: Update MyPassApp.swift
// MyPass/MyPassApp.swift
import SwiftUI

@main
struct MyPassApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
  • Step 3: Register the deep-link URL scheme

In Xcode → MyPass target → Info tab → add a URL Type:

  • Identifier: com.christophevila.mypass

  • URL Schemes: mypass

  • Step 4: Build and run on simulator

⌘R. Tap Open KDBX File…, pick a test .kdbx file from Files or drag one into the simulator. Enter the master password. Verify the group/entry list appears.

  • Step 5: Commit
git add MyPass/ContentView.swift MyPass/MyPassApp.swift
git commit -m "feat: wire ContentView router, macOS 3-column layout, scene lifecycle lock"

Phase 5 — AutoFill Extension

Task 18: AutoFillViewController

Files:

  • Create: AutoFillExtension/AutoFillViewController.swift

  • Step 1: Write AutoFillViewController

Replace the boilerplate CredentialProviderViewController.swift Xcode generated with:

// AutoFillExtension/AutoFillViewController.swift
import AuthenticationServices
import SwiftUI
import MyPassCore

final class AutoFillViewController: ASCredentialProviderViewController {

    private let session = VaultSession()
    private let bookmarkService = FileBookmarkService()
    private let keychainStore = KeychainStore(accessGroup: "com.christophevila.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 Quick Type suggestion (no UI shown).
    override func provideCredentialWithoutUserInteraction(for credentialIdentity: ASPasswordCredentialIdentity) {
        // If vault is already unlocked (biometric token valid), provide immediately.
        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 {
            let password = try keychainStore.load(for: "masterPassword")
            let url = try bookmarkService.resolveURL()
            defer { bookmarkService.stopAccess(url: url) }
            try session.unlock(url: url, password: password)
            return
        }
    }

    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: Build ⌘B (will fail until ExtensionRootView is created in Task 19).

  • Step 3: Commit (WIP)

git add AutoFillExtension/AutoFillViewController.swift
git commit -m "feat: add AutoFillViewController skeleton"

Task 19: ExtensionUnlockView + ExtensionRootView

Files:

  • Create: AutoFillExtension/Views/ExtensionUnlockView.swift

  • Step 1: Write ExtensionUnlockView and ExtensionRootView

// AutoFillExtension/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 {
            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 KDBXError.invalidPassword {
            errorMessage = "Incorrect 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)
    }
}
  • Step 2: Build ⌘B.

  • Step 3: Commit

git add AutoFillExtension/Views/ExtensionUnlockView.swift
git commit -m "feat: add ExtensionRootView and ExtensionViewModel"

Task 20: CredentialListView (extension)

Files:

  • Create: AutoFillExtension/Views/CredentialListView.swift

  • Step 1: Write CredentialListView

// AutoFillExtension/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 and run on simulator

⌘R. Open Settings → Passwords → AutoFill Passwords on the simulator. Enable MyPass. Open Safari and navigate to a site that matches an entry's URL. Tap a password field → the QuickType bar should show MyPass. Tap it → the extension UI appears with matching credentials.

  • Step 3: Final commit
git add AutoFillExtension/Views/CredentialListView.swift
git commit -m "feat: add CredentialListView for AutoFill extension"

Done

At this point:

  • MyPassCore package holds all KDBX, TOTP, Keychain, and AutoFill matching logic
  • The main app opens .kdbx files, unlocks with Face ID or password, and supports full CRUD on entries and groups
  • The AutoFill extension fills credentials in every app via ASCredentialProviderViewController
  • Unit tests cover ProtectedString, TOTPGenerator, CredentialMatcher, and KDBX round-trips

Known Limitations (follow-up work)

  • Save-conflict detection: The spec calls for NSFileCoordinator to detect external modifications to the KDBX file. KDBXDocument.write should be wrapped in an NSFileCoordinator.coordinate(writingItemAt:options:error:byAccessor:) call, and reads should use the read variant. Implement as a follow-up once core CRUD is working.
  • Attachment add/edit: EntryDetailView displays attachments (Task 14) but EntryEditView does not yet support adding/removing attachment files. Wire up a fileImporter to EntryEditView's custom fields section to add this.
  • macOS keyboard shortcuts: ⌘C → copy password and ⌘⌥C → copy TOTP in EntryDetailView on macOS. Add .keyboardShortcut("c", modifiers: .command) and .keyboardShortcut("c", modifiers: [.command, .option]) to the respective copy buttons.