diff --git a/docs/shell-split-recipes.md b/docs/shell-split-recipes.md index 8ac0f58e..a2f3e16d 100644 --- a/docs/shell-split-recipes.md +++ b/docs/shell-split-recipes.md @@ -80,6 +80,30 @@ grep -rn -E '^\s*(sudo )?(install|cp)\s.*(SCRIPT_DIR|REPO).*/usr/local/' \ linux_configuration --include='*.sh' ``` +## Three traps found while splitting (2026-08-16) + +- **A script with no `main()`.** `setup_periodic_system.sh` defines functions + and then runs a block of top-level calls. Moving that block into a lib + changes _when_ it runs. Move **only function definitions**; leave every + top-level invocation in the entry script, and check the `source` line comes + before the first call. +- **A script that needs root at source time.** The same file asks for sudo + while sourcing, so the stubbed run is not available. Fall back to: source + line before first call, plus every invoked function defined exactly once + across the two files (`grep -c '^fn() {'` in each). +- **An entry script that already declares `SCRIPT_DIR`** — often `readonly`. + Reuse it instead of adding a second declaration, or the script dies with + `readonly variable`. `bash -n` and `shellcheck` both pass on that. + +### When to give up on a seam + +`clean_audio.sh` was reverted. Two probe-result globals were read by functions +destined for different libs; renaming them to satisfy SC2154 just moved the +warning to a third. **A split that needs a suppression, or that would thread +globals through three files, is a refactor — not a line-count split.** Revert +it, leave the file over the cap, and say so. It is cheaper than a silent +behaviour change in a script you are not allowed to run. + - Prefer files that **nothing** references first — pure lint risk. - Watch **jscpd** on the first multi-lib commit: repeated `set -euo pipefail` + source-guard headers across several new libs is exactly what pushes diff --git a/docs/superpowers/contracts/linux-config-near-miss-batch2-2026-08-16.json b/docs/superpowers/contracts/linux-config-near-miss-batch2-2026-08-16.json new file mode 100644 index 00000000..f6af04d0 --- /dev/null +++ b/docs/superpowers/contracts/linux-config-near-miss-batch2-2026-08-16.json @@ -0,0 +1,18 @@ +{ + "title": "Split two more linux_configuration scripts, reverting a third", + "objective": "Bring control_from_mobile.sh (415) and setup_periodic_system.sh (408) under the 250-line cap, and establish when a seam should be abandoned rather than forced.", + "acceptance_criteria": [ + "Both entry scripts and both new libs are at most 250 lines.", + "bash -n and shellcheck pass on every touched file, with no new suppression and no shellcheck disable directive.", + "setup_periodic_system.sh keeps its top-level invocation block in the entry script: it has no main(), so moving that block into a lib would change when it runs. Only function definitions move.", + "For any script that cannot be stub-run (setup_periodic_system.sh asks for root at source time), verification is: the source line precedes the first top-level call, and every invoked function is defined exactly once across the entry script and its lib.", + "No script is executed. Both install systemd units and one requires root.", + "clean_audio.sh is fully reverted, leaving no partial split and no new lib directory behind, and remains over the cap with the reason recorded." + ], + "out_of_scope": [ + "Executing any installer or enforcement step.", + "Untangling clean_audio.sh's cross-file globals, which is a refactor rather than a line-count split.", + "Changing what any script does when run." + ], + "verifier": "scratchpad/verify_split.sh for control_from_mobile.sh; for setup_periodic_system.sh, bash -n + shellcheck -x plus grep proving source-before-call and one definition per invoked function; plus pre-commit run --files and the real git commit for jscpd." +} diff --git a/docs/superpowers/evidence/linux-config-near-miss-batch2-2026-08-16.json b/docs/superpowers/evidence/linux-config-near-miss-batch2-2026-08-16.json new file mode 100644 index 00000000..603f6dc0 --- /dev/null +++ b/docs/superpowers/evidence/linux-config-near-miss-batch2-2026-08-16.json @@ -0,0 +1,44 @@ +{ + "intent": "Split two more linux_configuration scripts under the cap, and record why a third was reverted rather than forced.", + "scope": [ + "linux_configuration/scripts/single_use/features/control_from_mobile.sh (415 -> 180) + lib/mobile_service.sh", + "linux_configuration/scripts/periodic_background/setup_periodic_system.sh (408 -> 228) + lib/periodic_browser.sh", + "Reverted: scripts/single_use/misc/testsAndMisc-bash/clean_audio.sh", + "Non-goal: no script executed, no behaviour change" + ], + "changes": [ + "control_from_mobile.sh: the credential, env, runner-script and systemd-unit generation moved to lib/mobile_service.sh. Straightforward - it has a main() and no cross-file globals.", + "setup_periodic_system.sh has NO main(): it defines functions and then runs a block of top-level calls. The splitter's default behaviour moved that executing block into the lib, which would have changed when it ran. Caught by the stubbed run and redone by hand, moving only function definitions and leaving the invocation block in the entry script.", + "clean_audio.sh was reverted. Its two probe-result globals (use_arnndn, afftdn_supports_md) are read by functions in what would be two separate libs, and renaming them to uppercase to satisfy SC2154 only pushed the warning to a third variable. Untangling that is a real refactor, not a line-count split, and doing it here would have meant either a suppression or a behaviour risk." + ], + "verification": [ + { + "command": "scratchpad/verify_split.sh control_from_mobile.sh ", + "result": "pass", + "evidence": "'libs sourced, functions defined' after bash -n and shellcheck -x." + }, + { + "command": "static verification of setup_periodic_system.sh", + "result": "pass", + "evidence": "The stubbed run could NOT be used: the script requests sudo at source time, so running it is excluded by decision 6. Verified instead that the source line is at 176 and the first top-level call at 178, and that all eleven invoked functions are defined exactly once across the two files (six in the entry, five in the lib). bash -n and shellcheck -x clean on both." + }, + { + "command": "git checkout + rm -rf lib, then shellcheck the restored clean_audio.sh", + "result": "pass", + "evidence": "Restored to 419 lines and clean, leaving no partial split behind." + }, + { + "command": "pre-commit run --files ", + "result": "pass", + "evidence": "Clean, including jscpd on the real commit with two more libs added." + } + ], + "risks": [ + "setup_periodic_system.sh requests root and installs systemd units; it was not run. The static check proves the lib is sourced before any call and that no function is missing or doubly defined, which is the strongest available evidence short of executing it.", + "clean_audio.sh remains over the cap at 419 lines and is now a known-harder case: its globals must be threaded explicitly, or the whole probe/filter section kept in one lib, before it can be split without a suppression." + ], + "rollback": [ + "git revert the commit; both scripts return to their previous sizes and the two libs disappear.", + "After rollback, re-run bash -n and shellcheck on the restored scripts." + ] +} diff --git a/linux_configuration/scripts/periodic_background/lib/periodic_browser.sh b/linux_configuration/scripts/periodic_background/lib/periodic_browser.sh new file mode 100755 index 00000000..f3b06c82 --- /dev/null +++ b/linux_configuration/scripts/periodic_background/lib/periodic_browser.sh @@ -0,0 +1,191 @@ +#!/bin/bash +# Browser wrapper install, desktop-entry redirect, auto-update units and +# service enablement for the periodic system setup. +# +# Sourced by setup_periodic_system.sh; split out to keep it under the +# 250-line cap. Sourced rather than run, so it inherits the caller's strict +# mode and the variables defined above the source line. + +# Function to install browser pre-exec wrapper and wire common browser names +install_browser_preexec_wrapper() { + echo "" + echo "6.1 Installing Browser Pre-Exec Wrapper..." + echo "=========================================" + + local wrapper="/usr/local/bin/browser-preexec-wrapper" + sed -e "s|__HOSTS_INSTALL_SCRIPT__|$HOSTS_INSTALL_SCRIPT|g" \ + "$TEMPLATE_BROWSER_WRAPPER" > "$wrapper" + chmod +x "$wrapper" + echo "✓ Installed wrapper: $wrapper" + + # Allow passwordless execution of hosts installer for root-only actions + local sudoers_file="/etc/sudoers.d/hosts-install-no-passwd" + if command -v visudo > /dev/null 2>&1; then + echo "${SUDO_USER:-$USER} ALL=(ALL) NOPASSWD: $HOSTS_INSTALL_SCRIPT" > "$sudoers_file" + chmod 440 "$sudoers_file" + # Validate syntax + visudo -c > /dev/null || echo "Warning: sudoers validation returned non-zero" + echo "✓ Sudoers drop-in created: $sudoers_file" + else + echo "visudo not found; skipping sudoers drop-in" + fi + + # Create symlinks for common browser commands to the wrapper in /usr/local/bin + # This takes precedence over /usr/bin in PATH on most systems. + local browsers=("thorium-browser" "google-chrome" "google-chrome-stable" "chromium" "brave" "brave-browser" "vivaldi-stable" "firefox") + for b in "${browsers[@]}"; do + local link="/usr/local/bin/$b" + ln -sf "$wrapper" "$link" + done + echo "✓ Symlinked wrapper for common browsers in /usr/local/bin" + + redirect_app_mode_desktop_entries +} + +# Chromium "app mode" shortcuts (--app-id=...) are auto-generated with a direct +# Exec=/opt// line, bypassing /usr/local/bin entirely. That +# means neither the flags file nor the crash logging applies to them, and it is +# silent: on 2026-08-09 five such Thorium entries were found still launching +# without the --disable-gpu-compositing mitigation added on 2026-07-25. +# Chromium regenerates these files, so this re-runs on every install. +redirect_app_mode_desktop_entries() { + local target_user="${SUDO_USER:-$USER}" + local apps_dir + apps_dir="$(getent passwd "$target_user" | cut -d: -f6)/.local/share/applications" + + [[ -d $apps_dir ]] || return 0 + + local rewritten=0 f + while IFS= read -r -d '' f; do + if grep -q '^Exec=/opt/' "$f"; then + # Keep only the BASENAME, whatever the nesting depth: + # /opt/thorium-browser/thorium-browser -> /usr/local/bin/thorium-browser + # /opt/google/chrome/google-chrome -> /usr/local/bin/google-chrome + # The greedy .* up to the last slash is what makes depth irrelevant; + # matching a single path segment silently mangles the nested case. + sed -i -E 's|^Exec=/opt/.*/([^/ ]+)|Exec=/usr/local/bin/\1|' "$f" + rewritten=$((rewritten + 1)) + fi + done < <(find "$apps_dir" -maxdepth 1 -type f -name '*.desktop' -print0 2> /dev/null) + + if ((rewritten > 0)); then + chown "$target_user" "$apps_dir"/*.desktop 2> /dev/null || true + echo "✓ Redirected $rewritten app-mode .desktop entries through the wrapper" + else + echo "✓ No app-mode .desktop entries needed redirecting" + fi +} + +# Function to install automatic system update service +install_auto_update() { + echo "" + echo "6.2 Installing Automatic System Update..." + echo "=========================================" + + local update_script="/usr/local/bin/auto-system-update.sh" + local update_service="/etc/systemd/system/auto-system-update.service" + local update_timer="/etc/systemd/system/auto-system-update.timer" + + # Install script from template with user substitution + local actual_user="${SUDO_USER:-$USER}" + sed -e "s|__ACTUAL_USER__|$actual_user|g" \ + "$TEMPLATE_AUTO_UPDATE" > "$update_script" + chmod +x "$update_script" + echo "✓ Installed auto-update script: $update_script (user: $actual_user)" + + # Install systemd service and timer from templates + install -m 0644 "$TEMPLATE_AUTO_UPDATE_SVC" "$update_service" + echo "✓ Installed auto-update service: $update_service" + + install -m 0644 "$TEMPLATE_AUTO_UPDATE_TIMER" "$update_timer" + echo "✓ Installed auto-update timer: $update_timer" +} + +# Function to enable and start services +enable_services() { + echo "" + echo "7. Enabling Services and Timer..." + echo "=================================" + + # Reload systemd daemon + systemctl daemon-reload + echo "✓ Systemd daemon reloaded" + + # Enable and start the timer + systemctl enable periodic-system-maintenance.timer + systemctl start periodic-system-maintenance.timer + echo "✓ Timer enabled and started" + + # Enable startup service (but don't start it now) + systemctl enable periodic-system-startup.service + echo "✓ Startup service enabled" + + # Enable hosts file monitor service + systemctl enable hosts-file-monitor.service + systemctl start hosts-file-monitor.service + echo "✓ Hosts file monitor service enabled and started" + + # Enable and start auto-update timer + systemctl enable auto-system-update.timer + systemctl start auto-system-update.timer + echo "✓ Auto-update timer enabled and started" + + # Show timer status + echo "" + echo "Timer Status:" + systemctl status periodic-system-maintenance.timer --no-pager -l + + echo "" + echo "Auto-Update Timer Status:" + systemctl status auto-system-update.timer --no-pager -l + + echo "" + echo "Hosts Monitor Status:" + systemctl status hosts-file-monitor.service --no-pager -l + + echo "" + echo "Next scheduled runs:" + systemctl list-timers periodic-system-maintenance.timer auto-system-update.timer --no-pager +} + +# Function to create log rotation configuration +create_log_rotation() { + echo "" + echo "8. Setting up Log Rotation..." + echo "=============================" + + local logrotate_conf="/etc/logrotate.d/periodic-system-maintenance" + install -m 0644 "$TEMPLATE_LOGROTATE" "$logrotate_conf" + echo "✓ Installed log rotation configuration from template: $logrotate_conf" +} + +# Function to run initial execution +run_initial_execution() { + echo "" + echo "9. Running Initial Execution..." + echo "===============================" + + local run_initial=true + + if [[ $INTERACTIVE_MODE == "true" ]]; then + echo "Would you like to run the system maintenance now to test the setup?" + read -p "Run initial execution? (y/N): " -n 1 -r + echo + + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + run_initial=false + fi + else + echo "Auto-running initial execution to test the setup (use --interactive to prompt)" + fi + + if [[ $run_initial == "true" ]]; then + echo "Running initial system maintenance..." + /usr/local/bin/periodic-system-maintenance.sh + echo "✓ Initial execution completed" + else + echo "Skipping initial execution" + fi +} + +# Main execution diff --git a/linux_configuration/scripts/periodic_background/setup_periodic_system.sh b/linux_configuration/scripts/periodic_background/setup_periodic_system.sh index ab757f24..25431594 100755 --- a/linux_configuration/scripts/periodic_background/setup_periodic_system.sh +++ b/linux_configuration/scripts/periodic_background/setup_periodic_system.sh @@ -172,189 +172,9 @@ create_hosts_monitor_service() { echo "✓ Installed hosts monitor service from template: $monitor_service" } -# Function to install browser pre-exec wrapper and wire common browser names -install_browser_preexec_wrapper() { - echo "" - echo "6.1 Installing Browser Pre-Exec Wrapper..." - echo "=========================================" +# shellcheck source=lib/periodic_browser.sh +source "$SCRIPT_DIR/lib/periodic_browser.sh" - local wrapper="/usr/local/bin/browser-preexec-wrapper" - sed -e "s|__HOSTS_INSTALL_SCRIPT__|$HOSTS_INSTALL_SCRIPT|g" \ - "$TEMPLATE_BROWSER_WRAPPER" > "$wrapper" - chmod +x "$wrapper" - echo "✓ Installed wrapper: $wrapper" - - # Allow passwordless execution of hosts installer for root-only actions - local sudoers_file="/etc/sudoers.d/hosts-install-no-passwd" - if command -v visudo > /dev/null 2>&1; then - echo "${SUDO_USER:-$USER} ALL=(ALL) NOPASSWD: $HOSTS_INSTALL_SCRIPT" > "$sudoers_file" - chmod 440 "$sudoers_file" - # Validate syntax - visudo -c > /dev/null || echo "Warning: sudoers validation returned non-zero" - echo "✓ Sudoers drop-in created: $sudoers_file" - else - echo "visudo not found; skipping sudoers drop-in" - fi - - # Create symlinks for common browser commands to the wrapper in /usr/local/bin - # This takes precedence over /usr/bin in PATH on most systems. - local browsers=("thorium-browser" "google-chrome" "google-chrome-stable" "chromium" "brave" "brave-browser" "vivaldi-stable" "firefox") - for b in "${browsers[@]}"; do - local link="/usr/local/bin/$b" - ln -sf "$wrapper" "$link" - done - echo "✓ Symlinked wrapper for common browsers in /usr/local/bin" - - redirect_app_mode_desktop_entries -} - -# Chromium "app mode" shortcuts (--app-id=...) are auto-generated with a direct -# Exec=/opt// line, bypassing /usr/local/bin entirely. That -# means neither the flags file nor the crash logging applies to them, and it is -# silent: on 2026-08-09 five such Thorium entries were found still launching -# without the --disable-gpu-compositing mitigation added on 2026-07-25. -# Chromium regenerates these files, so this re-runs on every install. -redirect_app_mode_desktop_entries() { - local target_user="${SUDO_USER:-$USER}" - local apps_dir - apps_dir="$(getent passwd "$target_user" | cut -d: -f6)/.local/share/applications" - - [[ -d $apps_dir ]] || return 0 - - local rewritten=0 f - while IFS= read -r -d '' f; do - if grep -q '^Exec=/opt/' "$f"; then - # Keep only the BASENAME, whatever the nesting depth: - # /opt/thorium-browser/thorium-browser -> /usr/local/bin/thorium-browser - # /opt/google/chrome/google-chrome -> /usr/local/bin/google-chrome - # The greedy .* up to the last slash is what makes depth irrelevant; - # matching a single path segment silently mangles the nested case. - sed -i -E 's|^Exec=/opt/.*/([^/ ]+)|Exec=/usr/local/bin/\1|' "$f" - rewritten=$((rewritten + 1)) - fi - done < <(find "$apps_dir" -maxdepth 1 -type f -name '*.desktop' -print0 2> /dev/null) - - if ((rewritten > 0)); then - chown "$target_user" "$apps_dir"/*.desktop 2> /dev/null || true - echo "✓ Redirected $rewritten app-mode .desktop entries through the wrapper" - else - echo "✓ No app-mode .desktop entries needed redirecting" - fi -} - -# Function to install automatic system update service -install_auto_update() { - echo "" - echo "6.2 Installing Automatic System Update..." - echo "=========================================" - - local update_script="/usr/local/bin/auto-system-update.sh" - local update_service="/etc/systemd/system/auto-system-update.service" - local update_timer="/etc/systemd/system/auto-system-update.timer" - - # Install script from template with user substitution - local actual_user="${SUDO_USER:-$USER}" - sed -e "s|__ACTUAL_USER__|$actual_user|g" \ - "$TEMPLATE_AUTO_UPDATE" > "$update_script" - chmod +x "$update_script" - echo "✓ Installed auto-update script: $update_script (user: $actual_user)" - - # Install systemd service and timer from templates - install -m 0644 "$TEMPLATE_AUTO_UPDATE_SVC" "$update_service" - echo "✓ Installed auto-update service: $update_service" - - install -m 0644 "$TEMPLATE_AUTO_UPDATE_TIMER" "$update_timer" - echo "✓ Installed auto-update timer: $update_timer" -} - -# Function to enable and start services -enable_services() { - echo "" - echo "7. Enabling Services and Timer..." - echo "=================================" - - # Reload systemd daemon - systemctl daemon-reload - echo "✓ Systemd daemon reloaded" - - # Enable and start the timer - systemctl enable periodic-system-maintenance.timer - systemctl start periodic-system-maintenance.timer - echo "✓ Timer enabled and started" - - # Enable startup service (but don't start it now) - systemctl enable periodic-system-startup.service - echo "✓ Startup service enabled" - - # Enable hosts file monitor service - systemctl enable hosts-file-monitor.service - systemctl start hosts-file-monitor.service - echo "✓ Hosts file monitor service enabled and started" - - # Enable and start auto-update timer - systemctl enable auto-system-update.timer - systemctl start auto-system-update.timer - echo "✓ Auto-update timer enabled and started" - - # Show timer status - echo "" - echo "Timer Status:" - systemctl status periodic-system-maintenance.timer --no-pager -l - - echo "" - echo "Auto-Update Timer Status:" - systemctl status auto-system-update.timer --no-pager -l - - echo "" - echo "Hosts Monitor Status:" - systemctl status hosts-file-monitor.service --no-pager -l - - echo "" - echo "Next scheduled runs:" - systemctl list-timers periodic-system-maintenance.timer auto-system-update.timer --no-pager -} - -# Function to create log rotation configuration -create_log_rotation() { - echo "" - echo "8. Setting up Log Rotation..." - echo "=============================" - - local logrotate_conf="/etc/logrotate.d/periodic-system-maintenance" - install -m 0644 "$TEMPLATE_LOGROTATE" "$logrotate_conf" - echo "✓ Installed log rotation configuration from template: $logrotate_conf" -} - -# Function to run initial execution -run_initial_execution() { - echo "" - echo "9. Running Initial Execution..." - echo "===============================" - - local run_initial=true - - if [[ $INTERACTIVE_MODE == "true" ]]; then - echo "Would you like to run the system maintenance now to test the setup?" - read -p "Run initial execution? (y/N): " -n 1 -r - echo - - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - run_initial=false - fi - else - echo "Auto-running initial execution to test the setup (use --interactive to prompt)" - fi - - if [[ $run_initial == "true" ]]; then - echo "Running initial system maintenance..." - /usr/local/bin/periodic-system-maintenance.sh - echo "✓ Initial execution completed" - else - echo "Skipping initial execution" - fi -} - -# Main execution verify_files create_execution_script create_systemd_service diff --git a/linux_configuration/scripts/single_use/features/control_from_mobile.sh b/linux_configuration/scripts/single_use/features/control_from_mobile.sh index 5e981f9f..b4386ed8 100755 --- a/linux_configuration/scripts/single_use/features/control_from_mobile.sh +++ b/linux_configuration/scripts/single_use/features/control_from_mobile.sh @@ -110,244 +110,9 @@ install_dependencies() { 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." -} +SCRIPT_DIR="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" +# shellcheck source=lib/mobile_service.sh +source "$SCRIPT_DIR/lib/mobile_service.sh" main() { require_non_root diff --git a/linux_configuration/scripts/single_use/features/lib/mobile_service.sh b/linux_configuration/scripts/single_use/features/lib/mobile_service.sh new file mode 100755 index 00000000..ec02b396 --- /dev/null +++ b/linux_configuration/scripts/single_use/features/lib/mobile_service.sh @@ -0,0 +1,245 @@ +#!/bin/bash +# Credential, env, runner and systemd unit generation. +# +# Sourced by control_from_mobile.sh; split out to keep it under the 250-line cap. +# Sourced rather than run, so it inherits the caller's strict mode and +# the helper functions and variables defined above the source line. + +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." +}