From 75baab8914a6d845697e7da5aad2d169aaeb6d32 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 13:47:43 +0000 Subject: [PATCH] Add Anki Sync Server addon (self-hosted flashcard sync) Installs the official self-hosted anki-sync-server (python -m anki.syncserver from PyPI's anki package, no Qt/GUI deps) as a systemd service under $KIOSK_USER, same shape as the Web UI addon: venv under $ANKI_SYNC_DIR, EnvironmentFile under $ANKI_SYNC_ENV_DIR (chmod 600 since it holds plaintext sync passwords), multi-user support via SYNC_USER1/2/... Lets Anki desktop/AnkiDroid clients on the LAN sync flashcards against this kiosk instead of AnkiWeb. Added to the existing Addons menu rather than a new top-level category - Addons is already a flat mix of unrelated self-hosted services (printing, telephony, media server, remote access), so one more service fits that pattern instead of fragmenting the menu structure for a single item. Wired into complete_uninstall.sh, clone_settings.sh's addon detection/checklist, and the webui helper script's source list (which documents itself as mirroring install.sh's sources exactly). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NoqDrsMq2dazAwGU1AodNc --- Readme.md | 11 ++ install.sh | 11 +- lib/config.sh | 2 + menus/addon_anki_sync.sh | 302 ++++++++++++++++++++++++++++++++++++ menus/addon_webui.sh | 1 + menus/clone_settings.sh | 8 + menus/complete_uninstall.sh | 4 +- 7 files changed, 335 insertions(+), 4 deletions(-) create mode 100644 menus/addon_anki_sync.sh diff --git a/Readme.md b/Readme.md index 3dabbee..337e7b0 100644 --- a/Readme.md +++ b/Readme.md @@ -1291,6 +1291,17 @@ terminal menu and the web UI, so they can't drift apart). steps, so any code or hardware-config change picked up by the pull actually takes effect. Also offers an on-demand Electron version check. See "Recent Updates (v2.15.0)" below. +- `menus/addon_anki_sync.sh` — **Anki Sync Server** (Addons): installs + the official self-hosted Anki sync server (`python -m + anki.syncserver`, from the `anki` PyPI package, no Qt/GUI deps) in + its own venv under `$ANKI_SYNC_DIR`, run as a systemd service under + `$KIOSK_USER`, same shape as the Web UI's Node app. Lets Anki desktop/ + AnkiDroid clients on the LAN sync flashcards (simple vocab/math-fact + decks or complex custom note types) against this kiosk instead of + AnkiWeb; supports multiple sync accounts (`SYNC_USER1`, `SYNC_USER2`, + ...). Quizlet sets still go through Anki's own tab-separated import + first — this server only syncs the resulting collection, same as it + would with AnkiWeb. - `webui/` + `menus/addon_webui.sh` — **Web UI** (Addons, installed by default during provisioning): a small Node/Express app running as a systemd service under `$KIOSK_USER` with zero ambient `sudo`, giving diff --git a/install.sh b/install.sh index 4034e87..d5ff42a 100755 --- a/install.sh +++ b/install.sh @@ -37,7 +37,10 @@ # Display/Lockout, the webui/ Node app installed as a systemd # service; no login of its own, put it behind your own reverse proxy # with Authelia forward-auth if it needs to be reachable beyond a -# trusted LAN - see the file header). +# trusted LAN - see the file header), Anki Sync Server +# (menus/addon_anki_sync.sh - self-hosted flashcard sync via the +# official `python -m anki.syncserver`, for Anki desktop/AnkiDroid +# clients on the LAN instead of AnkiWeb). # Advanced: Diagnostics (menus/diagnostics.sh - system status/logs/ # audio/network), Electron Maintenance (menus/advanced_electron.sh - # manual update, fix blank screen), Factory Reset @@ -94,6 +97,8 @@ source "$SCRIPT_DIR/menus/addon_lms_squeezelite.sh" source "$SCRIPT_DIR/menus/addon_asterisk_intercom.sh" # shellcheck source=menus/addon_webui.sh source "$SCRIPT_DIR/menus/addon_webui.sh" +# shellcheck source=menus/addon_anki_sync.sh +source "$SCRIPT_DIR/menus/addon_anki_sync.sh" # shellcheck source=menus/advanced_electron.sh source "$SCRIPT_DIR/menus/advanced_electron.sh" # shellcheck source=menus/advanced_upgrade.sh @@ -174,8 +179,8 @@ core_settings_menu() { } addons_menu_builder() { - MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player" "Asterisk Intercom (SIP Extension)" "Web UI") - MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu addon_asterisk_intercom_menu addon_webui_menu) + MENU_LABELS=("CUPS Printing" "Authelia Auto-Login" "Remote Access" "LMS Server / Squeezelite Player" "Asterisk Intercom (SIP Extension)" "Web UI" "Anki Sync Server") + MENU_HANDLERS=(addon_cups_menu addon_authelia_menu remote_access_menu addon_lms_squeezelite_menu addon_asterisk_intercom_menu addon_webui_menu addon_anki_sync_menu) } addons_menu() { diff --git a/lib/config.sh b/lib/config.sh index 9c66a5f..7fbd6ac 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -33,6 +33,8 @@ : "${WIREGUARD_DIR:=/etc/wireguard}" : "${WEBUI_DIR:=/opt/kiosk-webui}" : "${WEBUI_ENV_DIR:=/etc/kiosk-webui}" +: "${ANKI_SYNC_DIR:=/opt/kiosk-anki-sync}" +: "${ANKI_SYNC_ENV_DIR:=/etc/kiosk-anki-sync}" : "${SUDOERS_D_DIR:=/etc/sudoers.d}" # The one root-owned script the Web UI's addon-install/Update actions are # allowed to invoke via passwordless sudo (menus/addon_webui.sh writes it diff --git a/menus/addon_anki_sync.sh b/menus/addon_anki_sync.sh new file mode 100644 index 0000000..f2d9b0f --- /dev/null +++ b/menus/addon_anki_sync.sh @@ -0,0 +1,302 @@ +#!/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" +} diff --git a/menus/addon_webui.sh b/menus/addon_webui.sh index b454ed3..3b761e2 100644 --- a/menus/addon_webui.sh +++ b/menus/addon_webui.sh @@ -172,6 +172,7 @@ source "$SCRIPT_DIR/menus/addon_remote_access.sh" source "$SCRIPT_DIR/menus/addon_lms_squeezelite.sh" source "$SCRIPT_DIR/menus/addon_asterisk_intercom.sh" source "$SCRIPT_DIR/menus/addon_webui.sh" +source "$SCRIPT_DIR/menus/addon_anki_sync.sh" source "$SCRIPT_DIR/menus/advanced_electron.sh" source "$SCRIPT_DIR/menus/advanced_upgrade.sh" source "$SCRIPT_DIR/menus/advanced_factory_reset.sh" diff --git a/menus/clone_settings.sh b/menus/clone_settings.sh index 9994193..7d93375 100644 --- a/menus/clone_settings.sh +++ b/menus/clone_settings.sh @@ -65,6 +65,7 @@ clone_detect_addons() { command -v netbird &>/dev/null && addons+=("netbird") baresip_is_installed 2>/dev/null && addons+=("asterisk_intercom") webui_is_installed 2>/dev/null && addons+=("webui") + anki_sync_is_installed 2>/dev/null && addons+=("anki_sync") if sudo -u "$KIOSK_USER" test -f "$CONFIG_PATH" 2>/dev/null; then local authelia_url @@ -243,6 +244,13 @@ action_apply_clone_settings() { fi ;; asterisk_intercom) echo " [!] Asterisk Intercom - needs its own SIP extension on this device" ;; + anki_sync) + if anki_sync_is_installed; then + echo " [x] Anki Sync Server - already installed" + else + echo " [ ] Anki Sync Server - not installed, install via Addons" + missing_any=true + fi ;; authelia) echo " [!] Authelia - needs a fresh login on this device" ;; *) diff --git a/menus/complete_uninstall.sh b/menus/complete_uninstall.sh index 53d83a8..282acc3 100644 --- a/menus/complete_uninstall.sh +++ b/menus/complete_uninstall.sh @@ -7,7 +7,7 @@ # (cups_do_uninstall, vnc_do_uninstall, wireguard_do_uninstall, # tailscale_do_uninstall, netbird_do_uninstall, lms_do_uninstall, # squeezelite_do_uninstall, asterisk_intercom_do_uninstall, -# webui_do_uninstall, power_schedule_do_remove_all, +# webui_do_uninstall, anki_sync_do_uninstall, power_schedule_do_remove_all, # emergency_hotspot_do_disable) instead of # re-implementing removal logic for each addon a second time here - if an # addon's uninstall logic changes, this picks it up automatically. Only @@ -59,6 +59,7 @@ action_complete_uninstall() { echo " • Remote access (VNC, WireGuard, Tailscale, Netbird)" echo " • Asterisk Intercom (Baresip)" echo " • Web UI" + echo " • Anki Sync Server" echo " • LightDM and Openbox" echo " • All kiosk schedules and services" echo " • Emergency hotspot configuration" @@ -89,6 +90,7 @@ action_complete_uninstall() { squeezelite_do_uninstall asterisk_intercom_do_uninstall purge webui_do_uninstall + anki_sync_do_uninstall echo "[3/12] Removing schedules and emergency hotspot..." power_schedule_do_remove_all