#!/bin/bash ################################################################################ # menus/addon_anki_sync.sh - "Anki Sync Server" addon. # # Self-hosted flashcard sync, as an alternative to AnkiWeb - lets Anki # desktop and AnkiDroid clients on the LAN sync their collection (any # note type: simple vocab/math-fact cards or complex custom templates) # against this kiosk instead of ankiweb.net. Standard Anki-format decks # import/export as .apkg from the desktop client; Quizlet sets round-trip # through Anki via tab-separated text (Quizlet's own export/import), so # this server doesn't need to know anything about that format itself - # it only syncs whatever collection the client already has. # # Installed the officially documented way (docs.ankiweb.net/sync-server.html): # the real "anki" package from PyPI, run headless as `python -m # anki.syncserver` - no Qt/GUI deps, since that module never imports the # desktop app's aqt package. Runs in its own venv under $ANKI_SYNC_DIR # (lib/config.sh) as $KIOSK_USER, same shape as menus/addon_webui.sh's # Node app: an EnvironmentFile under $ANKI_SYNC_ENV_DIR feeding a # systemd unit's ExecStart, both parameterized so tests can point them # at a scratch directory instead of the real system. # # Unlike webui.env, anki-sync.env holds plaintext sync passwords # (SYNC_USER1=user:pass, ... - the only form the upstream server # accepts), so it's written chmod 600/root:root rather than left at the # default `sudo tee` mode - systemd reads EnvironmentFile as root before # dropping to User=$KIOSK_USER, so the service never needs read access # to it itself. # # No login/HTTPS beyond that account+password, same caveat as every # other plain self-hosted addon here (CUPS, LMS, Web UI): fine on a # trusted LAN, put it behind a reverse proxy with TLS if it needs to be # reachable beyond one. # # Depends on: lib/menu.sh, lib/config.sh being sourced first. ################################################################################ anki_sync_is_installed() { [[ -f "$SYSTEMD_DIR/kiosk-anki-sync.service" ]] } anki_sync_is_active() { systemctl is-active --quiet kiosk-anki-sync } anki_sync_env_file() { echo "$ANKI_SYNC_ENV_DIR/anki-sync.env" } anki_sync_port() { sudo grep -oP '^SYNC_PORT=\K.*' "$(anki_sync_env_file)" 2>/dev/null || echo "8080" } # No `|| echo 0` here: `grep -c` on a file with zero matches still # prints "0" to stdout while exiting non-zero, so chaining `||` would # run *both* branches and return "0\n0" - the pattern menus/ # addon_webui.sh's webui_port() uses safely only works because `-oP` # prints nothing at all on no match. anki_sync_user_count() { local count count=$(sudo grep -c '^SYNC_USER[0-9]\+=' "$(anki_sync_env_file)" 2>/dev/null) echo "${count:-0}" } addon_anki_sync_status() { if anki_sync_is_installed && anki_sync_is_active; then echo "Anki Sync Server: running at http://$(get_ip_address):$(anki_sync_port)/" echo " Sync users: $(anki_sync_user_count)" elif anki_sync_is_installed; then echo "Anki Sync Server: installed but not running" else echo "Anki Sync Server: not installed" fi } addon_anki_sync_menu_builder() { if anki_sync_is_installed; then MENU_LABELS=("Add another sync user" "Change port" "Restart service" "Uninstall") MENU_HANDLERS=(action_anki_sync_add_user action_anki_sync_reconfigure_port action_anki_sync_restart action_anki_sync_uninstall) else MENU_LABELS=("Install Anki Sync Server") MENU_HANDLERS=(action_anki_sync_install) fi } addon_anki_sync_menu() { run_menu "ANKI SYNC SERVER" addon_anki_sync_menu_builder addon_anki_sync_status } ################################################################################ # Shared install helpers ################################################################################ anki_sync_ask_username() { local default="$1" local uname while true; do uname=$(ask_text "Username" "$default") if [[ -z "$uname" ]]; then echo "❌ Username cannot be empty" >&2 continue fi if [[ "$uname" == *:* ]]; then echo "❌ Username cannot contain ':'" >&2 continue fi echo "$uname" return 0 done } # Prompts twice (masked) and returns the password once both entries # match - same shape as menus/lockout.sh's prompt_and_hash_password, # but returned as plain text (not hashed): the upstream sync server # authenticates against the plaintext SYNC_USERn value itself. anki_sync_ask_password() { local pass1 pass2 while true; do read -r -s -p "Password for $1: " pass1 echo read -r -s -p "Confirm password: " pass2 echo if [[ -z "$pass1" ]]; then echo "❌ Password cannot be empty" continue fi if [[ "$pass1" != "$pass2" ]]; then echo "❌ Passwords do not match" continue fi echo "$pass1" return 0 done } anki_sync_write_base_env() { local port="$1" sudo mkdir -p "$ANKI_SYNC_ENV_DIR" sudo tee "$(anki_sync_env_file)" > /dev/null < /dev/null } anki_sync_write_unit_file() { sudo tee "$SYSTEMD_DIR/kiosk-anki-sync.service" > /dev/null < Import in the desktop app) - this server only syncs" echo "the resulting collection." echo ask_yes_no "Install the Anki Sync Server?" "n" || { echo "Cancelled"; return; } echo "Installing python3-venv..." if ! sudo apt-get update -qq || ! sudo apt-get install -y python3-venv python3-pip; then log_error "Failed to install python3-venv/python3-pip" return 1 fi echo "Creating virtual environment..." sudo mkdir -p "$ANKI_SYNC_DIR" sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$ANKI_SYNC_DIR" if ! sudo -u "$KIOSK_USER" python3 -m venv "$ANKI_SYNC_DIR"; then log_error "Failed to create virtual environment" return 1 fi # Debian/Ubuntu's python3-venv doesn't always bootstrap pip into a # fresh venv on its own (ensurepip behavior varies by release) - fall # back to ensurepip explicitly rather than failing the whole install # over it. if ! sudo -u "$KIOSK_USER" test -x "$ANKI_SYNC_DIR/bin/pip"; then sudo -u "$KIOSK_USER" "$ANKI_SYNC_DIR/bin/python" -m ensurepip --upgrade fi echo "Installing the 'anki' package (this can take a minute)..." if ! sudo -u "$KIOSK_USER" "$ANKI_SYNC_DIR/bin/pip" install --upgrade pip anki; then log_error "pip install anki failed" return 1 fi echo echo "Set up the first sync account (used by Anki clients to log in):" local username password port username=$(anki_sync_ask_username "kiosk") password=$(anki_sync_ask_password "$username") port=$(ask_integer "Port to listen on" "8080" 1024 65535) sudo mkdir -p "$ANKI_SYNC_DIR/data" sudo chown "$KIOSK_USER:$KIOSK_USER" "$ANKI_SYNC_DIR/data" anki_sync_write_base_env "$port" anki_sync_append_user_line 1 "$username" "$password" anki_sync_write_unit_file if enable_and_start_units kiosk-anki-sync; then sudo ufw allow "${port}/tcp" comment 'Anki Sync Server' 2>/dev/null || true log_success "Anki Sync Server installed: http://$(get_ip_address):${port}/" echo " In Anki desktop: Preferences > Network > Self-hosted sync server" echo " Log in with: $username" else log_error "Installed but failed to start - check: sudo journalctl -u kiosk-anki-sync -n 50" return 1 fi } action_anki_sync_add_user() { echo local next_index next_index=$(( $(anki_sync_user_count) + 1 )) local username password username=$(anki_sync_ask_username "user${next_index}") password=$(anki_sync_ask_password "$username") anki_sync_append_user_line "$next_index" "$username" "$password" sudo systemctl restart kiosk-anki-sync log_success "Added sync user: $username" } action_anki_sync_reconfigure_port() { echo local current_port current_port=$(anki_sync_port) local port port=$(ask_integer "Port to listen on" "$current_port" 1024 65535) if [[ "$port" == "$current_port" ]]; then echo "No change" return fi sudo sed -i "s/^SYNC_PORT=.*/SYNC_PORT=${port}/" "$(anki_sync_env_file)" sudo systemctl restart kiosk-anki-sync sudo ufw allow "${port}/tcp" comment 'Anki Sync Server' 2>/dev/null || true log_success "Anki Sync Server now listening on port ${port}" } action_anki_sync_restart() { sudo systemctl restart kiosk-anki-sync sleep 1 if anki_sync_is_active; then log_success "Anki Sync Server restarted" else log_error "Anki Sync Server failed to restart - check: sudo journalctl -u kiosk-anki-sync -n 50" return 1 fi } action_anki_sync_uninstall() { echo ask_yes_no "Uninstall the Anki Sync Server? This deletes the synced collection data on this kiosk." "n" || { echo "Cancelled"; return; } anki_sync_do_uninstall } # The actual removal, no prompt - shared with Complete Uninstall so that # operation doesn't need to re-implement teardown a second time. anki_sync_do_uninstall() { sudo systemctl stop kiosk-anki-sync 2>/dev/null || true sudo systemctl disable kiosk-anki-sync 2>/dev/null || true sudo rm -f "$SYSTEMD_DIR/kiosk-anki-sync.service" sudo systemctl daemon-reload 2>/dev/null || true sudo rm -rf "$ANKI_SYNC_ENV_DIR" "$ANKI_SYNC_DIR" log_success "Anki Sync Server removed" }