diff --git a/api/create_checkout.py b/api/create_checkout.py new file mode 100644 index 000000000..7ac704acf --- /dev/null +++ b/api/create_checkout.py @@ -0,0 +1,108 @@ +import sys +import uuid +import requests + +TOKEN_FILE = "/var/www/html/config/sumup_token.txt" + +ACCESS_TOKEN = "" +MERCHANT_CODE = "" +AMOUNT_CENTS = 0 +RETURN_URL = "" + +API_BASE_V01 = "https://api.sumup.com/v0.1" + + +def load_access_token(): + global ACCESS_TOKEN + + try: + with open(TOKEN_FILE, "r", encoding="utf-8") as f: + ACCESS_TOKEN = f.read().strip() + except Exception as e: + print(f"ERROR: token file unreadable: {e}", file=sys.stderr) + sys.exit(1) + + if not ACCESS_TOKEN: + print("ERROR: token file is empty", file=sys.stderr) + sys.exit(1) + + +def auth_headers(): + return { + "Authorization": f"Bearer {ACCESS_TOKEN}", + "Content-Type": "application/json", + } + + +def parse_args(): + global MERCHANT_CODE, AMOUNT_CENTS, RETURN_URL + + if len(sys.argv) < 4: + print( + "Usage: create_checkout.py MERCHANT_CODE AMOUNT_CENTS RETURN_URL", + file=sys.stderr, + ) + sys.exit(1) + + MERCHANT_CODE = sys.argv[1].strip() + + try: + AMOUNT_CENTS = int(sys.argv[2]) + except ValueError: + print("ERROR: AMOUNT_CENTS must be an integer", file=sys.stderr) + sys.exit(1) + + RETURN_URL = sys.argv[3].strip() + + if not MERCHANT_CODE: + print("ERROR: MERCHANT_CODE missing", file=sys.stderr) + sys.exit(1) + + if AMOUNT_CENTS <= 0: + print("ERROR: AMOUNT_CENTS invalid", file=sys.stderr) + sys.exit(1) + + if not RETURN_URL: + print("ERROR: RETURN_URL missing", file=sys.stderr) + sys.exit(1) + + +def main(): + load_access_token() + parse_args() + + checkout_reference = "PHOTO-" + str(uuid.uuid4())[:8] + amount_eur = AMOUNT_CENTS / 100.0 + + url = f"{API_BASE_V01}/checkouts" + + payload = { + "checkout_reference": checkout_reference, + "amount": amount_eur, + "currency": "EUR", + "merchant_code": MERCHANT_CODE, + "description": "Fotobox Ausdruck", + "hosted_checkout": {"enabled": True}, + "return_url": RETURN_URL, + } + + response = requests.post(url, json=payload, headers=auth_headers(), timeout=30) + + print("Status:", response.status_code) + print("Antwort:", response.text) + + if response.status_code != 201: + sys.exit(1) + + data = response.json() + payment_link = data.get("hosted_checkout_url", "").strip() + + if not payment_link: + print("ERROR: hosted_checkout_url missing", file=sys.stderr) + sys.exit(1) + + print(payment_link) + + +if __name__ == "__main__": + main() diff --git a/api/paymentStatus.php b/api/paymentStatus.php new file mode 100644 index 000000000..d3dfc0608 --- /dev/null +++ b/api/paymentStatus.php @@ -0,0 +1,38 @@ + 'missing', + 'paid' => false, + 'printed' => false, + ]); + exit; +} + +$data = json_decode((string)file_get_contents($jobFile), true); + +if (!is_array($data)) { + echo json_encode([ + 'status' => 'invalid', + 'paid' => false, + 'printed' => false, + ]); + exit; +} + +echo json_encode([ + 'status' => 'ok', + 'paid' => (bool)($data['paid'] ?? false), + 'printed' => (bool)($data['printed'] ?? false), + 'filename' => (string)($data['filename'] ?? ''), +]); diff --git a/api/runConfiguredPrint.php b/api/runConfiguredPrint.php new file mode 100644 index 000000000..d9d0e7efc --- /dev/null +++ b/api/runConfiguredPrint.php @@ -0,0 +1,94 @@ +getConfiguration(); +$printCmd = (string)($config['commands']['print'] ?? ''); + +if ($printCmd === '') { + fwrite(STDERR, "ERROR: commands.print is empty in Photobooth config\n"); + exit(1); +} + +// Logger initialisieren (Schreibt in var/log/main.log) +$logger = LoggerService::getInstance()->getLogger('main'); + +// Pfad zum Bild auflösen +$resolvedFilename = $filename; +if (!str_starts_with($resolvedFilename, '/')) { + $resolvedFilename = PathUtility::getAbsolutePath('data/print/' . ltrim($resolvedFilename, '/')); +} + +// Druckbefehl vorbereiten (Platzhalter %s ersetzen) +$cmd = str_replace('%s', escapeshellarg($resolvedFilename), $printCmd); + +$output = []; +$returnVar = 1; + +// Logge den Start des Vorgangs +$logger->info('Payment Print: Starte System-Druckbefehl', [ + 'file' => $resolvedFilename, + 'copies' => $copies +]); + +// Befehl ausführen und Rückgabe sowie Fehler (2>&1) abfangen +exec($cmd . ' 2>&1', $output, $returnVar); + +$outputString = implode(' ', $output); + +// Ergebnis verarbeiten und loggen +if ($returnVar === 0) { + // Erfolgreich (an das Betriebssystem übergeben) + $logger->info('Payment Print: Druck erfolgreich ausgelöst', [ + 'output' => $outputString + ]); +} else { + // Fehler beim Druckbefehl + $logger->error('Payment Print: Fehler beim Ausführen des Druckbefehls', [ + 'return_code' => $returnVar, + 'cmd' => $cmd, + 'output' => $outputString + ]); +} + +// Backup-Log in private/ (hilfreich für Standalone-Debugging) +$logFile = PathUtility::getAbsolutePath('private/payment-print.log'); +$logEntry = sprintf( + "[%s] File: %s | Return: %d | Output: %s\n", + date('c'), + $resolvedFilename, + $returnVar, + $outputString +); +file_put_contents($logFile, $logEntry, FILE_APPEND); + +exit($returnVar); diff --git a/api/startPaymentPrint.php b/api/startPaymentPrint.php new file mode 100644 index 000000000..af750b7c6 --- /dev/null +++ b/api/startPaymentPrint.php @@ -0,0 +1,307 @@ +getConfiguration(); + + if (empty($config['payments']['enabled'])) { + echo json_encode([ + 'status' => 'disabled', + 'error' => 'Payment system disabled', + ]); + exit; + } + + $filename = trim((string)($_POST['filename'] ?? '')); + $copies = (int)($_POST['copies'] ?? 1); + + if ($filename === '') { + http_response_code(400); + echo json_encode([ + 'status' => 'error', + 'error' => 'Missing filename', + ]); + exit; + } + + $provider = trim((string)($config['payments']['provider'] ?? 'none')); + $displayMode = trim((string)($config['payments']['display_mode'] ?? 'solo')); + $webhookUrl = rtrim(trim((string)($config['payments']['webhook_url'] ?? '')), '/'); + + $merchantCode = trim((string)($config['payments']['sumup']['merchant_code'] ?? '')); + $readerId = trim((string)($config['payments']['sumup']['reader_id'] ?? '')); + $affiliateKey = trim((string)($config['payments']['sumup']['affiliate_key'] ?? '')); + + $amountCentsRaw = $config['payments']['price_cents'] ?? 0; + $amountCents = (int)$amountCentsRaw; + + $python = '/usr/bin/python3'; + $soloScript = PathUtility::getAbsolutePath('api/sumup_solo.py'); + $checkoutScript = PathUtility::getAbsolutePath('api/create_checkout.py'); + + $logFile = PathUtility::getAbsolutePath('private/payment-print.log'); + $jobFile = PathUtility::getAbsolutePath('private/photobooth_current_print.json'); + $soloBgLog = '/tmp/sumup_solo_both.log'; + + $logLines = [ + '[' . date('c') . '] startPaymentPrint', + 'filename=' . $filename, + 'copies=' . $copies, + 'provider=' . $provider, + 'display_mode=' . $displayMode, + 'merchant_code=' . $merchantCode, + 'reader_id=' . $readerId, + 'affiliate_key_present=' . ($affiliateKey !== '' ? 'yes' : 'no'), + 'amount_cents=' . $amountCents, + 'webhook_url=' . $webhookUrl, + ]; + + if ($provider !== 'sumup') { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'Payment provider is not SumUp', + ]); + exit; + } + + if ($merchantCode === '') { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'SumUp Merchant Code missing', + ]); + exit; + } + + if ($amountCents <= 0) { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'Price (cents) is invalid or missing', + ]); + exit; + } + + if ($displayMode === 'solo') { + if ($readerId === '') { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'SumUp Reader ID missing', + ]); + exit; + } + + if ($affiliateKey === '') { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'SumUp Affiliate Key missing', + ]); + exit; + } + + if (!is_file($soloScript)) { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'sumup_solo.py not found', + ]); + exit; + } + + $cmd = escapeshellcmd($python) . ' ' . + escapeshellarg($soloScript) . ' ' . + escapeshellarg($merchantCode) . ' ' . + escapeshellarg($readerId) . ' ' . + escapeshellarg($affiliateKey) . ' ' . + escapeshellarg((string)$amountCents) . ' 2>&1'; + + $output = []; + $returnVar = 1; + exec($cmd, $output, $returnVar); + + $logLines[] = 'command=' . $cmd; + $logLines[] = 'return_code=' . $returnVar; + $logLines[] = 'output:'; + $logLines[] = implode(PHP_EOL, $output); + $logLines[] = ''; + + file_put_contents($logFile, implode(PHP_EOL, $logLines) . PHP_EOL, FILE_APPEND); + + if ($returnVar === 0) { + echo json_encode([ + 'status' => 'success', + 'message' => 'Payment successful - printing starts...', + ]); + exit; + } + + echo json_encode([ + 'status' => 'error', + 'error' => 'Payment failed or was cancelled', + 'log' => implode("\n", $output), + ]); + exit; + } + + if ($displayMode === 'qr' || $displayMode === 'both') { + if ($webhookUrl === '') { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'ngrok URL / webhook_url missing', + ]); + exit; + } + + if (!is_file($checkoutScript)) { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'create_checkout.py not found', + ]); + exit; + } + + $returnUrl = $webhookUrl . '/sumup/webhook'; + + $checkoutCmd = escapeshellcmd($python) . ' ' . + escapeshellarg($checkoutScript) . ' ' . + escapeshellarg($merchantCode) . ' ' . + escapeshellarg((string)$amountCents) . ' ' . + escapeshellarg($returnUrl) . ' 2>&1'; + + $checkoutOutput = []; + $checkoutReturnVar = 1; + exec($checkoutCmd, $checkoutOutput, $checkoutReturnVar); + + $paymentUrl = ''; + if (!empty($checkoutOutput)) { + $paymentUrl = trim(end($checkoutOutput)); + } + + $logLines[] = 'checkout_command=' . $checkoutCmd; + $logLines[] = 'checkout_return_code=' . $checkoutReturnVar; + $logLines[] = 'checkout_output:'; + $logLines[] = implode(PHP_EOL, $checkoutOutput); + + if ($checkoutReturnVar !== 0 || $paymentUrl === '' || strpos($paymentUrl, 'https://') !== 0) { + $logLines[] = ''; + file_put_contents($logFile, implode(PHP_EOL, $logLines) . PHP_EOL, FILE_APPEND); + + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'The QR payment link could not be generated.', + 'log' => implode("\n", $checkoutOutput), + ]); + exit; + } + + $jobData = json_encode([ + 'filename' => $filename, + 'copies' => $copies, + 'printed' => false, + 'paid' => false, + 'created_at' => date('c'), + ], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + + $jobWriteResult = file_put_contents($jobFile, $jobData); + + $logLines[] = 'job_file=' . $jobFile; + $logLines[] = 'job_write_result=' . var_export($jobWriteResult, true); + $logLines[] = 'payment_url=' . $paymentUrl; + + if ($displayMode === 'qr') { + $logLines[] = ''; + file_put_contents($logFile, implode(PHP_EOL, $logLines) . PHP_EOL, FILE_APPEND); + + echo json_encode([ + 'status' => 'qr', + 'payment_url' => $paymentUrl, + 'message' => 'QR payment ready', + ]); + exit; + } + + if ($readerId === '') { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'SumUp Reader ID missing', + ]); + exit; + } + + if ($affiliateKey === '') { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'SumUp Affiliate Key missing', + ]); + exit; + } + + if (!is_file($soloScript)) { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'sumup_solo.py not found', + ]); + exit; + } + + $soloCmd = 'nohup ' . + escapeshellcmd($python) . ' ' . + escapeshellarg($soloScript) . ' ' . + escapeshellarg($merchantCode) . ' ' . + escapeshellarg($readerId) . ' ' . + escapeshellarg($affiliateKey) . ' ' . + escapeshellarg((string)$amountCents) . + ' >> ' . escapeshellarg($soloBgLog) . ' 2>&1 &'; + + exec($soloCmd); + + $logLines[] = 'solo_background_command=' . $soloCmd; + $logLines[] = ''; + + file_put_contents($logFile, implode(PHP_EOL, $logLines) . PHP_EOL, FILE_APPEND); + + echo json_encode([ + 'status' => 'both', + 'payment_url' => $paymentUrl, + 'message' => 'QR payment ready, Terminal started in background', + ]); + exit; + } + + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'error' => 'Invalid payment method', + ]); +} catch (\Throwable $e) { + http_response_code(500); + + $logFile = PathUtility::getAbsolutePath('private/payment-print.log'); + file_put_contents( + $logFile, + '[' . date('c') . '] EXCEPTION startPaymentPrint: ' . $e->getMessage() . PHP_EOL, + FILE_APPEND + ); + + echo json_encode([ + 'status' => 'error', + 'error' => $e->getMessage(), + ]); +} diff --git a/api/sumup_print_wrapper.php b/api/sumup_print_wrapper.php new file mode 100644 index 000000000..e3f987ef1 --- /dev/null +++ b/api/sumup_print_wrapper.php @@ -0,0 +1,46 @@ + { - remoteBuzzerClient.inProgress(false); - buttonPrint.trigger('blur'); - }); + if (config.payments.enabled) { + photoboothTools.printPayment(filename, copies, () => { + remoteBuzzerClient.inProgress(false); + buttonPrint.trigger('blur'); + }); + } else { + photoboothTools.printImage(filename, copies, () => { + remoteBuzzerClient.inProgress(false); + buttonPrint.trigger('blur'); + }); + } } }); diff --git a/assets/js/payment_poll.js b/assets/js/payment_poll.js new file mode 100644 index 000000000..dd6b20e3a --- /dev/null +++ b/assets/js/payment_poll.js @@ -0,0 +1,54 @@ +(function () { + let pollTimer = null; + + function startPolling() { + stopPolling(); + + pollTimer = setInterval(() => { + $.ajax({ + url: '/api/paymentStatus.php', + method: 'GET', + dataType: 'json', + success: function (data) { + console.log('Payment poll:', data); + + if (data.paid && data.printed) { + stopPolling(); + + const overlay = document.querySelector('.overlay'); + if (overlay) { + overlay.innerHTML = '✅ Zahlung erfolgreich – Druck abgeschlossen'; + setTimeout(() => { + overlay.remove(); + }, 1200); + } + } + }, + error: function (xhr, status, err) { + console.log('Payment poll failed:', status, err); + } + }); + }, 2000); + } + + function stopPolling() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + + const observer = new MutationObserver(() => { + const overlay = document.querySelector('.overlay'); + + if (!overlay) { + return; + } + + if (overlay.classList.contains('overlay-qr') || overlay.classList.contains('overlay-both')) { + startPolling(); + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); +})(); diff --git a/assets/js/photoswipe.js b/assets/js/photoswipe.js index 62dcc4a94..c3395fbff 100644 --- a/assets/js/photoswipe.js +++ b/assets/js/photoswipe.js @@ -160,11 +160,19 @@ function initPhotoSwipeFromDOM(gallerySelector) { const copies = config.print.max_multi === 1 ? 1 : await photoboothTools.askCopies(); if (copies && !isNaN(copies)) { - photoboothTools.printImage(img, copies, () => { - if (typeof remoteBuzzerClient !== 'undefined') { - remoteBuzzerClient.inProgress(false); - } - }); + if (config.payments.enabled) { + photoboothTools.printPayment(img, copies, () => { + if (typeof remoteBuzzerClient !== 'undefined') { + remoteBuzzerClient.inProgress(false); + } + }); + } else { + photoboothTools.printImage(img, copies, () => { + if (typeof remoteBuzzerClient !== 'undefined') { + remoteBuzzerClient.inProgress(false); + } + }); + } } } } diff --git a/assets/js/tools.js b/assets/js/tools.js index 6c9770dcd..a0767dd22 100644 --- a/assets/js/tools.js +++ b/assets/js/tools.js @@ -629,6 +629,77 @@ const photoboothTools = (function () { } }; + api.printPayment = function (imageSrc, copies, cb) { + const priceCents = Number(config.payments?.price_cents || 0); + const priceEuro = (priceCents / 100).toFixed(2).replace('.', ','); + const paymentMessage = (config.payments?.message || api.getTranslation('payments_message')).replace( + '%price%', + priceEuro + ); + const paymentQrMsg = api.getTranslation('payments_qr_message'); + const paymentTerminalMsg = api.getTranslation('payments_terminal_message'); + + api.overlay.show(paymentMessage); + api.isPrinting = true; + if (typeof remoteBuzzerClient !== 'undefined') { + remoteBuzzerClient.inProgress('print'); + } + + $.ajax({ + method: 'POST', + url: environment.publicFolders.api + '/startPaymentPrint.php', + dataType: 'json', + data: api.addCsrfToPayload({ filename: imageSrc, copies: copies }), + success: (data) => { + if (data.status === 'disabled') { + api.overlay.close(); + api.isPrinting = false; + api.printImage(imageSrc, copies, cb); + } else if (data.status === 'success') { + api.overlay.show(data.message || api.getTranslation('payments_success')); + setTimeout(() => { + api.overlay.close(); + api.isPrinting = false; + api.printImage(imageSrc, copies, cb); + }, 1200); + } else if (data.status === 'qr' || data.status === 'both') { + if (!data.payment_url) { + api.overlay.showError(api.getTranslation('payments_url_missing')); + api.resetPrintErrorMessage(cb, notificationTimeout); + return; + } + const qrUrl = + 'https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=' + + encodeURIComponent(data.payment_url); + const statusClass = { + both: 'overlay-both', + qr: 'overlay-qr' + }[data.status]; + const overlay = document.querySelector('.overlay'); + + api.overlay.show(` +
+
${paymentMessage}
+
${paymentQrMsg}
+ QR Code + ${data.status === 'both' ? `
${paymentTerminalMsg}
` : ''} +
+ `); + api.isPrinting = false; + overlay.classList.remove('overlay-both', 'overlay-qr'); + overlay.classList.add(statusClass); + } else { + api.overlay.showError(data.error || api.getTranslation('payments_failed')); + api.resetPrintErrorMessage(cb, notificationTimeout); + } + }, + error: () => { + api.overlay.showError(api.getTranslation('payments_error')); + api.resetPrintErrorMessage(cb, notificationTimeout); + } + }); + }; + $(document).on('keyup', function (ev) { if (config.reload.key && parseInt(config.reload.key, 10) === ev.keyCode) { api.reloadPage(); diff --git a/index.php b/index.php index 0d44b801f..b5bf916d7 100644 --- a/index.php +++ b/index.php @@ -117,5 +117,6 @@ })(); + diff --git a/install-photobooth-sumup-ngrok-gunicorn.sh b/install-photobooth-sumup-ngrok-gunicorn.sh new file mode 100755 index 000000000..50ba92614 --- /dev/null +++ b/install-photobooth-sumup-ngrok-gunicorn.sh @@ -0,0 +1,4525 @@ +#!/bin/bash + +export LC_ALL=C +export LANG=C + +# Initial Variables +LOGFILE="/var/log/photobooth_install.log" +SILENT=false +UPDATE=false +SKIP_AUTO_UPDATE=false +SKIP_WEBSERVER=false +SKIP_PHP=false +SKIP_NODE=false +SKIP_PYTHON=false +SKIP_GUNICORN=false +SKIP_NGROK=false +PHOTOBOOTH_FOUND=false +INSTALLFOLDERPATH="" +PHOTOBOOTH_SUBFOLDER="" + +# Webbrowser +WEBBROWSER="unknown" + +# GitHub +GIT_INSTALLED=false +GIT_REPO_URL="https://github.com/frogro/photobooth.git" +GIT_REMOTE_NAME="origin" +BRANCH="feature/sumup-payment" +REMOTE_BRANCH_API="" +REMOTE_BRANCH_SHA="" + +# OS environment +FORCE_RASPBERRY_PI=false +RUNNING_ON_PI=false +HAS_SYSTEMD=$([[ -x "$(command -v systemctl)" && "$(ps -p 1 -o comm=)" == "systemd" ]] && echo true || echo false) +LOCAL_ARCH=$(uname -m) +OS_CODENAME="unknown" + +# PHP +PHP_VERSION="8.4" +DEBIAN=( + "bullseye" + "bookworm" + "trixie" +) + +# Node.js +NODEJS_MAJOR="20" +NODEJS_MINOR="15" + +# Packages +COMMON_PACKAGES=( + "gphoto2" + "libimage-exiftool-perl" + "nodejs" + "python3" + "rsync" + "udisks2" +) + +APACHE_PACKAGES=( + "apache2" + "libapache2-mod-php${PHP_VERSION}" +) + +PHP_PACKAGES=( + "php${PHP_VERSION}" + "php${PHP_VERSION}-cli" + "php${PHP_VERSION}-gd" + "php${PHP_VERSION}-xml" + "php${PHP_VERSION}-zip" + "php${PHP_VERSION}-mbstring" +) + +EXTRA_PACKAGES=( + "git" + "jq" + "curl" + "gcc" + "g++" + "make" + "apt-transport-https" + "lsb-release" + "ca-certificates" + "software-properties-common" +) + +# go2rtc +DEFAULT_GO2RTC_VERSION="1.9.13" +GO2RTC_VERSIONS=("1.9.13" "1.9.12" "1.9.11" "1.9.10" "1.9.9" "1.9.8" "1.9.7" "1.9.6" "1.9.4" "1.9.2") +GO2RTC_UPDATE_ONLY=false +GO2RTC_EXTRA_PACKAGES=( + "ffmpeg" + "fswebcam" +) + +# gphoto2 webcam +GPHOTO2_WEBCAM_EXTRA_PACKAGES=( + "v4l2loopback-dkms" + "v4l-utils" + "python3" + "python3-gphoto2" + "python3-psutil" + "python3-zmq" +) + +# rembg +REMBG_PACKAGES=( + "python3" + "python3-pip" + "python3-venv" + "php${PHP_VERSION}-curl" +) + +REMBG_PIP_PACKAGES=( + "rembg[cpu,cli]" + "pillow" + "filetype" + "watchdog" + "aiohttp" +) + +# SumUp / Webhook +SUMUP_WEBHOOK_PACKAGES=( + "python3-flask" + "python3-requests" +) + +SUMUP_GUNICORN_PACKAGES=( + "gunicorn" +) + +# ================================================== +# Logging / helper functions +# ================================================== + +function log() { + echo "$(date '+%Y-%m-%d %H:%M:%S') - $*" >>"$LOGFILE" +} + +function confirm() { + local title=$1 + local message=$2 + local height=${3:-10} + local width=${4:-60} + echo "$(date '+%Y-%m-%d %H:%M:%S') - $1: $2" >>"$LOGFILE" + if [ "$SILENT" = true ]; then + echo "$title: $message" + sleep 2 + else + whiptail --title "$title" --msgbox "$message" "$height" "$width" + fi +} + +function info() { + local title=$1 + local message=$2 + local height=${3:-10} + local width=${4:-60} + echo "$(date '+%Y-%m-%d %H:%M:%S') - $1: $2" >>"$LOGFILE" + if [ "$SILENT" = true ]; then + echo "$title: $message" + else + whiptail --title "$title" --infobox "$message" "$height" "$width" < /dev/tty > /dev/tty 2>&1 + fi + sleep 1 +} + +function warn() { + local title="Warning" + local message=$1 + local height=${2:-10} + local width=${3:-60} + info "$title" "$message" "$height" "$width" +} + +function error() { + local title="Error" + local message=$1 + local height=${2:-10} + local width=${3:-60} + info "$title" "$message" "$height" "$width" +} + +function print_logo() { + local logo=" + %@@@@. + @@ @@* + @@@@@@@@@@@@@@@@@@@@@@ + @@%%%%%%%%%%%%%%%%%%%%%@ + @@ @@@@@@ @@ + @@ @@ @@ @@ + @@ @@ @@ @@ + @@ @@ @@ @@ + @@ @@ @@ @@ + @@ @@@@@@ @@ + @@%%%%%%%%%%%%%%%%%%%%%@ + + P H O T O B O O T H + + @@%%%%%%%%%%%%%%%%%%%%%@ +" + + if [ "$SILENT" = true ]; then + echo "$logo" + else + local height=22 + local width=50 + whiptail --title "Welcome!" --infobox "$logo" "$height" "$width" + fi + sleep 2 +} + +function show_help() { + echo "Photobooth Setup Wizard" + echo "" + echo "Adjust your setup for Photobooth. Available options:" + echo "" + echo " --branch= Specify the Git branch to use for installation or updates." + echo " --php= Set the PHP version for the setup (e.g., --php=8.3)." + echo " --silent Run the Photobooth Setup Wizard in silent mode" + echo " for automated installation or updates." + echo " --username=\"\" Required if --silent is used." + echo " Provide a username for installation or updates." + echo " --raspberry Skip automatic Raspberry Pi detection and enable Raspberry Pi specific configuration." + echo " --wayland Skip automatic Wayland detection and enable Wayland configuration." + echo " --update Requires --silent to update Photobooth if installed already." + echo " --skip-webserver Skip web server setup" + echo " (if already configured or e.g. Nginx is used as Webserver)." + echo " --skip-php Skip PHP installation" + echo " (if already configured for used Webserver)." + echo " --skip-node Skip Node.js and npm installation" + echo " (if already installed as required)." + echo " --skip-python Skip python3 installation" + echo " (if already installed as required)." + echo " --skip-gunicorn Skip Gunicorn installation" + echo " (for SumUp webhook service)." + echo " --skip-ngrok Skip ngrok installation" + echo " (for SumUp webhook tunnel)." + echo " --skip-auto-update Skip automatic updates for Photobooth Setup Wizard." + echo "" + echo "Examples:" + echo " $0 --silent --branch=dev --php=8.3 --username=\"photobooth\" --update" + echo " $0 --branch=stable4 --skip-webserver" + echo "" + echo "For more information, refer to the documentation at" + echo "https://photoboothproject.github.io" + exit 0 +} + +function photobooth_installed() { + local search_paths=("/var/www/html/photobooth" "/var/www/html") + + for full_path in "${search_paths[@]}"; do + if [[ -d "$full_path" && -f "$full_path/lib/configsetup.inc.php" ]]; then + PHOTOBOOTH_FOUND=true + INSTALLFOLDERPATH="$full_path" + PHOTOBOOTH_SUBFOLDER="${INSTALLFOLDERPATH#/var/www/html}" + return 0 + fi + done + + return 1 +} + +function check_installfolderpath() { + if [[ -z "$INSTALLFOLDERPATH" ]]; then + if ! photobooth_installed; then + error "INSTALLFOLDERPATH is not defined or empty!" + return 1 + fi + fi + + return 0 +} + +function is_wayland_env() { + if [ "${WAYLAND_ENV:-}" = "true" ]; then + return 0 + fi + + local conf="/etc/lightdm/lightdm.conf" + + if [ -f "$conf" ]; then + session=$(grep -E "^user-session=" "$conf" | cut -d= -f2) + + case "$session" in + rpd-labwc|LXDE-pi-labwc|rpd-wayfire|LXDE-pi-wayfire) + return 0 + ;; + esac + fi + + # Fallback: check if wayfire or labwc is running + if pgrep wayfire >/dev/null || pgrep labwc >/dev/null; then + return 0 + else + return 1 + fi +} + +function install_system_icon() { + local icon_dir="/usr/share/icons/hicolor/scalable/apps" + local icon_file="$icon_dir/photobooth.svg" + local icon_url="https://github.com/frogro/photobooth/raw/refs/heads/${BRANCH}/resources/img/favicon.svg" + local local_file="" + + # Return if icon already exists + if [[ -f "$icon_file" ]]; then + info "System Icon" "Photobooth icon exists already." + return 0 + fi + + mkdir -p "$icon_dir" + + # Only set local_file if INSTALLFOLDERPATH is valid + if check_installfolderpath; then + local_file="$INSTALLFOLDERPATH/resources/img/favicon.svg" + fi + + # Prefer local file over download + if [[ -n "$local_file" && -f "$local_file" ]]; then + cp "$local_file" "$icon_file" + info "System Icon" "Copied Photobooth icon." + elif command -v wget >/dev/null 2>&1; then + if wget -qO "$icon_file" "$icon_url"; then + info "System Icon" "Downloaded Photobooth icon." + else + error "Failed to download icon!" + return 1 + fi + else + error "wget not available and no local icon found!" + return 1 + fi + + chmod 644 "$icon_file" + + if command -v update-icon-caches >/dev/null 2>&1; then + update-icon-caches /usr/share/icons/hicolor > /dev/null 2>&1 + fi + + info "System Icon" "Photobooth system icon installed successfully" + return 0 +} + +function install_package() { + local package=$1 + + if dpkg-query -W -f='${Status}' "$package" 2>/dev/null | grep -q "ok installed"; then + info "Package installation" "${package} is already installed." + return 0 + else + info "[Package]" "Installing missing package: ${package}" + + # Handle PHP versioned packages with fallback + if [[ "$package" =~ ^php[0-9]+\.[0-9]+- ]] || [[ "$package" =~ ^libapache2-mod-php[0-9]+\.[0-9]+$ ]]; then + local pkg_generic + pkg_generic=$(echo "$package" | sed -E "s/[0-9]+\.[0-9]+-/-/; s/[0-9]+\.[0-9]+$//") + + if apt-get -qq install -y "$package" >/dev/null 2>&1; then + info "Package installation" "Successfully installed ${package}." + return 0 + else + warn "Package ${package} not available, falling back to ${pkg_generic}..." + if apt-get -qq install -y "$pkg_generic" >/dev/null 2>&1; then + info "Package installation" "Successfully installed ${pkg_generic}." + return 0 + else + error "Failed to install ${package} and fallback ${pkg_generic}." + return 1 + fi + fi + else + # Regular package install + if apt-get -qq install -y "$package" >/dev/null 2>&1; then + info "Package installation" "Successfully installed ${package}." + return 0 + else + # Special case: ignore failure on software-properties-common, unavailable on Debian Trixie + if [[ "$package" == "software-properties-common" ]]; then + warn "Ignoring failed install of ${package}." + return 0 + fi + + warn "Failed to install ${package}." + return 1 + fi + fi + fi +} + +function install_packages() { + local packages=("$@") + for package in "${packages[@]}"; do + if ! install_package "$package"; then + error "Aborting package installation due to failure: ${package}." + return 1 + fi + done + return 0 +} + +function install_sumup_webhook_dependencies() { + info "SumUp Setup" "Installing SumUp webhook dependencies (Flask/Requests)..." + if ! sudo apt-get update -qq; then + error "Failed to update package lists." + fi + if ! sudo apt-get install -y python3-flask python3-requests; then + error "Failed to install SumUp webhook dependencies via apt." + return 1 + fi + info "SumUp Setup" "SumUp webhook dependencies installed successfully." + return 0 +} + +function install_sumup_gunicorn() { + if [ "$SKIP_GUNICORN" = true ]; then + info "Gunicorn Setup" "Skipping Gunicorn installation." + return 0 + fi + + info "Gunicorn Setup" "Installing Gunicorn..." + if ! sudo apt-get update -qq; then + error "Failed to update package lists." + fi + if ! sudo apt-get install -y gunicorn; then + error "Failed to install Gunicorn." + return 1 + fi + + info "Gunicorn Setup" "Gunicorn installed successfully." + return 0 +} + +function install_ngrok() { + if [ "$SKIP_NGROK" = true ]; then + info "ngrok Setup" "Skipping ngrok installation." + return 0 + fi + + if command -v ngrok >/dev/null 2>&1; then + info "ngrok Setup" "ngrok is already installed." + return 0 + fi + + info "ngrok Setup" "Installing ngrok from official repository..." + + if ! curl -sSL https://ngrok-agent.s3.amazonaws.com/ngrok.asc | sudo tee /etc/apt/trusted.gpg.d/ngrok.asc >/dev/null; then + error "Failed to install ngrok signing key." + return 1 + fi + + if ! echo "deb https://ngrok-agent.s3.amazonaws.com buster main" | sudo tee /etc/apt/sources.list.d/ngrok.list >/dev/null; then + error "Failed to add ngrok apt repository." + return 1 + fi + + if ! sudo apt-get -qq update >/dev/null 2>&1; then + error "Failed to update package lists for ngrok." + return 1 + fi + + if ! sudo apt-get -qq install -y ngrok >/dev/null 2>&1; then + error "Failed to install ngrok." + return 1 + fi + + info "ngrok Setup" "ngrok installed successfully." + info "ngrok Setup" "Please run 'ngrok config add-authtoken ' once if not done already." + return 0 +} + +function create_sumup_token_file() { + if ! check_installfolderpath; then + return 1 + fi + + local token_file="$INSTALLFOLDERPATH/config/sumup_token.txt" + + if [[ ! -f "$token_file" ]]; then + : > "$token_file" || return 1 + info "SumUp Setup" "Created empty SumUp token file at $token_file" + else + info "SumUp Setup" "SumUp token file already exists at $token_file" + fi + + chown www-data:www-data "$token_file" >/dev/null 2>&1 || warn "Failed to set ownership on $token_file" + chmod 660 "$token_file" >/dev/null 2>&1 || warn "Failed to set permissions on $token_file" + return 0 +} + +function create_sumup_gunicorn_service() { + if ! check_installfolderpath; then + return 1 + fi + + if [ "$SKIP_GUNICORN" = true ]; then + info "Gunicorn Service" "Skipping Gunicorn service setup." + return 0 + fi + + if [[ ! -f "$INSTALLFOLDERPATH/api/webhook_sumup.py" ]]; then + warn "Gunicorn Service" "webhook_sumup.py not found. Skipping Gunicorn service setup." + return 1 + fi + + cat >/etc/systemd/system/photobooth-sumup-gunicorn.service </dev/null 2>&1 || return 2 + if ! systemctl enable photobooth-sumup-gunicorn.service >/dev/null 2>&1; then + return 3 + fi + info "Gunicorn Service" "photobooth-sumup-gunicorn.service installed." + else + mkdir -p /etc/systemd/system/multi-user.target.wants + ln -sf /etc/systemd/system/photobooth-sumup-gunicorn.service /etc/systemd/system/multi-user.target.wants/photobooth-sumup-gunicorn.service || return 4 + fi + + return 0 +} + +function create_ngrok_service() { + if [ "$SKIP_NGROK" = true ]; then + info "ngrok Service" "Skipping ngrok service setup." + return 0 + fi + + if ! command -v ngrok >/dev/null 2>&1; then + warn "ngrok Service" "ngrok is not installed. Skipping ngrok service setup." + return 1 + fi + + local ngrok_url="$1" + local env_file="/etc/default/photobooth-sumup-ngrok" + + if [[ -z "$ngrok_url" && "$SILENT" = false ]]; then + ngrok_url=$(whiptail --title "ngrok URL" \ + --inputbox "Enter your reserved ngrok URL without https://\nExample: myurl.ngrok-free.app" \ + 10 70 "" 3>&1 1>&2 2>&3) + fi + + if [[ -z "$ngrok_url" ]]; then + if [[ -f "$env_file" ]]; then + ngrok_url=$(grep -E '^NGROK_URL=' "$env_file" | cut -d= -f2-) + fi + fi + + if [[ -z "$ngrok_url" ]]; then + warn "ngrok Service" "No ngrok URL configured. Service file will not be created." + return 1 + fi + + cat >"$env_file" </etc/systemd/system/photobooth-sumup-ngrok.service <<'EOF' +[Unit] +Description=Photobooth SumUp ngrok Tunnel +After=network.target photobooth-sumup-gunicorn.service +Wants=photobooth-sumup-gunicorn.service + +[Service] +EnvironmentFile=/etc/default/photobooth-sumup-ngrok +ExecStart=/usr/local/bin/ngrok http 5000 --url=https://${NGROK_URL} +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF + + if [[ "$HAS_SYSTEMD" == true ]]; then + systemctl daemon-reload >/dev/null 2>&1 || return 2 + if ! systemctl enable photobooth-sumup-ngrok.service >/dev/null 2>&1; then + return 3 + fi + info "ngrok Service" "photobooth-sumup-ngrok.service installed." + else + mkdir -p /etc/systemd/system/multi-user.target.wants + ln -sf /etc/systemd/system/photobooth-sumup-ngrok.service /etc/systemd/system/multi-user.target.wants/photobooth-sumup-ngrok.service || return 4 + fi + + return 0 +} + +function manage_ngrok_gunicorn() { + local gunicorn_service="photobooth-sumup-gunicorn.service" + local ngrok_service="photobooth-sumup-ngrok.service" + + while true; do + MENU_OPTIONS=( + "1" "Install / configure Gunicorn service" + "2" "Install / configure ngrok service" + "3" "Start both services" + "4" "Stop both services" + "5" "Restart both services" + "6" "Show both services status" + ) + + if ! CHOICE=$(whiptail --title "Ngrok / Gunicorn" \ + --menu "Choose an option:" 20 70 10 \ + --cancel-button Back --ok-button Select \ + "${MENU_OPTIONS[@]}" 3>&1 1>&2 2>&3); then + break + fi + + case $CHOICE in + 1) + create_sumup_gunicorn_service + case $? in + 0) confirm "Gunicorn Service" "Gunicorn service configured successfully." ;; + 1) confirm "Gunicorn Service" "Failed because webhook_sumup.py or installation path is missing." ;; + 2) confirm "Gunicorn Service" "Failed to reload systemd daemon." ;; + 3) confirm "Gunicorn Service" "Failed to enable Gunicorn service." ;; + 4) confirm "Gunicorn Service" "Failed to create Gunicorn service symlink." ;; + *) confirm "Gunicorn Service" "An unknown error occurred." ;; + esac + ;; + 2) + create_ngrok_service + case $? in + 0) confirm "ngrok Service" "ngrok service configured successfully." ;; + 1) confirm "ngrok Service" "Failed because ngrok is missing or no URL was provided." ;; + 2) confirm "ngrok Service" "Failed to reload systemd daemon." ;; + 3) confirm "ngrok Service" "Failed to enable ngrok service." ;; + 4) confirm "ngrok Service" "Failed to create ngrok service symlink." ;; + *) confirm "ngrok Service" "An unknown error occurred." ;; + esac + ;; + 3) + if [[ "$HAS_SYSTEMD" == true ]]; then + systemctl start "$gunicorn_service" "$ngrok_service" >/dev/null 2>&1 + confirm "Ngrok / Gunicorn" "Start command sent." + else + confirm "Ngrok / Gunicorn" "systemd is not available on this system." + fi + ;; + 4) + if [[ "$HAS_SYSTEMD" == true ]]; then + systemctl stop "$ngrok_service" "$gunicorn_service" >/dev/null 2>&1 + confirm "Ngrok / Gunicorn" "Stop command sent." + else + confirm "Ngrok / Gunicorn" "systemd is not available on this system." + fi + ;; + 5) + if [[ "$HAS_SYSTEMD" == true ]]; then + systemctl restart "$gunicorn_service" "$ngrok_service" >/dev/null 2>&1 + confirm "Ngrok / Gunicorn" "Restart command sent." + else + confirm "Ngrok / Gunicorn" "systemd is not available on this system." + fi + ;; + 6) + if [[ "$HAS_SYSTEMD" == true ]]; then + local status_text + status_text="Gunicorn: $(systemctl is-active "$gunicorn_service" 2>/dev/null || echo unknown)\nngrok: $(systemctl is-active "$ngrok_service" 2>/dev/null || echo unknown)" + confirm "Ngrok / Gunicorn" "$status_text" 12 70 + else + confirm "Ngrok / Gunicorn" "systemd is not available on this system." + fi + ;; + *) + confirm "Invalid Option" "Please select a valid option." + ;; + esac + done +} + +function remove_package() { + local package=$1 + if dpkg-query -W -f='${Status}' "$package" 2>/dev/null | grep -q "ok installed"; then + info "Package uninstall" "Removing package: ${package}" + if apt-get -qq remove -y "$package" >/dev/null 2>&1; then + info "Package uninstall" "Successfully removed ${package}." + return 0 + else + error "Failed to remove ${package}." + return 1 + fi + else + info "Package uninstall" "${package} is not installed." + return 0 + fi +} + +function remove_packages() { + local packages=("$@") + for package in "${packages[@]}"; do + if ! remove_package "$package"; then + error "Aborting package removal due to failure: ${package}." + return 1 + fi + done + return 0 +} + +function test_command() { + local cmd="$1" + eval "$cmd" &>/dev/null + local status=$? + + if [[ -f "test.mjpeg" ]]; then + info "go2rtc installation" "Deleting existing test.mjpeg file." + rm test.mjpeg + fi + + return $status +} + +function add_source_list() { + local source_entry="$1" + local source_file="$2" + if grep -Fxq "$source_entry" "$source_file" 2>/dev/null; then + info "Source list" "Source list entry already exists: $source_entry" + else + echo "$source_entry" >>"$source_file" + info "Source list" "Added source list entry: $source_entry" + fi +} + +function ensure_add_apt_repository() { + if ! command -v add-apt-repository >/dev/null 2>&1; then + info "Setup" "add-apt-repository not found. Installing software-properties-common..." + if ! apt-get update -y >/dev/null 2>&1; then + error "Failed to update package lists (needed for software-properties-common)." + return 1 + fi + if ! apt-get install -y --no-install-recommends software-properties-common >/dev/null 2>&1; then + error "Failed to install software-properties-common." + return 1 + fi + info "Setup" "Installed software-properties-common successfully." + fi + return 0 +} + +function add_apt_repository_once() { + local repo="$1" + local clean_repo="${repo#ppa:}" + + # Sanity check + if [[ -z "$repo" ]]; then + error "No repository provided to add_apt_repository_once" + return 1 + fi + + # Check if repository already exists + if grep -q "$clean_repo" /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null; then + info "Add apt repository" "Repository '$repo' is already added." + return 0 + fi + if grep -q "^deb .*$repo" /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null; then + info "Add apt repository" "Repository '$repo' is already added." + return 0 + fi + + # Ensure add-apt-repository is available + if ! ensure_add_apt_repository; then + return 1 + fi + + # Add repository + info "Add apt repository" "Adding repository: $repo" + if add-apt-repository -y "$repo" >/dev/null 2>&1; then + info "Add apt repository" "Successfully added repository: $repo" + return 0 + else + error "Failed to add repository: $repo" + return 1 + fi +} + +# ================================================== +# Environment detection +# ================================================== + +# Photobooth Setup Wizard update +function self_update() { + local all_args=("$@") + + local curr_date + curr_date="$(date +%Y%m%d%H%M%S)" + + local script_name + script_name="$(basename "$0")" + local script_remote_url="https://raw.githubusercontent.com/frogro/photobooth/refs/heads/${BRANCH}/$script_name" + local script_temp_file="/tmp/$script_name" + local script_backup_file="/tmp/${script_name}.bak_${curr_date}" + local script_abs_path + script_abs_path="$(realpath "$0")" + + info "Photobooth Setup Wizard" "Checking for Photobooth Setup Wizard updates..." + + if ! wget -q -O "$script_temp_file" "$script_remote_url"; then + confirm "Error" "Unable to download the latest Photobooth Setup Wizard." + return 1 + fi + + if ! cmp -s "$script_temp_file" "$script_abs_path"; then + confirm "Photobooth Setup Wizard" "Updated Photobooth Setup Wizard found!" + + if ! whiptail --title "Photobooth Setup Wizard" \ + --yesno "Update Photobooth Setup Wizard to latest version?" \ + 12 60; then + info "Photobooth Setup Wizard" "Skipping Photobooth Setup Wizard update." + sleep 2 + return 0 + fi + + info "Photobooth Setup Wizard" "Updating the Photobooth Setup Wizard..." + if ! cp "$script_abs_path" "$script_backup_file"; then + confirm "Photobooth Setup Wizard" "Failed to create a backup of $script_abs_path. Update aborted." + return 1 + fi + info "Photobooth Setup Wizard" "Backup created: $script_backup_file" + + if mv -f "$script_temp_file" "$script_abs_path"; then + if ! chmod +x "$script_abs_path"; then + confirm "Photobooth Setup Wizard" "Failed to add execution permission to $script_abs_path. Update aborted." + return 1 + fi + confirm "Photobooth Setup Wizard" "Photobooth Setup Wizard updated successfully." + info "Photobooth Setup Wizard" "Restarting Photobooth Setup Wizard..." + sleep 2 + + exec "$script_abs_path" "${all_args[@]}" + else + confirm "Photobooth Setup Wizard" "Failed to update Photobooth Setup Wizard!" + return 1 + fi + else + info "Photobooth Setup Wizard" "No updates available." + rm -f "$script_temp_file" + fi +} + +function detect_os_codename() { + local os="" + + if command -v lsb_release >/dev/null 2>&1; then + os=$(lsb_release -sc 2>/dev/null) + elif [[ -r /etc/os-release ]]; then + # Try VERSION_CODENAME first + os=$(grep -E '^VERSION_CODENAME=' /etc/os-release | cut -d= -f2) + if [[ -z "$os" ]]; then + # Extract from VERSION string as fallback + os=$(grep -E '^VERSION=' /etc/os-release | sed -E 's/.*\((.*)\).*/\1/') + fi + fi + + if [[ -n "$os" ]]; then + echo "$os" + fi +} + + +# Check if running on Raspberry Pi +function detect_pi() { + if [ "$FORCE_RASPBERRY_PI" = false ]; then + local pi_model + if [ ! -f /proc/device-tree/model ]; then + no_raspberry 2 + else + pi_model=$(tr -d '\0' &1 1>&2 2>&3); then + if whiptail --title "Photobooth Setup Wizard" \ + --yesno "Are you sure you want to exit?" \ + 8 50; then + exit 0 + else + continue + fi + fi + fi + + # Validate username + if [ -n "$USERNAME" ]; then + if id "$USERNAME" &>/dev/null; then + break + else + if [ "$SILENT" = true ]; then + confirm "Invalid Username" "Error: The username '$USERNAME' does not exist. Continuing without a defined user." + USERNAME="" + break + else + confirm "Invalid Username" "The username '$USERNAME' does not exist. Please try again." + USERNAME="" + fi + fi + else + if [ "$SILENT" = true ]; then + confirm "Setup Wizard" "Username not defined. Ignoring..." + break + else + confirm "Setup Wizard" "Username cannot be empty. Please try again." + fi + fi + done +} + +function check_webserver() { + local servers=("apache2" "nginx" "lighttpd") + local installed_but_not_running=false + + for server in "${servers[@]}"; do + # Check if package is installed + if dpkg-query -W -f='${Status}' "$server" 2>/dev/null | grep -q "ok installed"; then + # Check if systemctl exists and service is active + if [[ "$HAS_SYSTEMD" == true ]] && systemctl is-active --quiet "$server"; then + info "Webserver Check" "$server is installed and running." + case $server in + apache2) return 1 ;; + nginx) return 2 ;; + lighttpd) return 3 ;; + esac + else + info "Webserver Check" "$server is installed but not running (or systemctl unavailable)." + installed_but_not_running=true + fi + fi + done + + if [[ "$installed_but_not_running" == true ]]; then + info "Webserver Check" "One or more webservers are installed but not running." + return 4 + fi + + info "Webserver Check" "No webserver is installed and running." + return 0 +} + +function prepare_php_environment() { + if detected_os=$(detect_os_codename) && [[ $detected_os ]]; then + OS_CODENAME="$detected_os" + info "OS Detection" "Detected distribution codename: $OS_CODENAME" + else + confirm "Warning" "Could not detect OS codename." + fi + info "PHP preparation" "Detected OS: $OS_CODENAME" + + # Add PHP repository based on OS + if [[ "${DEBIAN[*]}" =~ $OS_CODENAME ]]; then + info "PHP preparation" "Adding Sury PHP repository for Debian." + wget -qO /etc/apt/trusted.gpg.d/php.gpg https://packages.sury.org/php/apt.gpg >/dev/null 2>&1 + echo "deb https://packages.sury.org/php/ $OS_CODENAME main" \ + | tee /etc/apt/sources.list.d/php.list >/dev/null 2>&1 + elif [[ "$OS_CODENAME" == "mantic" ]]; then + info "PHP preparation" "No source lists available for 'mantic'." + else + if [[ "$OS_CODENAME" == "jammy" ]]; then + info "PHP preparation" "Checking for 'jammy-updates' in sources list." + add_source_list "deb http://archive.ubuntu.com/ubuntu/ jammy-updates main restricted" /etc/apt/sources.list + fi + + if ! ensure_add_apt_repository; then + error "Failed to install software-properties-common (needed for add-apt-repository)." + return 2 + fi + + info "PHP preparation" "Adding Ondrej PHP PPA." + if ! add_apt_repository_once "ppa:ondrej/php"; then + error "Failed to add Ondrej PHP PPA." + return 1 + fi + fi + + if apt-get -qq update 2>&1 | grep -q "does not have a Release file"; then + info "PPA ondrej/php is not valid for $OS_CODENAME, removing..." + add-apt-repository --remove ppa:ondrej/php + rm -f /etc/apt/sources.list.d/ondrej-ubuntu-php*.list + fi + + if ! apt-get -qq update >/dev/null 2>&1; then + error "Update after adding repositories failed." + return 1 + fi + + info "PHP preparation" "PHP preparation completed successfully." + return 0 +} + +function set_php_version_cli() { + local version="$1" + + if [[ -z "$version" ]]; then + error "No PHP version provided to set_php_version_cli" >&2 + return 1 + fi + + local php_bin="/usr/bin/php${version}" + + if [[ ! -x "$php_bin" ]]; then + error "$php_bin not found (is php${version} installed?)" >&2 + return 1 + fi + + local priority + priority=$(echo "$version" | tr -d '.') + + update-alternatives --install /usr/bin/php php "$php_bin" "$priority" >/dev/null 2>&1 || warn "Failed to install PHP via update-alternatives." + update-alternatives --set php "$php_bin" >/dev/null 2>&1 || error "Failed to set default PHP CLI version." + + info "PHP CLI" "CLI php now points to: $(php -v | head -n1)" + return 0 +} + +function set_php_version_apache() { + local version="$1" + + if [[ -z "$version" ]]; then + error "No PHP version provided." >&2 + return 1 + fi + + a2dismod -f php* >/dev/null 2>&1 || true + + if a2enmod "php${version}" >/dev/null 2>&1; then + confirm "Apache Webserver" "Apache is now configured to use PHP ${version} " + if [[ "$HAS_SYSTEMD" == true ]]; then + if systemctl is-active --quiet apache2; then + # Restart if already running + if ! systemctl restart apache2 &>/dev/null; then + confirm "Apache Webserver" "Failed to restart Apache Webserver. Please reboot to apply." + fi + fi + fi + return 0 + else + error "Could not enable php${version} for Apache" >&2 + return 1 + fi +} + +# ================================================== +# Change defaults for install / update +# ================================================== + +function set_branch() { + local new_branch + if new_branch=$(whiptail --title "Set Git Branch" \ + --inputbox "Enter the branch you want to use (e.g., dev):" 10 50 "$BRANCH" 3>&1 1>&2 2>&3); then + BRANCH="$new_branch" + info "Git Branch" "Branch set to $BRANCH" + else + info "Git Branch" "No changes made to branch." + fi +} + +function set_php_version() { + local new_php_version + if new_php_version=$(whiptail --title "Set PHP Version" \ + --inputbox "Enter the PHP version you want to use (e.g., 8.3):" 10 50 "$PHP_VERSION" 3>&1 1>&2 2>&3); then + PHP_VERSION="$new_php_version" + info "PHP Version" "PHP version set to $PHP_VERSION" + else + info "PHP Version" "No changes made to PHP version." + fi +} + +function toggle_skip_webserver() { + if whiptail --title "Web Server Setup" \ + --yesno "Current value: Skip web server setup = $SKIP_WEBSERVER\n\nToggle this option?" 10 50; then + SKIP_WEBSERVER=$([ "$SKIP_WEBSERVER" = true ] && echo false || echo true) + info "Web Server Setup" "Skip web server setup toggled to $SKIP_WEBSERVER" + else + info "Web Server Setup" "No changes made." + fi +} + +function toggle_skip_php() { + if whiptail --title "PHP Setup" \ + --yesno "Current value: Skip PHP setup = $SKIP_PHP\n\nToggle this option?" 10 50; then + SKIP_PHP=$([ "$SKIP_PHP" = true ] && echo false || echo true) + info "PHP Setup" "Skip PHP setup toggled to $SKIP_PHP" + else + info "PHP Setup" "No changes made." + fi +} + +function toggle_skip_node() { + if whiptail --title "Node.js Setup" \ + --yesno "Current value: Skip Node.js and npm setup = $SKIP_NODE\n\nToggle this option?" 10 50; then + SKIP_NODE=$([ "$SKIP_NODE" = true ] && echo false || echo true) + info "Node.js Setup" "Skip Node.js and npm setup toggled to $SKIP_NODE" + else + info "Node.js Setup" "No changes made." + fi +} + +function toggle_skip_python() { + if whiptail --title "Python3 Setup" \ + --yesno "Current value: Skip Python3 setup = $SKIP_PYTHON\n\nToggle this option?" 10 50; then + SKIP_PYTHON=$([ "$SKIP_PYTHON" = true ] && echo false || echo true) + info "Python3 Setup" "Skip Python3 setup toggled to $SKIP_PYTHON" + else + info "Python3 Setup" "No changes made." + fi +} + + +function toggle_skip_gunicorn() { + if whiptail --title "Gunicorn Setup" \ + --yesno "Current value: Skip Gunicorn setup = $SKIP_GUNICORN\n\nToggle this option?" 10 50; then + SKIP_GUNICORN=$([ "$SKIP_GUNICORN" = true ] && echo false || echo true) + info "Gunicorn Setup" "Skip Gunicorn setup toggled to $SKIP_GUNICORN" + else + info "Gunicorn Setup" "No changes made." + fi +} + +function toggle_skip_ngrok() { + if whiptail --title "ngrok Setup" \ + --yesno "Current value: Skip ngrok setup = $SKIP_NGROK\n\nToggle this option?" 10 50; then + SKIP_NGROK=$([ "$SKIP_NGROK" = true ] && echo false || echo true) + info "ngrok Setup" "Skip ngrok setup toggled to $SKIP_NGROK" + else + info "ngrok Setup" "No changes made." + fi +} + +# ================================================== +# +# ================================================== +function detect_browser() { + local browser="" + + if update-alternatives --query x-www-browser &>/dev/null; then + browser=$(update-alternatives --display x-www-browser \ + | grep 'currently' | awk -F/ '{print $4}') + fi + + case "$browser" in + chromium-browser|chromium|google-chrome|google-chrome-stable|google-chrome-beta) + WEBBROWSER="$browser" + CHROME_FLAGS=true + ;; + firefox|firefox-esr) + WEBBROWSER="$browser" + CHROME_FLAGS=false + ;; + *) + for b in chromium chromium-browser google-chrome google-chrome-stable google-chrome-beta firefox firefox-esr; do + if command -v "$b" >/dev/null; then + WEBBROWSER="$b" + [[ "$b" =~ chrome|chromium ]] && CHROME_FLAGS=true || CHROME_FLAGS=false + return + fi + done + + WEBBROWSER="unknown" + CHROME_FLAGS=false + ;; + esac +} + +# Returns the kiosk flag to be used +function setup_kiosk_browser() { + local kiosk_flag="${1:---kiosk http://localhost}" + echo "$kiosk_flag" +} + +# Returns the full Chrome command flags including kiosk +# Usage: setup_chrome_flags [kiosk_flag] [chrome_default_flags] +function setup_chrome_flags() { + local local_env="${1:-default}" + local kiosk_flag="${2:-$(setup_kiosk_browser "--kiosk http://localhost")}" + local chrome_default_flags="${3:---noerrdialogs --disable-infobars --disable-features=Translate --no-first-run --check-for-update-interval=31536000 --touch-events=enabled --password-store=basic}" + + local flags="" + + case "$local_env" in + pi-wayland) + flags="$chrome_default_flags --ozone-platform=wayland --start-maximized" + ;; + pi) + flags="$chrome_default_flags --use-gl=egl" + ;; + *) + flags="$chrome_default_flags" + ;; + esac + + echo "$flags $kiosk_flag" +} + +function browser_shortcut() { + local flags="" + local shortcut="$1" + local local_env="default" + + if [[ -z "$shortcut" ]]; then + confirm "Browser Shortcut" "Error: Shortcut path is required! Cannot create shortcut!" + return 1 + fi + + if ! photobooth_installed; then + confirm "Browser Shortcut" "Error: Photobooth not installed!" + return 1 + fi + + # Ensure parent directory exists + mkdir -p "$(dirname "$shortcut")" || { + confirm "Browser Shortcut" "Error: Could not create $(dirname "$shortcut")" + return 1 + } + + detect_browser + if [ "$WEBBROWSER" = "unknown" ]; then + confirm "Browser Shortcut" "No browser detected. Browser shortcut cannot proceed." + return 2 + fi + + if [ "$CHROME_FLAGS" = true ]; then + if [ "$RUNNING_ON_PI" = true ] && is_wayland_env; then + local_env="pi-wayland" + elif [ "$RUNNING_ON_PI" = true ]; then + local_env="pi" + fi + flags="$(setup_chrome_flags "$local_env")" + else + flags="$(setup_kiosk_browser)" + fi + + cat >"$shortcut" </dev/null 2>&1 || \ + warn "Failed to set default ACL for owner on $folder" + setfacl -d -m g::rwx "$folder" >/dev/null 2>&1 || \ + warn "Failed to set default ACL for group on $folder" + setfacl -d -m o::r "$folder" >/dev/null 2>&1 || \ + warn "Failed to set default ACL for others on $folder" + + # Apply ACLs recursively to existing files/folders + setfacl -R -m u::rwx "$folder" >/dev/null 2>&1 || \ + warn "Failed to apply ACL for owner recursively on $folder" + setfacl -R -m g::rwx "$folder" >/dev/null 2>&1 || \ + warn "Failed to apply ACL for group recursively on $folder" + setfacl -R -m o::r "$folder" >/dev/null 2>&1 || \ + warn "Failed to apply ACL for others recursively on $folder" + + info "ACL Setup" "Default and recursive ACLs applied to $folder" + return 0 +} + +function general_permissions() { + info "Permissions" "Setting general permissions." + + if ! check_installfolderpath; then + return 1 + fi + + # Change ownership of the installation folder + chown -R www-data:www-data "$INSTALLFOLDERPATH"/ >/dev/null 2>&1 || warn "Failed to set ownership for $INSTALLFOLDERPATH" + + # Set permissions on private folder + chmod 2775 "$INSTALLFOLDERPATH/private" >/dev/null 2>&1 || warn "Failed to set permissions and setgid bit on $INSTALLFOLDERPATH/private" + + if set_private_acl; then + info "Permissions" "ACLs successfully applied" + else + warn "Failed to apply ACLs" + fi + + # Add `www-data` to necessary groups + gpasswd -a www-data plugdev >/dev/null 2>&1 || warn "Failed to add www-data to plugdev group" + gpasswd -a www-data video >/dev/null 2>&1 || warn "Failed to add www-data to video group" + if [ -n "$USERNAME" ]; then + gpasswd -a "$USERNAME" www-data >/dev/null 2>&1 || warn "Failed to add $USERNAME to www-data group!" + else + warn "No username defined! Can not add user to www-data group!" + fi + + # Fix permissions on cache folder + info "Permissions" "Fixing permissions on cache folder." + mkdir -p "/var/www/.cache" >/dev/null 2>&1 || warn "Failed to create /var/www/.cache directory" + chown -R www-data:www-data "/var/www/.cache" >/dev/null 2>&1 || warn "Failed to set ownership for /var/www/.cache" + + # Fix permissions on npm folder + info "Permissions" "Fixing permissions on npm folder." + mkdir -p "/var/www/.npm" >/dev/null 2>&1 || warn "Failed to create /var/www/.npm directory" + chown -R www-data:www-data "/var/www/.npm" >/dev/null 2>&1 || warn "Failed to set ownership for /var/www/.npm" + + # Disable camera automount + info "Permissions" "Disabling camera automount." + chmod -x /usr/lib/gvfs/gvfs-gphoto2-volume-monitor >/dev/null 2>&1 || warn "Failed to disable camera automount" + + # Allow www-data to mount/unmount USB drives (required for sync-to-drive and move2usb) + info "Permissions" "Setting up USB mount permissions for www-data." + create_polkit_usb_rule || warn "Failed to create polkit USB rule" + install_usb_sudoers || warn "Failed to install USB sudoers rule" + disable_automount || info "Permissions" "No desktop automount config found to adjust." + + return 0 +} + +function gpio_permission() { + if [ "$RUNNING_ON_PI" = false ]; then + return + fi + + local boot_config + info "Remote Buzzer GPIO Configuration" "Removing deprecated GPIO settings and configuration" + + # Determine the correct boot configuration file + if [ -f '/boot/firmware/config.txt' ]; then + boot_config="/boot/firmware/config.txt" + else + boot_config="/boot/config.txt" + fi + + # Add the www-data user to the GPIO group + usermod -a -G gpio www-data >/dev/null 2>&1 || warn "Failed to add www-data to gpio group" + + sed -i '/# Photobooth/,/# Photobooth End/d' "$boot_config" >/dev/null 2>&1 || warn "Failed to remove old Photobooth GPIO configuration" + + # Remove old artifacts from the node-rpio library, if present + if [ -f '/etc/udev/rules.d/20-photobooth-gpiomem.rules' ]; then + info "Remote Buzzer Update" "Old artifacts from the node-rpio library detected. Removing obsolete configuration." + rm -f /etc/udev/rules.d/20-photobooth-gpiomem.rules >/dev/null 2>&1 || warn "Failed to remove old udev rules" + sed -i '/dtoverlay=gpio-no-irq/d' "$boot_config" >/dev/null 2>&1 || warn "Failed to remove dtoverlay from $boot_config" + fi + + # Update artifacts in the user configuration for the new implementation + if [ -f "$INSTALLFOLDERPATH/config/my.config.inc.php" ]; then + sed -i '/remotebuzzer/{n;n;s/enabled/usebuttons/}' "$INSTALLFOLDERPATH/config/my.config.inc.php" >/dev/null 2>&1 || warn "Failed to update remotebuzzer configuration in my.config.inc.php" + fi + + info "Remote Buzzer GPIO Configuration" "Setup complete. Reboot your Raspberry Pi to apply the changes." + + return 0 +} + +function remove_gpio_permission() { + if [ "$RUNNING_ON_PI" = false ]; then + return + fi + + local boot_config + info "Remote Buzzer GPIO Feature" "Removing GPIO access for www-data user." + + # Determine the correct boot configuration file + if [ -f "/boot/firmware/config.txt" ]; then + boot_config="/boot/firmware/config.txt" + elif [ -f "/boot/config.txt" ]; then + boot_config="/boot/config.txt" + else + error "Could not find a valid Raspberry Pi boot config file." + return 1 + fi + + # Check if www-data is part of the gpio group + if groups www-data | grep -q "\bgpio\b"; then + if gpasswd -d www-data gpio >/dev/null 2>&1; then + info "Remote Buzzer GPIO Feature" "Successfully removed www-data user from the gpio group." + else + warn "Failed to remove www-data user from the gpio group." + fi + else + info "Remote Buzzer GPIO Feature" "www-data user is not a member of the gpio group. No action needed." + fi + + # Remove old artifacts from the node-rpio library, if present + if [ -f '/etc/udev/rules.d/20-photobooth-gpiomem.rules' ]; then + info "Remote Buzzer Update" "Old artifacts from the node-rpio library detected. Removing obsolete configuration." + rm -f /etc/udev/rules.d/20-photobooth-gpiomem.rules >/dev/null 2>&1 || warn "Failed to remove old udev rules" + sed -i '/dtoverlay=gpio-no-irq/d' "$boot_config" >/dev/null 2>&1 || warn "Failed to remove dtoverlay from $boot_config" + fi + + # Remove configuration required for the onoff library + info "Remote Buzzer GPIO Configuration" "GPIO settings removed in $boot_config" + sed -i '/# Photobooth/,/# Photobooth End/d' "$boot_config" >/dev/null 2>&1 || warn "Failed to clean old Photobooth GPIO configuration." + + return 0 +} + +function setup_printer_groups() { + # Add www-data to lp group + if gpasswd -a www-data lp >/dev/null 2>&1; then + info "Printer Group Setup" "Added www-data to lp group." + else + warn "Failed to add www-data to lp group." + return 1 + fi + + # Add www-data to lpadmin group + if gpasswd -a www-data lpadmin >/dev/null 2>&1; then + info "Printer Group Setup" "Added www-data to lpadmin group." + else + warn "Failed to add www-data to lpadmin group." + return 2 + fi + + return 0 +} + +function remove_printer_groups() { + # Remove www-data from lp group + if groups www-data | grep -q "\blp\b"; then + if gpasswd -d www-data lp >/dev/null 2>&1; then + info "Printer Group Removal" "Removed www-data from lp group." + else + warn "Failed to remove www-data from lp group." + return 1 + fi + else + info "Printer Group Removal" "www-data is not a member of lp group. No action needed." + fi + + # Remove www-data from lpadmin group + if groups www-data | grep -q "\blpadmin\b"; then + if gpasswd -d www-data lpadmin >/dev/null 2>&1; then + info "Printer Group Removal" "Removed www-data from lpadmin group." + else + warn "Printer Group Removal" "Failed to remove www-data from lpadmin group." + return 2 + fi + else + info "www-data is not a member of lpadmin group. No action needed." + fi + + return 0 +} + +function install_wwwdata_sudoers() { + local sudoers_file="/etc/sudoers.d/020_www-data-shutdown" + + cat >"$sudoers_file" <<'EOF' +# Photobooth buttons for www-data to shutdown or reboot the system +www-data ALL=(ALL) NOPASSWD: /sbin/shutdown +www-data ALL=(ALL) NOPASSWD: /sbin/reboot +EOF + + chmod 440 "$sudoers_file" + + # Validate syntax (safe check, optional but recommended) + if visudo -cf "$sudoers_file" >/dev/null 2>&1; then + info "Setup" "Installed sudoers rule for www-data at $sudoers_file" + return 0 + else + error "Invalid sudoers file created at $sudoers_file. Please check manually." + return 1 + fi +} + +function install_usb_sudoers() { + local sudoers_file="/etc/sudoers.d/021_www-data-usb-sync" + # Portable paths: some distros use /bin vs /usr/bin for mount/umount/mkdir. + cat >"$sudoers_file" <<'EOF' +# Photobooth USB Sync — passwordless mount/umount/mkdir for www-data (sync-to-drive.js, move2usb). +# Use together with Polkit rules for udisksctl; this covers sudo fallbacks in the Node scripts. +Cmnd_Alias PHOTOBOOTH_USB_SYNC = /bin/mount /dev/* /media/*, /usr/bin/mount /dev/* /media/*, /bin/mount -o * /dev/* /media/*, /usr/bin/mount -o * /dev/* /media/*, /bin/umount /dev/*, /usr/bin/umount /dev/*, /bin/mkdir -p /media/*, /usr/bin/mkdir -p /media/* +www-data ALL=(root) NOPASSWD: PHOTOBOOTH_USB_SYNC +EOF + + chmod 440 "$sudoers_file" + + if visudo -cf "$sudoers_file" >/dev/null 2>&1; then + info "Setup" "Installed USB sudoers rule for www-data at $sudoers_file" + rm -f /etc/sudoers.d/020_www-data-usb 2>/dev/null || true + return 0 + else + error "Invalid sudoers file created at $sudoers_file. Please check manually." + rm -f "$sudoers_file" + return 1 + fi +} + +function create_polkit_usb_rule() { + local PKLA_DIR="/etc/polkit-1/localauthority/50-local.d" + local RULES_DIR="/etc/polkit-1/rules.d" + + if [[ -d "$PKLA_DIR" ]]; then + cat >"$PKLA_DIR/photobooth.pkla" <"$RULES_DIR/photobooth.rules" <<'EOF' +polkit.addRule(function(action, subject) { + if (subject.user == "www-data" && + (action.id.indexOf("org.freedesktop.udisks2.filesystem-mount") == 0 || + action.id.indexOf("org.freedesktop.udisks2.filesystem-unmount") == 0)) { + return polkit.Result.YES; + } +}); +EOF + return 0 + + else + return 1 + fi +} + +function remove_polkit_usb_rule() { + local PKLA_FILE="/etc/polkit-1/localauthority/50-local.d/photobooth.pkla" + local RULES_FILE="/etc/polkit-1/rules.d/photobooth.rules" + local LEGACY_RULES_FILE="/etc/polkit-1/rules.d/50-photobooth-udisks.rules" + local SUDOERS_FILE_LEGACY="/etc/sudoers.d/020_www-data-usb" + local SUDOERS_FILE="/etc/sudoers.d/021_www-data-usb-sync" + local REMOVED=false + + [[ -f "$PKLA_FILE" ]] && rm -f "$PKLA_FILE" && REMOVED=true + [[ -f "$RULES_FILE" ]] && rm -f "$RULES_FILE" && REMOVED=true + [[ -f "$LEGACY_RULES_FILE" ]] && rm -f "$LEGACY_RULES_FILE" && REMOVED=true + [[ -f "$SUDOERS_FILE_LEGACY" ]] && rm -f "$SUDOERS_FILE_LEGACY" && REMOVED=true + [[ -f "$SUDOERS_FILE" ]] && rm -f "$SUDOERS_FILE" && REMOVED=true + + $REMOVED && return 0 || return 1 +} + +function disable_automount() { + local configured=false + local pcmanfm_conf="" + + if [[ -f "/etc/xdg/pcmanfm/default/pcmanfm.conf" ]]; then + pcmanfm_conf="/etc/xdg/pcmanfm/default/pcmanfm.conf" + if ! grep -q "^\[volume\]" "$pcmanfm_conf"; then + echo "[volume]" >>"$pcmanfm_conf" + fi + for key in mount_on_startup mount_removable autorun; do + if grep -q "^$key=" "$pcmanfm_conf"; then + sed -i "s/^$key=.*/$key=0/" "$pcmanfm_conf" + else + echo "$key=0" >>"$pcmanfm_conf" + fi + done + configured=true + info "Auto mount Disable" "System default adjusted." + fi + + if [ -z "$USERNAME" ]; then + local detected_user + detected_user=$(detect_single_home_user) + if [[ -n "$detected_user" ]]; then + USERNAME="$detected_user" + info "Auto mount Disable" "Automatically detected username: $USERNAME" + else + warn "Auto mount Disable" "No username detected." + fi + fi + + if [ -n "$USERNAME" ]; then + local xdg_config="${XDG_CONFIG_HOME:-/home/$USERNAME/.config}" + local config_base="$xdg_config/pcmanfm" + local profile_folder="" + + if [[ -d "$config_base" ]]; then + profile_folder=$(find "$config_base" -mindepth 1 -maxdepth 1 -type d | head -n 1) + fi + + if [[ -n "$profile_folder" ]]; then + pcmanfm_conf="$profile_folder/pcmanfm.conf" + if [[ -f "$pcmanfm_conf" ]]; then + if ! grep -q "^\[volume\]" "$pcmanfm_conf"; then + echo "[volume]" >>"$pcmanfm_conf" + fi + for key in mount_on_startup mount_removable autorun; do + if grep -q "^$key=" "$pcmanfm_conf"; then + sed -i "s/^$key=.*/$key=0/" "$pcmanfm_conf" + else + echo "$key=0" >>"$pcmanfm_conf" + fi + done + chown "$USERNAME:$USERNAME" "$pcmanfm_conf" 2>/dev/null + configured=true + info "Auto mount Disable" "User config adjusted." + fi + fi + fi + + if $configured; then + return 0 + else + return 1 + fi +} + +function set_usb_sync() { + if whiptail --title "USB Sync" \ + --yesno "Setup USB Sync policy?\n\nInstalls Polkit rules for udisksctl and sudoers for mount/umount/mkdir as www-data.\nAlso tries to disable desktop auto-mount to avoid permission conflicts.\nUSB Sync is enabled in the Admin panel." \ + 12 60; then + + create_polkit_usb_rule || warn "Polkit USB rule could not be created; udisksctl may still need manual policy." + install_usb_sudoers || warn "USB sudoers rule could not be installed; sudo mount fallbacks may fail." + if disable_automount; then + confirm "USB Sync" "USB Sync policy and auto-mount adjustments applied." + else + confirm "USB Sync" "USB Sync Polkit/sudoers applied; no pcmanfm volume config found to adjust auto-mount." + fi + + else + if remove_polkit_usb_rule; then + confirm "USB Sync" "USB Sync policy removed." + else + confirm "USB Sync" "No USB Sync policy file found to remove." + fi + fi +} + +# ================================================== +# UI +# ================================================== +function hide_mouse() { + if is_wayland_env; then + if [ -f "/usr/share/icons/PiXflat/cursors/left_ptr" ]; then + mv /usr/share/icons/PiXflat/cursors/left_ptr /usr/share/icons/PiXflat/cursors/left_ptr.bak + confirm "Hide mouse" "Mouse cursor hidden for Wayland by backing up 'left_ptr' icon." + elif [ -f "/usr/share/icons/PiXtrix/cursors/left_ptr" ]; then + mv /usr/share/icons/PiXtrix/cursors/left_ptr /usr/share/icons/PiXtrix/cursors/left_ptr.bak + confirm "Hide mouse" "Mouse cursor hidden for Wayland by backing up 'left_ptr' icon." + else + confirm "Hide mouse" "Cursor already hidden or 'left_ptr' icon not found." + fi + else + + local lxde_autostart_file="/etc/xdg/lxsession/LXDE-pi/autostart" + if [ ! -f "$lxde_autostart_file" ]; then + confirm "Hide mouse" "Aborting. LXDE-pi autostart not found." + return 1 + fi + + if ! install_package "unclutter"; then + confirm "Hide mouse" "Aborting. Can not install unclutter." + return 2 + fi + + # Remove existing Photobooth-related configurations to avoid duplicates + sed -i '/# Photobooth/,/# Photobooth End/d' "$lxde_autostart_file" + + # Append new settings to autostart + cat >>"$lxde_autostart_file" <