diff --git a/Sources/Firelink/AppSettings.swift b/Sources/Firelink/AppSettings.swift index d6a5d33..b77ebb1 100644 --- a/Sources/Firelink/AppSettings.swift +++ b/Sources/Firelink/AppSettings.swift @@ -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? } diff --git a/Sources/Firelink/Settings/IntegrationSettingsPane.swift b/Sources/Firelink/Settings/IntegrationSettingsPane.swift index 7fac531..fab4600 100644 --- a/Sources/Firelink/Settings/IntegrationSettingsPane.swift +++ b/Sources/Firelink/Settings/IntegrationSettingsPane.swift @@ -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) } diff --git a/Sources/Firelink/Settings/KeychainAccessCard.swift b/Sources/Firelink/Settings/KeychainAccessCard.swift new file mode 100644 index 0000000..f198607 --- /dev/null +++ b/Sources/Firelink/Settings/KeychainAccessCard.swift @@ -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) + ) + } +} diff --git a/Sources/Firelink/Settings/SiteLoginsSettingsPane.swift b/Sources/Firelink/Settings/SiteLoginsSettingsPane.swift index de6313f..97f13c8 100644 --- a/Sources/Firelink/Settings/SiteLoginsSettingsPane.swift +++ b/Sources/Firelink/Settings/SiteLoginsSettingsPane.swift @@ -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) diff --git a/Sources/Firelink/_internal/.gitkeep b/Sources/Firelink/_internal/.gitkeep index 8b13789..e69de29 100644 --- a/Sources/Firelink/_internal/.gitkeep +++ b/Sources/Firelink/_internal/.gitkeep @@ -1 +0,0 @@ -