2761 lines
85 KiB
Markdown
2761 lines
85 KiB
Markdown
# 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```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.swift` → **Delete** → **Move to Trash**.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add MyPassCore/ MyPass/
|
||
git commit -m "feat: scaffold MyPassCore Swift Package"
|
||
```
|
||
|
||
**Fallback — if KeePassKit has no Package.swift:**
|
||
|
||
```bash
|
||
mkdir -p Vendor
|
||
git submodule add https://github.com/mstarke/KeePassKit Vendor/KeePassKit
|
||
```
|
||
|
||
In `Package.swift` replace the remote `.package(url:branch:)` with:
|
||
```swift
|
||
.package(path: "../Vendor/KeePassKit"),
|
||
```
|
||
If the submodule itself has no `Package.swift`, create one at `Vendor/KeePassKit/Package.swift`:
|
||
```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**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
`⌘U` → `ProtectedStringTests` passes (5 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```swift
|
||
// 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
|
||
}
|
||
}
|
||
```
|
||
|
||
```swift
|
||
// 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)
|
||
}
|
||
}
|
||
```
|
||
|
||
```swift
|
||
// 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
|
||
}
|
||
}
|
||
```
|
||
|
||
```swift
|
||
// 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
|
||
}
|
||
}
|
||
```
|
||
|
||
```swift
|
||
// 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 }
|
||
}
|
||
```
|
||
|
||
```swift
|
||
// 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 }
|
||
}
|
||
```
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```swift
|
||
// 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.
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```bash
|
||
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)**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```bash
|
||
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 **+ Capability** → **App Groups**. Add `group.com.christophevila.mypass`.
|
||
|
||
- [ ] **Step 3: Add Keychain Sharing to MyPass target**
|
||
|
||
Click **+ Capability** → **Keychain 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 2–3.
|
||
|
||
- [ ] **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:
|
||
|
||
```swift
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```swift
|
||
// 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)**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```swift
|
||
// 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**
|
||
|
||
```bash
|
||
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.
|