feat: defer keychain access until explicitly granted

This commit is contained in:
NimBold
2026-06-09 12:47:49 +03:30
parent 17aa34b95f
commit 0ec2213a4c
5 changed files with 126 additions and 9 deletions
+42 -6
View File
@@ -185,10 +185,16 @@ final class AppSettings: ObservableObject {
@Published var extensionPairingToken: String {
didSet {
KeychainCredentialStore.setExtensionToken(extensionPairingToken)
if isKeychainAccessGranted {
KeychainCredentialStore.setExtensionToken(extensionPairingToken)
}
}
}
@Published var isKeychainAccessGranted: Bool {
didSet { save() }
}
@Published var message = ""
private let defaults: UserDefaults
@@ -198,6 +204,7 @@ final class AppSettings: ObservableObject {
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
let granted: Bool
if let data = defaults.data(forKey: storageKey),
let stored = try? JSONDecoder().decode(StoredSettings.self, from: data) {
appTheme = stored.appTheme ?? .system
@@ -211,6 +218,8 @@ final class AppSettings: ObservableObject {
siteLogins = stored.siteLogins
mediaCookieSource = stored.mediaCookieSource ?? .none
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
granted = stored.isKeychainAccessGranted ?? false
isKeychainAccessGranted = granted
} else {
appTheme = .system
appFontSize = .standard
@@ -223,13 +232,19 @@ final class AppSettings: ObservableObject {
siteLogins = []
mediaCookieSource = .none
downloadDirectories = Self.defaultDirectories()
granted = false
isKeychainAccessGranted = granted
}
if let token = KeychainCredentialStore.extensionToken() {
extensionPairingToken = token
if granted {
if let token = KeychainCredentialStore.extensionToken() {
extensionPairingToken = token
} else {
extensionPairingToken = Self.generateSecureToken()
// The didSet of extensionPairingToken will handle setting it in the keychain since isKeychainAccessGranted is true.
}
} else {
extensionPairingToken = Self.generateSecureToken()
KeychainCredentialStore.setExtensionToken(extensionPairingToken)
extensionPairingToken = ""
}
for category in DownloadCategory.allCases where downloadDirectories[category] == nil {
@@ -333,6 +348,25 @@ final class AppSettings: ObservableObject {
return DownloadCredentials(username: login.username, password: password)
}
func grantKeychainAccess() {
isKeychainAccessGranted = true
if let token = KeychainCredentialStore.extensionToken() {
extensionPairingToken = token
} else {
extensionPairingToken = Self.generateSecureToken()
}
}
func revokeKeychainAccess() {
KeychainCredentialStore.deleteExtensionToken()
for login in siteLogins {
KeychainCredentialStore.deletePassword(for: login.id)
}
siteLogins.removeAll()
extensionPairingToken = ""
isKeychainAccessGranted = false
}
private func save() {
let stored = StoredSettings(
appTheme: appTheme,
@@ -345,7 +379,8 @@ final class AppSettings: ObservableObject {
proxySettings: proxySettings.normalized,
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
siteLogins: siteLogins,
mediaCookieSource: mediaCookieSource
mediaCookieSource: mediaCookieSource,
isKeychainAccessGranted: isKeychainAccessGranted
)
let defaults = self.defaults
let storageKey = self.storageKey
@@ -426,4 +461,5 @@ private struct StoredSettings: Codable {
var downloadDirectories: [String: String]
var siteLogins: [SiteLogin]
var mediaCookieSource: BrowserCookieSource?
var isKeychainAccessGranted: Bool?
}
@@ -29,7 +29,10 @@ struct IntegrationSettingsPane: View {
}
.padding(.bottom, 8)
// Step 1: Copy Token
KeychainAccessCard()
if settings.isKeychainAccessGranted {
// Step 1: Copy Token
StepCardView(
stepNumber: 1,
title: "Copy Pairing Token",
@@ -102,6 +105,7 @@ struct IntegrationSettingsPane: View {
.font(.footnote)
.padding(.top, 8)
}
}
.padding(32)
}
@@ -0,0 +1,70 @@
import SwiftUI
struct KeychainAccessCard: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 16) {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(settings.isKeychainAccessGranted ? Color.green.opacity(0.15) : Color.blue.opacity(0.15))
.frame(width: 48, height: 48)
Image(systemName: settings.isKeychainAccessGranted ? "lock.open.fill" : "lock.fill")
.font(.system(size: 24))
.foregroundStyle(settings.isKeychainAccessGranted ? .green : .blue)
}
VStack(alignment: .leading, spacing: 4) {
Text("Keychain Access")
.font(.headline)
Text("Firelink needs Keychain access to securely store your browser extension pairing token and site login passwords.")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
}
HStack {
Spacer()
if settings.isKeychainAccessGranted {
Label("Access Granted", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.subheadline.weight(.medium))
.padding(.trailing, 8)
Button(role: .destructive) {
settings.revokeKeychainAccess()
} label: {
Text("Revoke Access")
}
} else {
Button {
settings.grantKeychainAccess()
} label: {
Text("Grant Access")
.font(.subheadline.weight(.medium))
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.accentColor)
.foregroundColor(.white)
.cornerRadius(8)
}
.buttonStyle(.plain)
}
}
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(nsColor: .controlBackgroundColor))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
)
.overlay(
RoundedRectangle(cornerRadius: 16)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
)
}
}
@@ -9,7 +9,14 @@ struct SiteLoginsSettingsPane: View {
var body: some View {
Form {
Section(editingLoginID == nil ? "Add Login" : "Edit Login") {
Section {
KeychainAccessCard()
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
if settings.isKeychainAccessGranted {
Section(editingLoginID == nil ? "Add Login" : "Edit Login") {
TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
TextField("Username", text: $username)
SecureField(editingLoginID == nil ? "Password" : "Password (leave blank to keep current)", text: $password)
@@ -71,6 +78,7 @@ struct SiteLoginsSettingsPane: View {
}
.frame(minHeight: 180)
}
}
}
}
.formStyle(.grouped)
-1
View File
@@ -1 +0,0 @@