// // ReceiveModel.swift // QuickDrop // // Created by Leon Böttger on 16.08.25. // import SwiftUI import StoreKit import UserNotifications #if os(macOS) import BezelNotification #else import LUI #endif class ReceiveModel: ObservableObject, InboundAppDelegate { #if os(macOS) @Published var progress: Double? = nil private var toastWindow: NSWindow? private var toastHosting: NSHostingView? #endif let controlPlusScreen: (Bool) -> Void init(controlPlusScreen: @escaping (Bool) -> Void = { _ in }) { self.controlPlusScreen = controlPlusScreen NearbyConnectionManager.shared.mainAppDelegate = self NearbyConnectionManager.shared.becomeVisible() } func obtainUserConsent(transfer: TransferMetadata, device: RemoteDeviceInfo, acceptAutomatically: Bool) { #if os(macOS) NSApp.activate(ignoringOtherApps: true) AudioManager.playIncomingFileSound() #endif let fileStr: String if let textTitle = transfer.textDescription { fileStr = textTitle } else if transfer.files.count == 1 { fileStr = transfer.files[0].name } else { fileStr = String.localizedStringWithFormat("NFiles".localized(), transfer.files.count) } let mainMessage: String let name = device.name ?? "Android" switch transfer.type { case .file: mainMessage = String(format: (acceptAutomatically ? "DeviceCurrentlySendingFiles" : "DeviceSendingFiles").localized(), arguments: [name, fileStr]) case .text: mainMessage = String(format: (acceptAutomatically ? "DeviceCurrentlySendingText" : "DeviceSendingText").localized(), arguments: [name, fileStr]) case .url: mainMessage = String(format: (acceptAutomatically ? "DeviceCurrentlySendingUrl" : "DeviceSendingUrl").localized(), arguments: [name, fileStr]) } let pinCodeMessage = String(format: "PinCode".localized(), arguments: [transfer.pinCode ?? "?"]) let transferID = transfer.id #if os(macOS) let isMac = true #else let isMac = false #endif if acceptAutomatically && isMac { pressAcceptButton(transferID: transfer.id, trustDevice: false) if transfer.type != .file { UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in if granted { log("[ReceiveModel] User granted notification permissions") let notificationContent = UNMutableNotificationContent() notificationContent.title = "QuickDrop" notificationContent.body = mainMessage notificationContent.sound = nil let notificationId = UUID().uuidString let notificationReq = UNNotificationRequest(identifier: notificationId, content: notificationContent, trigger: nil) UNUserNotificationCenter.current().add(notificationReq) DispatchQueue.main.asyncAfter(deadline: .now() + 5) { UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [notificationId]) } } } } } else { let title = "QuickDrop - \(pinCodeMessage)" let primaryButtonTitle = "Accept".localized() let primaryButtonAction = { (trustDevice: Bool) in self.pressAcceptButton(transferID: transferID, trustDevice: trustDevice) } let secondaryButtonTitle = "Decline".localized() let secondaryButtonAction = { NearbyConnectionManager.shared.submitUserConsent(transferID: transferID, accept: false, trustDevice: false, storeInTemp: false) } #if os(macOS) let alert = NSAlert() alert.alertStyle = .informational alert.messageText = title alert.informativeText = mainMessage alert.addButton(withTitle: primaryButtonTitle) alert.addButton(withTitle: secondaryButtonTitle) if transfer.allowsToBeAddedAsTrustedDevice { alert.showsSuppressionButton = true alert.suppressionButton?.title = "AutoAcceptFromThisDevice".localized() } let result = alert.runModal() if result == .alertFirstButtonReturn { primaryButtonAction(alert.suppressionButton?.state == .on) } else if result == .alertSecondButtonReturn { secondaryButtonAction() } #else // iOS let alwaysAcceptLabel = transfer.allowsToBeAddedAsTrustedDevice ? "AlwaysAccept".localized() : nil ProgressAlert.shared.askForUserPermission(title: title, message: mainMessage, acceptLabel: primaryButtonTitle, acceptAlwaysLabel: alwaysAcceptLabel, rejectLabel: secondaryButtonTitle, acceptAutomatically: acceptAutomatically) { accepted in switch accepted { case .Accept: primaryButtonAction(false) case .AcceptAlways: primaryButtonAction(true) case .Decline: secondaryButtonAction() } } onCancel: { NearbyConnectionManager.shared.cancelTransfer(id: transferID) } #endif } } func transferProgress(connectionID: String, progress: Double) { #if os(iOS) ProgressAlert.shared.updateProgress(progress) #else DispatchQueue.main.async { self.progress = progress self.showQuickDropToast(for: connectionID) } #endif } func connectionWasTerminated(from device: RemoteDeviceInfo, wasPlainTextTransfer: Bool, error: (any Error)?) { #if os(macOS) DispatchQueue.main.async { self.hideQuickDropToast() self.progress = nil } finish() #else ProgressAlert.shared.updateProgress(nil) { finish() } #endif func finish() { if let error = error { #if os(iOS) errorVibration() #endif controlPlusScreen(false) ErrorAlertHandler.shared.showErrorAlert(for: device.name ?? "Android", error: error) } else { #if os(iOS) doubleVibration() #endif if wasPlainTextTransfer { showCopiedToClipboardAlert() } let currentCount = Settings.shared.incomingTransmissionCount #if os(macOS) if currentCount == 0 { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { SKStoreReviewController.requestReview() } } #endif Settings.shared.incomingTransmissionCount = currentCount + 1 log("[ReceiveModel] Successful transmission. Current count: \(currentCount)") } } } private func showCopiedToClipboardAlert() { #if os(macOS) DispatchQueue.main.async { BezelNotification.show(messageText: "InsertedIntoClipboard".localized(), icon: .clipboard) } #endif } private func pressAcceptButton(transferID: String, trustDevice: Bool) { if isFileTransferRestricted() { log("[ReceiveModel] Showing plus screen...") NearbyConnectionManager.shared.submitUserConsent(transferID: transferID, accept: true, trustDevice: trustDevice, storeInTemp: true) controlPlusScreen(true) } else { NearbyConnectionManager.shared.submitUserConsent(transferID: transferID, accept: true, trustDevice: trustDevice, storeInTemp: false) } } // MARK: - macOS Progress Overlay #if os(macOS) func showQuickDropToast(for connectionID: String) { guard toastWindow == nil else { return } let contentView = QuickDropToastView( receiveModel: self, onCancel: { NearbyConnectionManager.shared.cancelTransfer(id: connectionID) } ) let hostingView = NSHostingView(rootView: contentView) hostingView.frame.size = toastViewSize // Prefer the screen under the mouse, then the key window’s screen, then main let screen = NSScreen.screens.first { $0.frame.contains(NSEvent.mouseLocation) } ?? NSApp.keyWindow?.screen ?? NSScreen.main guard let screen else { return } let frame = screen.frame let visible = screen.visibleFrame let marginX: CGFloat = 24 let marginFromTop: CGFloat = 30 // If the menu bar is auto-hidden, visible.maxY == frame.maxY; subtract the status bar thickness to stay clear let menuBarHidden = abs(frame.maxY - visible.maxY) < 0.5 let extraTopInset = menuBarHidden ? NSStatusBar.system.thickness : 0 let targetX = visible.maxX - toastViewSize.width - marginX let targetY = visible.maxY - toastViewSize.height - marginFromTop - extraTopInset // Start slightly above the final spot for a smooth slide-in let startY = min(frame.maxY + 10, targetY + 10) let startFrame = CGRect(x: targetX, y: startY, width: toastViewSize.width, height: toastViewSize.height) let window = NSWindow(contentRect: startFrame, styleMask: [.borderless], backing: .buffered, defer: false) window.isOpaque = false window.backgroundColor = .clear window.hasShadow = true window.level = .statusBar window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] window.contentView = hostingView window.ignoresMouseEvents = false window.alphaValue = 0 window.makeKeyAndOrderFront(nil) self.toastWindow = window self.toastHosting = hostingView NSAnimationContext.runAnimationGroup { ctx in ctx.duration = 0.35 ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) window.animator().setFrameOrigin(CGPoint(x: targetX, y: targetY)) window.animator().alphaValue = 1 } } func hideQuickDropToast() { guard let window = toastWindow, let screen = NSScreen.main else { return } let offscreenY = screen.visibleFrame.origin.y + screen.visibleFrame.height + 20 NSAnimationContext.runAnimationGroup({ ctx in ctx.duration = 0.35 ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) window.animator().setFrameOrigin(CGPoint(x: window.frame.origin.x, y: offscreenY)) window.animator().alphaValue = 0 }, completionHandler: { window.orderOut(nil) self.toastWindow = nil self.toastHosting = nil }) } #endif }