#!/usr/bin/env bash set -euo pipefail IFS=$'\n\t' SCRIPT_NAME="$(basename "$0")" CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/control-from-mobile" STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/control-from-mobile" PASSWORD_FILE="$CONFIG_DIR/vnc.pass" ENV_FILE="$CONFIG_DIR/env" RUNNER_FILE="$CONFIG_DIR/start-x11vnc.sh" SERVICE_NAME="control-from-mobile.service" SYSTEMD_USER_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" SERVICE_FILE="$SYSTEMD_USER_DIR/$SERVICE_NAME" DEFAULT_DISPLAY="${DISPLAY:-:0}" DEFAULT_PORT=5901 DEFAULT_BIND_ADDR="0.0.0.0" readonly SCRIPT_NAME CONFIG_DIR STATE_DIR PASSWORD_FILE ENV_FILE RUNNER_FILE SERVICE_NAME SYSTEMD_USER_DIR SERVICE_FILE DEFAULT_DISPLAY DEFAULT_PORT DEFAULT_BIND_ADDR usage() { cat << 'EOF' Usage: control_from_mobile.sh [options] Commands: setup [--force-password] Install dependencies, create configs, and write the systemd user service. start Start the VNC bridge (via systemd user unit when available). stop Stop the bridge. restart Restart the bridge. status Show whether the bridge service is running. enable Enable the service so it starts after login. disable Disable automatic start after login. info Show connection details and Android app suggestions. uninstall Stop the service and remove generated files (keeps password unless --purge). help Show this message. Options: --force-password Regenerate the VNC password during setup. --purge Delete the stored VNC password during uninstall. Examples: ./control_from_mobile.sh setup ./control_from_mobile.sh start ./control_from_mobile.sh info EOF } log() { printf '[%s] %s\n' "$SCRIPT_NAME" "$*" } warn() { printf '[%s] %s\n' "$SCRIPT_NAME" "$*" >&2 } die() { warn "$*" exit 1 } require_non_root() { if [[ ${EUID:-$(id -u)} -eq 0 ]]; then die "Run this script as a regular desktop user, not root." fi } prompt_yes_no() { local prompt="$1" local reply read -r -p "$prompt [y/N]: " reply case "$reply" in [Yy][Ee][Ss] | [Yy]) return 0 ;; *) return 1 ;; esac } ensure_directories() { mkdir -p "$CONFIG_DIR" "$STATE_DIR" "$SYSTEMD_USER_DIR" chmod 700 "$CONFIG_DIR" } missing_commands() { local missing=() for cmd in "$@"; do if ! command -v "$cmd" > /dev/null 2>&1; then missing+=("$cmd") fi done printf '%s\n' "${missing[@]-}" } install_dependencies() { if ! command -v systemctl > /dev/null 2>&1; then die "systemctl not found. Install systemd before running this script." fi local required=(x11vnc qrencode ssh) local needed=() mapfile -t needed < <(missing_commands "${required[@]}") if ((${#needed[@]} == 0)); then log "All required packages (${required[*]}) are present." return fi if command -v pacman > /dev/null 2>&1; then log "Installing missing packages: ${needed[*]}" sudo pacman -S --needed --noconfirm "${needed[@]}" else die "Missing commands (${needed[*]}). Install them manually and rerun setup." fi } create_password_file() { local force=${1:-0} if [[ -f $PASSWORD_FILE && $force -ne 1 ]]; then log "Using existing VNC password file at $PASSWORD_FILE" return fi if [[ -f $PASSWORD_FILE ]]; then if ! prompt_yes_no "Regenerate the stored VNC password?"; then log "Keeping existing password." return fi fi local password confirm generated=0 read -rsp "Enter VNC password (leave blank to auto-generate): " password printf '\n' if [[ -z $password ]]; then generated=1 password=$(LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 8) log "Generated VNC password: $password" else read -rsp "Confirm password: " confirm printf '\n' if [[ $password != "$confirm" ]]; then die "Passwords do not match." fi fi local tmp tmp=$(mktemp) x11vnc -storepasswd "$password" "$tmp" > /dev/null install -m 600 "$tmp" "$PASSWORD_FILE" rm -f "$tmp" if ((generated == 0)); then log "Password stored securely at $PASSWORD_FILE (hashed)." else log "Please write down the generated password; it will be needed on your Android device." fi } create_env_file() { if [[ -f $ENV_FILE ]]; then return fi cat > "$ENV_FILE" << EOF # control-from-mobile configuration # Adjust these values if needed and rerun: systemctl --user restart $SERVICE_NAME X11_DISPLAY="$DEFAULT_DISPLAY" VNC_PORT="$DEFAULT_PORT" # Use 127.0.0.1 to force SSH tunnel-only access, or 0.0.0.0 to expose on LAN. VNC_BIND_ADDR="$DEFAULT_BIND_ADDR" EOF chmod 600 "$ENV_FILE" } create_runner_script() { cat > "$RUNNER_FILE" << 'EOF' #!/usr/bin/env bash set -euo pipefail IFS=$'\n\t' CONFIG_DIR="$(dirname "$(readlink -f "$0")")" PASSWORD_FILE="$CONFIG_DIR/vnc.pass" ENV_FILE="$CONFIG_DIR/env" STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/control-from-mobile" mkdir -p "$STATE_DIR" if [[ ! -f "$PASSWORD_FILE" ]]; then echo "Missing VNC password file at $PASSWORD_FILE" >&2 exit 1 fi if [[ -f "$ENV_FILE" ]]; then # shellcheck disable=SC1090 source "$ENV_FILE" fi X11_DISPLAY="${X11_DISPLAY:-${DISPLAY:-:0}}" VNC_PORT="${VNC_PORT:-5901}" VNC_BIND_ADDR="${VNC_BIND_ADDR:-0.0.0.0}" LOG_FILE="$STATE_DIR/x11vnc.log" exec /usr/bin/x11vnc \ -display "$X11_DISPLAY" \ -rfbport "$VNC_PORT" \ -listen "$VNC_BIND_ADDR" \ -forever \ -shared \ -auth guess \ -rfbauth "$PASSWORD_FILE" \ -noxdamage \ -repeat \ -ncache 10 \ -ncache_cr \ -o "$LOG_FILE" EOF chmod 700 "$RUNNER_FILE" } create_service_file() { cat > "$SERVICE_FILE" << EOF [Unit] Description=Expose X11 desktop over VNC for Android control After=graphical-session.target PartOf=graphical-session.target [Service] Type=simple EnvironmentFile=$ENV_FILE ExecStart=$RUNNER_FILE Restart=on-failure RestartSec=2 StandardOutput=journal StandardError=journal [Install] WantedBy=default.target EOF } reload_user_daemon() { systemctl --user daemon-reload } ensure_service_present() { if [[ ! -f $SERVICE_FILE || ! -x $RUNNER_FILE ]]; then die "Service files missing. Run: $SCRIPT_NAME setup" fi } start_service() { ensure_service_present systemctl --user start "$SERVICE_NAME" } stop_service() { systemctl --user stop "$SERVICE_NAME" || true } status_service() { if systemctl --user is-active --quiet "$SERVICE_NAME"; then log "Service is active." else log "Service is inactive." fi systemctl --user status "$SERVICE_NAME" --no-pager || true } enable_service() { ensure_service_present systemctl --user enable "$SERVICE_NAME" } disable_service() { systemctl --user disable "$SERVICE_NAME" || true } show_info() { ensure_service_present # shellcheck disable=SC1090 [[ -f $ENV_FILE ]] && source "$ENV_FILE" local port="${VNC_PORT:-$DEFAULT_PORT}" local bind_addr="${VNC_BIND_ADDR:-$DEFAULT_BIND_ADDR}" local display="${X11_DISPLAY:-$DEFAULT_DISPLAY}" local is_active="inactive" if systemctl --user is-active --quiet "$SERVICE_NAME"; then is_active="active" fi log "Service status: $is_active" log "Display: $display" log "Listening address: $bind_addr" log "VNC port: $port" log "Password file: $PASSWORD_FILE" local -a ip_list=() if command -v hostname > /dev/null 2>&1; then while IFS= read -r line; do [[ -z $line ]] && continue ip_list+=("$line") done < <(hostname -I 2> /dev/null | tr ' ' '\n' | grep -E '^[0-9]' || true) fi if ((${#ip_list[@]} > 0)); then log "Detected LAN IPs:" for ip in "${ip_list[@]}"; do printf ' - %s\n' "$ip" done else warn "Could not detect LAN IPs." fi printf '\nRecommended Android clients (FOSS):\n' printf ' • bVNC (available on F-Droid) — supports full control.\n' printf ' • Termux + OpenSSH for establishing an SSH tunnel when exposing only on 127.0.0.1.\n' printf '\nConnect via VNC:\n' printf ' Host: \n Port: %s\n Password: \n' "$port" local qr_host if ((${#ip_list[@]} > 0)); then qr_host="${ip_list[0]}" else qr_host="$bind_addr" if [[ $qr_host == "0.0.0.0" || $qr_host == "::" ]]; then qr_host="127.0.0.1" fi warn "Using fallback host $qr_host for QR code; replace with an accessible IP if needed." fi if command -v qrencode > /dev/null 2>&1; then printf '\nConnection QR (vnc://%s:%s):\n' "$qr_host" "$port" qrencode -o - "vnc://$qr_host:$port" -t ASCII || true else warn "qrencode not found; reinstall qrencode to get QR codes." fi printf '\nFor encrypted access outside your LAN, use Termux on Android:\n' printf ' ssh -L %s:localhost:%s @\n' "$port" "$port" printf 'Then point bVNC to 127.0.0.1:%s.\n' "$port" } uninstall_files() { local purge_password=${1:-0} stop_service disable_service rm -f "$SERVICE_FILE" rm -f "$RUNNER_FILE" rm -f "$ENV_FILE" if ((purge_password)); then rm -f "$PASSWORD_FILE" log "Removed password file." fi reload_user_daemon log "Removed generated files." } main() { require_non_root local cmd="${1:-}" shift || true case "$cmd" in setup) local force=0 if [[ ${1:-} == "--force-password" ]]; then force=1 shift || true fi ensure_directories install_dependencies create_password_file "$force" create_env_file create_runner_script create_service_file reload_user_daemon log "Setup complete. Start the service with: $SCRIPT_NAME start" ;; start) start_service show_info ;; stop) stop_service ;; restart) stop_service start_service ;; status) status_service ;; enable) enable_service ;; disable) disable_service ;; info) show_info ;; uninstall) local purge=0 if [[ ${1:-} == "--purge" ]]; then purge=1 shift || true fi uninstall_files "$purge" ;; help | --help | -h | "") usage ;; *) usage die "Unknown command: $cmd" ;; esac } main "$@"