erikberglund / SwiftPrivilegedHelper

Example application using a privileged helper tool with authentication in an unsandboxed application written in Swift
MIT License
180 stars 33 forks source link

How would this handle sudo commands? #32

Closed mansidak closed 1 year ago

mansidak commented 1 year ago

I've been studying this code and noticed that there is a 'path' parameter and an 'argument' parameter. Like I can give a command of '/bin/sudo' and it works but we know that it doesn't really do anything. How can I actually run a 'sudo' command in this? Let's say something like 'sudo pmset disable sleep 1'. Unless I'm misunderstanding something, I can't figure out a way to actually run a shell script.

chipjarred commented 1 year ago

Note: In this comment when I refer to path I am confusing it with command. path is set by the main app with the intention of its being used as the argument to /bin/ls. The discussion has moved on quite a bit since I posted this, so instead of correcting the text below, I just want to provide that context for anyone coming to this discussion at a later time.

I assume that you're referring to the call to runTask(command:arguments:completion:) in Helper.runCommandLs(withPath:completion:). First of all, you shouldn't need sudo since the point of the Helper is to authenticate that you can run the task. I'm guessing that you are indeed executing sudo which is silently waiting on a password (or maybe terminating) because it's not running on a tty. Think of a privileged helper tool as being a substitute for sudo.

Instead you'd provide the path to the thing that would come after sudo on the command line; however, you want to run a shell script, which is questionable for security, but you can do it. Installer packages for example, run shell scripts and sometimes need to run them with elevated privileges.

path has to refer to something that can be run by the link-loader, which on macOS means it has to be a Mach-O binary executable. It can't be a script or byte code.

So how do you run a shell script? While the script is not a Mach-O executable, the shell itself is. So you set path to the shell, for example, path = "/bin/zsh" for z-shell. Then you pass the name of the script as the first element of arguments. To avoid launching unnecessary sub-shells use the correct shell for the script, so if it starts with #!/bin/csh for example, use /bin/csh instead of /bin/zsh for path. If you use the wrong shell, it's not a big problem. It just means the shell you did specify will have to launch the one that the script wants in order to run the script.

If the script takes arguments, as it does for your example, don't pass them in as a single string. Each argument should be its own element in arguments. For your example, arguments = ["pmset", "disable", "sleep", "1"].

For a python script you could run it through the shell like that, calling python as the first of the arguments and the script as the second, but it's better to call the python interpreter directly: path = "/usr/bin/python", then python script becomes the first element in arguments.

This same pattern applies to pretty much any kind of script, like ruby, perl, awk, etc...

I would only run them via a shell if for some reason I needed some shell facility to resolve the arguments, like expanding wildcards (eg, if an argument needed to be something like "*.txt"). Obviously for a shell script you need the shell.

One other tip... remember the user's PATH environment variable may not be set the same as yours. If their PATH doesn't include the directory where the named script lives, the shell won't find the script. Instead, use the full path to the script as the first element of arguments.

If you can, do the work directly in the helper tool.

The project is a sort of template you're supposed to modify, so as one more piece of advice, if you don't need a script to do what you want, then don't use a script. A shell script is just a text file and if a malicious actor gets access to it, when you execute it with elevated privileges, you're running malicious code with privileges that can do some real damage or leak personal information. Instead, it's much more secure, if you can do what you want to do programmatically in Swift (or Objective-C, or C), and just call that code directly in your helper tool. In your helper tool you have access to pretty much the entire AppKit API, as well as to all the BSD/POSIX functions. In principle, anything you can do in a shell script you could do directly in Swift code.

Your example might not need a helper tool

If all you want to do is to disable sleep while you perform some task, you may not even need to use a helper tool at all. I don't think this code needs elevated privileges, so it should work directly in your app:

let activity = ProcessInfo.processInfo.beginActivity(
    options: [.idleDisplaySleepDisabled, .idleSystemSleepDisabled],
    reason: "I'm busy working! No time for sleep!"
)

someConcurrentDispatchQueue.async {
    doSomeLongRunningWork()
    ProcessInfo.processInfo.endActivity(activity)
}

Of course, that only works if you're just trying to prevent the system from going to sleep while your program performs some long running task. If instead the goal is to change the system settings so they persist after your app terminates, then yeah, you'll need the helper tool.

jeff-h commented 1 year ago

That's a fantastic answer and I didn't even ask the question 🤪

chipjarred commented 1 year ago

