diff --git a/macOS-Auto‐Start-Service-Setup-(for-Remote---MDM-Deployment).md b/macOS-Auto‐Start-Service-Setup-(for-Remote---MDM-Deployment).md new file mode 100644 index 0000000..d2f53f2 --- /dev/null +++ b/macOS-Auto‐Start-Service-Setup-(for-Remote---MDM-Deployment).md @@ -0,0 +1,572 @@ +# macOS Auto-Start Service Setup (for Remote / MDM Deployment) + +This guide explains how to use `install_service.sh` to set up RustDesk (or a Custom Client) as an auto-start service on macOS via the command line, for scenarios where the GUI "Install" button is not available. + +
install_service.sh + +```bash +#!/bin/bash +# +# rustdesk-daemon-install - public domain 2024 + +# Ver Who Date Note +# 001 GK 241219 Initial setup - tested on ProxMox machine - permissions had to be manually responded to in GUI +# 002 HM 250224 Fix custom client support: APP_NAME, pref paths, plist labels, MDM user detection +# 003 HM 250224 Fix GetAssets timing (require $APP), apply correct_app_name to install.scpt +# 004 HM 250224 Split GetAssets: view options (-v) no longer blocked by user/app checks +# 005 HM 250224 Fix -a/-d/-s blocked by user validation; fix dscl space-in-path truncation + + +# Setup RustDesk services for Mac - even in a VM environment (presuming a copy of Preferences from another machine) + +# Optionally set ID and password - to make it work on a MacOS Vm + +# Server is for a user +# Service is for the machine + +# +# This script will find the app in /Applications or a subfolder or subfolder of a subfolder +# + +# From lib/log +Msg() { echo "$(date +%d:%k%M%S) $$ $@" >&2; } +Err() { Msg "<***> $@ <***>"; return 42; } +Throw() { [ -n "$1" ] && Msg "=== $1 ==="; exit ${2:-22}; exit 9; } + +# +# ===== CUSTOM CLIENT CONFIGURATION ===== +# For custom clients, only change DEFAULTPATH to your app path. +# Everything else is auto-derived. +# +# Example: +# DEFAULTPATH='/Applications/MyApp.app' +# DEFAULTPATH='/Applications/Subfolder/MyApp.app' +# DEFAULTPATH='/opt/MyApp.app' (also works — FindApp checks DEFAULTPATH first) +# +# How naming works (hardcoded in RustDesk, cannot be changed): +# APP_NAME = basename of DEFAULTPATH without .app (e.g. "MyApp") +# FULL_NAME = "com.carriez.{APP_NAME}" (e.g. "com.carriez.MyApp") +# Plist files = /Library/LaunchDaemons/com.carriez.MyApp_service.plist +# /Library/LaunchAgents/com.carriez.MyApp_server.plist +# Config path = ~/Library/Preferences/com.carriez.MyApp/ +# Config files = MyApp.toml, MyApp2.toml +# +# Note: The app's bundle identifier (e.g. com.mycompany.myapp) does NOT affect +# any of the above. RustDesk always uses "com.carriez.{APP_NAME}" internally. +# ======================================== +# + +# ========================== CONFIGURATION ========================== + +DEFAULTPATH='/Applications/RustDesk.app' +# On reinstall, clear the "stop-service" flag left by a previous uninstall: +# 1 = remove stop-service line from existing toml configs before installing +# (recommended for MDM redeployment — prevents the "service running but not +# connecting" issue when redeploying to a Mac that had the service stopped/uninstalled) +# 0 = leave existing config untouched (original behavior) +CLEAR_STOP_SERVICE=1 +# If config files don't exist: +# 1 = auto-create empty config files (recommended for custom clients / MDM deployment, +# server config is embedded in the binary at build time, service will populate on first run) +# 0 = exit with error (original behavior, useful when you need pre-configured toml files) +AUTO_CREATE_CONFIG=1 + +# ========================== END CONFIGURATION ========================== + +# Derive APP_NAME from DEFAULTPATH (e.g. /Applications/Test2.app -> Test2) +APP_NAME="$(basename "$DEFAULTPATH" .app)" + +# FULL_NAME matches RustDesk's get_full_name(): "{ORG}.{APP_NAME}" +# ORG is always "com.carriez" on macOS (hardcoded in config.rs) +FULL_NAME="com.carriez.${APP_NAME}" + +# Are we running as root - not really normal for a mac - maybe sudo'd +# Enhanced for MDM context where SUDO_USER and USER may be empty +FORUSER_OVERRIDE='' + +if [ $(id -u) -eq 0 ]; then + SUDO='' + FORUSER=${SUDO_USER:-$USER} + # In MDM postinstall context, both SUDO_USER and USER can be empty + if [ -z "$FORUSER" ] || [ "$FORUSER" = "root" ]; then + FORUSER=$(scutil <<< "show State:/Users/ConsoleUser" 2>/dev/null | awk '/Name :/ && !/loginwindow/ { print $3 }') + fi +else + SUDO='sudo' + FORUSER=$(id -un) +fi + +APP='' + +AGENT='' # Assets from Github +DAEMON='' +ASCPT='' + +USESCRIPT=0 # Use AppleScript instead of direct install +USE_ID='' # Set ID of this machine via id= in toml file +USE_PWD='' # Set permanent password to this if non-null +VIEW_ONLY=0 # Set by -a/-d/-s to skip the infinite wait loop + +agent_path="/Library/LaunchAgents/${FULL_NAME}_server.plist" +daemon_path="/Library/LaunchDaemons/${FULL_NAME}_service.plist" +root_pref_path="/var/root/Library/Preferences/$FULL_NAME" +# user_pref_path is set after FORUSER is resolved (depends on user's home directory) + +pref_files="${APP_NAME}.toml ${APP_NAME}2.toml" + +EnsureConfig() + { + local i + Msg "Verify $FORUSER ($FORUID) preferences..." + + # Ensure preference directory and config files exist + if [ ! -d "$user_pref_path" ]; then + if [ "$AUTO_CREATE_CONFIG" -eq 1 ]; then + Msg "Creating preference directory: $user_pref_path" + $SUDO mkdir -p "$user_pref_path" + $SUDO chown "$FORUSER" "$user_pref_path" + else + Throw "Preference directory $user_pref_path does not exist for user $FORUSER" + fi + fi + for i in $pref_files; do + if [ ! -s "$user_pref_path/$i" ]; then + if [ "$AUTO_CREATE_CONFIG" -eq 1 ]; then + Msg "Config file $i not found for user $FORUSER — creating empty config (service will populate on first run)" + $SUDO touch "$user_pref_path/$i" + $SUDO chown "$FORUSER" "$user_pref_path/$i" + else + Throw "Missing preference file $i for user $FORUSER - this is needed for the system-wide settings copy + + If running this in a VM + Copy a known good $FULL_NAME + from ~/Library/Preferences + on a real mac + (or a Linux .config/$FULL_NAME) + to $user_pref_path on this VM + + Then run this script again and use the -i ID option to + set the ID of this ${APP_NAME} node. + + Or set AUTO_CREATE_CONFIG=1 to auto-create empty config files. +" + fi + fi + done + } + +DownloadAssets() + { + APP_NAME_LOWER=$(echo "$APP_NAME" | tr '[:upper:]' '[:lower:]') + + # Read real bundle identifier from the app's Info.plist (matches macos.rs get_bundle_id()) + BUNDLE_ID=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$APP/Contents/Info.plist" 2>/dev/null) + if [ -z "$BUNDLE_ID" ]; then + Msg "Warning: Cannot read CFBundleIdentifier from $APP/Contents/Info.plist — using com.carriez.${APP_NAME_LOWER} as fallback" + BUNDLE_ID="com.carriez.${APP_NAME_LOWER}" + fi + + Msg 'Loading assets from github' + # Download plist templates and apply replacements to match correct_app_name() in macos.rs: + # 1. Replace AssociatedBundleIdentifiers (com.carriez.rustdesk → real bundle ID) + # 2. Replace lowercase "rustdesk" → lowercase app name (for log paths etc.) + # 3. Replace "RustDesk" → APP_NAME (for labels, executable name, app path) + AGENT=$(curl -sf 'https://raw.githubusercontent.com/rustdesk/rustdesk/master/src/platform/privileges_scripts/agent.plist' \ + | sed -e "s|com.carriez.rustdesk|${BUNDLE_ID}|g" \ + -e "s|rustdesk|${APP_NAME_LOWER}|g" \ + -e "s|RustDesk|${APP_NAME}|g" \ + -e "s|/Applications/${APP_NAME}.app|$APP|g") + DAEMON=$(curl -sf 'https://raw.githubusercontent.com/rustdesk/rustdesk/master/src/platform/privileges_scripts/daemon.plist' \ + | sed -e "s|com.carriez.rustdesk|${BUNDLE_ID}|g" \ + -e "s|rustdesk|${APP_NAME_LOWER}|g" \ + -e "s|RustDesk|${APP_NAME}|g" \ + -e "s|/Applications/${APP_NAME}.app|$APP|g") + # Apply same correct_app_name() replacements to install.scpt (matches macos.rs:200) + ASCPT=$(curl -sf 'https://raw.githubusercontent.com/rustdesk/rustdesk/master/src/platform/privileges_scripts/install.scpt' \ + | sed -e "s|com.carriez.rustdesk|${BUNDLE_ID}|g" \ + -e "s|rustdesk|${APP_NAME_LOWER}|g" \ + -e "s|RustDesk|${APP_NAME}|g") + [ -z "$AGENT" -o -z "$DAEMON" -o -z "$ASCPT" ] && Throw "Error getting one or more assets - url probably changed. (A/D/S sizes: ${#AGENT}/${#DAEMON}/${#ASCPT})" + } + +InstallViaAppleScript() + { + Msg "Current User: $FORUSER" + + # This script is pretty stoopid. It does not get the home directory e.g. by algorithm + $SUDO osascript -e "$ASCPT" "$DAEMON" "$AGENT" "$FORUSER" + } + +KickstartServerGUI() + { + if [ -s "$agent_path" ]; then + Msg "Kickstart server for $FORUID ($FORUSER)" + $SUDO launchctl enable "gui/$FORUID/${FULL_NAME}_server" 2>/dev/null + # kickstart -k = kill existing, -p = print PID + if ! $SUDO launchctl kickstart -kp "gui/$FORUID/${FULL_NAME}_server" 2>/dev/null; then + Msg "Kickstart skipped — GUI session for $FORUSER not available (expected in MDM context). Agent will auto-start on user login." + fi + else + Err "Ooops - where is $agent_path" + fi + } + +InstallViaShell() + { + Msg "Install agent: $agent_path" + echo "$AGENT" | $SUDO tee "$agent_path" >/dev/null + + Msg "Install daemon: $daemon_path" + echo "$DAEMON" | $SUDO tee "$daemon_path" >/dev/null + + # Should already be - but incase they were created by user error + $SUDO chown 0:0 "$agent_path" "$daemon_path" + + # Clear stale stop-service flag from previous uninstall (prevents service from + # ignoring rendezvous connections even though launchd started it successfully). + # The flag is written by RustDesk's uninstall_service() into {APP_NAME}2.toml + # (Config2 options store). Controlled by CLEAR_STOP_SERVICE option. + if [ "$CLEAR_STOP_SERVICE" -eq 1 ]; then + local opts_toml="${APP_NAME}2.toml" + for _cfg in "$user_pref_path/$opts_toml" "$root_pref_path/$opts_toml"; do + if [ -f "$_cfg" ] && $SUDO grep -qE '^[[:space:]]*"?stop-service"?[[:space:]]*=' "$_cfg" 2>/dev/null; then + Msg "Clearing stale stop-service flag in $_cfg" + $SUDO sed -i'.bak' '/^[[:space:]]*"*stop-service"*[[:space:]]*=/d' "$_cfg" + $SUDO rm -f "$_cfg.bak" + fi + done + fi + + local i + $SUDO mkdir -p "$root_pref_path" + for i in $pref_files; do + $SUDO cp -a "$user_pref_path/$i" "$root_pref_path" + done + + # Use modern launchctl API (bootstrap/bootout) instead of deprecated load/remove + # Daemon = system-wide (system domain), Agent = per-user (gui/ domain) + + Msg "Unload / Reload Daemon (system domain)" + # Ensure clean state: disable + bootout (handles cases where service is stuck) + $SUDO launchctl disable "system/${FULL_NAME}_service" 2>/dev/null + $SUDO launchctl bootout "system/${FULL_NAME}_service" 2>/dev/null + $SUDO launchctl bootout system "$daemon_path" 2>/dev/null + # Also try legacy unload in case bootstrap API state is inconsistent + $SUDO launchctl unload -w "$daemon_path" 2>/dev/null + sleep 1 + if ! $SUDO launchctl bootstrap system "$daemon_path" 2>/dev/null; then + Msg "bootstrap failed — falling back to launchctl load -w" + if ! $SUDO launchctl load -w "$daemon_path"; then + Err "Daemon load also failed. Checking /tmp/${APP_NAME_LOWER}_service.err for details." + $SUDO cat "/tmp/${APP_NAME_LOWER}_service.err" 2>/dev/null + Throw "Failed to load daemon ${FULL_NAME}_service" + fi + fi + $SUDO launchctl enable "system/${FULL_NAME}_service" 2>/dev/null + + Msg "Unload / Reload Agent for $FORUSER (gui/$FORUID domain)" + # Agent must be loaded into the user's GUI domain, not the system domain. + # In MDM postinstall context, the GUI domain may not exist yet (user not logged in). + # In that case, bootstrap will fail — this is expected. launchd will automatically + # load the agent from /Library/LaunchAgents/ when the user logs in. + $SUDO launchctl bootout "gui/$FORUID/${FULL_NAME}_server" >/dev/null 2>&1 + if $SUDO launchctl bootstrap "gui/$FORUID" "$agent_path" 2>/dev/null; then + $SUDO launchctl enable "gui/$FORUID/${FULL_NAME}_server" + else + Msg "Agent bootstrap skipped — GUI session for $FORUSER not available (expected in MDM context). Agent will auto-load on user login." + fi + } + +FindApp() + { + # First check DEFAULTPATH directly (supports custom paths outside /Applications) + if [ -d "$DEFAULTPATH" ]; then + APP="$DEFAULTPATH" + Msg "Found app at DEFAULTPATH: $APP" + return + fi + # Fall back: scan /Applications up to two levels deep + local d + APP='' + for d in /Applications /Applications/* /Applications/*/*; do if [ -d "$d" ]; then + [ -d "$d/${APP_NAME}.app" ] && { APP="$d/${APP_NAME}.app"; Msg "Found app: $APP"; break; } + fi; done + } + +IsServiceLoaded() + { + local p + p=$($SUDO launchctl list 2>/dev/null | while read pid sta com; do + [ "$com" = "$1" ] && { echo "$pid:$sta"; break; } + done) + [ -n "$p" ] + } + +Help() + { + Throw " + +Usage: ${0##*/} [options] + + -a Show Agent plist contents + -d Show Daemon plist contents + -h This help + -i id Set ${APP_NAME} ID (does not install service) + -p pwd Set ${APP_NAME} permanent password (does not install service) + -s Show Script contents + -S Use AppleScript to install - not recommended + -u user Specify target user (for MDM/headless context) + -v List the Agent/Daemon plists installed +" + } + + +# Begin Script Body +Msg "##################################################################" +Msg "# $(date) | Starting ${0##*/}" +Msg "#* *#" +Msg "#** If prompted for a password - that is proably sudo **#" +Msg "##################################################################" + +# === Phase 1: Parse all options === +while getopts 'adhi:p:sSvu:' o; do case "$o" in + 'a'|'d'|'s') VIEW_ONLY=1 ;; + 'h') Help ;; + 'i') USE_ID="$OPTARG" ;; + 'p') USE_PWD="$OPTARG" ;; + 'u') FORUSER_OVERRIDE="$OPTARG" ;; + 'v') ls -l "$agent_path" "$daemon_path" 2>/dev/null; Throw 'Done.' ;; + 'S') USESCRIPT=1 ;; + *) ;; +esac; done +OPTIND=1 # Reset for second pass + +# === Phase 1.5: Handle -i/-p (set ID/password only, no service install) === +if [ -n "$USE_ID" ] || [ -n "$USE_PWD" ]; then + FindApp + while [ ! -d "$APP" ]; do + Msg "Could not find ${APP_NAME}.app installed in /Applications/... - sleeping 30" + sleep 30 + FindApp + done + local_bin="${APP}/Contents/MacOS/${APP_NAME}" + if [ -n "$USE_ID" ]; then + Msg "Setting ID to: $USE_ID" + $SUDO "$local_bin" --set-id "$USE_ID" + fi + if [ -n "$USE_PWD" ]; then + Msg "Setting permanent password" + $SUDO "$local_bin" --password "$USE_PWD" + fi + Throw "Done." 0 +fi + +# === Phase 2: Find app + download assets (needed by -a/-d/-s view options) === +FindApp + +# Wait for app to be installed (e.g. MDM deployment in progress) +# For view-only options (-a/-d/-s), don't loop — fail fast +while [ ! -d "$APP" ]; do + if [ "$VIEW_ONLY" -eq 1 ]; then + Throw "Cannot find ${APP_NAME}.app in /Applications — required to generate plist/script content" + fi + Msg "Could not find ${APP_NAME}.app installed in /Applications/... - sleeping 30" + sleep 30 + FindApp +done + +Msg "##################################################################" +# DownloadAssets requires $APP to be set (reads Info.plist, substitutes app path in plists) +DownloadAssets + +# === Phase 3: Handle view options that only need downloaded assets (-a/-d/-s) === +# Also collect install option (-S) for later use +while getopts 'adhsSvu:' o; do case "$o" in + 'a') echo "$AGENT"; Throw 'Done.' ;; + 'd') echo "$DAEMON"; Throw 'Done.' ;; + 'h') Help ;; + 's') echo "$ASCPT"; Throw 'Done.' ;; + 'S') USESCRIPT=1 ;; + 'u') ;; # Already handled in phase 1 + 'v') ;; # Already handled in phase 1 +esac; done; shift $(($OPTIND - 1)) + +# === Phase 4: Resolve target user (only needed for actual installation) === + +# Apply user override if specified via -u +if [ -n "$FORUSER_OVERRIDE" ]; then + FORUSER="$FORUSER_OVERRIDE" +fi + +# Validate FORUSER +if [ -z "$FORUSER" ] || [ "$FORUSER" = "root" ]; then + Throw "Cannot determine target user. In MDM context, use -u to specify the target user." +fi + +FORUID=$(id -u "$FORUSER" 2>/dev/null) || Throw "User '$FORUSER' not found on this system" + +# Resolve user's home directory for preference path +# Always use dscl when FORUSER differs from current user (covers both root and non-root with -u) +if [ "$FORUSER" != "$(id -un)" ]; then + user_home=$(dscl . -read "/Users/$FORUSER" NFSHomeDirectory 2>/dev/null | sed 's/^NFSHomeDirectory:[[:space:]]*//') + [ -z "$user_home" ] && Throw "Cannot resolve home directory for user $FORUSER" +else + user_home="$HOME" +fi +user_pref_path="${user_home}/Library/Preferences/$FULL_NAME" + +# === Phase 5: Install === + +IsServiceLoaded "${FULL_NAME}_service" && \ + Throw "${FULL_NAME}_service is already loaded" + +# EnsureConfig creates preference dirs/files only when actually installing (no side effects for view options) +EnsureConfig + +Msg "******* STARTING SERVICE INSTALLATION *******" +[ $USESCRIPT -eq 1 ] && InstallViaAppleScript || InstallViaShell +KickstartServerGUI + +Msg "Done. Note - if you move the app, you will have to reinstall or edit the LaunchDaemon and LaunchAgent plists" +ls -l "$agent_path" "$daemon_path" + +``` + +
+ +## When Do You Need This Script? + +For normal desktop installations, just click the "Install" button inside the RustDesk app. This script is designed for: + +- Remote deployment via SSH (no GUI access) +- MDM batch deployment (postinstall script) +- VM environments (headless setup) +- Any scenario where you need to automate service registration without user interaction + +## What the Script Does + +The script installs two launchd plist files and loads them: + +| Component | Plist Path | Runs As | Purpose | +|-----------|-----------|---------|---------| +| Daemon | `/Library/LaunchDaemons/com.carriez._service.plist` | root | IPC config management service — runs at boot, no login required | +| Agent | `/Library/LaunchAgents/com.carriez._server.plist` | user | RustDesk server process — handles screen capture, input, audio, remote connections | + +Both are set to auto-start: the daemon starts at boot, the agent starts at the login screen (LoginWindow session) and persists through user login (Aqua session). + +## What the Script Does NOT Do + +The script does not grant macOS privacy permissions. Screen Recording, Accessibility, and Input Monitoring permissions must be granted separately: + +- Manually: System Settings > Privacy & Security +- MDM: Deploy a PPPC (Privacy Preferences Policy Control) configuration profile + +Without these permissions, the service will run, but remote control will not work properly. + +## Prerequisites + +- macOS with the app already installed in `/Applications/` (or a subfolder) +- Admin privileges (`sudo`) +- Internet access (to download plist templates from GitHub) + +## Quick Start + +### Standard RustDesk + +```bash +sudo bash install_service.sh +``` + +### Custom Client + +Edit the `DEFAULTPATH` variable at the top of the script: + +```bash +DEFAULTPATH='/Applications/MyCustomApp.app' +``` + +Everything else (app name, plist labels, config paths) is auto-derived from this single path: + +| Item | Example (`DEFAULTPATH = /Applications/MyApp.app`) | +|------|---------------------------------------------------| +| APP_NAME | `MyApp` | +| Plist label | `com.carriez.MyApp_service` / `com.carriez.MyApp_server` | +| Config dir | `~/Library/Preferences/com.carriez.MyApp/` | +| Config files | `MyApp.toml`, `MyApp2.toml` | + +> The app's bundle identifier (e.g., `com.mycompany.myapp`) does not affect these paths. RustDesk always uses `com.carriez.` internally. + +## Options + +``` +Usage: install_service.sh [options] + + -i id Set the RustDesk ID (does not install service) + -p pwd Set a permanent password (does not install service) + -u user Specify target user (for MDM/headless context) + -S Use AppleScript to install (not recommended) + -a Show generated Agent plist (view only) + -d Show generated Daemon plist (view only) + -s Show generated AppleScript (view only) + -v List installed plist files + -h Help +``` + +### Examples + +```bash +# Set ID and password (service must already be installed) +sudo bash install_service.sh -i 123456789 -p "MySecurePassword" + +# Install the auto-start service +sudo bash install_service.sh + +# MDM deployment — specify the target user explicitly +sudo bash install_service.sh -u johndoe + +# Preview the generated daemon plist without installing +bash install_service.sh -d +``` + +## Configuration Flags + +Two flags at the top of the script control install behavior: + +| Flag | Default | Description | +|------|---------|-------------| +| `AUTO_CREATE_CONFIG` | `1` | Auto-create empty config files if they don't exist. Set to `0` to require pre-configured toml files. | +| `CLEAR_STOP_SERVICE` | `0` | Remove the `stop-service` flag from existing config on reinstall. Set to `1` for MDM redeployment to a machine that previously had the service uninstalled. | + +## Re-running the Script + +If the service is already loaded, the script exits immediately with: + +``` +com.carriez._service is already loaded +``` + +This is safe — existing plist files and auto-start are not affected. To force reinstall, unload the service first: + +```bash +sudo launchctl bootout system/com.carriez._service +``` + +## Uninstalling + +```bash +# Replace with your app name (e.g., RustDesk, MyApp) +sudo launchctl bootout system/com.carriez._service +sudo launchctl bootout gui/$(id -u)/com.carriez._server +sudo rm /Library/LaunchDaemons/com.carriez._service.plist +sudo rm /Library/LaunchAgents/com.carriez._server.plist +``` + +## Troubleshooting + +| Problem | Solution | +|---------|----------| +| "Cannot determine target user" | In MDM context, use `-u ` to specify the user | +| "Could not find app installed in /Applications" | The script retries every 30 seconds. Ensure the `.app` bundle exists in `/Applications/` or a subfolder | +| Service loaded but not connecting | Set `CLEAR_STOP_SERVICE=1` and reinstall — a previous uninstall may have left a `stop-service` flag in config | +| Remote control not working after install | Privacy permissions not granted. Deploy a PPPC profile via MDM, or grant Screen Recording / Accessibility / Input Monitoring manually in System Settings | +