Explorar el Código

Add Print Contacts screen and fix sandboxed printing reliability.

Enable searching and printing address book contacts from the home screen, and make the shared print flow more reliable in sandboxed overlay views.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 hace 1 mes
padre
commit
401b7e379f

+ 2 - 0
smart_printer.xcodeproj/project.pbxproj

@@ -257,6 +257,7 @@
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_NSContactsUsageDescription = "Smart Printer needs access to your contacts so you can search and print them.";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
@@ -289,6 +290,7 @@
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_NSContactsUsageDescription = "Smart Printer needs access to your contacts so you can search and print them.";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;

+ 565 - 0
smart_printer/PrintContactsView.swift

@@ -0,0 +1,565 @@
+import Cocoa
+import Contacts
+
+// MARK: - Models
+
+struct PrintableContact: Equatable {
+    let identifier: String
+    let displayName: String
+    let organization: String?
+    let phoneNumbers: [String]
+    let emailAddresses: [String]
+    let postalAddresses: [String]
+
+    var searchableText: String {
+        ([displayName, organization] + phoneNumbers + emailAddresses + postalAddresses)
+            .compactMap { $0 }
+            .joined(separator: " ")
+            .lowercased()
+    }
+}
+
+// MARK: - Contacts Service
+
+enum ContactsService {
+    static func requestAccess() async -> Bool {
+        let store = CNContactStore()
+        do {
+            return try await store.requestAccess(for: .contacts)
+        } catch {
+            return false
+        }
+    }
+
+    static func fetchContacts() throws -> [PrintableContact] {
+        let store = CNContactStore()
+        let keys: [CNKeyDescriptor] = [
+            CNContactIdentifierKey as CNKeyDescriptor,
+            CNContactGivenNameKey as CNKeyDescriptor,
+            CNContactFamilyNameKey as CNKeyDescriptor,
+            CNContactOrganizationNameKey as CNKeyDescriptor,
+            CNContactPhoneNumbersKey as CNKeyDescriptor,
+            CNContactEmailAddressesKey as CNKeyDescriptor,
+            CNContactPostalAddressesKey as CNKeyDescriptor,
+        ]
+
+        let request = CNContactFetchRequest(keysToFetch: keys)
+        request.sortOrder = .givenName
+
+        var contacts: [PrintableContact] = []
+        try store.enumerateContacts(with: request) { contact, _ in
+            let name = CNContactFormatter.string(from: contact, style: .fullName)?
+                .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+            let organization = contact.organizationName.trimmingCharacters(in: .whitespacesAndNewlines)
+            let displayName: String
+            if !name.isEmpty {
+                displayName = name
+            } else if !organization.isEmpty {
+                displayName = organization
+            } else {
+                displayName = "Unnamed Contact"
+            }
+
+            let phones = contact.phoneNumbers.map(\.value.stringValue).filter { !$0.isEmpty }
+            let emails = contact.emailAddresses.map { $0.value as String }.filter { !$0.isEmpty }
+            let addresses = contact.postalAddresses.map { entry in
+                CNPostalAddressFormatter.string(from: entry.value, style: .mailingAddress)
+            }.filter { !$0.isEmpty }
+
+            contacts.append(
+                PrintableContact(
+                    identifier: contact.identifier,
+                    displayName: displayName,
+                    organization: organization.isEmpty ? nil : organization,
+                    phoneNumbers: phones,
+                    emailAddresses: emails,
+                    postalAddresses: addresses
+                )
+            )
+        }
+
+        return contacts
+    }
+}
+
+// MARK: - Print Contacts Service
+
+enum PrintContactsService {
+    static func present(from window: NSWindow?) {
+        let hostWindow = window ?? NSApp.keyWindow
+        guard let viewController = hostWindow?.contentViewController as? ViewController else { return }
+        viewController.presentPrintContacts()
+    }
+}
+
+// MARK: - Overlay
+
+final class PrintContactsOverlayView: NSView, AppearanceRefreshable {
+    var onDismiss: (() -> Void)?
+
+    private let backButton = PrintContactsToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
+    private let searchContainer = NSView()
+    private let searchIconView = NSImageView()
+    private let searchField = NSTextField()
+    private let scrollView = NSScrollView()
+    private let listStack = NSStackView()
+    private let printButton = PrintContactsPrintButton()
+    private let emptyLabel = NSTextField(labelWithString: "No contacts found")
+
+    private let horizontalInset: CGFloat = 16
+    private let searchHeight: CGFloat = 44
+    private let contactRowHeight: CGFloat = 52
+
+    private var allContacts: [PrintableContact] = []
+    private var selectedIDs = Set<String>()
+    private var rowViews: [ContactRowView] = []
+
+    init() {
+        super.init(frame: .zero)
+        translatesAutoresizingMaskIntoConstraints = false
+        setup()
+        refreshAppearance()
+        NotificationCenter.default.addObserver(
+            self,
+            selector: #selector(appearanceDidChange),
+            name: .appearanceDidChange,
+            object: nil
+        )
+        loadContacts()
+    }
+
+    deinit {
+        NotificationCenter.default.removeObserver(self)
+    }
+
+    @objc private func appearanceDidChange() {
+        refreshAppearance()
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    func refreshAppearance() {
+        layer?.backgroundColor = AppTheme.background.cgColor
+        searchContainer.layer?.backgroundColor = AppTheme.blueLight.cgColor
+        searchField.textColor = AppTheme.textPrimary
+        searchField.backgroundColor = .clear
+        searchIconView.contentTintColor = AppTheme.textSecondary
+        emptyLabel.textColor = AppTheme.textSecondary
+        backButton.refreshAppearance()
+        printButton.refreshAppearance()
+        rowViews.forEach { $0.refreshAppearance() }
+    }
+
+    func present(in parent: NSView) {
+        guard superview == nil else { return }
+        parent.addSubview(self)
+        NSLayoutConstraint.activate([
+            leadingAnchor.constraint(equalTo: parent.leadingAnchor),
+            trailingAnchor.constraint(equalTo: parent.trailingAnchor),
+            topAnchor.constraint(equalTo: parent.topAnchor),
+            bottomAnchor.constraint(equalTo: parent.bottomAnchor),
+        ])
+        alphaValue = 0
+        NSAnimationContext.runAnimationGroup { context in
+            context.duration = 0.2
+            animator().alphaValue = 1
+        }
+        window?.makeFirstResponder(searchField)
+    }
+
+    func dismiss(animated: Bool = true) {
+        let remove = { [weak self] in
+            self?.removeFromSuperview()
+            self?.onDismiss?()
+        }
+        guard animated else {
+            remove()
+            return
+        }
+        NSAnimationContext.runAnimationGroup({ context in
+            context.duration = 0.15
+            animator().alphaValue = 0
+        }, completionHandler: remove)
+    }
+
+    private func setup() {
+        wantsLayer = true
+
+        searchContainer.wantsLayer = true
+        searchContainer.layer?.cornerRadius = searchHeight / 2
+        searchContainer.translatesAutoresizingMaskIntoConstraints = false
+
+        if let image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search") {
+            let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .medium)
+            searchIconView.image = image.withSymbolConfiguration(config)
+        }
+        searchIconView.translatesAutoresizingMaskIntoConstraints = false
+
+        searchField.placeholderAttributedString = NSAttributedString(
+            string: "Search",
+            attributes: [
+                .foregroundColor: AppTheme.textSecondary,
+                .font: AppTheme.regularFont(size: 15),
+            ]
+        )
+        searchField.font = AppTheme.regularFont(size: 15)
+        searchField.isBordered = false
+        searchField.isBezeled = false
+        searchField.drawsBackground = false
+        searchField.focusRingType = .none
+        searchField.translatesAutoresizingMaskIntoConstraints = false
+        searchField.delegate = self
+
+        scrollView.translatesAutoresizingMaskIntoConstraints = false
+        scrollView.hasVerticalScroller = true
+        scrollView.hasHorizontalScroller = false
+        scrollView.autohidesScrollers = true
+        scrollView.borderType = .noBorder
+        scrollView.drawsBackground = false
+
+        listStack.orientation = .vertical
+        listStack.alignment = .leading
+        listStack.spacing = 12
+        listStack.translatesAutoresizingMaskIntoConstraints = false
+
+        emptyLabel.font = AppTheme.regularFont(size: 14)
+        emptyLabel.alignment = .center
+        emptyLabel.isHidden = true
+        emptyLabel.translatesAutoresizingMaskIntoConstraints = false
+
+        scrollView.documentView = listStack
+
+        backButton.translatesAutoresizingMaskIntoConstraints = false
+        printButton.translatesAutoresizingMaskIntoConstraints = false
+
+        backButton.onClick = { [weak self] in self?.dismiss() }
+        printButton.onClick = { [weak self] in self?.printContacts() }
+
+        addSubview(backButton)
+        addSubview(searchContainer)
+        searchContainer.addSubview(searchIconView)
+        searchContainer.addSubview(searchField)
+        addSubview(scrollView)
+        addSubview(emptyLabel)
+        addSubview(printButton)
+
+        NSLayoutConstraint.activate([
+            backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
+            backButton.topAnchor.constraint(equalTo: topAnchor, constant: 20),
+            backButton.widthAnchor.constraint(equalToConstant: 40),
+            backButton.heightAnchor.constraint(equalToConstant: 40),
+
+            searchContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
+            searchContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
+            searchContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 16),
+            searchContainer.heightAnchor.constraint(equalToConstant: searchHeight),
+
+            searchIconView.leadingAnchor.constraint(equalTo: searchContainer.leadingAnchor, constant: 16),
+            searchIconView.centerYAnchor.constraint(equalTo: searchContainer.centerYAnchor),
+            searchIconView.widthAnchor.constraint(equalToConstant: 16),
+            searchIconView.heightAnchor.constraint(equalToConstant: 16),
+
+            searchField.leadingAnchor.constraint(equalTo: searchIconView.trailingAnchor, constant: 10),
+            searchField.trailingAnchor.constraint(equalTo: searchContainer.trailingAnchor, constant: -16),
+            searchField.centerYAnchor.constraint(equalTo: searchContainer.centerYAnchor),
+
+            scrollView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
+            scrollView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
+            scrollView.topAnchor.constraint(equalTo: searchContainer.bottomAnchor, constant: 16),
+            scrollView.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -28),
+
+            listStack.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor),
+            listStack.trailingAnchor.constraint(equalTo: scrollView.contentView.trailingAnchor),
+            listStack.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
+            listStack.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
+
+            emptyLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
+            emptyLabel.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor),
+
+            printButton.centerXAnchor.constraint(equalTo: centerXAnchor),
+            printButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
+            printButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
+        ])
+    }
+
+    private func loadContacts() {
+        Task { @MainActor in
+            let granted = await ContactsService.requestAccess()
+            guard granted else {
+                showAccessDeniedAlert()
+                return
+            }
+
+            do {
+                allContacts = try ContactsService.fetchContacts()
+                selectedIDs = Set(allContacts.map(\.identifier))
+                reloadList()
+            } catch {
+                showLoadFailedAlert()
+            }
+        }
+    }
+
+    private func filteredContacts() -> [PrintableContact] {
+        let query = searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        guard !query.isEmpty else { return allContacts }
+        return allContacts.filter { $0.searchableText.contains(query) }
+    }
+
+    private func reloadList() {
+        rowViews.forEach { $0.removeFromSuperview() }
+        rowViews.removeAll()
+
+        let contacts = filteredContacts()
+        emptyLabel.isHidden = !contacts.isEmpty
+
+        for contact in contacts {
+            let row = ContactRowView(
+                name: contact.displayName,
+                isSelected: selectedIDs.contains(contact.identifier),
+                rowHeight: contactRowHeight
+            )
+            row.onToggle = { [weak self] in
+                self?.toggleSelection(for: contact.identifier)
+            }
+            rowViews.append(row)
+            listStack.addArrangedSubview(row)
+            row.leadingAnchor.constraint(equalTo: listStack.leadingAnchor).isActive = true
+            row.trailingAnchor.constraint(equalTo: listStack.trailingAnchor).isActive = true
+        }
+    }
+
+    private func toggleSelection(for identifier: String) {
+        if selectedIDs.contains(identifier) {
+            selectedIDs.remove(identifier)
+        } else {
+            selectedIDs.insert(identifier)
+        }
+        reloadList()
+    }
+
+    private func printContacts() {
+        let contacts = filteredContacts().filter { selectedIDs.contains($0.identifier) }
+        PrintService.printContacts(contacts, from: window)
+    }
+
+    private func showAccessDeniedAlert() {
+        let alert = NSAlert()
+        alert.messageText = "Contacts Access Required"
+        alert.informativeText = "Allow Smart Printer to access your contacts in System Settings to print them."
+        alert.alertStyle = .warning
+        alert.addButton(withTitle: "Open Settings")
+        alert.addButton(withTitle: "Cancel")
+        if alert.runModal() == .alertFirstButtonReturn {
+            if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Contacts") {
+                NSWorkspace.shared.open(url)
+            }
+        }
+    }
+
+    private func showLoadFailedAlert() {
+        let alert = NSAlert()
+        alert.messageText = "Could Not Load Contacts"
+        alert.informativeText = "Your contacts could not be read. Try again after granting access."
+        alert.alertStyle = .warning
+        alert.addButton(withTitle: "OK")
+        alert.runModal()
+    }
+}
+
+// MARK: - Contact Row
+
+private final class ContactRowView: NSControl, AppearanceRefreshable {
+    var onToggle: (() -> Void)?
+
+    private let nameLabel = NSTextField(labelWithString: "")
+    private var isSelected = false
+    private let rowHeight: CGFloat
+
+    init(name: String, isSelected: Bool, rowHeight: CGFloat) {
+        self.isSelected = isSelected
+        self.rowHeight = rowHeight
+        super.init(frame: .zero)
+        wantsLayer = true
+        layer?.cornerRadius = rowHeight / 2
+
+        nameLabel.stringValue = name
+        nameLabel.font = AppTheme.semiboldFont(size: 16)
+        nameLabel.lineBreakMode = .byTruncatingTail
+        nameLabel.translatesAutoresizingMaskIntoConstraints = false
+
+        addSubview(nameLabel)
+        NSLayoutConstraint.activate([
+            heightAnchor.constraint(equalToConstant: rowHeight),
+            nameLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
+            nameLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
+            nameLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
+        ])
+        refreshAppearance()
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    func refreshAppearance() {
+        layer?.borderWidth = 0
+        if isSelected {
+            layer?.backgroundColor = AppTheme.homeActiveBackground.cgColor
+            nameLabel.textColor = AppTheme.homeActiveForeground
+        } else {
+            layer?.backgroundColor = AppTheme.blue.withAlphaComponent(0.14).cgColor
+            nameLabel.textColor = AppTheme.textPrimary
+        }
+    }
+
+    override func mouseUp(with event: NSEvent) {
+        guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
+        onToggle?()
+    }
+
+    override func resetCursorRects() {
+        addCursorRect(bounds, cursor: .pointingHand)
+    }
+}
+
+// MARK: - Search Delegate
+
+extension PrintContactsOverlayView: NSTextFieldDelegate {
+    func controlTextDidChange(_ obj: Notification) {
+        reloadList()
+    }
+}
+
+// MARK: - Toolbar Button
+
+private final class PrintContactsToolbarButton: NSControl, AppearanceRefreshable {
+    var onClick: (() -> Void)?
+
+    private let symbolName: String
+    private let iconView = NSImageView()
+
+    init(symbolName: String, accessibilityLabel: String) {
+        self.symbolName = symbolName
+        super.init(frame: .zero)
+        toolTip = accessibilityLabel
+
+        wantsLayer = true
+        layer?.cornerRadius = 12
+
+        if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) {
+            let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
+            iconView.image = image.withSymbolConfiguration(config)
+        }
+        iconView.translatesAutoresizingMaskIntoConstraints = false
+
+        addSubview(iconView)
+        NSLayoutConstraint.activate([
+            iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
+            iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
+        ])
+        refreshAppearance()
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    func refreshAppearance() {
+        layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
+        layer?.borderColor = AppTheme.paywallBorder.cgColor
+        layer?.borderWidth = 1.5
+        iconView.contentTintColor = AppTheme.textPrimary
+    }
+
+    override func mouseUp(with event: NSEvent) {
+        guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
+        onClick?()
+    }
+
+    override func resetCursorRects() {
+        addCursorRect(bounds, cursor: .pointingHand)
+    }
+}
+
+// MARK: - Print Button
+
+private final class PrintContactsPrintButton: NSControl, AppearanceRefreshable {
+    var onClick: (() -> Void)?
+
+    private let titleLabel = NSTextField(labelWithString: "Print Contacts")
+    private let iconView = NSImageView()
+    private var hoverTracker: HoverTracker?
+    private var isHovered = false
+
+    init() {
+        super.init(frame: .zero)
+        wantsLayer = true
+        layer?.cornerRadius = 22
+
+        titleLabel.font = AppTheme.semiboldFont(size: 16)
+        titleLabel.textColor = .white
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
+
+        if let image = NSImage(systemSymbolName: "printer.fill", accessibilityDescription: "Print") {
+            let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
+            iconView.image = image.withSymbolConfiguration(config)
+        }
+        iconView.contentTintColor = .white
+        iconView.translatesAutoresizingMaskIntoConstraints = false
+
+        addSubview(titleLabel)
+        addSubview(iconView)
+
+        NSLayoutConstraint.activate([
+            heightAnchor.constraint(equalToConstant: 52),
+
+            titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 28),
+            titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
+
+            iconView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 10),
+            iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
+            iconView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -28),
+            iconView.widthAnchor.constraint(equalToConstant: 20),
+            iconView.heightAnchor.constraint(equalToConstant: 20),
+        ])
+
+        hoverTracker = HoverTracker(view: self) { [weak self] hovering in
+            self?.setHovered(hovering)
+        }
+        refreshAppearance()
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    func refreshAppearance() {
+        let color = isHovered
+            ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
+            : AppTheme.blue
+        layer?.backgroundColor = color.cgColor
+        titleLabel.textColor = .white
+        iconView.contentTintColor = .white
+    }
+
+    private func setHovered(_ hovering: Bool) {
+        isHovered = hovering
+        animateHover {
+            let color = hovering
+                ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
+                : AppTheme.blue
+            layer?.backgroundColor = color.cgColor
+            layer?.transform = hovering
+                ? CATransform3DMakeScale(1.03, 1.03, 1)
+                : CATransform3DIdentity
+        }
+    }
+
+    override func mouseUp(with event: NSEvent) {
+        guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
+        onClick?()
+    }
+
+    override func resetCursorRects() {
+        addCursorRect(bounds, cursor: .pointingHand)
+    }
+}

