beeware / briefcase

Tools to support converting a Python project into a standalone native application.
https://briefcase.readthedocs.io/
BSD 3-Clause "New" or "Revised" License
2.48k stars 352 forks source link

Add support for "start app on login" as part of packaging #1803

Open freakboy3742 opened 1 month ago

freakboy3742 commented 1 month ago

What is the problem or limitation you are having?

Some apps (in particular server and toolbar style apps) need to add themselves to the startup items list. Briefcase should be able to produce apps that can install themselves in the startup item list

Describe the solution you'd like

Apps should have a configuration option so that when an app is installed, it is added to the startup items for the platform - e.g.,

startup_item = True

On macOS, this will require the use of a .pkg installer (see #1781) On Windows, it will require an MSI installer Linux packages will result in the app being added to systemd/initd.

It might be desirable to add this in a way that the user can opt-in, rather than it being a forced requirement on installation.

Describe alternatives you've considered

None.

Additional context

No response

michelcrypt4d4mus commented 1 month ago

fwiw i have implemented this feature as part of my briefcase app. while there are a few ways to do it and theoretically the preferred method these days is via Apple's SMJobBless() that approach requires the execution of Swift or objective C code, which could require compiling an entirely separate small app in the bundle.

i used the old method, which involves a postinstall script to execute two steps:

  1. copying an appropriate .plist file (which is already in the app bundle at Contents/Library/LaunchAgents) to ~/Library/LaunchAgents
  2. calling launchctl bootstrap

If it's helpful here's the contents of my postinstall script which will install an app in user space (gui/501). it should work for a system level daemon as well with some changes.

#!/bin/bash
# Create .plist file in ~/Library/LaunchAgents and bootstrap. Output of this script can be seen in /var/log/install.log
set -e

# Arg list: https://discussions.apple.com/thread/2184714?sortBy=best
# $1 is the full path of the .pkg file
# $2 is the path to the installed app, usually just '/Applications/'
# $3 is the mountpoint of the installer disk, usually just '/'
# $4 is root directory of currently booted system, usually just '/'

APP_BASENAME='IllmaticApp'
BUNDLE_ID='com.illmatic.app'

APP_EXECUTABLE_PATH="$2/$APP_BASENAME.app/Contents/Macos/$APP_BASENAME"
INSTALLED_LAUNCH_AGENT_PATH="$HOME/Library/LaunchAgents/$BUNDLE_ID.plist"

# In the env vars accessed by this script $USER = the_actual_user but $UID = 0 (root).
INSTALLING_USER_UID=$(id -u "$USER")
LAUNCHD_DOMAIN="gui/$INSTALLING_USER_UID"

echo "Post installation process started by user $USER..."
echo "-> Checking for and removing existing service..."
INSTALLED_SERVICE=$(launchctl print $LAUNCHD_DOMAIN | grep $BUNDLE_ID | awk '{print $3}')

if [ ! -z $INSTALLED_SERVICE ]; then
    echo "   -> Existing service found; running launchctl bootout $DOMAIN/$BUNDLE_ID"
    launchctl bootout "$DOMAIN/$BUNDLE_ID"
fi

echo "-> Generating .plist at $INSTALLED_LAUNCH_AGENT_PATH"
"$APP_EXECUTABLE_PATH" --generate-plist

echo "-> Running: chown $USER $INSTALLED_LAUNCH_AGENT_PATH"
chown "$USER" "$INSTALLED_LAUNCH_AGENT_PATH"

echo "-> Running: launchctl bootstrap $LAUNCHD_DOMAIN $INSTALLED_LAUNCH_AGENT_PATH"
launchctl bootstrap "$LAUNCHD_DOMAIN" "$INSTALLED_LAUNCH_AGENT_PATH"

echo "Post installation process finished."

The .plist file is generated by postinstall calling to the just installed app ("$APP_EXECUTABLE_PATH" --generate-plist) which has something like this in app.py:

def main():
    if args.generate_plist:
        generate_briefcase_plist()
        return

and this in plist_helper.py:

import plistlib
from os.path import join, expanduser

from .app_metadata_helper import MAIN_APP_NAME, get_app_name, get_bundle_identifier, installed_executable_path

# Directories
LAUNCHAGENTS_DIR = expanduser(f"~/Library/LaunchAgents")
LAUNCHAGENT_PLIST_PATH = join(LAUNCHAGENTS_DIR, get_bundle_identifier() + '.plist')

APPLICATION_SUPPORT_DIR = expanduser(f"~/Library/Application Support/{MAIN_APP_NAME}")
APPLICATION_LOG_DIR = join(APPLICATION_SUPPORT_DIR, 'log')

def generate_briefcase_plist():
    """Run with briefcase run -u -- --generate-plist"""
    build_logfile_path = lambda stream: join(APPLICATION_LOG_DIR, f"{get_app_name()}.{stream}.log")

    plist_contents = {
        "EnvironmentVariables": {
            "PATH": f"{installed_executable_path().parent}:/usr/bin:/bin",
        },
        "KeepAlive": True,
        "Label": get_bundle_identifier(),
        "ProgramArguments": [str(installed_executable_path())],
        "RunAtLoad": True,
        "StandardErrorPath": build_logfile_path("stderr"),
        "StandardOutPath": build_logfile_path("stdout"),
        "WorkingDirectory": str(installed_executable_path().parent),
    }

    with open(LAUNCHAGENT_PLIST_PATH, "w") as _plist_file:
        _plist_file.write(plistlib.dumps(plist_contents).decode())

    print(f"Wrote {get_app_name()} .plist config to '{LAUNCHAGENT_PLIST_PATH}'")

And here's the generated .plist file (launchctl is very finicky)

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>/Applications/IllmaticApp.app/Contents/MacOS:/usr/bin:/bin</string>
    </dict>
    <key>KeepAlive</key>
    <true/>
    <key>Label</key>
    <string>com.illmatic.app</string>
    <key>ProgramArguments</key>
    <array>
        <string>/Applications/IllmaticApp.app/Contents/MacOS/IllmaticApp</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>StandardErrorPath</key>
    <string>/Users/uzor/Library/Application Support/IllmaticApp/log/IllmaticApp.stderr.log</string>
    <key>StandardOutPath</key>
    <string>/Users/uzor/Library/Application Support/IllmaticApp/log/IllmaticApp.stdout.log</string>
    <key>WorkingDirectory</key>
    <string>/Applications/IllmaticApp.app/Contents/MacOS</string>
</dict>
</plist>

As far as i can tell the main advantage of SMJobBless is that you can add a login item without copying the .plist to ~/Library/LaunchAgents.

freakboy3742 commented 1 month ago

fwiw i have implemented this feature as part of my briefcase app. while there are a few ways to do it and theoretically the preferred method these days is via Apple's SMJobBless() that approach requires the execution of Swift or objective C code, which could require compiling an entirely separate small app in the bundle.

Thanks for those details. There's obviously a couple of details in that literal plist and config script that would need to be templated/configured, but it seems like that should be reasonably straightforward to include in a bundled macOS pkg (once #1781 lands).

The question I have about this configuration is how it interacts with individual users. The current state of #1781 only supports global installs; AIUI, login items are specific to individual users. This script seems to be hard-coding the installation of the launch item use of UID 501, and the uzor username. Would introducing this code also require the introduction of user-space app installers? How would a second user on the same machine get this tool installed as a launch item?

Also - I don't think we wouldn't necessarily need to ship an extra executable. You can invoke Objective C APIs with ctypes; and although a bundled Briefcase app isn't a "true" Python interpreter, you can control the Python entry point using the BRIEFCASE_MAIN_MODULE environment variable. I think it should be possible to write a Python script that uses ctypes to invoke SMJobBless, and use the app binary itself to run that script.

michelcrypt4d4mus commented 1 month ago

login items are specific to individual users.

Sort of... but a system wide startup item is installed the same way. The only differences are that

  1. you place the .plist file in /Library/LaunchDaemons instead of ~/Library/LaunchAgents
  2. you have to run launchctl bootstrap system as opposed to launchctl bootstrap gui/501
  3. you have to run the launchctl command with sudo. (As an aside it seems like the standard installer output by pkgbuild runs as root, which is mildly terrifying but it is what it is).

There's also the user/ domain. gui/ domain theoretically waits until login to launch, user/ domain theoretically does not but I remember that not really working as intended (though I could be remembering incorrectly). Some more good details on the extremely poorly documented launchctl world can be found here.

Also - I don't think we wouldn't necessarily need to ship an extra executable. You can invoke Objective C APIs with ctypes;

That sounds like it should work - in fact I may try to incorporate that in my app, and if so i will report back - but Apple's documentation is extremely lacking about all this stuff.

michelcrypt4d4mus commented 1 month ago

This script seems to be hard-coding the installation of the launch item use of UID 501, and the uzor username.

You are correct. FWIW the .plist generation is templated and actually currently done by my briefcase app's python code at build time, but out in the real world that templating would need to happen at either install or initial launch time. postinstall has access to the $USER and $UID variables so it's more annoying than difficult. launchctl is extremely finicky about everything (and horrifically annoying to debug) and IIRC does not accept things like relative paths or the ~ variable. If you are on a mac take a look at the .plist files in the various LaunchAgents and LaunchDaemons dirs and you will see everything is hard coded with full paths.

Would introducing this code also require the introduction of user-space app installers? How would a second user on the same machine get this tool installed as a launch item?

I think the answer is "yes" though fwiw Apple's entire approach tends to kind of casually assume single-tenancy machines.

EDIT 2024-06-05:

postinstall has access to the $USER and $UID variables

i found that bizarrely postinstall runs with $USER as the installing user but $UID as 0 (root account) and in general does indeed run with root privileges other than this weird qurik

michelcrypt4d4mus commented 1 month ago

i just noticed this piece of the official apple docs:

The Service Management framework only supports the kSMDomainSystemLaunchd domain.

so apparently SMJobBless() does not even support installing login items for individual users and only supports running your app from startup as root, which is a bit scary.

michelcrypt4d4mus commented 1 month ago

and only supports running your app from startup as root

Apparently you can use the UserName key in your .plist file to run from boot as a specific (non-root) user.

michelcrypt4d4mus commented 1 month ago

i changed my situation so that the .plist is generated by postinstall calling out to the app it just installed so there's no templating required, just a bit of additional code in the briefcase app's python. edited my comment above with the updated code.

michelcrypt4d4mus commented 4 weeks ago

added a couple of lines to the comment above to check for and bootout the service if it's already installed because otherwise postinstall will fail which is a small glitch i just ran into but otherwise this setup has been serving me very well.