diff --git a/Sources/Services/BrowserCookieImport/ChromiumCookies.swift b/Sources/Services/BrowserCookieImport/ChromiumCookies.swift index f174813..17ae9ec 100644 --- a/Sources/Services/BrowserCookieImport/ChromiumCookies.swift +++ b/Sources/Services/BrowserCookieImport/ChromiumCookies.swift @@ -1,5 +1,6 @@ import CommonCrypto import Foundation +import LocalAuthentication import SQLite3 import Security @@ -87,12 +88,24 @@ enum ChromiumCookies { // MARK: - Keychain private static func fetchSafeStoragePassword(service: String, account: String, browser: Browser) throws -> String { + // Passing an LAContext lets the system upgrade the prompt to Touch + // ID on Macs that have it enrolled — but only if the keychain + // item's ACL was created with biometric-compatible flags. Chromium + // browsers don't do that when they add their Safe Storage key, so + // in practice the OS still falls back to a password prompt. Harmless + // to pass it either way; costs nothing and gives Touch ID to any + // future Chromium build whose ACL ever gains biometric support. + let context = LAContext() + context.localizedReason = "Import your YouTube session from \(browser.displayName)" + let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, + kSecUseAuthenticationContext as String: context, + kSecUseOperationPrompt as String: "read \(browser.displayName)'s cookie-encryption key", ] var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) diff --git a/Sources/Views/ImportSessionWindow.swift b/Sources/Views/ImportSessionWindow.swift index 5f75faa..a7ed7bc 100644 --- a/Sources/Views/ImportSessionWindow.swift +++ b/Sources/Views/ImportSessionWindow.swift @@ -34,13 +34,14 @@ struct ImportSessionWindow: View { header Divider() browserPicker + headsUpBanner statusRow Spacer(minLength: 0) Divider() footer } .padding(20) - .frame(width: 480, height: 440) + .frame(width: 480, height: 460) .onAppear { dock.present(WindowID.importSession) browsers = BrowserDetector.installed() @@ -82,6 +83,31 @@ struct ImportSessionWindow: View { } } + /// Warns the user about the one-time OS prompt their selected browser + /// will trigger, so the "why is this app asking for my login password?" + /// moment doesn't come out of nowhere. Firefox has no prompt → no banner. + @ViewBuilder + private var headsUpBanner: some View { + if let browser = selected { + switch browser.format { + case .chromium: + Banner( + icon: "lock.shield", + text: + "macOS will ask for your login password once — that's the standard Keychain prompt that lets YouMenuTube read \(browser.displayName)'s cookie-encryption key. Click **Always Allow** to skip it on later imports. Touch ID may appear on Macs that support it." + ) + case .safari: + Banner( + icon: "externaldrive.badge.exclamationmark", + text: + "Safari's cookies live in a protected container. Import will fail the first time unless YouMenuTube has **Full Disk Access** in System Settings → Privacy & Security." + ) + case .firefox: + EmptyView() + } + } + } + @ViewBuilder private var statusRow: some View { switch status { @@ -179,11 +205,46 @@ struct ImportSessionWindow: View { } private func bringToFront() { - NSApp.activate(ignoringOtherApps: true) + // `NSApp.activate(ignoringOtherApps:)` was softened in macOS 14 — it + // only activates if the caller was recently user-facing, which a + // MenuBarExtra popover is not once it closes. `activate()` (no arg) + // is the replacement and works reliably for LSUIElement apps. + NSApp.activate() + // The NSWindow usually isn't in `NSApp.windows` yet when `onAppear` + // fires on first open. Poll a few times instead of assuming one + // runloop is enough — fixes the "Sign in button did nothing" + // symptom when the host app is an LSUIElement + MenuBarExtra. Task { @MainActor in - NSApp.windows - .first { $0.title == "Import YouTube session" }? - .makeKeyAndOrderFront(nil) + for _ in 0..<10 { + if let w = NSApp.windows.first(where: { $0.title == "Import YouTube session" }) { + w.makeKeyAndOrderFront(nil) + return + } + try? await Task.sleep(for: .milliseconds(50)) + } + } + } +} + +private struct Banner: View { + let icon: String + let text: String + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: icon) + .foregroundStyle(.tint) + .font(.title3) + Text(.init(text)) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.accentColor.opacity(0.08)) + ) } }