فهرست منبع

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 1 ماه پیش
والد
کامیت
401b7e379f

+ 2 - 0
smart_printer.xcodeproj/project.pbxproj

@@ -257,6 +257,7 @@
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
 				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_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
@@ -289,6 +290,7 @@
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
 				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_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				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
 import UniformTypeIdentifiers
 
 
 enum PrintService {
 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) {
     static func printText(_ text: String, title: String = "Print Text", from window: NSWindow? = nil) {
         let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
         let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
         guard !trimmed.isEmpty else {
         guard !trimmed.isEmpty else {
@@ -71,12 +84,18 @@ enum PrintService {
             for: printInfo,
             for: printInfo,
             scalingMode: .pageScaleToFit,
             scalingMode: .pageScaleToFit,
             autoRotate: true
             autoRotate: true
-        ) else { return }
+        ) else {
+            showPrintFailedAlert()
+            return
+        }
         runPrintOperation(operation, title: title, from: window)
         runPrintOperation(operation, title: title, from: window)
     }
     }
 
 
     private static func printImage(_ image: NSImage, title: String, from window: NSWindow?) {
     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)
         printPDF(document, title: title, from: window)
     }
     }
 
 
@@ -93,7 +112,7 @@ enum PrintService {
 
 
     private static func runPrintOperation(_ operation: NSPrintOperation, title: String, from window: NSWindow?) {
     private static func runPrintOperation(_ operation: NSPrintOperation, title: String, from window: NSWindow?) {
         operation.showsPrintPanel = true
         operation.showsPrintPanel = true
-        operation.showsProgressPanel = true
+        operation.showsProgressPanel = false
         operation.jobTitle = title
         operation.jobTitle = title
         operation.printPanel.options.insert([
         operation.printPanel.options.insert([
             .showsCopies,
             .showsCopies,
@@ -103,18 +122,17 @@ enum PrintService {
             .showsScaling,
             .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 {
     private static func configuredPrintInfo() -> NSPrintInfo {
         let printInfo = (NSPrintInfo.shared.copy() as? NSPrintInfo) ?? NSPrintInfo()
         let printInfo = (NSPrintInfo.shared.copy() as? NSPrintInfo) ?? NSPrintInfo()
 
 
         let printerName = AppSettings.effectiveDefaultPrinter
         let printerName = AppSettings.effectiveDefaultPrinter
-        if let printer = NSPrinter(name: printerName) {
+        if let printer = NSPrinter(name: printerName),
+           NSPrinter.printerNames.contains(printerName) {
             printInfo.printer = printer
             printInfo.printer = printer
         }
         }
 
 
@@ -225,6 +243,36 @@ enum PrintService {
         return PDFDocument(data: pdfData as Data)
         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() {
     private static func showPrintFailedAlert() {
         let alert = NSAlert()
         let alert = NSAlert()
         alert.messageText = "Print Failed"
         alert.messageText = "Print Failed"

+ 20 - 0
smart_printer/ViewController.swift

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

+ 2 - 0
smart_printer/smart_printer.entitlements

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