NSUserScriptTask difficulties
Asked Answered
I

2

7

I've been trying to make do (see this and this) with the recent NSUserScriptTask class and its subclasses and so far I've solved some problems, but some others remain to be solved. As you can see from the docs, NSUserScriptTask does not allow for the cancellation of tasks. So, I decided to create a simple executable that takes as arguments the path to the script and runs the script. That way, I can launch the helper from my main app using NSTask and call [task terminate] when necessary. However, I require:

  • The main app to receive output and errors from the helper it launched
  • The helper only terminating when the NSUserScriptTask is done

The code for the main app is simple: just launch an NSTask with the proper info. Here's what I have now (for the sake of simplicity I ignored the code for security-scoped bookmarks and the like, which are out of the problem. But don't forget this is running sandboxed):

// Create task
task = [NSTask new];
[task setLaunchPath: [[NSBundle mainBundle] pathForResource: @"ScriptHelper" ofType: @""]];
[task setArguments: [NSArray arrayWithObjects: scriptPath, nil]];

// Create error pipe
NSPipe* errorPipe = [NSPipe new];
[task setStandardError: errorPipe];

// Create output pipe
NSPipe* outputPipe = [NSPipe new];
[task setStandardOutput: outputPipe];

// Set termination handler
[task setTerminationHandler: ^(NSTask* task){        
    // Save output
    NSFileHandle* outFile = [outputPipe fileHandleForReading];
    NSString* output = [[NSString alloc] initWithData: [outFile readDataToEndOfFile] encoding: NSUTF8StringEncoding];

    if ([output length]) {
        [output writeToFile: outputPath atomically: NO encoding: NSUTF8StringEncoding error: nil];
    }

    // Log errors
    NSFileHandle* errFile = [errorPipe fileHandleForReading];
    NSString* error = [[NSString alloc] initWithData: [errFile readDataToEndOfFile] encoding: NSUTF8StringEncoding];

    if ([error length]) {
        [error writeToFile: errorPath atomically: NO encoding: NSUTF8StringEncoding error: nil];
    }

    // Do some other stuff after the script finished running <-- IMPORTANT!
}];

// Start task
[task launch];

Remember, I need the termination handler to only run when: (a) the task was cancelled (b) the task terminated on its own because the script finished running.

Now, on the helper side things start to get hairy, at least for me. Let's imagine for the sake of simplicity that the script is an AppleScript file (so I use the NSUserAppleScriptTask subclass - on the real world I'd have to accomodate for the three types of tasks). Here's what I got so far:

int main(int argc, const char * argv[])
{
    @autoreleasepool {
        NSString* filePath = [NSString stringWithUTF8String: argv[1]];
        __block BOOL done = NO;

        NSError* error;
        NSUserAppleScriptTask* task = [[NSUserAppleScriptTask alloc] initWithURL: [NSURL fileURLWithPath: filePath] error: &error];

        NSLog(@"Task: %@", task); // Prints: "Task: <NSUserAppleScriptTask: 0x1043001f0>" Everything OK

        if (error) {
            NSLog(@"Error creating task: %@", error); // This is not printed
            return 0;
        }

        NSLog(@"Starting task");

        [task executeWithAppleEvent: nil completionHandler: ^(NSAppleEventDescriptor *result, NSError *error) {
            NSLog(@"Finished task");

            if (error) {
                NSLog(@"Error running task: %@", error);
            }

            done = YES;
        }];

        // Wait until (done == YES). How??
    }

    return 0;
}

Now, I have three questions (which are the ones I want to ask with this SO entry). Firstly, "Finished task" never gets printed (the block never gets called) because the task never even starts executing. Instead, I get this on my console:

MessageTracer: msgtracer_vlog_with_keys:377: odd number of keys (domain: com.apple.automation.nsuserscripttask_run, last key: com.apple.message.signature)

I tried running the exact same code from the main app and it completes without a fuss (but from the main app I lose the ability to cancel the script).

Secondly, I only want to reach the end of main (return 0;) after the completion handler is called. But I have no idea how to do that.

Thridly, whenever there's an error or output from the helper I want to send that error/output back to the app, which will receive them through the errorPipe/outputPipe. Something like fprintf(stderr/stdout, "string") does the trick, but I'm not sure if it is the right way to do it.

So, in short, any help regarding the first and second problems is appreciated. The third one I just want to make sure that's how I'm supposed to do it.

Thanks