While writing my answer, I kept thinking that pmset is a binary command line tool, so I checked and it is:

chip@Chips-MacBook-Pro:~$ file `which pmset`
/usr/bin/pmset: Mach-O universal binary with 2 architectures: [x86_64:Mach-O 64-bit executable x86_64] [arm64e:Mach-O 64-bit executable arm64e]
/usr/bin/pmset (for architecture x86_64):   Mach-O 64-bit executable x86_64
/usr/bin/pmset (for architecture arm64e):   Mach-O 64-bit executable arm64e

So there's no need to go through a shell. pmset can be used directly: path = "/usr/bin/pmset" and arguments = ["disable", "sleep", "1"]

mansidak commented 1 year ago

@chipjarred Wow. First off, let me just thank you for such a great answer. I loved reading through the entire thing.

I believe I understand what you're saying. As far as running the process as path = "/usr/bin/pmset" and pushing in an argument of ["disablesleep", "1"], I tried that but it didn't work and and gives me this in the console: '/usr/bin/pmset' must be run as root...

Now as far as running the activity via .idleDisplaySleepDisabled and .idleSystemSleepDisabled, that didn't work either unfortunately. I don't get anything when I trigger .beginActivity.

You're right in the sense that I only wish to disable sleep while my program is running a process. Another thing I should mentioned is that I wish to disablesleep so the computer keeps running after the lid is closed, which is something that sudo pmset disablesleep 1 achieves. I'm not sure what could be going wrong when I tried your code sample. Any thoughts?

chipjarred commented 1 year ago

@mansidak

I found the source code for pmset.c. It's from when Darwin was open source, but I don't expect that it will have changed much. It reports the error that you must run it as root here:

                    // We write the settings to disk here; PM configd picks them up and
                    // sends them to xnu from configd space.
                    ret = IOPMSetSystemPowerSetting(keys[iii], vals[iii]);
                    if(kIOReturnNotPrivileged == ret)
                    {
                        printf("\'%s\' must be run as root...\n", argv[0]);
                    }
                    else if (kIOReturnSuccess != ret)
                    {
                        printf("\'%s\' failed to set the value.\n", argv[0]);
                    }

It looks like it's reporting an error that it's not being run by a privileged user, which, despite the error text, doesn't seem to indicate that it has to be run by literally the root user. I would think that having administrator privileges should be sufficient. Are you sure you've set up your privileged helper tool correctly? Are you running pmset from the helper tool or from your main app?

In any case, with that source code you can probably write your own function enable and disable sleep programmatically so you can do it directly in your helper tool instead of calling out to another program . It looks like all it does for disablesleep is set a CFDictionary key/value pair, and then use IOPMSetSystemPowerSettings to write it out. IOPMSetSystemPowerSettings will still require privileged access through. Here's the make file for it, so you can see what libraries you'd need to link to.

