Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Sources/Services/BrowserCookieImport/ChromiumCookies.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import CommonCrypto
import Foundation
import LocalAuthentication
import SQLite3
import Security

Expand Down Expand Up @@ -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)
Expand Down
71 changes: 66 additions & 5 deletions Sources/Views/ImportSessionWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
)
}
}