Interfluent answered 25/3, 2013 at 13:6 Comment(9)
Have you looked at XPC?Ajax
@Ajax I looked at XPC for other purposes but honestly I didn't understand how to use it. Do you think it'll do here?Interfluent
I can't say for sure, because I haven't tried it, but it seems like it's more suited toward what you're doing. It might be worth a look.Ajax
@Ajax Why do you think it would be more suited? I'm curious now, I really know next to zero about XPC servicesInterfluent
If you can target 10.8, there are some nice Cocoa wrappers for XPC. Otherwise, you'll be writing some simple wrappers of your own. I think XPC would work quite well for your use case. Another option would be to use distributed notifications to signal the completion of the task and have the main app register for those. 1Password's helper app uses XPC and distributed notifications to communicate between the helper and main app, depending on the situation.Cowbane
@Cowbane Yes, XPC does solve the second and third problems (and it's even nicer because I'm targetting 10.8 only), but what about the first question? I guess I'll have to try and see if running from an XPC service still produces an error... If it doesn't then that's settled.Interfluent
Are you sure the path is being resolved properly and not pointing you somewhere inside your Container rather than the real location?Cowbane
@Cowbane Yes, I'm using absolute paths and any sandboxed app has read-only access to ~/Library/Application Scripts/com.devname.appname/. And both the app and the helper are sandboxed, but NSUserScriptTask works on the app and unfortunately not on the helper.Interfluent
@Cowbane Also, please note that XPC is only useful for me if I'm able to quit the helper (which should in turn cancel the NSUserScriptTask). That was the reason I tried to use an helper in the first place...Interfluent
W
4

Question 1: The sub-task doesn't run because its parent exits immediately. (The log message about "odd number of keys" is a bug in NSUserScriptTask, and happens because your helper doesn't have a bundle identifier, but is otherwise harmless and irrelevant to your problem.) It exits immediately because it's not waiting for the completion block to fire, which brings us to...

Question 2: How do you wait for an asynchronous completion block? This has been answered elsewhere, including Wait until multiple networking requests have all executed - including their completion blocks, but to recap, use dispatch groups, something like this:

dispatch_group_t g = dispatch_group_create();
dispatch_group_enter(g);
[task executeWithAppleEvent:nil completionHandler:^(NSAppleEventDescriptor *result, NSError *e) {
    ...
    dispatch_group_leave(g);
}];
dispatch_group_wait(g, DISPATCH_TIME_FOREVER);
dispatch_release(g);

This same pattern works for any call that has a completion block you want to wait for. If you wanted another notification when the group finishes instead of waiting for it, use dispatch_group_notify instead of dispatch_group_wait.

Warmup answered 30/3, 2013 at 23:11 Comment(8)
Thanks, this is a cool way to do it. But it still hasn't solved my problem, because now, although I see on the console @"Finished task" and no @"Error: ..." line, the script still isn't running. As an example, the test applescript I'm running is say "Hello", and I'm not hearing anything... And it completes way too quickly for applescript. (I still see the odd number of keys message, btw)Interfluent
Let me get this straight: you see your "Finished task" message, so we know the callback is firing, and the callback’s error parameter is nil, so it thinks it succeeded, but you hear nothing? What happens if you run the helper directly from the command line? Or if you change the script to something compute-only (such as 2+2) and log the result?Warmup
Re. "odd number of keys": as I said before, that’s essentially harmless -- it’s some diagnostic code failing. If it really bothers you, you can add an embedded plist with a CFBundleIdentifier to your helper; see <#8235446>.Warmup
It doesn't bother me at all. But upon further testing I found that I do sometimes get errors when running the script, but other times I don't. It's always the same error: Error running script: Error Domain=NSCocoaErrorDomain Code=257 "The file “Hello.scpt” couldn’t be opened because you don’t have permission to view it." UserInfo=0x102504800 {NSURL=file://localhost/Users/Alex/Library/Application20%Scripts/appID/Hello.scpt, NSLocalizedFailureReason=Script file is not in the application scripts folder.}Interfluent
And from the command line I get this error (still the same say "Hello" applescript): Error running script: Error Domain=NSPOSIXErrorDomain Code=2 "The operation couldn’t be completed. /usr/bin/osascript: couldn't set default target: no eligible process with specified descriptor " UserInfo=0x7ffefbe04f60 {NSURL=file://localhost/Users/Alex/Library/Application%20Scripts/net.ACT-Productions.Clockwise/Hello.scpt, NSLocalizedFailureReason=/usr/bin/osascript: couldn't set default target: no eligible process with specified descriptor }Interfluent
And chaging to a simpler c=a*b; log c (pseudocode) in applescript run from the command line yields the same error as say "Hello"Interfluent
Having struggled with this for a while, I’ve concluded that it’s not going to work. It’s fundamentally flawed in that killing a process will not immediately kill XPC services it has spawned (launchd may let them live for up to 20 seconds), and it’s specifically flawed for NSUserScriptTask because of how it locates the application scripts folder. My advice is to hold out for a real cancelation API.Warmup
Thanks, I started to imagine something like this would be the case. Thanks for your trouble.Interfluent
W
3

As a side note, the way you’re testing error after allocating the NSUserAppleScriptTask is incorrect. The value of error is defined if and only if the function result is nil (or NO, or whatever indicates failure). If the function succeeds (which you know if it returns non-nil), then error may be anything -- the function may set it to nil, it may leave it undefined, it may even fill it in with a real object. (See also What's the Point of (NSError**)error?)

Warmup answered 31/3, 2013 at 17:57 Comment(1)
I see, you're right. I should replace if (error) with if (task)Interfluent

© 2022 - 2024 — McMap. All rights reserved.