As for using ProcessInfo.processInfo.beginActivity(... what do you mean you don't "get anything"? What are you expecting to get. It won't ask for a password and shouldn't need to be in a helper tool, it should just do whatever the long running thing is without going to sleep. Are you saying that the Mac goes to sleep while you task is running anyway?

I don't think the beginActivity approach will prevent sleep when the lid is closed, nor if the user explicitly chooses to sleep from the Apple menu. It only prevents the computer from sleeping as a result of being idle (ie. no interaction).

mansidak commented 1 year ago

@chipjarred

Woah I see. So I do need the helper tool after all I guess.

Are you sure you've set up your privileged helper tool correctly? Are you running pmset from the helper tool or from your main app?

as for that^ I tried running the privileged helper with the path as 'usr/bin/psmet' but I'm not sure where to pass the argument for 'disablesleep 1'. I tried doing that as arguments = ["disablesleep", "1"] in the code but that didn't help. Would you mind hopping on a chat to help me with this? I've been really struggling with this and your code seems to achieve what I wish to do but I just can't wrap my head around it. Let me know if you'd be down to hop on discord or smth :)

chipjarred commented 1 year ago

While I'm not against helping in chat, making contiguous time for it will be a challenge.

If you look in the helper source code you'll find these methods in Helper.swift:

    func runCommandLs(withPath path: String, completion: @escaping (NSNumber) -> Void) {

        // For security reasons, all commands should be hardcoded in the helper
        let command = "/bin/ls"
        let arguments = [path]

        // Run the task
        self.runTask(command: command, arguments: arguments, completion: completion)
    }

    func runCommandLs(withPath path: String, authData: NSData?, completion: @escaping (NSNumber) -> Void) {

        // Check the passed authorization, if the user need to authenticate to use this command the user might be prompted depending on the settings and/or cached authentication.
        guard self.verifyAuthorization(authData, forCommand: #selector(HelperProtocol.runCommandLs(withPath:authData:completion:))) else {
            completion(kAuthorizationFailedExitCode)
            return
        }

        self.runCommandLs(withPath: path, completion: completion)
    }

That's where the current demo helper is running the ls command. Note that there are two versions of runCommandLs, one with and one without authData. The one without does the actual work, while the one with authenticates the user and then calls the one without to do the work.

They are also defined in HelperProtocol.swift

@objc(HelperProtocol)
protocol HelperProtocol {
    func getVersion(completion: @escaping (String) -> Void)
    func runCommandLs(withPath: String, completion: @escaping (NSNumber) -> Void)
    func runCommandLs(withPath: String, authData: NSData?, completion: @escaping (NSNumber) -> Void)
}

The protocol is used both for the selector in the authentication, and also via the magic of XPC, to call the helper from the main application.

You can use these as a template for your own to call pmset. I'm thinking something like this in Helper.swift:

    func runPmset(disableSleep: Bool, completion: @escaping (NSNumber) -> Void) 
    {
        let command = "/usr/bin/pmset"
        let arguments = ["disablesleep", "\(disableSleep ? 1 : 0)"]

        self.runTask(command: command, arguments: arguments, completion: completion)
    }

    func runPmset(disableSleep: Bool, authData: NSData?, completion: @escaping (NSNumber) -> Void) 
    {
        guard self.verifyAuthorization(
            authData, 
            forCommand: #selector(HelperProtocol.runPmset(disableSleep:authData:completion:))) 
        else 
        {
            completion(kAuthorizationFailedExitCode)
            return
        }

        runPmset(disableSleep: disableSleep, completion: completion)
    }

And add them to HelperProtocol.swift

@objc(HelperProtocol)
protocol HelperProtocol {
    func getVersion(completion: @escaping (String) -> Void)

    // Remove these if you removed them from Helper.swift
    func runCommandLs(withPath: String, completion: @escaping (NSNumber) -> Void)
    func runCommandLs(withPath: String, authData: NSData?, completion: @escaping (NSNumber) -> Void)

    // Add these
    func runPmset(disableSleep: Bool, completion: @escaping (NSNumber) -> Void)
    func runPmset(disableSleep: Bool, authData: NSData?, completion: @escaping (NSNumber) -> Void)
}

You'll need to call it from the main app. In this project it's called from AppDelegate.swift in response to the user clicking a button in the buttonRunCommand(_:) method. To test it, you can hook it up there replacing the runCommandLs calls.

In your actual app, you'll need to run that code when the user initiates your long-running task, which might not be from a button click, so you'll need to move/adapt the code from buttonRunCommand(_:) for however you do that. Presumably you re-eable sleep when you're done, which means calling it again. It shouldn't trigger the authentication UI the second time, since the user already authenticated for the first one.

As a user experience suggestion, I wouldn't have a significant delay between the user doing something that will result in needing authentication and actually doing the authentication. Think about your own experience: You're probably fine with being prompted for a password when you just clicked something that requires it. But if you're anything like me, it's annoying AF when you've moved on from clicking and are suddenly interrupted by something needing a password, plus its less obvious what you did that triggered it.

mansidak commented 1 year ago

I see what you're proposing here. I tried it but I get an error in your output text view as follows:

Helper connection was closed with error: Error Domain=NSCocoaErrorDomain Code=4097 "connection to service with pid 6555 named com.github.erikberglund.SwiftPrivilegedHelper" UserInfo={NSDebugDescription=connection to service with pid 6555 named com.github.erikberglund.SwiftPrivilegedHelper}

Not sure what could've gone wrong. And your concerns about UX are valid; I'll definitely keep them in mind. As far as meeting over discord or something, I'm available most of the day. Just let me know what would work for you and I'll try to conform since I really need to figure this out. Hopefully, it shouldn't take long if I let you look at what I'm working with. Your help will be much appreciated :)

chipjarred commented 1 year ago

No time to go in-depth right now, but did you first compile and get the project working without changes? Make sure it works "as-is" before making your changes. If it doesn't work as-is, then you know there's some project set-up issue, so we can focus on that. If it works as-is, but not after you make changes, then we know to look at your changes, and maybe for needed changes that were overlooked.

You'll also want to use your own developer certificate information for it.

Also this project isn't actually mine. I just watch the issues, because I have an interest in helper tools after going through the frustration of implementing my own, and found this project helped me resolve the problems I had. The original owner seems to have been inactive for years now.

I have a fork you can use. The main change I made was to convert the CodeSignUpate shell script to be a Swift script, which gives more useful output when you have problems with that, because that's often a big part of the headache of privileged helper tools. If you do decide to use mine, be sure to go to the Scripts folder and read the README there, because it describes how to set up the Run Script build phase in Xcode. Mine's a little different because the original shell script required you to edit the script with cert information. With mine you set them in environment variables in Xcode in the Run Script phase, and CodeSignUpdate gets them from there, so you don't need to alter CodeSignUpdate.swift.

I'm not suggesting that code signing has anything to do with the problem you're describing. It might or might not. My version just gives you more information if there is a problem with it. It also checks and fixes a small number of things the original didn't, but those are unlikely to be the problem.

Something else to check is to make sure that the build actually copied your helper tool into your app's bundle. If I remember correctly it should be in Contents/LaunchServices or maybe Contents/Resources/LaunchServices, but just poke around in the bundle looking for something like that, making sure your helper tool is in there. If it didn't copy it, it's probably because its build failed, which might be a code signing issue. The original CodeSignUpdate script would terminate without any output. It actually generated errors, but it couldn't emit them because it was using I/O redirection to build strings for updating Info.plist files. Actually emitting those errors is the main of the advantage of my Swift version.

chipjarred commented 1 year ago

@mansidak,

So I just tried the basic app as-is locally, and I see the error you're reporting:

Helper connection was closed with error: Error Domain=NSCocoaErrorDomain Code=4097 "connection to service on pid 0 named com.github.erikberglund.SwiftPrivilegedHelper

I had forgotten about that detail.

That's actually not a real problem. Remember the project is a demo app. The helper tool has to be installed, which in this demo is done by clicking the Install Helper button. That calls the AppDelegate.helperInstall method which calls SMJobBless. SMJobBless is the Apple API to install helper tools from your app's bundle into your Library folder with appropriate permissions. SMJobBless prompts you for your password to allow the install. I think helper tools go into the LaunchServices subfolder, so you'll want to check there and maybe remove the demo helper when you're done playing with it. If you click Install Helper, enter your password when prompted, you should get this is the text view:

Helper installed successfully

It should also enable the Run Command button. When you click that it should run the helper, putting the output in the text view.

In your real app, you almost certainly won't do it exactly that way. On launch, probably in didFinishLaunching, you'd check to see if the helper tool is installed (this project has code to do that too), and if it's not, prompt the user to inform them that a helper tool is needed and why, and ask if they want to install it.

You'll have to decide for your app what is the appropriate thing to do if the user says no. Since your app is just trying to disable sleep while your app does its work, maybe it's just slower or less effective if the Mac sleeps, but otherwise OK. In that case, you could continue to run - you just won't try to disable sleep. If disabling sleep is essential for what you're doing, then maybe you just terminate.

Assuming the user approves, you'd call helperInstall to install the helper, which will prompt the user for their password to allow the install. It should be a one-time thing. Once the tool is installed, when you check that it's there, it will be, so you can skip the step to install the helper on future launches.

When you get your basic app working, you can do some fancier stuff, like checking that the installed helper tool is the correct version for the current version of your app and provide UI for the user to uninstall it so they don't have to dig around in their LIbrary folder manually.

You also probably don't need to dump the tool's output for what you want to do. Just check it for errors, and report those in some other way, though logging them too might a good idea.

chipjarred commented 1 year ago

Correction: Helper tools are installed in /Library/PrivilegedHelperTools, that is, in a subfolder of the Library folder that's in the file system's root directory, not anywhere in your personal ~/Library. So that's where you'd need to remove it from when you want to remove it.

mansidak commented 1 year ago

@chipjarred

Okay, so I did try the fork you listed. I was able to install the program 'as-is' and run sample commands. However, when I run the runPmset method like we discussed above, I get the same error. I'm not sure what you mean when you say 'It's not a real problem'. (Do you mean it's just a warning and not an error?) I did try going to /Library/PrivilegedHelperTools but I didnt' find PrivilegedHelperTools folder in there. Does that mean the helper wasn't installed properly? If so, how was the program able to run other commands like /bin/ls etc.? The program clearly says 'Yes: Helper Installed' but I don't know why I cannot find it in the Library folder.

I've been trying everything as you suggested, @chipjarred, but literally nothing seems to be getting anywhere. Do you know if there's a more straightforward way to run this pmset command? I appreciate you still taking the effort to answer my queries but as you can see nothing's working out so far :/

mansidak commented 1 year ago

@chipjarred Another thing: The program says 'Authorization Cached: Yes' when I run it as is but when I use the runPmest function as listed above and get the error listed above, it changes to 'Authorization Cached: No'. Not sure if that helps us debug this in any way :/

chipjarred commented 1 year ago

@mansidak,

I wrote most of this comment earlier, which goes into finding the installed helper tool. You still need to go through that process because you need to know where it's installed so you can remove it when you want, and to be sure it's being installed properly; HOWEVER, in trying it out myself again, I think I found the problem when you use the changes I suggested earlier... and I apologize because it's an oversight on my part. I completely forgot to include the changes needed in HelperAuthorization.swift. It won't work without it. Basically it's adding (or replacing) an array entry for the new runPmset method. I've included all the changes including those and the ones I made to AppDelegate.swift below. It's after the section on finding the helper tool.

And now back to my original comment... 🙂

When I said that the error wasn't a real problem, I was referring to getting the error before installing the helper (ie. when you first launch the main app, before clicking the "Install Helper" button). If you get it after installing the helper, then it's a real error. Although as I just tried, I also get it after clicking "Install Helper", immediately before it prints "Helper installed successfully".

Finding the installed helper tool

You say that you can't find the helper.

Let's double-check the obvious thing first, there are three Library folders.

Just to double check the expected location, in Terminal:

ls -al /Library/PrivilegedHelperTools

This is what I get:

drwxr-xr-t   4 root  wheel     128 Jan 21 11:55 .
drwxr-xr-x  66 root  wheel    2112 Nov 19  2021 ..
-r-xr--r--   1 root  wheel  284176 Jan 21 11:55 com.github.erikberglund.SwiftPrivilegedHelper
-r-xr--r--   1 root  wheel  248016 Dec 10  2021 io.github.halo.linkhelper

com.github.erikberglund.SwiftPrivilegedHelper is the helper tool as it comes with this project. If you renamed, of course, you'll have to look for the name you set.

You say it works when the app is set up to run /bin/ls, so it's clearly being installed somewhere. To find it, in Terminal

sudo find / -name 'com.github.erikberglund.SwiftPrivilegedHelper' -print

That command is a bit overkill, because it's going to search the entire filesystem, including places the helper can't possibly be, like /System and /bin, but since we're at a loss to find it manually, we want to be sure to find it wherever it is, so we search everywhere. If you're on a real, spinning rust hard drive, get it started, and go get a cup of coffee. It's going to take a while. It shouldn't be too long a wait on an SSD.

using sudo for this just prevents polluting the output with a ton of permissions errors in the search.

You'll get some false positives too. For example it will find the helper tool in the app's bundle, and maybe elsewhere in your build folder which will be in some subtree under ~/Library/Developer/Xcode/DerivedData. Obviously those aren't install locations, so you have to use some discernment in reading the output.

While you wait, you can try typing SwiftPrivilegedHelper in Spotlight's search bar. Spotlight is fast, but the results depend on whether the metadata daemon has indexed helper tool yet, and whether the helper tool happens to be in an excluded location.

Anyway, if you find it, please share the location.

Investigating why it doesn't run when you use it with pmset

Let's assume that the helper tool is installed. Why wouldn't it work. It would be helpful if I could see your code. You can paste in this discussion using Markdown to mark it as code just like you would in a README.md.

Anyway things to try:

  1. Just edit the existing runCommandLs method to run pmset instead:

    func runCommandLs(withPath path: String, completion: @escaping (NSNumber) -> Void) {
    
        // For security reasons, all commands should be hardcoded in the helper
        /* Original code
        let command = "/bin/ls"
        let arguments = [path]
        */
        // New code
        let command = "/usr/bin/pmset"
        let arguments = ["disablesleep", "1"]
    
        // Run the task
        self.runTask(command: command, arguments: arguments, completion: completion)
    }

    If it works, that should disable sleep. We're completely ignoring path, which comes from the UI and was intended as an argument for ls. We're just hooking into a known working method to be sure there isn't a problem calling pmset.

It works for me. Does it work for you?

  1. Use your own runPmset method: This is the code I suggested in an earlier comment, but with some that I left out. I purposefully left out the AppDelegate.swift changes, because I figured you could handle that yourself perfectly well. Unfortunately I also neglected to include essential changes to HelperAuthorization.swift, which aren't so obvious, and that was not intentional. Oops.

In Helper.swift:

    func runPmset(disableSleep: Bool, completion: @escaping (NSNumber) -> Void) 
    {
        let command = "/usr/bin/pmset"
        let arguments = ["disablesleep", "\(disableSleep ? 1 : 0)"]

        self.runTask(command: command, arguments: arguments, completion: completion)
    }

    func runPmset(disableSleep: Bool, authData: NSData?, completion: @escaping (NSNumber) -> Void) 
    {
        guard self.verifyAuthorization(
            authData, 
            forCommand: #selector(HelperProtocol.runPmset(disableSleep:authData:completion:))) 
        else 
        {
            completion(kAuthorizationFailedExitCode)
            return
        }

        runPmset(disableSleep: disableSleep, completion: completion)
    }

In HelperProtocol.swift

@objc(HelperProtocol)
protocol HelperProtocol {
    func getVersion(completion: @escaping (String) -> Void)

    // Remove these if you removed them from Helper.swift
    func runCommandLs(withPath: String, completion: @escaping (NSNumber) -> Void)
    func runCommandLs(withPath: String, authData: NSData?, completion: @escaping (NSNumber) -> Void)

    // Add these
    func runPmset(disableSleep: Bool, completion: @escaping (NSNumber) -> Void)
    func runPmset(disableSleep: Bool, authData: NSData?, completion: @escaping (NSNumber) -> Void)
}

In HelperAuthorization.swift, we need to add an authorization right for runPmset(disableSleep:authData:compltion).

IMPORTANT: Earlier I completely forgot to say that we need to add an authorization right for our new method. I think this is the problem you're having, but even if it's not, it is definitely a problem you will have if we don't address it. The following code fixes that.

    static let authorizationRights = [
        /* OLD CODE
        HelperAuthorizationRight(command: #selector(HelperProtocol.runCommandLs(withPath:authData:completion:)),
                                 description: "SwiftPrivilegedHelper wants to run the command /bin/ls",
                                 ruleCustom: [kAuthorizationRightKeyClass: "user", kAuthorizationRightKeyGroup: "admin", kAuthorizationRightKeyVersion: 1])
        */
        // NEW CODE <-------------
        HelperAuthorizationRight(command: #selector(HelperProtocol.runPmset(disableSleep:authData:completion:)),
                                 description: "SwiftPrivilegedHelper wants to run the command /usr/bin/pmset",
                                 ruleCustom: [kAuthorizationRightKeyClass: "user", kAuthorizationRightKeyGroup: "admin", kAuthorizationRightKeyVersion: 1])
    ]

In AppDelegate.swift (in the main app) modify buttonRunCommand (I've marked the new code and the old code it replaces):

    var disableSleep: Bool = true // <--------- NEW CODE

    @IBAction func buttonRunCommand(_ sender: Any) {
        guard
            /* OLD CODE
            let inputPath = self.inputPath,
            */
            let helper = self.helper(nil) else { return }

        if self.checkboxRequireAuthentication.state == .on {
            do {
                guard let authData = try self.currentHelperAuthData ?? HelperAuthorization.emptyAuthorizationExternalFormData() else {
                    self.textViewOutput.appendText("Failed to get the empty authorization external form")
                    return
                }

                /* OLD CODE
                helper.runCommandLs(withPath: inputPath, authData: authData) { (exitCode) in
                */
                // NEW CODE <------------------------
                helper.runPmset(disableSleep: disableSleep, authData: authData) { (exitCode) in
                    OperationQueue.main.addOperation {

                        // Verify that authentication was successful

                        guard exitCode != kAuthorizationFailedExitCode else {
                            self.textViewOutput.appendText("Authentication Failed")
                            return
                        }

                        // NEW CODE <----------------------
                        self.textViewOutput.appendText("pmset called with disableSleep = \(self.disableSleep)")
                        self.disableSleep.toggle() // NEW CODE <----------------------

                        self.textViewOutput.appendText("Command exit code: \(exitCode)")
                        if self.checkboxCacheAuthentication.state == .on, self.currentHelperAuthData == nil {
                            self.currentHelperAuthData = authData
                            self.textFieldAuthorizationCached.stringValue = "Yes"
                            self.buttonDestroyCachedAuthorization.isEnabled = true
                        }

                    }
                }
            } catch {
                self.textViewOutput.appendText("Command failed with error: \(error)")
            }
        } else {
           /* OLD CODE
            helper.runCommandLs(withPath: inputPath) { (exitCode) in
            */
           // NEW CODE <---------------------------
            helper.runPmset(disableSleep: disableSleep) { (exitCode) in
               // NEW CODE <----------------------
               self.textViewOutput.appendText("pmset called with disableSleep = \(self.disableSleep)")
               self.disableSleep.toggle() // NEW CODE <----------------------

               self.textViewOutput.appendText("Command exit code: \(exitCode)")
            }
        }
    }

Probably the toggling of the disableSleep boolean I added isn't quite right, because it toggles regardless of exitCode as long as it's not kAuthorizationFailedExitCode, but pmset could fail for other reasons, I suppose - like maybe in the case of disk error when it tries to write out its CFDictionary. It should probably check that the exit code is 0 instead before toggling. But it's just a temporary UI anyway, so hardly worth fretting over at this stage. The important thing right now is the make sure that it's called.

Anyway, does that work? If so, do you see a difference between this and what you tried earlier? If it doesn't work, let me know.

mansidak commented 1 year ago

@chipjarred Okay, I'm gonna try all of this. BUT before that, I just tried creating a very simple harcoded prompt to see if the code is actually going through. This is what I did:

let command = "/usr/bin/say" let arguments = ["HELLO USER"]

In hopes that it would say 'Hello User' as a regular executable should. However, it didn't do anything. As a result, I also tried creating a custom executable on the desktop and tried putting that as the path in the input, that didn't work either. So should we first see if something else is wrong?

As far as the helper being actually installed, this is what I get when I check it:

Screenshot 2023-01-21 at 1 48 41 PM

so I'm pretty sure it's installed. What's your thoughts on the 'say hello user' experiment. Does that help us debug this? FYI I completely deleted and copied the project afresh to avoid any errors. Let me know what you think :)

mansidak commented 1 year ago

@chipjarred Okay hell. Deleting the helper and recreating the project helped run the 'hello user' command. Now let me try the pmset command

chipjarred commented 1 year ago

That directory listing shows that the helper is definitely being installed. You might have been looking in the wrong place when you did before.

As for the say command not working, I'd need to see more context. If you replaced the /bin/ls command with it, in the original version, I would expect that to work. However, if you replaced in runPmset(...) I wouldn't expect it to work until at least you made added the authorization right in HelperAuthorization.swift. Basically verifyAuthorization is checking for the selector of the method to execute, so if it's not there, the authorization fails.

As, I was typing this, I see that you say you got say to work. Yeah, when you make changes to the helper tool you need to delete it. Which is annoying. Once you get the basics working, you might want to add an uninstall method in the helper tool to delete itself and exit, and for DEBUG builds in the main app after checking if it's installed, call it to uninstall. It means you have to type the password to install every time to run in DEBUG, but that's less of a pain than manually deleting the installed helper.

mansidak commented 1 year ago

@chipjarred

Lovely. I'll keep that in mind. As far as using your PrivelagedHelper: it's in the MIT domain, correct? I can just tailor this to my app (while crediting you of course), right? Or is there some specific method I need to follow to adapt this to my app? Basically in my SwiftUI, the user initiated a certain task and that's when the runCommand needs to be called. And then they manually disable the task during which runCommand will be called again to disable sleep to 0.

chipjarred commented 1 year ago

Yes, it's an MIT License, so you can use how you like. As for credit, it's nice if you credit me as well, but Erik Berglund is the original author, and as such should definitely be credited.

I haven't tried to use a helper tool in a SwiftUI app, but I don't think it should be much of a problem. The way I would approach it would be to first, in this project, create something like a HelperToolController and start moving code into it from AppDelegate that sets the XPC connection and calls the helper, completely disentangling it from the AppKit types. So HelperToolController should import Foundation but should not import AppKit or Cocoa. Change the AppDelegate to call HelperToolController. Once that's done, you should be able switch over to SwiftUI, and initialize your HelperToolController in whatever @main.

The tricky bit is if you decide to move all the Helper Tool stuff to another project, rather than moving the other project code into the Helper Tool project. That's because there's specific Xcode project set up to properly build it, and you'll have to recreate all of that in your project. It would be easier to move existing project's code into this project, renaming things.

This isn't an exact recipe, but I'd probably do it like this (after each step, build, test, and if it works, commit):

  1. Move the SwiftPrivilegedHelper project into your existing project's folder in such a way that its Xcode project file is in the same directory as your existing project's project file. That way you don't lose your existing git history, and relative paths start at the same place. Don't move the invisible .git directory from SwiftPrivilegedHelper. You'll lose whatever you might have committed there, but that's probably not that much compared to your existing project's history and certainly not as important.
  2. Do the HelperToolController refactoring I suggested above in SwiftPrivilegedHelper.
  3. Change the existing SwiftPrivilegedHelper target to use SwiftUI. Don't worry about a fancy interface, and you can even just dump tool I/O via print for now. Get that working so you can delete AppDelegate.swift. You can probably remove AppProtocol.swift too, It just provides some log methods that you can provide a different way in SwiftUI. Don't create a new target.
  4. In SwiftPrivilegedHelper change the name of the helper tool and app bundle identifiers, and rename targets to something appropriate your project, but don't rename the project itself yet. This is tricky, so pay close attention to the errors in the build log from CodeSignUpdate. This is where my fork helps a lot over the original.
  5. Add your project's source code, resources, etc.. to the SwiftPrivilegedHelper project into the app target, as well as adding any Swift Package dependencies you may have. Don't delete SwiftUI code you already had in SwiftPrivilegedHelper from step 3, but comment it out so it doesn't interfere with the code you're moving over. Don't try to call the helper in the code you move over just yet. Just get your existing project code working in the SwiftPivilegedHelper project file. Don't forget to move unit tests too - that will require creating a new unit test bundle target. That's fine. Just don't create a new app target. Verify that your app's code works in its new home just like it did in your old project.
  6. With all code/resources moved to SwiftPrivilegedHelper, delete the old project file. And rename SwiftPrivilegedHelper to be whatever your old project file name was.

Basically it's going to be a bit of pain either way, but I really think moving existing code into the SwiftPrivilegedHelper project will be easier than moving SwiftPrivilegedHelper code into your existing project, just because the project set up for using a privileged helper tool is finicky, so its easier to keep with one that you know is working. That said, you'll almost certainly temporarily break it in step 4 anyway.

chipjarred commented 1 year ago

@mansidak

In my fork, I extracted the helper tool/helper connection code from the AppDelegate into a HelperToolController as I described, and pushed those changes after verifying it all still works. I think it will make it little easier to integrate into a SwiftUI app. So now AppDelegate calls HelperController to do anything with the helper tool, and HelperToolController doesn't know anything about the UI. I renamed AppProtocol to HelperControllerProtocol and moved conformance from the AppDelegate to HelperToolController... which by the way you can't delete the that protocol. It andHelperProtocol are part of what XPC uses for communication between the app and the helper tool.

There's still a bit more work to do on it, but not that much, at least for this project. Error handling was very locally entangled with the UI, which I guess made sense when the AppDelegate was doing everything, but less so when you decouple it. I want to think through how that's done a bit more, try to make it more consistent. Anyway, there were some gotchas.

mansidak commented 1 year ago

@chipjarred After iterating like 20 times, I was finally able to transfer my code to the helper project. I agree with what you said:

I really think moving existing code into the SwiftPrivilegedHelper project will be easier than moving SwiftPrivilegedHelper code into your existing project

I basically had to rebuild my project in UIKIt like 10 times but eh, whatever, it works now. Thanks for all your help man. I'll make sure to credit Erik and will try to throw in a link to this discussion so it may help other people.

I did get a few errors while notarizing though and I feel like they are related to the Helper tool. This is what I get:

The executable requests the com.apple.security.get-task-allow entitlement.

The signature does not include a secure timestamp.

The binary is not signed with a valid Developer ID certificate.

I do have an active developer subscription and got apps on the App Store so I'm not sure why it's showing that error. Any idea what they mean? I followed the links but that didn't seem to provide much info. Lemme know if you got any thoughts :)

mansidak commented 1 year ago

@chipjarred I figured it out. It was the Launch at Login package. I removed it and it got notarized.

HOWEVER (and this is a big however lol), the helper doesn't work after being notarized! I'll continue this on a new issue since this seems different than anything.