Files
QuickDrop/ShareExtension/ShareViewController.swift
T

485 lines
18 KiB
Swift

//
// ShareViewController.swift
// ShareExtension
//
// Created by Grishka on 12.09.2023.
//
import Cocoa
import Foundation
import SwiftUI
import QRCode
class ShareViewController: NSViewController, ShareExtensionDelegate {
private var urls: [URL] = []
private var textToSend: String? = nil
private var foundDevices: [RemoteDeviceInfo] = []
private var chosenDevice: RemoteDeviceInfo?
private var lastError: Error?
private var errorAlertHandler = ErrorAlertHandler.shared
private var connectionEstablished = false
private var timeoutDispatchWorkItem: DispatchWorkItem? = nil
@IBOutlet var filesIcon: NSImageView?
@IBOutlet var filesLabel: NSTextField?
@IBOutlet var loadingOverlay: NSStackView?
@IBOutlet var largeProgress: NSProgressIndicator?
@IBOutlet var listView: NSCollectionView?
@IBOutlet var listViewWrapper: NSView?
@IBOutlet var contentWrap: NSView?
@IBOutlet var progressView: NSView?
@IBOutlet var progressDeviceIcon: NSImageView?
@IBOutlet var progressDeviceName: NSTextField?
@IBOutlet var progressProgressBar: NSProgressIndicator?
@IBOutlet var progressState: NSTextField?
@IBOutlet var progressDeviceIconWrap: NSView?
@IBOutlet var progressDeviceSecondaryIcon: NSImageView?
@IBOutlet var dontSeeDeviceButton: NSButton?
private var qrCodeSheetView: NSPanel? = nil
private var sheetAttachedWindow: NSWindow? = nil
override var nibName: NSNib.Name? {
return NSNib.Name("ShareViewController")
}
override func loadView() {
super.loadView()
let item = extensionContext!.inputItems[0] as! NSExtensionItem
if let attachments = item.attachments {
if let text = item.attributedContentText?.string, attachments.isEmpty {
textToSend = text
DispatchQueue.main.async {
self.zipFolderAndSetUpIcon()
}
} else {
for attachment in attachments {
if attachment.hasItemConformingToTypeIdentifier(kUTTypeURL as String) {
attachment.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil) { data, _ in
if let urlData = data as? Data,
let url = URL(dataRepresentation: urlData, relativeTo: nil, isAbsolute: false)
{
self.urls.append(url)
} else if let url = data as? NSURL {
self.urls.append(url as URL)
}
if self.urls.count == attachments.count {
DispatchQueue.main.async {
self.zipFolderAndSetUpIcon()
}
}
}
}
}
}
} else {
let cancelError = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError, userInfo: nil)
extensionContext!.cancelRequest(withError: cancelError)
return
}
contentWrap!.addSubview(listViewWrapper!)
contentWrap!.addSubview(loadingOverlay!)
contentWrap!.addSubview(progressView!)
progressView!.isHidden = true
listViewWrapper!.translatesAutoresizingMaskIntoConstraints = false
loadingOverlay!.translatesAutoresizingMaskIntoConstraints = false
progressView!.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
NSLayoutConstraint(item: listViewWrapper!, attribute: .width, relatedBy: .equal, toItem: contentWrap, attribute: .width, multiplier: 1, constant: 0),
NSLayoutConstraint(item: listViewWrapper!, attribute: .height, relatedBy: .equal, toItem: contentWrap, attribute: .height, multiplier: 1, constant: 0),
NSLayoutConstraint(item: loadingOverlay!, attribute: .width, relatedBy: .equal, toItem: contentWrap, attribute: .width, multiplier: 1, constant: 0),
NSLayoutConstraint(item: loadingOverlay!, attribute: .centerY, relatedBy: .equal, toItem: contentWrap, attribute: .centerY, multiplier: 1, constant: 0),
NSLayoutConstraint(item: progressView!, attribute: .width, relatedBy: .equal, toItem: contentWrap, attribute: .width, multiplier: 1, constant: 0),
NSLayoutConstraint(item: progressView!, attribute: .centerY, relatedBy: .equal, toItem: contentWrap, attribute: .centerY, multiplier: 1, constant: 0),
])
largeProgress!.startAnimation(nil)
let flowLayout = NSCollectionViewFlowLayout()
flowLayout.itemSize = NSSize(width: 75, height: 90)
flowLayout.sectionInset = NSEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
flowLayout.minimumInteritemSpacing = 10
flowLayout.minimumLineSpacing = 10
listView!.collectionViewLayout = flowLayout
listView!.dataSource = self
progressDeviceIconWrap!.wantsLayer = true
progressDeviceIconWrap!.layer!.masksToBounds = false
}
override func viewDidLoad() {
super.viewDidLoad()
dicoverDevices()
NearbyConnectionManager.shared.addShareExtensionDelegate(self)
}
override func viewWillDisappear() {
log("ShareViewController: viewWillDisappear")
timeoutDispatchWorkItem?.cancel()
if chosenDevice == nil {
NearbyConnectionManager.shared.stopDeviceDiscovery()
}
NearbyConnectionManager.shared.removeShareExtensionDelegate(self)
}
@IBAction func cancel(_: AnyObject?) {
if let device = chosenDevice {
NearbyConnectionManager.shared.cancelOutgoingTransfer(id: device.id!)
}
let cancelError = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError, userInfo: nil)
extensionContext!.cancelRequest(withError: cancelError)
}
@IBAction func dontSeeDeviceButton(_: AnyObject?) {
openQrCodeView()
}
private func openQrCodeView() {
if qrCodeSheetView == nil {
var image: Image? = nil
do {
let qrKey = NearbyConnectionManager.shared.generateQrCodeKey()
let qrCodeImage = try QRCode.build
.text("https://quickshare.google/qrcode#key=\(qrKey)")
.backgroundColor(CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 0))
.quietZonePixelCount(3)
.onPixels.shape(.circle())
.eye.shape(.squircle())
.errorCorrection(.low)
.generate.image(dimension: 1000)
image = Image(nsImage: NSImage(cgImage: qrCodeImage, size: qrCodeImage.size))
}
catch {
log("Error generating QR code: \(error)")
}
let contentView = SmallSheetView(type: .sendToDeviceQrCode, dynamicQrCode: image) {
self.closeQrCodeView()
}
let hostingView = NSHostingView(rootView: contentView)
let panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: smallSheetViewSize.width, height: smallSheetViewSize.height),
styleMask: [.titled, .closable, .utilityWindow],
backing: .buffered,
defer: false)
panel.contentView = hostingView
qrCodeSheetView = panel
if let mainWindow = NSApp.mainWindow {
sheetAttachedWindow = mainWindow
mainWindow.beginSheet(panel) { _ in }
}
}
}
private func closeQrCodeView() {
if let mainWindow = sheetAttachedWindow, let qrCodeView = qrCodeSheetView {
mainWindow.endSheet(qrCodeView)
qrCodeSheetView = nil
sheetAttachedWindow = nil
}
}
private func zipFolderAndSetUpIcon() {
for url in urls {
if url.isFileURL {
let isDirectory = UnsafeMutablePointer<ObjCBool>.allocate(capacity: 1)
if FileManager.default.fileExists(atPath: url.path, isDirectory: isDirectory) && isDirectory.pointee.boolValue {
do {
let zipUrl = try createZipAtTmp(zipFilename: url.lastPathComponent, fromDirectory: url)
let index = urls.firstIndex(of: url)
urls[index!] = zipUrl
} catch {
log("Error creating zip file: \(error)")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
log("Canceling share request because URL \(url) is a directory")
AudioManager.playErrorSound()
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "TypeNotSupported".localized()
alert.informativeText = "TypeNotSupportedDescription".localized()
alert.addButton(withTitle: "TypeNotSupportedButton".localized())
alert.beginSheetModal(for: self.view.window!) { _ in
let cancelError = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError, userInfo: nil)
self.extensionContext!.cancelRequest(withError: cancelError)
}
}
return
}
}
}
}
if let textToSend = textToSend {
let maxLength = 50
let cleanedText = textToSend.replacingOccurrences(of: "\n", with: " ").replacingOccurrences(of: "\r", with: "")
if cleanedText.count > maxLength {
let index = cleanedText.index(cleanedText.startIndex, offsetBy: maxLength)
filesLabel!.stringValue = String(cleanedText[..<index]) + "..."
} else {
filesLabel!.stringValue = cleanedText
}
filesIcon!.image = NSImage(named: NSImage.multipleDocumentsName)
}
else if urls.count == 1 {
if urls[0].isFileURL {
filesLabel!.stringValue = urls[0].lastPathComponent
filesIcon!.image = NSWorkspace.shared.icon(forFile: urls[0].path)
} else if urls[0].scheme == "http" || urls[0].scheme == "https" {
filesLabel!.stringValue = urls[0].absoluteString
filesIcon!.image = NSImage(named: NSImage.networkName)
}
} else {
filesLabel!.stringValue = String.localizedStringWithFormat("NFiles".localized(), urls.count)
filesIcon!.image = NSImage(named: NSImage.multipleDocumentsName)
}
}
func addDevice(device: RemoteDeviceInfo) {
if foundDevices.isEmpty {
loadingOverlay?.animator().isHidden = true
}
foundDevices.append(device)
listView?.animator().insertItems(at: [[0, foundDevices.count - 1]])
closeQrCodeView()
}
func removeDevice(id: String) {
if chosenDevice != nil {
return
}
for i in foundDevices.indices {
if foundDevices[i].id == id {
foundDevices.remove(at: i)
listView?.animator().deleteItems(at: [[0, i]])
break
}
}
if foundDevices.isEmpty {
loadingOverlay?.animator().isHidden = false
}
}
func startTransferWithQrCode(device: RemoteDeviceInfo) {
closeQrCodeView()
selectDevice(device: device)
}
func connectionWasEstablished(pinCode: String) {
connectionEstablished = true
progressState?.stringValue = String(format: "PinCode".localized(), arguments: [pinCode])
progressProgressBar?.isIndeterminate = false
progressProgressBar?.maxValue = 1000
progressProgressBar?.doubleValue = 0
}
func connectionFailed(with error: Error) {
progressProgressBar?.isIndeterminate = false
progressProgressBar?.maxValue = 1000
progressProgressBar?.doubleValue = 0
lastError = error
ErrorAlertHandler.shared.showErrorAlert(for: chosenDevice?.name ?? "", error: error)
self.extensionContext!.cancelRequest(withError: error)
}
func transferAccepted() {
progressState?.stringValue = "Sending".localized()
}
func transferProgress(progress: Double) {
progressProgressBar!.doubleValue = progress * progressProgressBar!.maxValue
}
func transferFinished() {
progressState?.stringValue = "TransferFinished".localized()
dismissDelayed()
}
func selectDevice(device: RemoteDeviceInfo) {
NearbyConnectionManager.shared.stopDeviceDiscovery()
listViewWrapper?.animator().isHidden = true
dontSeeDeviceButton?.animator().isHidden = true
progressView?.animator().isHidden = false
progressDeviceName?.stringValue = getDeviceName(device: device)
progressDeviceIcon?.image = imageForDeviceType(type: device.type)
progressProgressBar?.startAnimation(nil)
progressState?.stringValue = "Connecting".localized()
chosenDevice = device
NearbyConnectionManager.shared.startOutgoingTransfer(deviceID: device.id!, delegate: self, urls: urls, textToSend: textToSend)
let timeoutAlert = DispatchWorkItem {
if !self.connectionEstablished {
AudioManager.playErrorSound()
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "TimeoutTitle".localized()
alert.informativeText = "TimeoutDescription".localized()
alert.addButton(withTitle: "TimeoutButton".localized())
alert.beginSheetModal(for: self.view.window!) { _ in }
}
}
timeoutDispatchWorkItem = timeoutAlert
DispatchQueue.main.asyncAfter(deadline: .now() + 10.0, execute: timeoutAlert)
}
private func dismissDelayed() {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
if let error = self.lastError {
self.extensionContext!.cancelRequest(withError: error)
} else {
self.extensionContext!.completeRequest(returningItems: nil, completionHandler: nil)
}
}
}
private func dicoverDevices() {
let bundleIdentifier = "com.leonboettger.neardrop"
let runningApps = NSWorkspace.shared.runningApplications
// Check if the app is already running
let isRunning = runningApps.contains { $0.bundleIdentifier == bundleIdentifier }
if !isRunning {
log("Launching main app")
if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) {
let configuration = NSWorkspace.OpenConfiguration()
NSWorkspace.shared.openApplication(at: url, configuration: configuration) { app, error in
if let error = error {
print("Failed to launch application: \(error)")
} else {
print("Application launched successfully")
}
}
} else {
print("Could not find application with bundle identifier \(bundleIdentifier)")
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
NearbyConnectionManager.shared.startDeviceDiscovery()
self.scheduleAutomaticQrCodeView()
}
} else {
log("Main app is already running")
NearbyConnectionManager.shared.startDeviceDiscovery()
scheduleAutomaticQrCodeView()
}
}
private func scheduleAutomaticQrCodeView() {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
if self.foundDevices.isEmpty {
self.openQrCodeView()
}
}
}
}
private func imageForDeviceType(type: RemoteDeviceInfo.DeviceType) -> NSImage {
let imageName: String
switch type {
case .tablet:
imageName = "com.apple.ipad"
case .computer:
imageName = "com.apple.macbookpro-13-unibody"
default: // also .phone
imageName = "com.apple.iphone"
}
return NSImage(contentsOfFile: "/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/\(imageName).icns")!
}
extension ShareViewController: NSCollectionViewDataSource {
func numberOfSections(in _: NSCollectionView) -> Int {
return 1
}
func collectionView(_: NSCollectionView, numberOfItemsInSection _: Int) -> Int {
return foundDevices.count
}
func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
let item = collectionView.makeItem(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "DeviceListCell"), for: indexPath)
guard let collectionViewItem = item as? DeviceListCell else { return item }
let device = foundDevices[indexPath[1]]
collectionViewItem.textField?.stringValue = getDeviceName(device: device)
collectionViewItem.imageView?.image = imageForDeviceType(type: device.type)
collectionViewItem.clickHandler = {
self.selectDevice(device: device)
}
return collectionViewItem
}
func getDeviceName(device: RemoteDeviceInfo) -> String {
if let name = device.name, name.count > 1 {
return name
}
return "Android"
}
}