mirror of
https://github.com/leonboe1/QuickDrop.git
synced 2026-11-11 02:51:11 +00:00
Replace alert with toast view
This commit is contained in:
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? {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
+2731
-1098
File diff suppressed because it is too large.
Load diff
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user