+ 57 - 9
smart_printer/PrintService.swift

@@ -3,6 +3,19 @@ import PDFKit
 import UniformTypeIdentifiers
 
 enum PrintService {
+    static func printContacts(_ contacts: [PrintableContact], from window: NSWindow? = nil) {
+        guard !contacts.isEmpty else {
+            showNoContactsAlert()
+            return
+        }
+        let text = formattedContactsText(contacts)
+        guard let document = pdfDocument(from: text) else {
+            showPrintFailedAlert()
+            return
+        }
+        printPDF(document, title: "Print Contacts", from: window ?? NSApp.keyWindow)
+    }
+
     static func printText(_ text: String, title: String = "Print Text", from window: NSWindow? = nil) {
         let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
         guard !trimmed.isEmpty else {
@@ -71,12 +84,18 @@ enum PrintService {
             for: printInfo,
             scalingMode: .pageScaleToFit,
             autoRotate: true
-        ) else { return }
+        ) else {
+            showPrintFailedAlert()
+            return
+        }
         runPrintOperation(operation, title: title, from: window)
     }
 
     private static func printImage(_ image: NSImage, title: String, from window: NSWindow?) {
-        guard let document = pdfDocument(from: image) else { return }
+        guard let document = pdfDocument(from: image) else {
+            showPrintFailedAlert()
+            return
+        }
         printPDF(document, title: title, from: window)
     }
 
@@ -93,7 +112,7 @@ enum PrintService {
 
     private static func runPrintOperation(_ operation: NSPrintOperation, title: String, from window: NSWindow?) {
         operation.showsPrintPanel = true
-        operation.showsProgressPanel = true
+        operation.showsProgressPanel = false
         operation.jobTitle = title
         operation.printPanel.options.insert([
             .showsCopies,
@@ -103,18 +122,17 @@ enum PrintService {
             .showsScaling,
         ])
 
-        if let window {
-            operation.runModal(for: window, delegate: nil, didRun: nil, contextInfo: nil)
-        } else {
-            operation.run()
-        }
+        NSApp.activate(ignoringOtherApps: true)
+        window?.makeKeyAndOrderFront(nil)
+        _ = operation.run()
     }
 
     private static func configuredPrintInfo() -> NSPrintInfo {
         let printInfo = (NSPrintInfo.shared.copy() as? NSPrintInfo) ?? NSPrintInfo()
 
         let printerName = AppSettings.effectiveDefaultPrinter
-        if let printer = NSPrinter(name: printerName) {
+        if let printer = NSPrinter(name: printerName),
+           NSPrinter.printerNames.contains(printerName) {
             printInfo.printer = printer
         }
 
@@ -225,6 +243,36 @@ enum PrintService {
         return PDFDocument(data: pdfData as Data)
     }
 
+    private static func formattedContactsText(_ contacts: [PrintableContact]) -> String {
+        contacts.map { contact in
+            var lines = [contact.displayName]
+            if let organization = contact.organization, !organization.isEmpty,
+               organization.caseInsensitiveCompare(contact.displayName) != .orderedSame {
+                lines.append("Organization: \(organization)")
+            }
+            for phone in contact.phoneNumbers {
+                lines.append("Phone: \(phone)")
+            }
+            for email in contact.emailAddresses {
+                lines.append("Email: \(email)")
+            }
+            for address in contact.postalAddresses {
+                lines.append("Address:\n\(address)")
+            }
+            return lines.joined(separator: "\n")
+        }
+        .joined(separator: "\n\n" + String(repeating: "─", count: 40) + "\n\n")
+    }
+
+    private static func showNoContactsAlert() {
+        let alert = NSAlert()
+        alert.messageText = "No Contacts Selected"
+        alert.informativeText = "Select at least one contact to print."
+        alert.alertStyle = .informational
+        alert.addButton(withTitle: "OK")
+        alert.runModal()
+    }
+
     private static func showPrintFailedAlert() {
         let alert = NSAlert()
         alert.messageText = "Print Failed"

+ 20 - 0
smart_printer/ViewController.swift

@@ -18,6 +18,7 @@ class ViewController: NSViewController {
     private var photoPreviewOverlay: PhotoPreviewOverlayView?
     private var filePreviewOverlay: FilePreviewOverlayView?
     private var printTextOverlay: PrintTextOverlayView?
+    private var printContactsOverlay: PrintContactsOverlayView?
 
     private var headerView: NSView!
     private var contentTopBelowHeader: NSLayoutConstraint!
@@ -82,11 +83,13 @@ class ViewController: NSViewController {
         photoPreviewOverlay?.refreshAppearance()
         filePreviewOverlay?.refreshAppearance()
         printTextOverlay?.refreshAppearance()
+        printContactsOverlay?.refreshAppearance()
     }
 
     func presentPrintText() {
         photoPreviewOverlay?.dismiss(animated: false)
         filePreviewOverlay?.dismiss(animated: false)
+        printContactsOverlay?.dismiss(animated: false)
 
         let overlay = PrintTextOverlayView()
         overlay.onDismiss = { [weak self] in
@@ -96,6 +99,19 @@ class ViewController: NSViewController {
         overlay.present(in: mainContentView)
     }
 
+    func presentPrintContacts() {
+        photoPreviewOverlay?.dismiss(animated: false)
+        filePreviewOverlay?.dismiss(animated: false)
+        printTextOverlay?.dismiss(animated: false)
+
+        let overlay = PrintContactsOverlayView()
+        overlay.onDismiss = { [weak self] in
+            self?.printContactsOverlay = nil
+        }
+        printContactsOverlay = overlay
+        overlay.present(in: mainContentView)
+    }
+
     func presentPhotoPreview(urls: [URL]) {
         photoPreviewOverlay?.dismiss(animated: false)
         filePreviewOverlay?.dismiss(animated: false)
@@ -590,6 +606,10 @@ class ViewController: NSViewController {
                 updated.onActivate = { [weak self] in
                     PrintTextService.present(from: self?.view.window)
                 }
+            } else if card.iconKind == .printContacts {
+                updated.onActivate = { [weak self] in
+                    PrintContactsService.present(from: self?.view.window)
+                }
             }
             return updated
         }

+ 2 - 0
smart_printer/smart_printer.entitlements

@@ -10,5 +10,7 @@
 	<true/>
 	<key>com.apple.security.print</key>
 	<true/>
+	<key>com.apple.security.personal-information.addressbook</key>
+	<true/>
 </dict>
 </plist>