mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-06 01:17:48 +00:00
feat: defer keychain access until explicitly granted
This commit is contained in:
@@ -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 +0,0 @@
|
||||
|
||||
|
||||
Reference in New Issue
Block a user