Replace alert with toast view

This commit is contained in:
Leon Böttger committed 2026-02-07 15:15:26 +01:00
1 parent b55d6ff07b
commit ed20c9155e
9 files changed
+3311 -1270

No files matched your search

@@ -539,6 +539,12 @@ class InboundNearbyConnection: NearbyConnection {
let acceptAutomatically = isAuthenticated || Settings.sharedInstance.automaticallyAcceptFiles
DispatchQueue.main.async {
if !NearbyConnectionManager.shared.claimIncomingTransfer(id: self.id) {
log("[InboundNearbyConnection \(self.id)] Another transfer is active. Rejecting incoming transfer.")
self.rejectTransfer()
return
}
if acceptAutomatically {
self.delegate?.obtainedUserConsentAutomatically(transfer: metadata, device: self.remoteDeviceInfo!, connection: self)
self.submitUserConsent(accepted: true, trustDevice: false)
@@ -29,6 +29,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
private var tcpListener: NWListener
private var mdnsServices: [NetService] = []
private var incomingConnections: [String: InboundNearbyConnection] = [:]
private var activeIncomingTransferID: String?
private var foundServices: [String: FoundServiceInfo] = [:]
private var outboundAppDelegates: [OutboundAppDelegate] = []
private var inboundAppDelegates: [InboundAppDelegate] = []
@@ -340,6 +341,9 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
func connectionWasTerminated(connection: InboundNearbyConnection, savedFiles: [URL], error: Error?) {
if activeIncomingTransferID == connection.id {
activeIncomingTransferID = nil
}
incomingConnections.removeValue(forKey: connection.id)
if !connection.wasUserRejected {
@@ -481,6 +485,14 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
info.connection.cancel()
}
}
func claimIncomingTransfer(id: String) -> Bool {
if let activeIncomingTransferID, activeIncomingTransferID != id {
return false
}
activeIncomingTransferID = id
return true
}
private func endpointID(for service: NWBrowser.Result) -> String? {
+36 -6
View File
@@ -6,6 +6,7 @@
//
import Foundation
import UniformTypeIdentifiers
import Network
import CryptoKit
import SwiftECC
@@ -144,28 +145,57 @@ public struct TransferMetadata {
if let textTitle = textDescription {
return textTitle
} else if files.count == 1 {
return files[0].name
return fileTypeSummary(for: files[0])
} else {
return String.localizedStringWithFormat("NFiles".localized(), files.count)
}
}
private func fileTypeSummary(for file: FileMetadata) -> String {
let mimeType = file.mimeType.lowercased()
let ext = URL(fileURLWithPath: file.name).pathExtension.lowercased()
let type = UTType(mimeType: mimeType) ?? UTType(filenameExtension: ext)
if let type, type.conforms(to: .pdf) {
return UTType.pdf.localizedDescription ?? "PDF"
}
if let type, type.conforms(to: .image) {
return UTType.image.localizedDescription ?? "Image"
}
if let type, type.conforms(to: .video) || type.conforms(to: .movie) {
return UTType.video.localizedDescription ?? "Video"
}
if let type, type.conforms(to: .audio) {
return UTType.audio.localizedDescription ?? "Audio"
}
if let type, type.conforms(to: .text) {
return UTType.text.localizedDescription ?? "Text"
}
if let type, type.conforms(to: .archive) {
return UTType.archive.localizedDescription ?? "Archive"
}
if let type, let description = type.localizedDescription {
return description
}
return "File"
}
public func getDescription(deviceName: String, alreadyAccepted: Bool) -> String {
public func getDescription(deviceName: String) -> String {
let fileStr = getSummary()
switch type {
case .file:
return String(format: (alreadyAccepted ? "DeviceCurrentlySendingFiles" : "DeviceSendingFiles").localized(), arguments: [deviceName, fileStr])
return "DeviceSendingFiles".localized(with: fileStr, deviceName)
case .text:
return String(format: (alreadyAccepted ? "DeviceCurrentlySendingText" : "DeviceSendingText").localized(), arguments: [deviceName, fileStr])
return "DeviceSendingText".localized(with: deviceName)
case .url:
return String(format: (alreadyAccepted ? "DeviceCurrentlySendingUrl" : "DeviceSendingUrl").localized(), arguments: [deviceName, fileStr])
return "DeviceSendingUrl".localized(with: deviceName)
case .wifiPassword:
return String(format: (alreadyAccepted ? "DeviceCurrentlySendingWifiPassword" : "DeviceSendingWifiPassword").localized(), arguments: [deviceName, fileStr])
return "DeviceSendingWifiPassword".localized(with: deviceName)
}
}
@@ -52,7 +52,7 @@
"location" : "https://github.com/leonboe1/LUI.git",
"state" : {
"branch" : "main",
"revision" : "87f9d26e499b49838dc9135e063d10e1701480e9"
"revision" : "b35bcccf183c075d8d58d5b5763702b29a779b32"
}
},
{
@@ -87,8 +87,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git",
"state" : {
"revision" : "959a56cda79509e66abd367a6498951b656aa460",
"version" : "1.34.0"
"revision" : "c5ab62237f21cad094812719a1bbe29443407c5f",
"version" : "1.34.1"
}
},
{
@@ -124,7 +124,7 @@
"location" : "https://github.com/siteline/swiftui-introspect",
"state" : {
"branch" : "main",
"revision" : "69d97e5463727a67a5aef8db2986667d3bd83a83"
"revision" : "379cbeb697ef528b24c56c59f77a8cfc394ae793"
}
},
{
@@ -34,7 +34,6 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
language = "de"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
+1 -1
View File
@@ -329,7 +329,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
// Ensure the window is always on top
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
window.level = .normal
window.level = .floating
}
File diff suppressed because it is too large. Load diff
+348 -91
View File
@@ -9,13 +9,16 @@ import AppKit
import LUI
import SwiftUI
let toastViewSize = CGSize(width: 400, height: 120)
let toastViewSize = CGSize(width: 420, height: 75)
let actionColumnWidth: CGFloat = 120
let actionColumnWidthEnd: CGFloat = 165
struct QuickDropToastView: View {
@ObservedObject var settings = Settings.sharedInstance
@ObservedObject var receiveModel: ReceiveModel
@State var autoHider = DispatchWorkItem(block: {})
@State private var isHovering = false
/// Cancel while receiving.
public var onCancel: () -> Void
@@ -28,93 +31,188 @@ struct QuickDropToastView: View {
self.onCancel = onCancel
}
private func startAutoHide(_ actions: ReceiveModel.ToastViewAction) {
autoHider.cancel()
autoHider = DispatchWorkItem(block: {
actions.closeToastAction()
})
DispatchQueue.main.asyncAfter(deadline: .now() + actions.autoHideDelay, execute: autoHider)
}
private func startAutoHideIfPossible() {
guard !isHovering, let actions = receiveModel.toastActions else { return }
startAutoHide(actions)
}
public var body: some View {
let isDone = receiveModel.toastActions != nil
let consent = receiveModel.consentState
let actions = receiveModel.toastActions
let hasActionButtons = actions?.openFilesAction != nil || actions?.importPhotosAction != nil
let showsRightColumn = consent != nil || hasActionButtons
VStack(spacing: 12) {
HStack(spacing: 10) {
let iconSize = 39.0
AppIconView(hasPlusIcon: false, size: iconSize)
.frame(width: iconSize, height: iconSize)
let subHeaderSize = 21.0
VStack(alignment: .leading, spacing: 2) {
Text(String("QuickDrop"))
.font(.system(size: 16, weight: .semibold))
let closeButtonVisible = isHovering && actions != nil
let deviceName = receiveModel.activeDeviceName ?? "UnknownDevice".localized()
ZStack(alignment: .topTrailing) {
HStack(alignment: .center, spacing: 12) {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
Text(isDone ? "FileTransferCompleted" : "Receiving")
.font(.system(size: 13))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
let iconSize = 39.0
AppIconView(hasPlusIcon: false, size: iconSize)
.frame(width: iconSize, height: iconSize)
VStack(alignment: .leading, spacing: 2) {
if let consent = consent {
Text(verbatim: "QuickDrop | \(consent.pinCodeMessage)")
.font(.system(size: 14, weight: .semibold))
Text(consent.message)
.font(.system(size: 13))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.frame(height: subHeaderSize)
} else {
HStack {
Text(String("QuickDrop"))
.font(.system(size: 14, weight: .semibold))
if consent == nil, actions == nil {
Text("FromDevice".localized(with: deviceName))
.font(.system(size: 13))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.padding(.trailing, 16)
}
}
if consent == nil, actions == nil {
HStack {
ZStack {
CapsuleProgress(value: 0.5)
.opacity(0)
if let progress = receiveModel.progress {
CapsuleProgress(value: progress)
}
}
.animation(.easeInOut, value: receiveModel.progress == nil)
Button(action: onCancel) {
Image(systemName: "xmark")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(.secondary)
.frame(width: 18, height: 18)
}
.buttonStyle(PlainButtonStyle())
}
.padding(.trailing, 16)
.frame(height: subHeaderSize)
}
else {
Text(LocalizedStringKey(actions?.completionMessageKey ?? "FileTransferCompleted"))
.font(.system(size: 13))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.frame(height: subHeaderSize)
}
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
Spacer(minLength: 12)
if showsRightColumn {
ActionColumn(width: consent == nil ? actionColumnWidthEnd : actionColumnWidth) {
if let consent = consent {
ActionButtonRow {
QuickDropToastViewButton(title: "Decline") {
consent.declineAction()
}
}
ActionDividerLine()
ActionButtonRow {
if consent.allowsTrust {
QuickDropToastViewMenuButton(title: "Accept") {
Button("Accept".localized()) {
consent.acceptAction(false)
}
Button("AutoAcceptFromThisDevice".localized()) {
consent.acceptAction(true)
}
}
} else {
QuickDropToastViewButton(title: "Accept") {
consent.acceptAction(false)
}
}
}
} else if let actions = actions {
if let openFilesAction = actions.openFilesAction {
ActionButtonRow {
QuickDropToastViewButton(title: "OpenInFinder") {
openFilesAction()
actions.closeToastAction()
}
}
}
if let onImportToPhotos = actions.importPhotosAction {
if actions.openFilesAction != nil {
ActionDividerLine()
}
ActionButtonRow {
QuickDropToastViewButton(title: "ImportToPhotos") {
onImportToPhotos()
actions.closeToastAction()
}
}
}
}
}
}
}
ZStack {
QuickDropToastViewButton(title: "OpenInFinder") { }
.opacity(0)
if let actions = receiveModel.toastActions {
HStack(spacing: 8) {
QuickDropToastViewButton(title: "OpenInFinder") {
actions.openFilesAction()
actions.closeToastAction()
}
}
.padding(.leading, 16)
.frame(height: toastViewSize.height)
.contentShape(Rectangle())
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.15)) {
isHovering = hovering
}
if let onImportToPhotos = actions.importPhotosAction {
QuickDropToastViewButton(title: "ImportToPhotos") {
onImportToPhotos()
actions.closeToastAction()
}
}
Spacer()
QuickDropToastViewButton(title: "Done", action: actions.closeToastAction)
.onAppear {
self.autoHider = DispatchWorkItem(block: {
actions.closeToastAction()
})
DispatchQueue.main.asyncAfter(deadline: .now() + 10, execute: self.autoHider)
}
.onDisappear {
self.autoHider.cancel()
}
}
.buttonStyle(.bordered)
.controlSize(.small)
}
HStack {
ZStack {
CapsuleProgress(value: 0.5)
.opacity(0)
if let progress = receiveModel.progress {
CapsuleProgress(value: progress)
}
}
.animation(.easeInOut, value: receiveModel.progress == nil)
Button(action: onCancel) {
Image(systemName: "xmark")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(.secondary)
.frame(width: 18, height: 18)
}
.buttonStyle(PlainButtonStyle())
}
.opacity(isDone ? 0 : 1)
if hovering {
autoHider.cancel()
} else {
startAutoHideIfPossible()
}
}
.padding(16)
.onChange(of: receiveModel.toastActions != nil) { hasActions in
if hasActions {
startAutoHideIfPossible()
} else {
autoHider.cancel()
}
}
.onAppear {
startAutoHideIfPossible()
}
.background(
VisualEffectView(material: .hudWindow)
.clipShape(RoundedRectangle(cornerRadius: 25, style: .continuous))
@@ -123,6 +221,30 @@ struct QuickDropToastView: View {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(Color.black.opacity(0.03), lineWidth: 1)
)
.overlay(alignment: .topTrailing) {
if closeButtonVisible, let actions = actions {
Button(action: actions.closeToastAction) {
Image(systemName: "xmark")
.font(.system(size: 9, weight: .semibold))
.foregroundColor(.secondary)
.frame(width: 18, height: 18)
.background(
Circle()
.fill(Color(NSColor.windowBackgroundColor).opacity(0.9))
.shadow(color: Color.black.opacity(0.12), radius: 2, x: 0, y: 1)
)
}
.buttonStyle(PlainButtonStyle())
.onHover { hovering in
if hovering {
withAnimation(.easeInOut(duration: 0.15)) {
isHovering = true
}
}
}
.transition(.opacity.combined(with: .scale(scale: 0.95)))
}
}
.shadow(color: Color.black.opacity(0.03), radius: 20)
.frame(maxWidth: .infinity)
}
@@ -135,22 +257,79 @@ fileprivate struct QuickDropToastViewButton: View {
var body: some View {
Button(action: action) {
Text(title.localized())
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(
Color.gray.opacity(0.15)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color.black.opacity(0.04), lineWidth: 1)
)
ToastButtonLabel(title: title)
}
.buttonStyle(.plain) // prevents blue macOS button look
}
}
fileprivate struct QuickDropToastViewMenuButton<Content: View>: View {
let title: String
@ViewBuilder let content: () -> Content
var body: some View {
Menu {
content()
} label: {
ToastButtonLabel(title: title)
}
.menuStyle(.borderlessButton)
}
}
fileprivate struct ToastButtonLabel: View {
let title: String
var body: some View {
Text(title.localized())
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.mainColor.opacity(0.75))
.lineLimit(1)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.contentShape(Rectangle())
}
}
fileprivate struct ActionColumn<Content: View>: View {
let width: CGFloat
@ViewBuilder let content: () -> Content
var body: some View {
VStack(spacing: 0) {
content()
}
.padding(.vertical, 3)
.frame(width: width)
.frame(maxHeight: .infinity)
.overlay(alignment: .leading) {
Rectangle()
.foregroundColor(.mainColor.opacity(0.05))
.frame(width: 2)
}
.minimumScaleFactor(0.7)
}
}
fileprivate struct ActionButtonRow<Content: View>: View {
@ViewBuilder let content: () -> Content
var body: some View {
content()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
fileprivate struct ActionDividerLine: View {
var body: some View {
Rectangle()
.foregroundColor(.mainColor.opacity(0.05))
.frame(height: 2)
.frame(maxWidth: .infinity)
}
}
// NSVisualEffectView wrapper for macOS 11 compatible blur/vibrancy
struct VisualEffectView: NSViewRepresentable {
@@ -196,7 +375,7 @@ struct CapsuleProgress: View {
)
}
}
.frame(height: 8)
.frame(height: 6)
.animation(.easeInOut(duration: 0.25), value: value)
}
}
@@ -206,7 +385,6 @@ struct CapsuleProgress: View {
struct QuickDropToastView_Previews: PreviewProvider {
struct Demo: View {
@State var model = ReceiveModel(controlPlusScreen: { _ in })
@State var done = true
var body: some View {
QuickDropToastView(
@@ -219,10 +397,89 @@ struct QuickDropToastView_Previews: PreviewProvider {
.background(Color.black.opacity(0.2))
.onAppear {
model.progress = 1
model.toastActions = .init(openFilesAction: {}, importPhotosAction: {}, closeToastAction: {})
model.toastActions = .init(completionMessageKey: "Saved", autoHideDelay: 10, openFilesAction: {}, importPhotosAction: {}, closeToastAction: {})
}
}
}
static var previews: some View { Demo() }
struct ConsentDemo: View {
@State var model = ReceiveModel(controlPlusScreen: { _ in })
var body: some View {
QuickDropToastView(
receiveModel: model,
onCancel: { }
)
.frame(width: toastViewSize.width, height: toastViewSize.height)
.clipped()
.padding(100)
.background(QuickDropToastView_Previews.previewWallpaper)
.onAppear {
model.consentState = .init(
transferID: "preview",
pinCodeMessage: "PIN: 1233",
message: "45 images from Pixel 6 Pro",
allowsTrust: true,
acceptAction: { _ in },
declineAction: { }
)
}
}
}
struct ProgressDemo: View {
@State var model = ReceiveModel(controlPlusScreen: { _ in })
var body: some View {
QuickDropToastView(
receiveModel: model,
onCancel: { }
)
.frame(width: toastViewSize.width, height: toastViewSize.height)
.clipped()
.padding(100)
.background(QuickDropToastView_Previews.previewWallpaper)
.onAppear {
model.progress = 0.42
model.toastActions = nil
}
}
}
struct CompletedDemo: View {
@State var model = ReceiveModel(controlPlusScreen: { _ in })
var body: some View {
QuickDropToastView(
receiveModel: model,
onCancel: { }
)
.frame(width: toastViewSize.width, height: toastViewSize.height)
.clipped()
.padding(100)
.background(previewWallpaper)
.onAppear {
model.progress = 1
model.toastActions = .init(completionMessageKey: "Saved", autoHideDelay: 10, openFilesAction: {}, importPhotosAction: {}, closeToastAction: {})
}
}
}
private static let previewWallpaper = LinearGradient(
gradient: Gradient(colors: [
Color(red: 0.92, green: 0.82, blue: 0.94),
Color(red: 0.86, green: 0.76, blue: 0.90),
Color(red: 0.78, green: 0.70, blue: 0.86)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
static var previews: some View {
Group {
ConsentDemo()
ProgressDemo()
CompletedDemo()
}
}
}
+173 -69
View File
@@ -7,26 +7,23 @@
import SwiftUI
import StoreKit
import UserNotifications
import LUI
#if os(macOS)
import BezelNotification
#endif
class ReceiveModel: ObservableObject, InboundAppDelegate {
/// For each connection ID, store the last reported progress value
private var processes: [String: Double] = [:]
#if os(macOS)
#if os(macOS)
@Published var progress: Double? = nil
@Published var toastActions: ToastViewAction? = nil
@Published var consentState: ConsentToastState? = nil
@Published var activeDeviceName: String? = nil
private var pendingReview = false
private var toastWindow: NSWindow?
private var toastHosting: NSHostingView<QuickDropToastView>?
private let monitor = AllowedWorkMonitor()
#endif
#endif
let controlPlusScreen: (Bool) -> Void
@@ -67,7 +64,7 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
AudioManager.playIncomingFileSound()
#endif
let mainMessage = transfer.getDescription(deviceName: device.name ?? "UnknownDevice".localized(), alreadyAccepted: false)
let mainMessage = transfer.getDescription(deviceName: device.name ?? "UnknownDevice".localized())
let pinCodeMessage = transfer.getPinCodeMessage()
let transferID = transfer.id
@@ -75,39 +72,36 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
let primaryButtonTitle = "Accept".localized()
let primaryButtonAction = { (trustDevice: Bool) in
NearbyConnectionManager.shared.submitUserConsent(transferID: transferID, accept: true, trustDevice: trustDevice)
#if os(macOS)
if transfer.type == .text {
DispatchQueue.main.async {
BezelNotification.show(messageText: "InsertedIntoClipboard".localized(), icon: .clipboard)
}
}
#endif
}
let secondaryButtonTitle = "Decline".localized()
let secondaryButtonAction = { NearbyConnectionManager.shared.submitUserConsent(transferID: transferID, accept: false, trustDevice: 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()
DispatchQueue.main.async {
self.toastActions = nil
self.progress = nil
self.activeDeviceName = device.name ?? "UnknownDevice".localized()
self.consentState = ConsentToastState(
transferID: transferID,
pinCodeMessage: pinCodeMessage,
message: mainMessage,
allowsTrust: transfer.allowsToBeAddedAsTrustedDevice,
acceptAction: { [weak self] trustDevice in
DispatchQueue.main.async {
withAnimation {
self?.consentState = nil
}
}
primaryButtonAction(trustDevice)
},
declineAction: { [weak self] in
self?.consentState = nil
secondaryButtonAction()
self?.hideQuickDropToast()
}
)
self.showQuickDropToast(for: transferID)
}
#else
// iOS
@@ -129,33 +123,18 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
func obtainedUserConsentAutomatically(transfer: TransferMetadata, device: RemoteDeviceInfo) {
let mainMessage = transfer.getDescription(deviceName: device.name ?? "UnknownDevice".localized(), alreadyAccepted: true)
let mainMessage = transfer.getDescription(deviceName: device.name ?? "UnknownDevice".localized())
#if os(macOS)
NSApp.activate(ignoringOtherApps: true)
AudioManager.playIncomingFileSound()
// If file -> no notification, as there is the QuickDrop progress toast
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])
}
}
}
DispatchQueue.main.async {
self.toastActions = nil
self.progress = nil
self.activeDeviceName = device.name ?? "UnknownDevice".localized()
self.consentState = nil
self.showQuickDropToast(for: transfer.id)
}
#else
// If text is received, nothing is shown, as it is directly inserted into clipboard automatically
@@ -196,11 +175,17 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
#if os(macOS)
DispatchQueue.main.async {
let wasPendingConsent = self.consentState?.transferID == connectionID
if self.processes.isEmpty {
self.progress = nil
}
if wasPendingConsent {
self.consentState = nil
self.toastActions = nil
self.hideQuickDropToast()
}
finishMacTermination(device: device, savedFiles: savedFiles, error: error, wasPendingConsent: wasPendingConsent)
}
finish()
#else
if self.processes.isEmpty {
ProgressAlert.shared.updateProgress(nil, onCancel: {}) {
@@ -251,16 +236,27 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
#if os(macOS)
let completionKey: String
let autoHideDelay: TimeInterval
if !savedFiles.isEmpty {
completionKey = "Saved"
autoHideDelay = 10
} else if wasPlainTextTransfer {
completionKey = "CopiedToClipboard"
autoHideDelay = 10
} else {
completionKey = "URLOpened"
autoHideDelay = 4
}
if currentCount == 0 {
pendingReview = true
}
let openFilesAction = {
if !savedFiles.isEmpty {
log("[SaveFilesManager] Opening \(savedFiles.count) file(s) in Finder.")
NSWorkspace.shared.activateFileViewerSelecting(savedFiles)
}
let openFilesAction: (() -> ())? = savedFiles.isEmpty ? nil : {
log("[SaveFilesManager] Opening \(savedFiles.count) file(s) in Finder.")
NSWorkspace.shared.activateFileViewerSelecting(savedFiles)
showReviewIfAppropriate(currentTransmissionCount: currentCount)
}
@@ -292,12 +288,12 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
DispatchQueue.main.async {
withAnimation {
self.toastActions = ToastViewAction(openFilesAction: openFilesAction, importPhotosAction: importPhotosAction, closeToastAction: closeToastAction)
self.toastActions = ToastViewAction(completionMessageKey: completionKey, autoHideDelay: autoHideDelay, openFilesAction: openFilesAction, importPhotosAction: importPhotosAction, closeToastAction: closeToastAction)
}
// Show received files if wanted
if Settings.sharedInstance.openFinderAfterReceiving {
self.toastActions?.openFilesAction()
self.toastActions?.openFilesAction?()
}
}
#endif
@@ -307,7 +303,89 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
}
}
#if os(macOS)
func finishMacTermination(device: RemoteDeviceInfo?, savedFiles: [URL], error: (any Error)?, wasPendingConsent: Bool) {
if wasPendingConsent {
return
}
if let error = error {
if let name = device?.name {
self.hideQuickDropToast()
controlPlusScreen(false)
errorVibration()
ErrorAlertHandler.shared.showErrorAlert(for: name, error: error)
}
return
}
let currentCount = Settings.sharedInstance.incomingTransmissionCount
let completionKey: String
let autoHideDelay: TimeInterval
if !savedFiles.isEmpty {
completionKey = "Saved"
autoHideDelay = 10
} else if wasPlainTextTransfer {
completionKey = "CopiedToClipboard"
autoHideDelay = 10
} else {
completionKey = "URLOpened"
autoHideDelay = 4
}
if currentCount == 0 {
pendingReview = true
}
let openFilesAction: (() -> ())? = savedFiles.isEmpty ? nil : {
log("[SaveFilesManager] Opening \(savedFiles.count) file(s) in Finder.")
NSWorkspace.shared.activateFileViewerSelecting(savedFiles)
showReviewIfAppropriate(currentTransmissionCount: currentCount)
}
let hasPhotoOrVideos = PhotoManager.hasPhotosOrVideos(at: savedFiles)
let importPhotosAction: (()->())? = hasPhotoOrVideos ? {
Task {
do {
try await PhotoManager.saveMediaToPhotoLibrary(from: savedFiles)
showReviewIfAppropriate(currentTransmissionCount: currentCount)
}
catch {
DispatchQueue.main.async {
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "CouldNotSaveMediaToPhotoLibrary".localized()
alert.informativeText = error.localizedDescription
alert.addButton(withTitle: "CloseAlert".localized())
alert.runModal()
}
}
}
} : nil
let closeToastAction = {
self.hideQuickDropToast()
showReviewIfAppropriate(currentTransmissionCount: currentCount)
}
withAnimation {
self.toastActions = ToastViewAction(completionMessageKey: completionKey, autoHideDelay: autoHideDelay, openFilesAction: openFilesAction, importPhotosAction: importPhotosAction, closeToastAction: closeToastAction)
}
// Show received files if wanted
if Settings.sharedInstance.openFinderAfterReceiving {
self.toastActions?.openFilesAction?()
}
Settings.sharedInstance.incomingTransmissionCount = currentCount + 1
log("[ReceiveModel] Successful transmission. Current count: \(currentCount)")
}
func showReviewIfAppropriate(currentTransmissionCount: Int) {
if pendingReview {
// Dont request review again
@@ -324,6 +402,12 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
func showPlusScreen() {
DispatchQueue.main.async {
#if os(macOS)
self.consentState = nil
self.toastActions = nil
self.progress = nil
self.hideQuickDropToast()
#endif
self.controlPlusScreen(true)
}
}
@@ -397,7 +481,13 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
func hideQuickDropToast() {
guard let window = toastWindow,
let screen = NSScreen.main else { return }
let screen = NSScreen.main else {
self.toastWindow = nil
self.toastHosting = nil
self.toastActions = nil
self.consentState = nil
return
}
let offscreenY = screen.visibleFrame.origin.y + screen.visibleFrame.height + 20
@@ -411,14 +501,28 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
self.toastWindow = nil
self.toastHosting = nil
self.toastActions = nil
self.consentState = nil
self.activeDeviceName = nil
})
}
struct ToastViewAction {
let openFilesAction: () -> ()
let completionMessageKey: String
let autoHideDelay: TimeInterval
let openFilesAction: (() -> ())?
let importPhotosAction: (() -> ())?
let closeToastAction: () -> ()
}
struct ConsentToastState {
let transferID: String
let pinCodeMessage: String
let message: String
let allowsTrust: Bool
let acceptAction: (Bool) -> ()
let declineAction: () -> ()
}
#endif
}