I'm using Xcode to do UI testing on a sandboxed macOS app that has the com.apple.security.files.user-selected.read-write entitlement (i.e., can access files and folders explicitly selected by the user via an NSOpenPanel GUI).
I have noticed that code coverage stops right after the open panel is presented modally. This is my code:
#IBAction func go(_ sender: Any) {
let panel = NSOpenPanel()
panel.canCreateDirectories = true
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
let response = panel.runModal()
switch response {
case NSApplication.ModalResponse.OK:
openPanelDidSelectURL(panel.urls[0])
default:
return
}
}
(I have recorded my UI tests so that the NSOpenPanel is accepted right away, choosing the folder where it was open.)
Code coverage ends up highlighted like this:
I have tried replacing the switch statement with a fatalError() call, but the UI test still completes successfully, suggesting that anything immediately after:
let response = panel.runModal()
...is not executed during the test.
Disabling sandboxing seems to have no effect, so I suspect it is running the open panel modally that causes trouble...
I tried all other available methods for presenting the open panel, namely:
panel.begin { (response) in
switch response {
case NSApplication.ModalResponse.OK:
self.openPanelDidSelectURL(panel.urls[0])
default:
return
}
}
...and also:
panel.beginSheetModal(for: view.window!) { (response) in
switch response {
case NSApplication.ModalResponse.OK:
self.openPanelDidSelectURL(panel.urls[0])
default:
return
}
}
...but the result is always the same: All code immediately after presenting the panel is not covered during tests.
In the end, I realized that my UI tests cannot rely on some user-selectable folder being present wherever the open panel lands (last visited directory?), so I opted for using mocking instead.
First, in my UI test classes, I adopted this setup logic:
override func setUp() {
continueAfterFailure = false
let app = XCUIApplication()
app.launchArguments.append("-Testing")
app.launch()
}
(the hyphen before "Testing" is mandatory, otherwise my document-based macOS app will think I am launching it to open a document named "Testing", and fail to do so)
Next, On the app side, I defined a global computed property to determine whether we are running under a test or not:
public var isTesting: Bool {
return ProcessInfo().arguments.contains("-Testing")
}
Finally, also on the app side I wrapped all NSOpenPanel calls into two methods: One for prompting the user for input files to read, and another to prompt the user for an output directory into which to write the resulting files (this is all my app needs from NSOpenPanel):
public func promptImportInput(completionHandler: #escaping (([URL]) -> Void)) {
guard isTesting == false else {
/*
Always returns the URLs of the bundled resource files:
- 01#2x.png,
- 02#2x.png,
- 03#2x.png,
...
- 09#2x.png,
*/
let urls = (1 ... 9).compactMap { (index) -> URL? in
let fileName = String(format: "%02d", index) + "#2x"
return Bundle.main.url(forResource: fileName, withExtension: "png")
}
return completionHandler(urls)
}
// (The code below cannot be covered during automated testing)
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = true
panel.canCreateDirectories = false
panel.allowsMultipleSelection = true
let response = panel.runModal()
switch response {
case NSApplication.ModalResponse.OK:
completionHandler(panel.urls)
default:
completionHandler([])
}
}
public func promptExportDestination(completionHandler: #escaping((URL?) -> Void)) {
guard isTesting == false else {
// Testing: write output to the temp directory
// (works even on sandboxed apps):
let tempPath = NSTemporaryDirectory()
return completionHandler(URL(fileURLWithPath: tempPath))
}
// (The code below cannot be covered during automated testing)
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.canCreateDirectories = true
panel.allowsMultipleSelection = false
let response = panel.runModal()
switch response {
case NSApplication.ModalResponse.OK:
completionHandler(panel.urls.first)
default:
completionHandler(nil)
}
}
The portions of these two functions that use the actual NSOpenPanel instead of mocking the user-selected files/directories are still excluded from gathering code coverage statistics (but this time, it's by design).
But at least now it's just this two places. The rest of my code just calls these two functions and does no longer interact with NSOpenPanel directly. I have 'abstracted' the OS's file browsing interface away from my app...
Related
I am working on a MacOS app that can process images. The idea is the app hides out of the way most of the time, but if you drag an image from another app or the finder, my app will show and you can drag the image on top of it. Basically a drop zone on standby for when you need it.
Everything is working perfectly except I can't figure out how to only show the app for certain types of draggable items (URLs, fileURLs, FilePromises and Images). What I have now shows the app for any kind of drag, even selecting text on a page or clicking and clicking and dragging through the menu bar.
I've tried looking at the NSPasteboard for dragging, but that doesn't seem to be updated at drag time. I've seen some posts about using accessibility to see what's under the mouse, but that seems brittle and I'm not yet understanding how to do it.
Here is the code I'm using to detect global drag and drop:
dragMonitor = NSEvent.addGlobalMonitorForEvents(matching:.leftMouseDragged) { event in
if !self.isDragging {
self.isDragging = true
if let dropzoneViewController = self.dropzoneViewController, dropzoneViewController.shouldShowForDrag(event: event) {
self.show()
}
}
}
upMonitor = NSEvent.addGlobalMonitorForEvents(matching:.leftMouseUp) { event in
if self.isDragging {
self.hide()
self.isDragging = false
}
}
That function, in turn, calls the following, which applies the app's logic for determining whether to handle a drag or not.
func shouldShowForDrag(event: NSEvent) -> Bool {
return self.dropTarget.canHandleDrop(NSPasteboard(name: .drag))
}
For clarity's sake, here's how the app handles drags once they are over the app's window:
override func performDragOperation(_ draggingInfo: NSDraggingInfo) -> Bool {
isReceivingDrag = false
if let dropTarget = dropTarget, dropTarget.canHandleDrop(draggingInfo.draggingPasteboard) {
dropTarget.handleDrop(draggingInfo.draggingPasteboard)
return true
} else {
return false
}
}
The only difference between those two checks is the global check (shouldShowForDrag(event:)) uses NSPasteboard(name: .drag) which is not current at the time NSEvent.addGlobalMonitorForEvents(matching:) fires. The logic when the draggable enters my window uses the provided pasteboard (draggingInfo.draggingPasteboard) which, of course, is accurate to what's being dragged.
Finally, here's the basic logic for determining what drags to accept:
func canHandleDrop(_ pasteBoard: NSPasteboard) -> Bool {
let urlFilteringOptions = [NSPasteboard.ReadingOptionKey.urlReadingContentsConformToTypes:NSImage.imageTypes]
if let urls = pasteBoard.readObjects(forClasses: [NSURL.self], options:urlFilteringOptions) as? [URL], urls.count > 0 {
return true
} else if let filePromises = pasteBoard.readObjects(forClasses: [NSFilePromiseReceiver.self], options: nil) as? [NSFilePromiseReceiver], filePromises.count > 0 {
return true
} else if let images = pasteBoard.readObjects(forClasses: [NSImage.self], options: [:]) as? [NSImage], images.count > 0 {
return true
}
return false
}
The first two clauses are the most important. Detecting NSImages is not strictly required.
I know it can be done because I'm using other apps (to do similar, but different, things), and they work exactly like I'm trying to achieve. But so far I'm banging my head against the wall.
Thanks
as per the documentation, it should be pretty straightforward. example for a List: https://developer.apple.com/documentation/swiftui/list/ondrop(of:istargeted:perform:)-75hvy#
the UTType should be the parameter restricting what a SwiftUI object can receive. in my case i want to accept only Apps. the UTType is .applicationBundle: https://developer.apple.com/documentation/uniformtypeidentifiers/uttype/3551459-applicationbundle
but it doesn't work. the SwiftUI object never changes status and never accepts the drop. the closure is never run. whether on Lists, H/VStacks, Buttons, whatever. the pdf type don't seem to work either, as well as many others. the only type that i'm able to use if fileURL, which is mainly like no restriction.
i'm not sure if i'm doing something wrong or if SwiftUI is half working for the mac.
here's the code:
List(appsToIgnore, id: \.self, selection: $selection) {
Text($0)
}
.onDrop(of: [.applicationBundle, .application], isTargeted: isTargeted) { providers in
print("hehe")
return true
}
replacing or just adding .fileURL in the UTType array makes the drop work but without any type restriction.
i've also tried to use .onInsert on a ForEach instead (https://developer.apple.com/documentation/swiftui/foreach/oninsert(of:perform:)-2whxl#), and to go through a proper DropDelegate (https://developer.apple.com/documentation/swiftui/dropdelegate#) but keep getting the same results. it would seem the SwiftUI drop for macOS is not yet working, but i can't find any official information about this. in the docs it is written macOS 11.0+ so i would expect it to work?
any info appreciated! thanks.
You need to validate manually, using DropDelegate of what kind of file is dragged over.
Here is a simplified demo of possible approach. Tested with Xcode 13 / macOS 11.6
let delegate = MyDelegate()
...
List(appsToIgnore, id: \.self, selection: $selection) {
Text($0)
}
.onDrop(of: [.fileURL], delegate: delegate) // << accept file URLs
and verification part like
class MyDelegate: DropDelegate {
func validateDrop(info: DropInfo) -> Bool {
// find provider with file URL
guard info.hasItemsConforming(to: [.fileURL]) else { return false }
guard let provider = info.itemProviders(for: [.fileURL]).first else { return false }
var result = false
if provider.canLoadObject(ofClass: String.self) {
let group = DispatchGroup()
group.enter() // << make decoding sync
// decode URL from item provider
_ = provider.loadObject(ofClass: String.self) { value, _ in
defer { group.leave() }
guard let fileURL = value, let url = URL(string: fileURL) else { return }
// verify type of content by URL
let flag = try? url.resourceValues(forKeys: [.contentTypeKey]).contentType == .applicationBundle
result = flag ?? false
}
// wait a bit for verification result
_ = group.wait(timeout: .now() + 0.5)
}
return result
}
func performDrop(info: DropInfo) -> Bool {
// handling code is here
return true
}
}
I know how to add an AccessoryView to an NSOpenPanel (and that works correctly).
Now I would like to make the options that the user selects in the AccessoryView available to the document that is opened.
Any suggestions how that can be doen (if at all?)
I have not found a standard solution, so I created my own:
Introduced a dictionary in the NSDocumentController that associates file URLs with option sets
Override the runModalOpenPanel and wrap the runModalOpenPanel of super with first the setup of the accessory view, and afterwards the evaluation of the options and adding of the options to the dictionary for the associated urls.
When a document is opened, the document can -through the shared NSDocumentController- access the dictionary and retrieve the options.
I am not blown away by this solution, but I also do not see an easier path.
Example code:
struct OptionsAtFileOpen {
let alsoLoadFormat: Bool
}
class DocumentController: NSDocumentController {
var fileOptions: Dictionary<URL, OptionsAtFileOpen> = [:]
var accessoryViewController: OpenPanelAccessoryViewController!
override func runModalOpenPanel(_ openPanel: NSOpenPanel, forTypes types: [String]?) -> Int {
// Load accessory view
let accessoryViewController = OpenPanelAccessoryViewController(nibName: NSNib.Name(rawValue: "OpenPanelAccessoryView"), bundle: nil)
// Add accessory view and make sure it is shown
openPanel.accessoryView = accessoryViewController.view
openPanel.isAccessoryViewDisclosed = true
// Run the dialog
let result = super.runModalOpenPanel(openPanel, forTypes: types)
// If not cancelled, add the files to open to the fileOptions dictionary
if result == 1 {
// Return the state of the checkbox that selects the loading of the formatting file
let alsoLoadFormat = accessoryViewController.alsoLoadFormatFile.state == NSControl.StateValue.on
for url in openPanel.urls {
fileOptions[url] = OptionsAtFileOpen(alsoLoadFormat: alsoLoadFormat)
}
}
return result
}
}
And then in Document
override func read(from data: Data, ofType typeName: String) throws {
...
if let fileUrl = fileURL {
if let dc = (NSDocumentController.shared as? DocumentController) {
if let loadFormat = dc.fileOptions[fileUrl]?.alsoLoadFormat {
...
}
}
}
}
We have an issue when we screenshare from iOS client. During screenshare, the invitees are unable to view other apps or screens when the publisher navigates to other application.
//Following is the code that we are using--
fileprivate func startScreenSharing() {
self.isSharingScreen = true
multipartyScreenSharer = OTMultiPartyCommunicator.init(view: UIApplication.shared.keyWindow)
multipartyScreenSharer?.dataSource = self
// publishOnly here is to avoid subscripting to those who already subscribed
multipartyScreenSharer?.isPublishOnly = true
publisherView?.isHidden = true
multipartyScreenSharer?.connect {
[unowned self](signal, remote, error) in
self.isSharingScreen = true
guard error == nil else {
self.dismiss(animated: true) {
SVProgressHUD.showError(withStatus: error!.localizedDescription)
}
return
}
if signal == .publisherCreated {
self.multipartyScreenSharer?.isPublishAudio = true
}
}
}
multipartyScreenSharer = OTMultiPartyCommunicator.init(view:UIApplication.shared.keyWindow)
We only can share the application window. Can someone explain how we can share besides the application window.
Thanks.
It is not straight forward to share other apps screens or home screen. You will need to implement screen sharing extension and implement OpenTok streaming in the extension.
I was wondering how can I populate a NSTableView by first getting files through NSOpenPanel. It's like listing/displaying the files after I choose from my folders at runtime.
EDIT: So i have this kind of working (through binding).... however i can't get it to display unless i click at the top of the table. And if you open to get more files the display won't update again at all.
I feel like this maybe be because the controllerArray's Received Action add: is not linked.
The code below is what I have now
#IBAction func openPanel(sender: NSButton) {
let openPanel = NSOpenPanel()
openPanel.allowedFileTypes = ["pdf"]
openPanel.canChooseFiles = true
openPanel.allowsMultipleSelection = false
openPanel.canChooseDirectories = true
openPanel.beginWithCompletionHandler { (result) -> Void in
if result == NSFileHandlingPanelOKButton {
var url:NSURL = openPanel.URL!
var pdfFile: PDFModel = PDFModel()
pdfFile.initWithURL(url)
self.insertFileLibrary(pdfFile, inFileListAtIndex: self.fileLibrary.count)
}
}
}
func insertFileLibrary(pdfModel:PDFModel, inFileListAtIndex index:Int){
fileLibrary.insertObject(pdfModel, atIndex: index)
}
func removeObjectFromFileLibraryAtIndex(index:Int){
fileLibrary.removeObjectAtIndex(index)
}
Bindings use KVO, you have to mutate fileLibrary in a KVO way surrounded by willChange… and didChange…. It is easier to let NSArrayContoller do this. You can use the addObject(s), insertObject(s) and removeObject(s) methods of NSArrayController.