#!/bin/bash # macOS 13+ installation / exhibition setup. Run as the kiosk user, NOT with sudo. # Optional: edit these two values before double-clicking this file. KIOSK_URL="" FOCUS_SHORTCUT="Kiosk Focus On" IDLE_SECONDS=10 set -euo pipefail export PATH=/usr/bin:/bin:/usr/sbin:/sbin umask 077 SCRIPT_DIR="$(cd -- "$(dirname -- "$0")" && pwd -P)" ROOT="$HOME/Library/Application Support/Mac Kiosk" AGENTS="$HOME/Library/LaunchAgents" PREFIX="local.mac-kiosk" GUI="gui/$(id -u)" SKIP_FOCUS=0 ACTION=install CHROME_APP="" TEMP_DIR="" say() { printf '%s\n' "$*"; } die() { say "ERROR: $*" >&2; exit 1; } cleanup() { if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then rm -rf -- "$TEMP_DIR"; fi; } trap cleanup EXIT usage() { cat <<'HELP' Usage (run as the logged-in kiosk user): ./mac-kiosk.command install [--url 'https://example.com'] [--focus-shortcut 'Kiosk Focus On'] [--idle-seconds 10] [--skip-focus] ./mac-kiosk.command status ./mac-kiosk.command stop # stop login helpers now; keep settings/files ./mac-kiosk.command start # restart login helpers ./mac-kiosk.command uninstall # stop helpers and restore saved settings Chrome is downloaded from Google and installed if missing, even without --url. With --url, Chrome launches full screen at login and relaunches if it exits. Without --url, Chrome is installed but is not automatically opened. Before install: in Shortcuts, create "Kiosk Focus On" with one action: Set Focus -> Turn Do Not Disturb On -> Until Turned Off. Configure DND to allow no people/apps, repeated calls or time-sensitive alerts. Turn off Share Across Devices and Intelligent Breakthrough & Silencing. --skip-focus is only for a Mac where you will manage notifications manually. Unattended boot also requires System Settings -> Users & Groups -> Automatically log in as (FileVault or device management can prevent this). Power recovery is hardware dependent; deliberate shutdown while power stays connected cannot be reversed by a script. See README.md for exact limits. HELP } if [[ $# -gt 0 ]]; then case "$1" in install|status|stop|start|uninstall) ACTION="$1"; shift ;; -h|--help) usage; exit 0 ;; --*) ;; # default action is install *) usage; die "Unknown action: $1" ;; esac fi while [[ $# -gt 0 ]]; do case "$1" in --url) [[ $# -ge 2 ]] || die "--url needs a value"; KIOSK_URL="$2"; shift 2 ;; --focus-shortcut) [[ $# -ge 2 ]] || die "--focus-shortcut needs a name"; FOCUS_SHORTCUT="$2"; shift 2 ;; --idle-seconds) [[ $# -ge 2 ]] || die "--idle-seconds needs a number"; IDLE_SECONDS="$2"; shift 2 ;; --skip-focus) SKIP_FOCUS=1; shift ;; -h|--help) usage; exit 0 ;; *) die "Unknown option: $1" ;; esac done [[ "$(uname -s)" == Darwin ]] || die "This script requires macOS." [[ "$EUID" -ne 0 ]] || die "Run this without sudo, as the kiosk user. It requests sudo only when needed." jobs=(awake cursor focus chrome) stop_agents() { local job for job in "${jobs[@]}"; do launchctl bootout "$GUI/$PREFIX.$job" 2>/dev/null || true done } start_agents() { local job file for job in "${jobs[@]}"; do file="$AGENTS/$PREFIX.$job.plist" if [[ -f "$file" ]]; then launchctl enable "$GUI/$PREFIX.$job" launchctl bootstrap "$GUI" "$file" fi done } case "$ACTION" in status) say "Power settings:"; pmset -g custom say "Screen saver (global / this Mac):" defaults read com.apple.screensaver idleTime 2>/dev/null || true defaults -currentHost read com.apple.screensaver idleTime 2>/dev/null || true say "Login helpers:" for job in "${jobs[@]}"; do if launchctl print "$GUI/$PREFIX.$job" > /dev/null 2>&1; then say " $job: loaded (inspect $ROOT/$job.log for failures)" else say " $job: not loaded"; fi done if [[ -f "$ROOT/config.txt" ]]; then cat "$ROOT/config.txt"; fi say "Power assertions:"; pmset -g assertions exit 0 ;; stop) stop_agents; say "Helpers stopped. Persistent power/screen saver settings still apply."; exit 0 ;; start) [[ -d "$ROOT" ]] || die "Install first."; stop_agents; start_agents; say "Helpers started."; exit 0 ;; uninstall) [[ -f "$ROOT/backup/pmset.tsv" ]] || die "No backup found at $ROOT/backup." sudo -v stop_agents while IFS=$'\t' read -r source key value; do case "$source" in -a|-b|-c|-u) ;; *) die "Invalid backup power source" ;; esac case "$key" in sleep|displaysleep|disksleep|autorestart|autorestartatconnect) ;; *) die "Invalid backup key" ;; esac [[ "$value" =~ ^[0-9]+$ ]] || die "Invalid backup value" sudo pmset "$source" "$key" "$value" done < "$ROOT/backup/pmset.tsv" for scope in global host; do value="$(cat "$ROOT/backup/screensaver-$scope.txt")" if [[ "$value" == absent ]]; then if [[ "$scope" == host ]]; then defaults -currentHost delete com.apple.screensaver idleTime 2>/dev/null || true else defaults delete com.apple.screensaver idleTime 2>/dev/null || true; fi else [[ "$value" =~ ^[0-9]+$ ]] || die "Invalid screen saver backup" if [[ "$scope" == host ]]; then defaults -currentHost write com.apple.screensaver idleTime -int "$value" else defaults write com.apple.screensaver idleTime -int "$value"; fi fi done for job in "${jobs[@]}"; do rm -f -- "$AGENTS/$PREFIX.$job.plist"; done say "Uninstalled helpers and restored saved power/screen saver values." say "Chrome, the kiosk profile, logs and backup remain at: $ROOT" say "Turn off DND in Control Center if desired. Automatic login/Focus setup are manual settings." exit 0 ;; esac version="$(sw_vers -productVersion)" [[ "${version%%.*}" -ge 13 ]] || die "Current Chrome requires macOS 13 or newer." [[ "$IDLE_SECONDS" =~ ^[0-9]+$ && "$IDLE_SECONDS" -ge 1 && "$IDLE_SECONDS" -le 3600 ]] || die "Idle seconds must be 1..3600." if [[ -n "$KIOSK_URL" ]]; then case "$KIOSK_URL" in http://?*|https://?*|file:///?*) ;; *) die "Use an http://, https:// or file:/// URL." ;; esac [[ "$KIOSK_URL" != *$'\n'* && "$KIOSK_URL" != *$'\r'* ]] || die "URL must fit on one line." fi [[ "$FOCUS_SHORTCUT" != *$'\n'* && "$FOCUS_SHORTCUT" != *$'\r'* ]] || die "Shortcut name must fit on one line." [[ "$(stat -f %Su /dev/console)" == "$(id -un)" ]] || die "Run locally in the kiosk user's logged-in desktop session." if [[ "$SKIP_FOCUS" == 0 ]]; then [[ -n "$FOCUS_SHORTCUT" ]] || die "Choose a Focus shortcut or explicitly use --skip-focus." if ! shortcuts list | /usr/bin/grep -Fx -- "$FOCUS_SHORTCUT" >/dev/null; then usage die "Create the shortcut '$FOCUS_SHORTCUT' in Shortcuts, then run again. No kiosk settings have been changed." fi fi TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mac-kiosk.XXXXXX")" # Prefer the included universal binary. Source is included for rebuilding. if [[ -x "$SCRIPT_DIR/kiosk-cursor" ]]; then cp "$SCRIPT_DIR/kiosk-cursor" "$TEMP_DIR/kiosk-cursor" elif [[ -f "$SCRIPT_DIR/CursorHider.swift" ]] && xcode-select -p >/dev/null 2>&1; then say "Building cursor helper from source..." xcrun swiftc -swift-version 5 -O "$SCRIPT_DIR/CursorHider.swift" -o "$TEMP_DIR/kiosk-cursor" else die "Keep kiosk-cursor beside this script (use the ZIP), or install Command Line Tools with xcode-select --install and include CursorHider.swift." fi # Checks compatibility only: this does not hide the cursor or change settings. "$TEMP_DIR/kiosk-cursor" --check # Validate the manually configured shortcut before changing persistent settings. if [[ "$SKIP_FOCUS" == 0 ]]; then shortcuts run "$FOCUS_SHORTCUT" || die "Focus shortcut failed. Fix it, then rerun install." fi sudo -v for app in "/Applications/Google Chrome.app" "$HOME/Applications/Google Chrome.app"; do if [[ -x "$app/Contents/MacOS/Google Chrome" ]]; then CHROME_APP="$app"; break; fi done if [[ -z "$CHROME_APP" ]]; then say "Downloading Chrome's universal installer from Google..." curl --fail --location --proto '=https' --proto-redir '=https' \ --retry 3 --connect-timeout 15 --max-time 900 \ --output "$TEMP_DIR/GoogleChrome.pkg" \ 'https://dl.google.com/dl/chrome/mac/universal/stable/gcem/GoogleChrome.pkg' pkgutil --check-signature "$TEMP_DIR/GoogleChrome.pkg" > "$TEMP_DIR/signature.txt" cat "$TEMP_DIR/signature.txt" /usr/bin/grep -Eq 'Developer ID Installer: Google (LLC|Inc\.) \(EQHXZ8M8AV\)' "$TEMP_DIR/signature.txt" \ || die "Installer was not signed by the expected Google developer identity." spctl --assess --type install "$TEMP_DIR/GoogleChrome.pkg" \ || die "macOS did not accept Google's installer signature." sudo /usr/sbin/installer -pkg "$TEMP_DIR/GoogleChrome.pkg" -target / CHROME_APP="/Applications/Google Chrome.app" [[ -x "$CHROME_APP/Contents/MacOS/Google Chrome" ]] || die "Chrome installation did not produce the expected application." fi say "Using: $CHROME_APP" mkdir -p "$ROOT" "$AGENTS" # Retain the installer so maintenance and undo do not depend on Downloads. if [[ ! "$0" -ef "$ROOT/setup.command" ]]; then cp "$0" "$ROOT/setup.command"; fi chmod 700 "$ROOT/setup.command" if [[ ! -d "$ROOT/backup" ]]; then mkdir "$TEMP_DIR/backup" pmset -g custom > "$TEMP_DIR/backup/pmset-original.txt" awk ' /^Battery Power:/ { source="-b"; next } /^AC Power:/ { source="-c"; next } /^UPS Power:/ { source="-u"; next } source && $1 ~ /^(sleep|displaysleep|disksleep|autorestart|autorestartatconnect)$/ && $2 ~ /^[0-9]+$/ { printf "%s\t%s\t%s\n", source, $1, $2 } ' "$TEMP_DIR/backup/pmset-original.txt" > "$TEMP_DIR/backup/pmset.tsv" [[ -s "$TEMP_DIR/backup/pmset.tsv" ]] || die "Could not save existing power settings." for scope in global host; do if [[ "$scope" == host ]]; then value="$(defaults -currentHost read com.apple.screensaver idleTime 2>/dev/null || printf absent)" else value="$(defaults read com.apple.screensaver idleTime 2>/dev/null || printf absent)"; fi [[ "$value" == absent || "$value" =~ ^[0-9]+$ ]] || die "Unexpected screen saver preference: $value" printf '%s\n' "$value" > "$TEMP_DIR/backup/screensaver-$scope.txt" done mv "$TEMP_DIR/backup" "$ROOT/backup" fi stop_agents for job in "${jobs[@]}"; do rm -f -- "$AGENTS/$PREFIX.$job.plist"; done cp "$TEMP_DIR/kiosk-cursor" "$ROOT/kiosk-cursor" chmod 700 "$ROOT/kiosk-cursor" sudo pmset -a sleep 0 displaysleep 0 disksleep 0 defaults write com.apple.screensaver idleTime -int 0 defaults -currentHost write com.apple.screensaver idleTime -int 0 say "Idle sleep, display sleep and automatic screen saver disabled." # Unsupported pmset switches can be silently ignored. Set only advertised keys # and verify readback instead of claiming unsupported hardware can restart. for key in autorestart autorestartatconnect; do if pmset -g custom | awk -v k="$key" '$1==k {found=1} END {exit !found}'; then sudo pmset -a "$key" 1 if pmset -g custom | awk -v k="$key" '$1==k {found=1; if($2!=1)bad=1} END {exit (!found || bad)}'; then say "Enabled supported power setting: $key" else say "MANUAL: $key did not read back as enabled. Check System Settings -> Energy."; fi else say "MANUAL: $key is not exposed on this Mac. Check System Settings -> Energy." fi done cat > "$ROOT/focus-loop.sh" <<'FOCUS' #!/bin/bash export PATH=/usr/bin:/bin:/usr/sbin:/sbin while true; do /usr/bin/shortcuts run "$1" || printf '%s Focus shortcut failed; check Shortcuts permissions.\n' "$(date)" >&2 /bin/sleep 60 done FOCUS chmod 700 "$ROOT/focus-loop.sh" cat > "$ROOT/chrome-launch.sh" <<'CHROME' #!/bin/bash # Focus and Chrome are separate services; enforce Focus before browser startup. export PATH=/usr/bin:/bin:/usr/sbin:/sbin if [[ -n "$4" ]]; then until /usr/bin/shortcuts run "$4"; do printf '%s Focus failed; retrying before opening Chrome.\n' "$(date)" >&2 /bin/sleep 10 done fi exec "$1" "--user-data-dir=$2" --kiosk --no-first-run \ --no-default-browser-check --disable-session-crashed-bubble \ --disable-notifications "$3" CHROME chmod 700 "$ROOT/chrome-launch.sh" xml_escape() { # XML element text only needs ampersand and angle brackets escaped. printf '%s' "$1" | /usr/bin/sed -e 's/\&/\&/g' -e 's//\>/g' } write_agent() { local job="$1" arg file shift file="$AGENTS/$PREFIX.$job.plist" { cat <
Label$PREFIX.$job ProgramArguments HEADER for arg in "$@"; do printf '%s\n' "$(xml_escape "$arg")"; done cat <