Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05942fb44a | ||
|
|
d41da6356d | ||
|
|
750c55a4d4 | ||
|
|
92e9ee19a7 | ||
|
|
cf879f0090 | ||
|
|
c63237a3db | ||
|
|
575c4ac185 | ||
|
|
a339d5fbb0 | ||
|
|
c2cf5bfe69 | ||
|
|
92f8503d48 | ||
|
|
d95bd7fd3c | ||
|
|
3cd9a1ece3 | ||
|
|
5c13054cdd | ||
|
|
7d5674aad8 | ||
|
|
2d82b2b278 | ||
|
|
08a2617b06 | ||
|
|
39387b5e0f | ||
|
|
e67ac50c61 | ||
|
|
3ebbeba672 | ||
|
|
07394769ca | ||
|
|
ae939c4085 | ||
|
|
28996eff57 | ||
|
|
93efe0d607 |
@@ -623,6 +623,25 @@ in `services/authelia.sh`) — prompts for a new duration (`12h`, `7d`,
|
||||
Sessions persist through reboots regardless of duration (Redis stores
|
||||
session state in a volume).
|
||||
|
||||
**`inactivity` must track `remember_me`, or a long remember_me is a lie.**
|
||||
`inactivity` is a separate session field — how long a session can sit idle
|
||||
before Authelia ends it — and it is NOT extended or bypassed by the
|
||||
"Remember me" checkbox; the two are independent. Confirmed live: a user
|
||||
set `remember_me: 1y` expecting "won't be asked to log in again for a
|
||||
year," but the install default left `inactivity` at a much shorter value
|
||||
(2h at the time), so ordinary daily gaps between visits (overnight, a
|
||||
workday) ended the session on inactivity grounds well before remember_me
|
||||
ever came into play — the 1y setting was doing nothing. Fixed at both ends
|
||||
so this can't recur silently: `install_authelia()`'s own template now sets
|
||||
`inactivity: 7d`, matching its `remember_me: 7d` default instead of a
|
||||
shorter one, and `_authelia_set_remember_me()` now writes the SAME new
|
||||
duration into both keys on every change, not just `remember_me` alone. If
|
||||
you ever hand-edit `session:` instead of using the menu option, keep
|
||||
`inactivity` and `remember_me` equal — a mismatch here is exactly the bug
|
||||
above, not a valid intentional configuration. `expiration` (the cap for a
|
||||
session that never checked "Remember me") is a legitimately different,
|
||||
shorter-by-design setting and is untouched by any of this.
|
||||
|
||||
**The config key is `remember_me`, not `remember_me_duration`.** Authelia
|
||||
renamed it in 4.38; this repo pins `4.39.20`. A stale `remember_me_duration`
|
||||
key doesn't error, Authelia just silently ignores it — confirmed against
|
||||
@@ -635,7 +654,7 @@ touch this by hand instead of the menu option, the current schema is:
|
||||
session:
|
||||
secret: 'your-existing-secret'
|
||||
expiration: 1h
|
||||
inactivity: 5m
|
||||
inactivity: 1y
|
||||
remember_me: 1y
|
||||
cookies:
|
||||
- domain: 'example.com'
|
||||
|
||||
@@ -186,7 +186,7 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`.
|
||||
|-------|---------|
|
||||
| `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network |
|
||||
| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk` (own dedicated coturn for TURN/STUN — see `mattermost` below for the other coturn-owning service), `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine`, `vpn-data-mount` (mount existing SMB shares from a NetBird-connected home box — SSH trust bootstrap, then read-only discovery of shares already configured there; never writes to the home box's Samba config; repeatable, pick from any number of a home box's shares in one pass; optional per-share [gocryptfs decrypt layer](#client-side-encryption-for-vpn-data-mount) so the VPS only ever handles ciphertext) |
|
||||
| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `garage` (self-hosted S3-compatible object storage, single node — MinIO CE's actively-maintained replacement), `garage-webui` (browser-based bucket/object browser for an existing `garage` install — folders/files view, the same kind of thing Backblaze's own web console gives you), `gatus`, `gitea` (self-hosted Git server — raw local clones plus optional two-way GitHub mirror sync, standalone from the `ai-stack` bundle's own Gitea container), `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `pihole` (standalone DNS ad/tracker blocking — not wired into any VPN's DNS push), `portainer`, `pressbooks` (self-hosted book platform — WordPress Multisite, drag-and-drop chapter editing, PDF export via PrinceXML/DocRaptor, Authelia-gated), `rustdesk`, `samba` (SMB/CIFS file sharing — shares, dedicated Samba users/passwords, LAN-scoped firewall by default; also offered as an optional nudge from `base`), `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) |
|
||||
| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `anki-sync-server` (self-hosted sync backend for the Anki flashcard app — spaced-repetition scheduling stays in the Anki client, this just syncs collections across devices without AnkiWeb; supports multiple independent accounts per instance), `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `garage` (self-hosted S3-compatible object storage, single node — MinIO CE's actively-maintained replacement), `garage-webui` (browser-based bucket/object browser for an existing `garage` install — folders/files view, the same kind of thing Backblaze's own web console gives you), `gatus`, `gitea` (self-hosted Git server — raw local clones plus optional two-way GitHub mirror sync, standalone from the `ai-stack` bundle's own Gitea container), `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `pihole` (standalone DNS ad/tracker blocking — not wired into any VPN's DNS push), `portainer`, `pressbooks` (self-hosted book platform — WordPress Multisite, drag-and-drop chapter editing, PDF export via PrinceXML/DocRaptor, Authelia-gated), `rustdesk`, `samba` (SMB/CIFS file sharing — shares, dedicated Samba users/passwords, LAN-scoped firewall by default; also offered as an optional nudge from `base`), `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) |
|
||||
| `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` |
|
||||
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
|
||||
| `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` |
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
## Client setup — pointing Anki at this server instead of AnkiWeb
|
||||
|
||||
Every client below needs the **Sync URL** and one of the **accounts** shown
|
||||
higher up in this README. Do this on every device you want synced — a client
|
||||
still pointed at AnkiWeb won't see collections synced here, and vice versa.
|
||||
|
||||
### Anki Desktop (2.1.66 and newer)
|
||||
1. **Preferences → Network**
|
||||
2. Tick **"Self-hosted sync server"**
|
||||
3. Paste the Sync URL into the field that appears
|
||||
4. **Sync → log in** with one of the accounts above
|
||||
|
||||
### Anki Desktop (older than 2.1.66)
|
||||
There's no GUI field yet — set an environment variable before launching Anki
|
||||
instead, then sync normally:
|
||||
```bash
|
||||
# Linux/macOS
|
||||
export SYNC_ENDPOINT="https://your-sync-url/"
|
||||
anki
|
||||
|
||||
# Windows (Command Prompt)
|
||||
set SYNC_ENDPOINT=https://your-sync-url/
|
||||
anki.exe
|
||||
```
|
||||
Upgrading Anki to 2.1.66+ is the easier long-term fix — do that if you're
|
||||
setting this up for anyone who isn't comfortable with environment variables.
|
||||
|
||||
### AnkiDroid
|
||||
**Settings → Advanced → Custom sync server**, then enter the Sync URL and
|
||||
log in with one of the accounts above (AnkiDroid 2.16+; update the app if
|
||||
this option isn't there).
|
||||
|
||||
### AnkiMobile (iOS)
|
||||
**Settings → Advanced → Custom Sync Server**, same as AnkiDroid — enter the
|
||||
Sync URL and log in.
|
||||
|
||||
### First sync on each device
|
||||
The very first sync from a device that already has a local collection will
|
||||
ask whether to upload local data or download from the server — pick upload
|
||||
from whichever device has your real collection, and download on every other
|
||||
device, or you'll end up with two different collections that never merge.
|
||||
|
||||
## Importing your existing Quizlet sets
|
||||
|
||||
This server only handles syncing already-existing Anki collections — it
|
||||
doesn't import anything itself. Quizlet import happens once, locally, in the
|
||||
Anki desktop app, before your first sync:
|
||||
|
||||
1. **In Quizlet:** open the set → **Export** → choose the plain-text /
|
||||
tab-separated format (Quizlet's export dialog lets you pick the delimiter
|
||||
between term and definition, and between rows — tab and newline are the
|
||||
Anki-friendly defaults) → copy the exported text or download it as a
|
||||
`.txt`/`.csv` file.
|
||||
2. **In Anki Desktop:** **File → Import**, pick the file (or paste the text
|
||||
into a `.txt` file first if you copied it to the clipboard).
|
||||
3. Map the two columns to **Front** and **Back** in the import dialog, pick
|
||||
or create the deck and note type, and import.
|
||||
4. For **math facts or other simple front/back cards**, the Basic note type
|
||||
is enough. For **more complex cards** (extra example fields, images,
|
||||
audio, cloze deletions), switch the note type in the import dialog to a
|
||||
template with more fields, or convert cards afterward — Anki's own
|
||||
built-in note types (Basic, Basic (and reversed card), Cloze) cover most
|
||||
of what Quizlet's own card types can do.
|
||||
5. Sync from this device once the import looks right, so the imported deck
|
||||
becomes the copy every other device downloads.
|
||||
|
||||
### Exporting back out (Anki → Quizlet or anywhere else)
|
||||
**File → Export**, choose "Notes in Plain Text" and pick the deck — this
|
||||
produces the same tab-separated format Quizlet's own import expects, so the
|
||||
round trip works in both directions.
|
||||
|
||||
## Why spaced repetition here actually reschedules failed cards
|
||||
|
||||
Anki's scheduler (FSRS, the default since recent Anki versions) tracks a
|
||||
per-card memory-strength estimate and schedules the next review right before
|
||||
you'd be expected to forget it. Answering "Again" on a card doesn't just
|
||||
requeue it for later the same session — it lowers that card's estimated
|
||||
strength, which shortens every subsequent interval for it until you've
|
||||
proven you know it again, so a card you keep failing gets shown far more
|
||||
often than one you consistently get right. This is scheduling logic inside
|
||||
the Anki client itself; this sync server only stores and syncs the resulting
|
||||
review history, it doesn't change how reviews are scheduled.
|
||||
@@ -0,0 +1,669 @@
|
||||
#!/bin/bash
|
||||
# services/anki-sync-server.sh — Self-hosted Anki flashcard sync server.
|
||||
# Part of the modular post-install system (sourced by setup.sh).
|
||||
#
|
||||
# Can also be run standalone on any machine:
|
||||
# sudo bash anki-sync-server.sh
|
||||
# (Docker must already be installed when run standalone)
|
||||
|
||||
# ── Standalone bootstrap ──────────────────────────────────────────────────────
|
||||
# Detected when the script is executed directly rather than sourced by setup.sh.
|
||||
# Sets up helpers and globals, then defers execution until after the function
|
||||
# definition at the bottom of this file.
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
|
||||
|
||||
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
_COMMON="$_SELF_DIR/../lib/common.sh"
|
||||
|
||||
if [[ -f "$_COMMON" ]]; then
|
||||
# Full repo present — use the real helpers (picks up ~/docker/.config too)
|
||||
# shellcheck source=../lib/common.sh
|
||||
source "$_COMMON"
|
||||
else
|
||||
# One-off copy — inline minimal stubs so the script works without the repo
|
||||
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
|
||||
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
|
||||
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
|
||||
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
|
||||
|
||||
require_docker() {
|
||||
command -v docker &>/dev/null || {
|
||||
log_error "Docker not found. Install it first:"
|
||||
log_error " curl -fsSL https://get.docker.com | sudo sh"
|
||||
return 1
|
||||
}
|
||||
docker compose version &>/dev/null || {
|
||||
log_error "Docker Compose plugin missing:"
|
||||
log_error " sudo apt-get install -y docker-compose-plugin"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
ensure_docker_dir_ownership() {
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
read -r -p " $_q " _r
|
||||
eval "$_var='${_r:-$_def}'"
|
||||
}
|
||||
|
||||
prompt_yn() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
read -r -p " $_q " _r
|
||||
eval "$_var='${_r:-$_def}'"
|
||||
}
|
||||
|
||||
prompt_reinstall_mode() {
|
||||
local _var="$1" _r
|
||||
if [[ "${UNATTENDED:-false}" == "true" ]]; then eval "$_var='cancel'"; return; fi
|
||||
echo " Already installed."
|
||||
read -r -p " (u)pdate / (f)resh reinstall / (c)ancel [c]: " _r
|
||||
case "${_r,,}" in
|
||||
u|update) eval "$_var='update'" ;;
|
||||
f|fresh) eval "$_var='fresh'" ;;
|
||||
*) eval "$_var='cancel'" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
generate_password() {
|
||||
local length="${1:-32}"
|
||||
openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$length"
|
||||
}
|
||||
|
||||
configure_caddy_for_service() {
|
||||
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
|
||||
local _caddy_dir="$DOCKER_DIR/caddy"
|
||||
local _caddyfile="$_caddy_dir/Caddyfile"
|
||||
local _display_port="${_upstream##*:}"
|
||||
|
||||
# Determine mode: local Caddy, remote Caddy, or none
|
||||
local _mode="none"
|
||||
[[ -d "$_caddy_dir" ]] && _mode="local"
|
||||
[[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote"
|
||||
[[ "$_mode" == "none" ]] && {
|
||||
log_info "Access $_name directly on port $_display_port."
|
||||
return 0
|
||||
}
|
||||
|
||||
echo ""
|
||||
local _do_caddy=""
|
||||
if [[ "$_mode" == "remote" ]]; then
|
||||
log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})."
|
||||
log_info "A snippet file will be saved to ~/docker/caddy-snippets/."
|
||||
fi
|
||||
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
|
||||
[[ "${_do_caddy,,}" == "y" ]] || {
|
||||
log_info "Skipping — access at: http://localhost:$_display_port"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Domain prompt — pre-fill from SITE_DOMAIN when available
|
||||
local _default_domain=""
|
||||
if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then
|
||||
_default_domain="${_subdomain}.${SITE_DOMAIN}"
|
||||
log_info "Default: $_default_domain"
|
||||
fi
|
||||
local _domain=""
|
||||
read -r -p " Domain [${_default_domain:-required}]: " _domain
|
||||
_domain="${_domain:-$_default_domain}"
|
||||
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
|
||||
|
||||
# Build upstream — remote Caddy uses host IP:port, not container name
|
||||
local _block_upstream="$_upstream"
|
||||
if [[ "$_mode" == "remote" ]]; then
|
||||
_block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
|
||||
fi
|
||||
|
||||
local _site_block
|
||||
_site_block="$(cat << CBLOCK
|
||||
|
||||
# $_name
|
||||
${_domain} {
|
||||
reverse_proxy ${_block_upstream}
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/${_domain}.log
|
||||
format json
|
||||
}
|
||||
${_extra}
|
||||
}
|
||||
CBLOCK
|
||||
)"
|
||||
|
||||
if [[ "$_mode" == "local" ]]; then
|
||||
if [[ -f "$_caddyfile" ]]; then
|
||||
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
|
||||
cp "$_caddyfile" "$_bk"
|
||||
log_info "Backed up Caddyfile to $(basename "$_bk")"
|
||||
else
|
||||
touch "$_caddyfile"
|
||||
fi
|
||||
|
||||
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
|
||||
log_warning "$_domain already in Caddyfile"
|
||||
local _ow=""
|
||||
read -r -p " Overwrite? [y/N]: " _ow
|
||||
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; }
|
||||
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
|
||||
fi
|
||||
|
||||
printf '%s\n' "$_site_block" >> "$_caddyfile"
|
||||
log_success "Added $_domain to Caddyfile"
|
||||
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
|
||||
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
|
||||
log_success "$_name accessible at: https://$_domain"
|
||||
else
|
||||
log_warning "Reload failed — check: docker logs caddy"
|
||||
log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile"
|
||||
fi
|
||||
else
|
||||
local _snippet_dir="$DOCKER_DIR/caddy-snippets"
|
||||
local _snippet_file="$_snippet_dir/${_subdomain}.caddy"
|
||||
mkdir -p "$_snippet_dir"
|
||||
printf '%s\n' "$_site_block" > "$_snippet_file"
|
||||
chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true
|
||||
log_success "Snippet saved: $_snippet_file"
|
||||
log_info "Copy to Caddy machine:"
|
||||
log_info " scp $_snippet_file caddy-host:~/caddy-snippets/"
|
||||
log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)"
|
||||
fi
|
||||
}
|
||||
write_readme() {
|
||||
local _dir="$1"; shift
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
backup_if_exists() {
|
||||
local _file="$1"
|
||||
[ -f "$_file" ] || return 0
|
||||
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
# ($HOME under sudo is /root, not the real user's home)
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
|
||||
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
|
||||
DRY_RUN="${DRY_RUN:-false}"
|
||||
UNATTENDED="${UNATTENDED:-false}"
|
||||
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
||||
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
|
||||
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
|
||||
|
||||
register_service() { :; } # no-op — no wizard to register into
|
||||
_RUN_STANDALONE=1
|
||||
fi
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
register_service anki-sync-server utilities "Self-hosted Anki flashcard sync server (spaced repetition, syncs across devices without AnkiWeb)" 8080
|
||||
|
||||
# Reads the current ANKI_SYNC_USERn/ANKI_SYNC_PASSWORDn pairs out of an
|
||||
# instance's .env into the caller's ANKI_USERS/ANKI_PASSWORDS arrays (bash's
|
||||
# dynamic scoping means a `local` array declared in the caller is visible
|
||||
# here without being passed explicitly — same assumption every other helper
|
||||
# below makes). Numbering is always kept contiguous from 1 by
|
||||
# _anki_rewrite_account_block, so stopping at the first missing index is
|
||||
# safe — there's never a gap to skip over.
|
||||
_anki_load_accounts() {
|
||||
local _dir="$1" _n=1 _u _p
|
||||
ANKI_USERS=() ANKI_PASSWORDS=()
|
||||
while true; do
|
||||
_u="$(grep "^ANKI_SYNC_USER${_n}=" "$_dir/.env" 2>/dev/null | cut -d= -f2-)"
|
||||
[ -z "$_u" ] && break
|
||||
_p="$(grep "^ANKI_SYNC_PASSWORD${_n}=" "$_dir/.env" 2>/dev/null | cut -d= -f2-)"
|
||||
ANKI_USERS+=("$_u")
|
||||
ANKI_PASSWORDS+=("$_p")
|
||||
_n=$((_n + 1))
|
||||
done
|
||||
}
|
||||
|
||||
# Regenerates the SYNC_USERn=... lines in docker-compose.yml and the
|
||||
# matching ANKI_SYNC_USERn/ANKI_SYNC_PASSWORDn pairs in .env from the
|
||||
# caller's current ANKI_USERS/ANKI_PASSWORDS arrays (always renumbered
|
||||
# contiguously from 1 — see _anki_load_accounts). Used by both the initial
|
||||
# install and every account-management mutation (add/remove/rotate) so the
|
||||
# two never drift apart, same reasoning as CLAUDE.md's shared-helper
|
||||
# guidance for update vs. fresh-install codepaths. Leaves the port, Caddy
|
||||
# block, and every other line in either file untouched — only lines
|
||||
# matching the SYNC_USER/ANKI_SYNC_* patterns are touched.
|
||||
_anki_rewrite_account_block() {
|
||||
local _dir="$1"
|
||||
local _compose="$_dir/docker-compose.yml"
|
||||
local _env="$_dir/.env"
|
||||
|
||||
sed -i '/^ - SYNC_USER[0-9]\+=/d' "$_compose"
|
||||
sed -i '/^ANKI_SYNC_USER[0-9]\+=/d; /^ANKI_SYNC_PASSWORD[0-9]\+=/d' "$_env"
|
||||
|
||||
local _compose_lines="" _env_lines="" i idx
|
||||
for i in "${!ANKI_USERS[@]}"; do
|
||||
idx=$((i + 1))
|
||||
_compose_lines+=" - SYNC_USER${idx}=\${ANKI_SYNC_USER${idx}}:\${ANKI_SYNC_PASSWORD${idx}}
|
||||
"
|
||||
_env_lines+="ANKI_SYNC_USER${idx}=${ANKI_USERS[$i]}
|
||||
ANKI_SYNC_PASSWORD${idx}=${ANKI_PASSWORDS[$i]}
|
||||
"
|
||||
done
|
||||
|
||||
# Insert right after the fixed SYNC_BASE anchor line — always present,
|
||||
# written by every version of this script's install flow — instead of
|
||||
# appending at the end, so the block stays grouped with SYNC_HOST/
|
||||
# SYNC_PORT/SYNC_BASE rather than drifting after `volumes:`.
|
||||
local _tmp
|
||||
_tmp="$(mktemp)"
|
||||
printf '%s' "$_compose_lines" > "$_tmp"
|
||||
sed -i "\|^ - SYNC_BASE=/data\$|r $_tmp" "$_compose"
|
||||
rm -f "$_tmp"
|
||||
|
||||
printf '%s' "$_env_lines" >> "$_env"
|
||||
}
|
||||
|
||||
# Interactive add/remove/rotate menu for an existing instance's sync
|
||||
# accounts, offered from install_anki-sync-server's "already installed"
|
||||
# menu. Every mutation restarts the container (`docker compose up -d`
|
||||
# re-reads .env for the new/removed/rotated credentials) but never touches
|
||||
# the port, Caddy config, or the image — the things CLAUDE.md's "update vs.
|
||||
# fresh reinstall" convention says a non-destructive path must leave alone.
|
||||
_anki_manage_accounts() {
|
||||
local _dir="$1"
|
||||
local ANKI_USERS=() ANKI_PASSWORDS=()
|
||||
while true; do
|
||||
_anki_load_accounts "$_dir"
|
||||
echo ""
|
||||
echo " Current sync accounts:"
|
||||
local i
|
||||
for i in "${!ANKI_USERS[@]}"; do
|
||||
echo " $((i + 1))) ${ANKI_USERS[$i]}"
|
||||
done
|
||||
[ "${#ANKI_USERS[@]}" -eq 0 ] && echo " (none)"
|
||||
echo ""
|
||||
echo " a) Add an account"
|
||||
echo " r) Remove an account"
|
||||
echo " p) Rotate (reset) an account's password"
|
||||
echo " 0) Done"
|
||||
echo ""
|
||||
local ACTION=""
|
||||
prompt_text " Choice [a/r/p/0]:" "0" ACTION
|
||||
case "$ACTION" in
|
||||
a|A)
|
||||
if [ "${#ANKI_USERS[@]}" -ge 8 ]; then
|
||||
log_warning "That's plenty — stopping at 8 accounts."
|
||||
continue
|
||||
fi
|
||||
local _u=""
|
||||
prompt_text " New username:" "" _u
|
||||
if [ -z "$_u" ]; then
|
||||
log_warning "Name can't be empty."; continue
|
||||
fi
|
||||
ANKI_USERS+=("$_u")
|
||||
ANKI_PASSWORDS+=("$(generate_password 24)")
|
||||
_anki_rewrite_account_block "$_dir"
|
||||
( cd "$_dir" && docker compose up -d ) \
|
||||
&& log_success "Account '$_u' added — password: ${ANKI_PASSWORDS[-1]} (also saved in $_dir/.env)" \
|
||||
|| log_warning "Container restart failed — check: docker compose -f $_dir/docker-compose.yml logs"
|
||||
;;
|
||||
r|R)
|
||||
if [ "${#ANKI_USERS[@]}" -eq 0 ]; then
|
||||
log_warning "No accounts to remove."; continue
|
||||
fi
|
||||
local _n=""
|
||||
prompt_text " Remove which number?" "" _n
|
||||
if ! [[ "$_n" =~ ^[0-9]+$ ]] || [ "$_n" -lt 1 ] || [ "$_n" -gt "${#ANKI_USERS[@]}" ]; then
|
||||
log_warning "Invalid choice."; continue
|
||||
fi
|
||||
local _removed="${ANKI_USERS[$((_n - 1))]}"
|
||||
unset 'ANKI_USERS[_n - 1]' 'ANKI_PASSWORDS[_n - 1]'
|
||||
ANKI_USERS=("${ANKI_USERS[@]}")
|
||||
ANKI_PASSWORDS=("${ANKI_PASSWORDS[@]}")
|
||||
_anki_rewrite_account_block "$_dir"
|
||||
( cd "$_dir" && docker compose up -d ) \
|
||||
&& log_success "Account '$_removed' removed" \
|
||||
|| log_warning "Container restart failed — check: docker compose -f $_dir/docker-compose.yml logs"
|
||||
;;
|
||||
p|P)
|
||||
if [ "${#ANKI_USERS[@]}" -eq 0 ]; then
|
||||
log_warning "No accounts yet."; continue
|
||||
fi
|
||||
local _n=""
|
||||
prompt_text " Rotate password for which number?" "" _n
|
||||
if ! [[ "$_n" =~ ^[0-9]+$ ]] || [ "$_n" -lt 1 ] || [ "$_n" -gt "${#ANKI_USERS[@]}" ]; then
|
||||
log_warning "Invalid choice."; continue
|
||||
fi
|
||||
ANKI_PASSWORDS[$((_n - 1))]="$(generate_password 24)"
|
||||
_anki_rewrite_account_block "$_dir"
|
||||
( cd "$_dir" && docker compose up -d ) \
|
||||
&& log_success "New password for '${ANKI_USERS[$((_n - 1))]}': ${ANKI_PASSWORDS[$((_n - 1))]} (also saved in $_dir/.env)" \
|
||||
|| log_warning "Container restart failed — check: docker compose -f $_dir/docker-compose.yml logs"
|
||||
;;
|
||||
0)
|
||||
break
|
||||
;;
|
||||
*)
|
||||
log_warning "Unrecognized choice."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
install_anki-sync-server() {
|
||||
require_docker || return 1
|
||||
log_info "Installing Anki Sync Server..."
|
||||
|
||||
# ── Instance selection ───────────────────────────────────────────────────
|
||||
# First instance keeps the plain "anki-sync-server" name/paths/port exactly
|
||||
# as before (zero behavior change for anyone with a single instance). Only
|
||||
# asking to add a second one introduces suffixed naming — same pattern as
|
||||
# services/ntfy.sh and services/homebox.sh. A second instance is a real
|
||||
# use case here (e.g. a second household wanting fully separate data on
|
||||
# the same box) even though one instance already supports multiple
|
||||
# independent accounts via SYNC_USER1/SYNC_USER2/... — see CLAUDE.md's
|
||||
# "Multi-instance services" section.
|
||||
local ANKI_DIR="$DOCKER_DIR/anki-sync-server"
|
||||
local INSTANCE_SUFFIX="" CONTAINER="anki-sync-server"
|
||||
local WEB_PORT="8080"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists"
|
||||
echo "[DRY-RUN] Would create $ANKI_DIR(-<name>)"
|
||||
echo "[DRY-RUN] Would prompt for one or more sync accounts and generate passwords"
|
||||
echo "[DRY-RUN] Would write docker-compose.yml and .env"
|
||||
echo "[DRY-RUN] Would auto-scan for a free host port"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -d "$ANKI_DIR" ]; then
|
||||
echo ""
|
||||
echo " Anki Sync Server is already installed at $ANKI_DIR."
|
||||
echo " 1) Manage sync accounts (add / remove / rotate a password — doesn't"
|
||||
echo " touch the port, Caddy, or the image)"
|
||||
echo " 2) Manage that install (update image / full reinstall / cancel)"
|
||||
echo " 3) Add a NEW, separate Anki Sync Server instance alongside it (its"
|
||||
echo " own data and port — full isolation)"
|
||||
echo ""
|
||||
local _TOP_CHOICE=""
|
||||
prompt_text " Choice [1/2/3]:" "2" _TOP_CHOICE
|
||||
if [ "$_TOP_CHOICE" = "1" ]; then
|
||||
_anki_manage_accounts "$ANKI_DIR"
|
||||
return 0
|
||||
elif [ "$_TOP_CHOICE" = "3" ]; then
|
||||
local _suffix=""
|
||||
while true; do
|
||||
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'family'):" "" _suffix
|
||||
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
|
||||
if [ -z "$_suffix" ]; then
|
||||
log_warning "Name can't be empty."; continue
|
||||
fi
|
||||
if [ -d "$DOCKER_DIR/anki-sync-server-$_suffix" ]; then
|
||||
log_warning "anki-sync-server-$_suffix already exists — pick another name."; continue
|
||||
fi
|
||||
break
|
||||
done
|
||||
INSTANCE_SUFFIX="$_suffix"
|
||||
ANKI_DIR="$DOCKER_DIR/anki-sync-server-$_suffix"
|
||||
CONTAINER="anki-sync-server-$_suffix"
|
||||
log_info "New instance: $ANKI_DIR"
|
||||
else
|
||||
# "Manage that install" on THIS instance — the banner above promises
|
||||
# update/fresh/cancel, so actually offer it instead of falling straight
|
||||
# through into the same unconditional-overwrite flow as a new install.
|
||||
if [[ -f "$ANKI_DIR/docker-compose.yml" ]]; then
|
||||
local MODE=""
|
||||
prompt_reinstall_mode MODE
|
||||
case "$MODE" in
|
||||
update)
|
||||
log_info "Refreshing the Anki Sync Server image only — existing accounts, port, and Caddy setup are left as-is."
|
||||
# The image is a Google distroless "nonroot" build (fixed UID/GID
|
||||
# 65532, no shell — it can't chown anything itself at startup), so
|
||||
# ./data has to already be writable by that exact UID or the
|
||||
# container fails to start. Versions of this installer before this
|
||||
# fix chowned it to ACTUAL_USER instead, which the container can't
|
||||
# write to — re-asserting the correct ownership here repairs any
|
||||
# install made under that bug, non-destructively (it's the
|
||||
# installer's own bug being corrected, not a config choice, so it
|
||||
# belongs in the non-destructive update path).
|
||||
chown -R 65532:65532 "$ANKI_DIR/data" 2>/dev/null
|
||||
( cd "$ANKI_DIR" && docker compose pull && docker compose up -d ) \
|
||||
&& log_success "Anki Sync Server image refreshed" \
|
||||
|| log_warning "Refresh failed — check: docker compose -f $ANKI_DIR/docker-compose.yml logs"
|
||||
return 0
|
||||
;;
|
||||
cancel)
|
||||
log_info "Leaving the existing install as-is."
|
||||
return 0
|
||||
;;
|
||||
fresh) ;; # fall through to the full install flow below
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Scan for a free port unconditionally — not just when adding an explicit
|
||||
# additional instance. A plain first install can just as easily collide
|
||||
# with an unrelated service that already claimed this default port — see
|
||||
# CLAUDE.md's "Port collision avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
# ── Sync accounts ─────────────────────────────────────────────────────────
|
||||
# The official sync server has no signup flow of its own — accounts are
|
||||
# fixed credentials baked in as SYNC_USER1, SYNC_USER2, ... at container
|
||||
# start, one per line in .env. Ask for at least one now (each Anki client
|
||||
# — desktop, AnkiDroid, AnkiMobile — logs in with one of these) and offer
|
||||
# to add more for other people sharing this box, since a single instance
|
||||
# already keeps each account's collection completely separate.
|
||||
local ANKI_USERS=() ANKI_PASSWORDS=()
|
||||
local _u=""
|
||||
prompt_text " Username for your Anki sync account:" "$ACTUAL_USER" _u
|
||||
ANKI_USERS+=("$_u")
|
||||
ANKI_PASSWORDS+=("$(generate_password 24)")
|
||||
while true; do
|
||||
local _more=""
|
||||
prompt_yn " Add another Anki sync account (e.g. for a family member)? (y/n):" "n" _more
|
||||
[[ "$_more" =~ ^[Yy]$ ]] || break
|
||||
prompt_text " Username for the additional account:" "" _u
|
||||
if [ -z "$_u" ]; then
|
||||
log_warning "Name can't be empty."; continue
|
||||
fi
|
||||
ANKI_USERS+=("$_u")
|
||||
ANKI_PASSWORDS+=("$(generate_password 24)")
|
||||
if [ "${#ANKI_USERS[@]}" -ge 8 ]; then
|
||||
log_warning "That's plenty — stopping at 8 accounts."
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
mkdir -p "$ANKI_DIR/data"
|
||||
ensure_docker_dir_ownership "$ANKI_DIR"
|
||||
cd "$ANKI_DIR" || return 1
|
||||
|
||||
# Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh):
|
||||
# explicit CADDY_MODE from the site config wins, then a local ~/docker/caddy,
|
||||
# then the legacy CADDY_REMOTE_HOST var. Only "local" joins caddy_net — a
|
||||
# remote Caddy box can't resolve container names on this host's bridge
|
||||
# network anyway; it reaches this service via the host's published port.
|
||||
local _CADDY_MODE="${CADDY_MODE:-none}"
|
||||
[ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local"
|
||||
[ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote"
|
||||
|
||||
local _CADDY_NET_BLOCK=""
|
||||
local _CADDY_NET_SECTION=""
|
||||
if [ "$_CADDY_MODE" = "local" ]; then
|
||||
_CADDY_NET_BLOCK=" networks:
|
||||
- caddy_net
|
||||
"
|
||||
_CADDY_NET_SECTION="
|
||||
networks:
|
||||
caddy_net:
|
||||
external: true
|
||||
name: ${SITE_CADDY_NET:-caddy_net}
|
||||
"
|
||||
fi
|
||||
|
||||
# Build the SYNC_USERn=... lines for docker-compose.yml (compose-time
|
||||
# interpolation of ${ANKI_SYNC_USERn}/${ANKI_SYNC_PASSWORDn} from .env —
|
||||
# same \${VAR} pattern services/homebox.sh uses for its own .env values)
|
||||
# and the matching ANKI_SYNC_USERn/ANKI_SYNC_PASSWORDn lines for .env.
|
||||
local _COMPOSE_USER_LINES="" _ENV_USER_LINES="" i idx
|
||||
for i in "${!ANKI_USERS[@]}"; do
|
||||
idx=$((i + 1))
|
||||
_COMPOSE_USER_LINES+=" - SYNC_USER${idx}=\${ANKI_SYNC_USER${idx}}:\${ANKI_SYNC_PASSWORD${idx}}
|
||||
"
|
||||
_ENV_USER_LINES+="ANKI_SYNC_USER${idx}=${ANKI_USERS[$i]}
|
||||
ANKI_SYNC_PASSWORD${idx}=${ANKI_PASSWORDS[$i]}
|
||||
"
|
||||
done
|
||||
|
||||
backup_if_exists docker-compose.yml
|
||||
cat > docker-compose.yml << ANKI_COMPOSE
|
||||
name: $CONTAINER
|
||||
|
||||
services:
|
||||
anki-sync-server:
|
||||
image: afrima/anki-sync-server:latest
|
||||
container_name: $CONTAINER
|
||||
hostname: $CONTAINER
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- SYNC_HOST=0.0.0.0
|
||||
- SYNC_PORT=8080
|
||||
- SYNC_BASE=/data
|
||||
${_COMPOSE_USER_LINES} volumes:
|
||||
- ./data:/data
|
||||
ports:
|
||||
- "${WEB_PORT}:8080"
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
ANKI_COMPOSE
|
||||
|
||||
backup_if_exists .env
|
||||
cat > .env << ANKI_ENV
|
||||
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
|
||||
# One username/password pair per Anki sync account (SYNC_USER1, SYNC_USER2,
|
||||
# ... in docker-compose.yml). Enter these exact values as the account on
|
||||
# each Anki client (Preferences/Settings → self-hosted sync server). To add,
|
||||
# remove, or reset one of these later, re-run this installer against the
|
||||
# existing install and pick "Manage sync accounts" — don't hand-edit these
|
||||
# lines, the matching docker-compose.yml lines have to change in lockstep.
|
||||
${_ENV_USER_LINES}
|
||||
ANKI_ENV
|
||||
chmod 600 .env
|
||||
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ANKI_DIR"
|
||||
|
||||
# afrima/anki-sync-server is built on gcr.io/distroless/static-debian12:nonroot
|
||||
# — the process always runs as that image's fixed "nonroot" UID/GID (65532),
|
||||
# never as ACTUAL_USER, and distroless has no shell so nothing inside the
|
||||
# container can chown its own data dir at startup. Applied AFTER the
|
||||
# ACTUAL_USER chown above (not before — that call would just clobber it,
|
||||
# since it recurses over the whole $ANKI_DIR including data/) so ./data ends
|
||||
# up owned by 65532 specifically while docker-compose.yml/.env/README.md
|
||||
# (which the sysadmin edits, not the container) stay owned by ACTUAL_USER.
|
||||
# Confirmed live: getting this wrong is exactly what makes the container
|
||||
# fail to come up with a permissions error the moment it tries to create
|
||||
# anything under /data (e.g. a new user's collection).
|
||||
chown -R 65532:65532 "$ANKI_DIR/data"
|
||||
|
||||
echo ""
|
||||
log_success "Anki Sync Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $ANKI_DIR (port $WEB_PORT)"
|
||||
echo ""
|
||||
echo " Sync accounts (also saved in $ANKI_DIR/.env):"
|
||||
for i in "${!ANKI_USERS[@]}"; do
|
||||
echo " ${ANKI_USERS[$i]} / ${ANKI_PASSWORDS[$i]}"
|
||||
done
|
||||
echo ""
|
||||
|
||||
# No Authelia gate here, unlike most other web-facing services in this
|
||||
# repo: this is a raw HTTP sync API that the Anki client itself talks to
|
||||
# (not a browser session), so a forward_auth login portal in front of it
|
||||
# would just break every sync request instead of protecting anything.
|
||||
# SYNC_USER1/SYNC_USER2/... above is this service's own auth boundary —
|
||||
# same reasoning as the has-built-in-auth services in CLAUDE.md, just
|
||||
# with no web UI to additionally gate.
|
||||
configure_caddy_for_service "Anki Sync Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:8080" "anki${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
|
||||
|
||||
local START=""
|
||||
prompt_yn "Start Anki Sync Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START
|
||||
if [ "$START" = "y" ] || [ "$START" = "Y" ]; then
|
||||
docker compose up -d \
|
||||
&& log_success "Anki Sync Server started" \
|
||||
|| log_warning "Start failed — check: docker compose logs"
|
||||
fi
|
||||
|
||||
write_readme "$ANKI_DIR" << MD
|
||||
# Anki Sync Server${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||
|
||||
Self-hosted sync server for the [Anki](https://apps.ankiweb.net/) flashcard
|
||||
app — syncs your collection across devices without going through AnkiWeb.
|
||||
Anki's own spaced-repetition scheduler (FSRS) gives failed cards more
|
||||
repetition and correctly-recalled cards longer gaps automatically; nothing
|
||||
here changes that, it's purely the sync backend.
|
||||
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
|
||||
This is a separate, fully isolated instance (own data directory, own
|
||||
accounts, own port) — not shared collections with another Anki Sync Server
|
||||
instance.")
|
||||
|
||||
## Access
|
||||
- Sync URL: $( [ -n "${CADDY_SERVICE_CONFIGURED:-}" ] && [ "$CADDY_SERVICE_CONFIGURED" = "true" ] && echo "https://${CADDY_SERVICE_DOMAIN}/" || echo "http://localhost:${WEB_PORT}/" )
|
||||
- Accounts (username / password):
|
||||
$(for i in "${!ANKI_USERS[@]}"; do echo " - ${ANKI_USERS[$i]} / ${ANKI_PASSWORDS[$i]}"; done)
|
||||
|
||||
Enter the Sync URL and one of the above accounts on each Anki client — see
|
||||
the client setup section below for exactly where.
|
||||
|
||||
## Data
|
||||
- Collections: \`$ANKI_DIR/data\`
|
||||
- Credentials: \`$ANKI_DIR/.env\` (readable by $ACTUAL_USER only)
|
||||
|
||||
## Manage
|
||||
\`\`\`bash
|
||||
cd $ANKI_DIR
|
||||
docker compose up -d
|
||||
docker compose down
|
||||
docker compose logs -f
|
||||
docker compose pull && docker compose up -d
|
||||
\`\`\`
|
||||
|
||||
To add, remove, or reset the password of a sync account later, re-run the
|
||||
installer against this install and pick **"Manage sync accounts"** —
|
||||
don't hand-edit \`.env\`, the matching lines in \`docker-compose.yml\` have
|
||||
to change alongside it:
|
||||
\`\`\`bash
|
||||
sudo ./setup.sh anki-sync-server
|
||||
\`\`\`
|
||||
MD
|
||||
|
||||
log_info "Full client setup + Quizlet import walkthrough written to $ANKI_DIR/README.md"
|
||||
}
|
||||
|
||||
# ── Standalone execution ───────────────────────────────────────────────────
|
||||
if [[ "${_RUN_STANDALONE:-0}" == "1" ]]; then
|
||||
install_anki-sync-server
|
||||
fi
|
||||
+51
-10
@@ -242,7 +242,8 @@ install_authelia() {
|
||||
echo " 7) Reconfigure from scratch (regenerates secrets/users — breaks"
|
||||
echo " existing sessions for every domain already on this instance)"
|
||||
echo " 8) Show who has universal vs. service-scoped access"
|
||||
echo " 9) Change \"Remember me\" session duration (stay logged in longer)"
|
||||
echo " 9) Change \"Remember me\" session duration (stay logged in longer — also"
|
||||
echo " raises the inactivity timeout to match, so it can't cut it short)"
|
||||
echo " 10) Protect an existing site with this instance (pick a local Caddy site,"
|
||||
echo " or type one on a different box — gates it with a login, same as any"
|
||||
echo " other service already protected this way)"
|
||||
@@ -501,7 +502,12 @@ access_control:
|
||||
session:
|
||||
name: authelia_session
|
||||
expiration: 12h
|
||||
inactivity: 2h
|
||||
# Matches remember_me below, not a shorter default — an idle timeout
|
||||
# shorter than remember_me silently cuts a "remembered" session short
|
||||
# regardless of its own duration. See _authelia_set_remember_me()'s
|
||||
# comment for the live case this caused. Change both together (that
|
||||
# function does exactly this) rather than one at a time.
|
||||
inactivity: 7d
|
||||
remember_me: 7d
|
||||
cookies:
|
||||
- domain: ${AUTHELIA_DOMAIN}
|
||||
@@ -2383,6 +2389,19 @@ _authelia_report_access_scope() {
|
||||
# earlier version of this very file's own README section) uses the old
|
||||
# name, which Authelia would just silently ignore rather than error on.
|
||||
#
|
||||
# Also writes the SAME value into `inactivity` — a separate session field
|
||||
# (default 2h, set alongside remember_me in install_authelia()'s own
|
||||
# template) that ends a session after that much idle time regardless of
|
||||
# remember_me, since it isn't disabled or extended by the "Remember me"
|
||||
# checkbox. Confirmed live: a user who'd set remember_me to 1y still got
|
||||
# logged out after ordinary daily gaps (overnight, a workday) because
|
||||
# inactivity was still sitting at its 2h default — remember_me alone does
|
||||
# NOT deliver "won't be asked to log in again for the duration I set"
|
||||
# without this. Tying the two together is what actually delivers that.
|
||||
# `expiration` (the session cap when "Remember me" is NOT checked) is left
|
||||
# alone — a shorter default there for an un-remembered session is correct,
|
||||
# separate behavior, not the same gap.
|
||||
#
|
||||
# This only controls AUTHELIA's own session — it does not touch how long
|
||||
# a native-OIDC app's (Gitea/Mealie/ActualBudget) own session/token lasts
|
||||
# after logging in via Authelia. A long remember_me makes re-authenticating
|
||||
@@ -2393,27 +2412,48 @@ _authelia_set_remember_me() {
|
||||
local config_file="$DOCKER_DIR/authelia/config/configuration.yml"
|
||||
[ -f "$config_file" ] || { log_warning "No configuration.yml found — install Authelia first."; return 1; }
|
||||
|
||||
local current
|
||||
local current current_inactivity
|
||||
current="$(grep -E '^ remember_me:' "$config_file" | awk '{print $2}' | tr -d "'\"")"
|
||||
current_inactivity="$(grep -E '^ inactivity:' "$config_file" | awk '{print $2}' | tr -d "'\"")"
|
||||
echo ""
|
||||
echo " Current \"remember me\" duration: ${current:-not set}"
|
||||
echo " Current \"remember me\" duration: ${current:-not set} (inactivity timeout: ${current_inactivity:-not set})"
|
||||
echo " How long a session lasts when someone checks \"Remember me\" at login —"
|
||||
echo " applies to every domain this Authelia instance protects."
|
||||
echo " applies to every domain this Authelia instance protects. Also sets"
|
||||
echo " \"inactivity\" (idle timeout) to the same value, so a gap between visits"
|
||||
echo " shorter than this can't log you out early — otherwise inactivity's own"
|
||||
echo " separate, much shorter default cuts a long remember_me short."
|
||||
echo " Examples: 12h, 7d, 1M (month), 1y. Set to -1 to disable Remember Me entirely."
|
||||
local new_duration=""
|
||||
prompt_text " New duration [${current:-7d}]:" "${current:-7d}" new_duration
|
||||
if [ -z "$new_duration" ] || [ "$new_duration" = "$current" ]; then
|
||||
if [ -z "$new_duration" ]; then
|
||||
log_info "No change made."
|
||||
return 0
|
||||
fi
|
||||
# Only truly a no-op if BOTH keys already match — remember_me alone
|
||||
# matching isn't enough to skip, or an install still carrying the old
|
||||
# mismatched inactivity default (from before this function synced the
|
||||
# two) could never actually get inactivity fixed by re-entering the
|
||||
# same remember_me value. Confirmed live: this is exactly what
|
||||
# happened on a box that had already set remember_me: 1y before this
|
||||
# sync existed — re-running with "1y" again hit this early return and
|
||||
# left inactivity untouched.
|
||||
if [ "$new_duration" = "$current" ] && [ "$new_duration" = "$current_inactivity" ]; then
|
||||
log_info "No change made — remember_me and inactivity already both ${new_duration}."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if grep -qE '^ remember_me:' "$config_file"; then
|
||||
sed -i "s/^ remember_me:.*/ remember_me: '${new_duration}'/" "$config_file"
|
||||
else
|
||||
sed -i "/^session:\$/a\\ remember_me: '${new_duration}'" "$config_file"
|
||||
fi
|
||||
if grep -qE '^ inactivity:' "$config_file"; then
|
||||
sed -i "s/^ inactivity:.*/ inactivity: '${new_duration}'/" "$config_file"
|
||||
else
|
||||
sed -i "/^ remember_me:/a\\ inactivity: '${new_duration}'" "$config_file"
|
||||
fi
|
||||
chown 1000:1000 "$config_file" 2>/dev/null || true
|
||||
log_success "\"Remember me\" duration set to ${new_duration}."
|
||||
log_success "\"Remember me\" duration and inactivity timeout both set to ${new_duration}."
|
||||
|
||||
local restart_auth=""
|
||||
prompt_yn " Restart Authelia to apply? (y/n):" "y" restart_auth
|
||||
@@ -2425,9 +2465,10 @@ _authelia_set_remember_me() {
|
||||
|
||||
echo ""
|
||||
log_info "Takes effect for NEW logins where \"Remember me\" is checked at Authelia's"
|
||||
log_info "login page — existing sessions keep whatever expiration they already had."
|
||||
log_info "The checkbox itself is already on the login form by default; this only"
|
||||
log_info "changes how long checking it actually keeps you signed in."
|
||||
log_info "login page — existing sessions keep whatever expiration/inactivity they"
|
||||
log_info "already had. The checkbox itself is already on the login form by default;"
|
||||
log_info "this only changes how long checking it actually keeps you signed in, and"
|
||||
log_info "stops the separate inactivity timeout from cutting that short."
|
||||
}
|
||||
|
||||
# Export/import accounts (+ optionally 2FA/session state) — for migrating to
|
||||
|
||||
+583
-21
@@ -285,6 +285,106 @@ print(pick[0]["browser_download_url"] if pick else "")
|
||||
esac
|
||||
}
|
||||
|
||||
# Same job as _wolf_download_emulator_appimage above, but against GitLab's
|
||||
# Releases API instead of GitHub's — needed for any project (ES-DE included)
|
||||
# that's hosted on GitLab rather than GitHub, since GitHub's API obviously
|
||||
# can't answer for a repo it doesn't host. Mirrors the same arch-matching /
|
||||
# post-download ELF-header verification logic so both call sites behave
|
||||
# identically from the caller's point of view.
|
||||
_wolf_download_emulator_appimage_gitlab() {
|
||||
local _display_name="$1" _project_path="$2" _existing_glob="$3" _dir="$4"
|
||||
if ls "$_dir"/$_existing_glob 2>/dev/null | grep -q .; then
|
||||
log_info "$_display_name already present in $_dir/"
|
||||
return 0
|
||||
fi
|
||||
local _get=""
|
||||
echo ""
|
||||
log_info "$_display_name can be auto-downloaded."
|
||||
prompt_yn "Download $_display_name AppImage now? (y/n):" "y" _get
|
||||
[[ "$_get" =~ ^[Yy]$ ]] || return 0
|
||||
|
||||
log_info "Fetching latest $_display_name release from GitLab..."
|
||||
local _encoded_path
|
||||
_encoded_path=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$_project_path")
|
||||
local _url
|
||||
_url=$(curl -fsSL "https://gitlab.com/api/v4/projects/${_encoded_path}/releases" \
|
||||
| HOST_ARCH="$(uname -m)" python3 -c '
|
||||
import sys, json, os
|
||||
host = os.environ.get("HOST_ARCH", "")
|
||||
arch_tags = {
|
||||
"x86_64": ["x86_64", "amd64", "x64"],
|
||||
"aarch64": ["aarch64", "arm64"],
|
||||
"arm64": ["aarch64", "arm64"],
|
||||
}.get(host, [host] if host else [])
|
||||
all_arch_tags = ["x86_64", "amd64", "x64", "aarch64", "arm64", "armv7", "armhf", "i386", "i686"]
|
||||
def has(name, tags):
|
||||
n = name.lower()
|
||||
return any(t in n for t in tags)
|
||||
releases = json.load(sys.stdin)
|
||||
# GitLab does not guarantee list order — sort explicitly instead of
|
||||
# assuming index 0 is the newest (the mistake that would silently pick a
|
||||
# stale/older release on some future API response ordering change).
|
||||
releases = sorted(releases, key=lambda r: r.get("released_at") or "", reverse=True)
|
||||
assets = []
|
||||
for r in releases:
|
||||
for link in r.get("assets", {}).get("links", []):
|
||||
# The GitLab asset URL itself is an opaque .../package_files/<id>/download
|
||||
# link with no filename/extension in it at all — confirmed live: only
|
||||
# the link own "name" field carries the real filename
|
||||
# (e.g. "ES-DE_x64.AppImage"), so filtering on the URL suffix (as
|
||||
# this used to) matches nothing and silently fails with no
|
||||
# resolvable download, even though the release genuinely has an
|
||||
# AppImage asset sitting right there.
|
||||
name = link.get("name", "")
|
||||
url = link.get("direct_asset_url") or link.get("url") or ""
|
||||
if name.endswith(".AppImage"):
|
||||
assets.append({"name": name, "browser_download_url": url})
|
||||
if assets:
|
||||
break
|
||||
matching = [a for a in assets if arch_tags and has(a["name"], arch_tags)]
|
||||
untagged = [a for a in assets if not has(a["name"], all_arch_tags)]
|
||||
pick = matching or untagged or assets
|
||||
# No f-string here on purpose: this whole script is wrapped in a bash
|
||||
# single-quoted string (see the "python3 -c" call above it), so a single
|
||||
# quote anywhere in this code — the way an f-string would normally quote
|
||||
# a dict key — would terminate that bash string early. Plain
|
||||
# concatenation with double-quoted literals sidesteps that entirely.
|
||||
print(pick[0]["browser_download_url"] + "\t" + pick[0]["name"] if pick else "")
|
||||
' 2>/dev/null)
|
||||
if [[ -z "$_url" ]]; then
|
||||
log_warning "Could not resolve download URL — get it manually from https://gitlab.com/${_project_path}/-/releases"
|
||||
return 1
|
||||
fi
|
||||
# The URL and the real filename are two different things here — GitLab's
|
||||
# own asset URL is an opaque .../package_files/<id>/download link with no
|
||||
# filename in it at all, so basename($_url) would save the file as
|
||||
# literally "download" instead of e.g. "ES-DE_x64.AppImage". The release
|
||||
# asset's own "name" field (tab-separated from the URL above) is the only
|
||||
# place the real filename actually lives.
|
||||
local _asset_name
|
||||
_asset_name="${_url#*$'\t'}"
|
||||
_url="${_url%%$'\t'*}"
|
||||
local _file="$_dir/$_asset_name"
|
||||
curl -fL --progress-bar -o "$_file" "$_url" \
|
||||
&& chmod +x "$_file" \
|
||||
&& chown "$ACTUAL_USER:$ACTUAL_USER" "$_file" \
|
||||
&& log_success "$_display_name downloaded: $_file" \
|
||||
|| { log_warning "Download failed — get it manually from https://gitlab.com/${_project_path}/-/releases"; return 1; }
|
||||
|
||||
local _got_arch
|
||||
_got_arch=$(file -b "$_file" 2>/dev/null)
|
||||
case "$(uname -m)" in
|
||||
x86_64)
|
||||
echo "$_got_arch" | grep -qi 'x86-64\|x86_64' || \
|
||||
log_warning "$_file doesn't look like an x86_64 build ($_got_arch) — it will fail with 'exec format error'. Grab the x86_64 asset by hand from https://gitlab.com/${_project_path}/-/releases"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
echo "$_got_arch" | grep -qi 'aarch64\|arm64' || \
|
||||
log_warning "$_file doesn't look like an aarch64 build ($_got_arch) — it may fail to run. Grab the aarch64 asset by hand from https://gitlab.com/${_project_path}/-/releases"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
install_wolf() {
|
||||
require_docker || return 1
|
||||
|
||||
@@ -939,6 +1039,85 @@ UDEV
|
||||
log_info "not something this installer can supply. Cemu's own First-Time Setup Wizard covers where"
|
||||
log_info "to put it once you have one."
|
||||
|
||||
# ── Optional: ES-DE and RetroArch as standalone AppImages (for Steam) ────
|
||||
# These are ADDITIONAL to the esde/retroarch Wolf catalog containers
|
||||
# above, not a replacement — nothing here removes or changes those. The
|
||||
# only reason to want this: once added as a Steam non-Steam game
|
||||
# (./manage.sh steam-add-nonsteam-game, which the wolf mount fix above
|
||||
# already extended to reach the same roms/saves/bios/retro-home/
|
||||
# retroarch paths the esde/retroarch containers use), Steam Input can
|
||||
# give each of up to 4 identical-model controllers its own distinct
|
||||
# identity by device path — the one thing Wolf's own 3-concrete-pad-type
|
||||
# ceiling can't do for a 4th controller (see manage.sh's own "4
|
||||
# controllers (Cemu / Wii U games)" help text). Skip both prompts below
|
||||
# if you're happy running Wii U/retro systems through the esde app
|
||||
# directly and don't need Steam's per-device controller assignment.
|
||||
#
|
||||
# Both download to a FIXED, predictable filename (ES-DE.AppImage /
|
||||
# RetroArch.AppImage) regardless of the real upstream release asset's
|
||||
# own name — steam-add-nonsteam-game matches by substring against the
|
||||
# actual filename on disk, and bash glob matching is case-sensitive, so
|
||||
# a fixed name (same symlink trick already used for Dolphin above) is
|
||||
# what makes './manage.sh steam-add-nonsteam-game es-de' reliably find
|
||||
# it regardless of how the vendor's own release happens to be named.
|
||||
if [ ! -f "$_EMU_DIR/ES-DE.AppImage" ]; then
|
||||
echo ""
|
||||
# ES-DE is hosted on GitLab, not GitHub (confirmed against its own
|
||||
# project page) — a different Releases API than every other
|
||||
# standalone emulator above, hence the separate _gitlab helper.
|
||||
log_info "ES-DE also ships an official standalone Linux AppImage — separate from the esde Wolf"
|
||||
log_info "app above. Only useful for adding to Steam (see this repo's wolf README); skip this if"
|
||||
log_info "you'll only ever use the esde app directly."
|
||||
local _GET_ESDE_APPIMAGE=""
|
||||
prompt_yn "Download the ES-DE AppImage for use via Steam? (y/n):" "n" _GET_ESDE_APPIMAGE
|
||||
if [[ "$_GET_ESDE_APPIMAGE" =~ ^[Yy]$ ]]; then
|
||||
_wolf_download_emulator_appimage_gitlab \
|
||||
"ES-DE" "es-de/emulationstation-de" "ES-DE.AppImage" "$_EMU_DIR"
|
||||
local _ESDE_REAL
|
||||
_ESDE_REAL=$(ls "$_EMU_DIR"/*.AppImage 2>/dev/null \
|
||||
| grep -iE '/(es-?de|emulationstation)[^/]*\.AppImage$' \
|
||||
| grep -v '/ES-DE\.AppImage$' | head -1)
|
||||
if [[ -n "$_ESDE_REAL" ]]; then
|
||||
ln -sf "$(basename "$_ESDE_REAL")" "$_EMU_DIR/ES-DE.AppImage"
|
||||
chown -h "$ACTUAL_USER:$ACTUAL_USER" "$_EMU_DIR/ES-DE.AppImage" 2>/dev/null || true
|
||||
log_success "Linked $_EMU_DIR/ES-DE.AppImage -> $(basename "$_ESDE_REAL")"
|
||||
log_info "Once Wolf is running, add it to Steam with: cd $WOLF_DIR && ./manage.sh steam-setup-frontends"
|
||||
log_info "(waits for Steam sign-in — QR code via Moonlight — then wires this in automatically)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -f "$_EMU_DIR/RetroArch.AppImage" ]; then
|
||||
echo ""
|
||||
# Libretro's own buildbot doesn't publish through a GitHub/GitLab
|
||||
# Releases API this installer can automate against — hizzlekizzle/
|
||||
# RetroArch-AppImage is the well-regarded THIRD-PARTY nightly-build
|
||||
# project the AppImage community catalogs (appimage.github.io etc.)
|
||||
# themselves point to, same "flagged, not silently offered as
|
||||
# official" treatment as the Dolphin community build above.
|
||||
log_info "RetroArch also has a standalone Linux AppImage, separate from the retroarch Wolf app"
|
||||
log_info "above — same rationale as ES-DE just above (Steam Input's per-device controller"
|
||||
log_info "assignment). This comes from hizzlekizzle/RetroArch-AppImage, a well-regarded but"
|
||||
log_info "THIRD-PARTY nightly-build project, not an official libretro.org release — grab"
|
||||
log_info "RetroArch's own build by hand instead if you'd rather not run that."
|
||||
local _GET_RA_APPIMAGE=""
|
||||
prompt_yn "Download the RetroArch AppImage for use via Steam? (y/n):" "n" _GET_RA_APPIMAGE
|
||||
if [[ "$_GET_RA_APPIMAGE" =~ ^[Yy]$ ]]; then
|
||||
_wolf_download_emulator_appimage \
|
||||
"RetroArch" "hizzlekizzle/RetroArch-AppImage" "RetroArch.AppImage" "$_EMU_DIR"
|
||||
local _RA_REAL
|
||||
_RA_REAL=$(ls "$_EMU_DIR"/*[Rr]etro[Aa]rch*.AppImage 2>/dev/null \
|
||||
| grep -v '/RetroArch\.AppImage$' | head -1)
|
||||
if [[ -n "$_RA_REAL" ]]; then
|
||||
ln -sf "$(basename "$_RA_REAL")" "$_EMU_DIR/RetroArch.AppImage"
|
||||
chown -h "$ACTUAL_USER:$ACTUAL_USER" "$_EMU_DIR/RetroArch.AppImage" 2>/dev/null || true
|
||||
log_success "Linked $_EMU_DIR/RetroArch.AppImage -> $(basename "$_RA_REAL")"
|
||||
log_info "Once Wolf is running, add it to Steam with: cd $WOLF_DIR && ./manage.sh steam-setup-frontends"
|
||||
log_info "(waits for Steam sign-in — QR code via Moonlight — then wires this in automatically)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Optional: TI-99/4A as its own ES-DE system ────────────────────────────
|
||||
# TI-99/4A has no libretro core and isn't one of ES-DE's built-in systems,
|
||||
# so getting it real ES-DE treatment (artwork scraping, gameplay-time
|
||||
@@ -2166,7 +2345,26 @@ CATALOG = {
|
||||
# (see below) — without it, an emulator AppImage (Cemu, etc.)
|
||||
# added as a non-Steam game (./manage.sh steam-add-nonsteam-game)
|
||||
# has no file to actually point Exe at from inside this container.
|
||||
f'{games}/emulators:/home/retro/Applications:rw'],
|
||||
f'{games}/emulators:/home/retro/Applications:rw',
|
||||
# Same roms/saves/bios/retro-home/retroarch mounts as esde/
|
||||
# retroarch below — without these, a standalone ES-DE or
|
||||
# RetroArch AppImage added here as a non-Steam game (same
|
||||
# mechanism as Cemu above) would see none of the ROMs, cores,
|
||||
# save states, BIOS files, or ES-DE's own settings/custom
|
||||
# systems (TI-99, Wii U AntiMicroX command) that the esde/
|
||||
# retroarch containers already have — it'd start from a
|
||||
# completely empty config instead of reusing what's already
|
||||
# set up. Every path here is the exact same host directory
|
||||
# those two containers mount, just also visible from Steam.
|
||||
f'{games}/roms:/ROMs:rw',
|
||||
f'{games}/saves:/mnt/games/saves:rw',
|
||||
f'{games}/media:/media:rw',
|
||||
f'{games}/bios:/home/retro/bioses:rw',
|
||||
f'{games}/retro-home:/home/retro/.config:rw',
|
||||
f'{games}/retro-home-data:/home/retro/.local/share:rw',
|
||||
f'{games}/retroarch:/home/retro/.config/retroarch:rw',
|
||||
f'{games}/esde-custom-systems:/home/retro/ES-DE/custom_systems:rw',
|
||||
f'{games}/esde-settings:/home/retro/ES-DE/settings:rw'],
|
||||
env=['PROTON_LOG=1', 'RUN_SWAY=true',
|
||||
'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*'],
|
||||
cap_add=['SYS_ADMIN', 'SYS_NICE', 'SYS_PTRACE', 'NET_RAW', 'MKNOD', 'NET_ADMIN'],
|
||||
@@ -3554,7 +3752,7 @@ _manage_wolf_complete() {
|
||||
local commands="start stop restart logs status pin controllers update apps cores reorder
|
||||
add-web ge-proton games setup-swbf2 fix-ea-game wait-ea-app
|
||||
install-ea-app diagnose-ea fix-perms install-completion backup
|
||||
steam-add-nonsteam-game cemu-clone-controller cemu-sync-controllers"
|
||||
steam-add-nonsteam-game steam-setup-frontends cemu-clone-controller cemu-sync-controllers"
|
||||
COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
|
||||
}
|
||||
# Register for both 'manage.sh' and './manage.sh' invocation styles
|
||||
@@ -3873,9 +4071,50 @@ except Exception:
|
||||
ls "$_SANG_EMU_DIR" 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
_SANG_EXE="/home/retro/Applications/$(basename "$_SANG_HOST_FILE")"
|
||||
_SANG_REAL_EXE="/home/retro/Applications/$(basename "$_SANG_HOST_FILE")"
|
||||
[ -z "$_SANG_NAME" ] && _SANG_NAME=$(basename "$_SANG_HOST_FILE" | sed -E 's/\.(AppImage|sh|x86_64)$//I')
|
||||
|
||||
# Route the actual launch through a small fullscreen-forcing wrapper
|
||||
# instead of pointing Steam's Exe directly at the AppImage. Why: a
|
||||
# non-Steam game launched from Steam is a SECOND top-level window in
|
||||
# Wolf's single-app Steam Sway session (RUN_SWAY=true on the 'steam'
|
||||
# CATALOG entry) — Sway's own kiosk config only auto-fullscreens the
|
||||
# ONE window it expects (Steam's own), so anything launched from
|
||||
# inside Steam opens at whatever default size it requests instead,
|
||||
# which reads as roughly half the screen against a full
|
||||
# Moonlight-resolution display. Reported live against ES-DE/Dolphin
|
||||
# added this way. NOT yet confirmed live that swaymsg actually
|
||||
# reaches Sway from inside this exact container/session — if it
|
||||
# doesn't, the wrapper's fullscreen loop just fails silently
|
||||
# (2>/dev/null below) and the app launches exactly as it did before
|
||||
# this existed, so this is safe to try without risking the launch
|
||||
# itself. Regenerated on every call (cheap, stateless — nothing to
|
||||
# lose by overwriting it) so a fix to the wrapper reaches every
|
||||
# existing shortcut the next time it's (re-)added, not just new ones.
|
||||
_SANG_WRAP="$_SANG_EMU_DIR/steam-fullscreen-wrap"
|
||||
cat > "$_SANG_WRAP" << 'WRAPEOF'
|
||||
#!/bin/bash
|
||||
# steam-fullscreen-wrap <real-binary> [args...]
|
||||
# Launches the real target, then repeatedly asks Sway to fullscreen whatever
|
||||
# currently has input focus for a few seconds after launch — the newly
|
||||
# launched window is expected to grab focus once it maps, same as any
|
||||
# ordinary X11/Wayland client. 'fullscreen enable' (not 'toggle') is
|
||||
# idempotent, so repeating it while the window is already fullscreen is a
|
||||
# harmless no-op rather than flipping it back off.
|
||||
REAL_BIN="$1"; shift
|
||||
"$REAL_BIN" "$@" &
|
||||
PID=$!
|
||||
(
|
||||
for _i in $(seq 1 20); do
|
||||
sleep 0.5
|
||||
swaymsg fullscreen enable 2>/dev/null
|
||||
done
|
||||
) &
|
||||
wait "$PID"
|
||||
WRAPEOF
|
||||
chmod +x "$_SANG_WRAP"
|
||||
_SANG_EXE="/home/retro/Applications/steam-fullscreen-wrap"
|
||||
|
||||
STEAM_HOME=$(_steam_home)
|
||||
if [ -z "$STEAM_HOME" ]; then
|
||||
echo "No Steam home found yet under ${WOLF_STATE_DIR:-/etc/wolf}."
|
||||
@@ -3902,10 +4141,10 @@ except Exception:
|
||||
sleep 3
|
||||
fi
|
||||
|
||||
sudo python3 - "$_SANG_VDF" "$_SANG_EXE" "$_SANG_NAME" "/home/retro/Applications" << 'VDFPY'
|
||||
sudo python3 - "$_SANG_VDF" "$_SANG_EXE" "$_SANG_NAME" "/home/retro/Applications" "$_SANG_REAL_EXE" << 'VDFPY'
|
||||
import sys, struct, os, zlib
|
||||
|
||||
path, exe_path, app_name, start_dir = sys.argv[1:5]
|
||||
path, exe_path, app_name, start_dir, real_exe = sys.argv[1:6]
|
||||
|
||||
TYPE_MAP, TYPE_STR, TYPE_INT, TYPE_END = 0x00, 0x01, 0x02, 0x08
|
||||
|
||||
@@ -3969,13 +4208,30 @@ if shortcuts_entry is None:
|
||||
|
||||
entries_list = shortcuts_entry[2]
|
||||
|
||||
# exe_path now points at the shared steam-fullscreen-wrap script (same Exe
|
||||
# for every emulator added this way), with the real per-emulator target
|
||||
# carried in LaunchOptions instead — so matching (for both idempotent
|
||||
# re-adds and appid uniqueness) has to key on the (exe, LaunchOptions) PAIR,
|
||||
# not exe alone, or adding a second emulator would silently overwrite the
|
||||
# first one's shortcut entry. Also cleans up a legacy entry from before this
|
||||
# wrapper existed, where Exe pointed directly at this same real binary with
|
||||
# no LaunchOptions at all — re-adding an emulator added under the old
|
||||
# scheme replaces that stale direct-launch entry instead of leaving a
|
||||
# duplicate tile behind.
|
||||
quoted_exe = f'"{exe_path}"'
|
||||
entries_list[:] = [e for e in entries_list if get_field(e[2], 'exe') != quoted_exe]
|
||||
quoted_launch = f'"{real_exe}"'
|
||||
entries_list[:] = [
|
||||
e for e in entries_list
|
||||
if not (
|
||||
(get_field(e[2], 'exe') == quoted_exe and get_field(e[2], 'LaunchOptions') == quoted_launch)
|
||||
or get_field(e[2], 'exe') == quoted_launch
|
||||
)
|
||||
]
|
||||
for idx, e in enumerate(entries_list):
|
||||
e[0] = str(idx)
|
||||
|
||||
new_index = str(len(entries_list))
|
||||
crc_input = (exe_path + app_name).encode('utf-8')
|
||||
crc_input = (exe_path + real_exe + app_name).encode('utf-8')
|
||||
appid = (zlib.crc32(crc_input) | 0x80000000) & 0xFFFFFFFF
|
||||
appid_signed = appid - 0x100000000 if appid >= 0x80000000 else appid
|
||||
|
||||
@@ -3986,7 +4242,7 @@ new_entry_fields = [
|
||||
['StartDir', TYPE_STR, f'"{start_dir}"'],
|
||||
['icon', TYPE_STR, ''],
|
||||
['ShortcutPath', TYPE_STR, ''],
|
||||
['LaunchOptions', TYPE_STR, ''],
|
||||
['LaunchOptions', TYPE_STR, quoted_launch],
|
||||
['IsHidden', TYPE_INT, 0],
|
||||
['AllowDesktopConfig', TYPE_INT, 1],
|
||||
['AllowOverlay', TYPE_INT, 1],
|
||||
@@ -4002,18 +4258,127 @@ entries_list.append([new_index, TYPE_MAP, new_entry_fields])
|
||||
|
||||
with open(path, 'wb') as f:
|
||||
f.write(serialize_map(root))
|
||||
print(f"Wrote shortcuts.vdf: {app_name} -> {exe_path} (appid {appid_signed})")
|
||||
print(f"Wrote shortcuts.vdf: {app_name} -> {real_exe} (via {exe_path}, appid {appid_signed})")
|
||||
VDFPY
|
||||
sudo chown 1000:1000 "$_SANG_VDF"
|
||||
|
||||
echo ""
|
||||
echo "Added '$_SANG_NAME' -> $_SANG_EXE to Steam's shortcuts.vdf."
|
||||
echo "Added '$_SANG_NAME' -> $_SANG_REAL_EXE (via the fullscreen wrapper) to Steam's shortcuts.vdf."
|
||||
echo "Open Steam in Moonlight (it will restart since it was stopped above) —"
|
||||
echo "the new tile appears in your Library (may need the Library view, not just Home)."
|
||||
echo "First launch: right-click it -> Properties -> Compatibility, and confirm"
|
||||
echo "'Force the use of a specific Steam Play compatibility tool' is OFF — Cemu and"
|
||||
echo "other native Linux AppImages don't run through Proton."
|
||||
;;
|
||||
steam-setup-frontends)
|
||||
# One command covering what the emulator AppImage download step in
|
||||
# setup.sh can't finish on its own: that step runs before Wolf/Steam
|
||||
# containers even exist, so it can only download the AppImages and
|
||||
# print instructions. This picks up from there — start Steam if
|
||||
# needed, WAIT for it to be signed in (Steam Guard's QR-code sign-in
|
||||
# itself can't be scripted: it needs a phone approving a prompt, so
|
||||
# this only polls for the result, never performs the sign-in), then
|
||||
# add EVERY AppImage already sitting in emulators/ (ES-DE, RetroArch,
|
||||
# Cemu, Azahar, PCSX2, Dolphin — whatever setup.sh's download prompts
|
||||
# were said yes to) as a Steam non-Steam game via the existing
|
||||
# steam-add-nonsteam-game command above (re-invoked, not
|
||||
# reimplemented, so the two never drift apart). Safe to re-run any
|
||||
# time — already-added shortcuts are updated in place, never
|
||||
# duplicated, so running this again after downloading one more
|
||||
# emulator only adds the new one.
|
||||
if ! docker ps --format '{{.Names}}' | grep -qi WolfSteam; then
|
||||
echo "Starting Wolf (docker compose up -d)..."
|
||||
docker compose up -d
|
||||
sleep 5
|
||||
fi
|
||||
|
||||
_SSF_SIGNED_IN() {
|
||||
local _home
|
||||
_home=$(_steam_home)
|
||||
[ -n "$_home" ] && [ -n "$(sudo ls "$_home/.steam/steam/userdata/" 2>/dev/null)" ]
|
||||
}
|
||||
|
||||
if _SSF_SIGNED_IN; then
|
||||
echo "Steam is already signed in — proceeding."
|
||||
else
|
||||
echo ""
|
||||
echo "Steam isn't signed in yet. In Moonlight:"
|
||||
echo " 1. Connect to the Steam app."
|
||||
echo " 2. On Steam's login screen, choose 'Sign in with QR code'."
|
||||
echo " 3. Scan it with your phone's Steam app and approve the prompt."
|
||||
echo ""
|
||||
echo "Waiting for sign-in (up to 10 minutes, checking every 5s — Ctrl+C to give up"
|
||||
echo "and finish this later by re-running './manage.sh steam-setup-frontends')..."
|
||||
_SSF_WAITED=0
|
||||
until _SSF_SIGNED_IN; do
|
||||
sleep 5
|
||||
_SSF_WAITED=$((_SSF_WAITED + 5))
|
||||
if [ "$_SSF_WAITED" -ge 600 ]; then
|
||||
echo "Still not signed in after 10 minutes — giving up for now."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "Signed in."
|
||||
fi
|
||||
|
||||
GAME_DIR=$(grep '^GAME_STORAGE_DIR=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d= -f2-)
|
||||
if [ -z "$GAME_DIR" ]; then read -r -p " Game storage path: " GAME_DIR; fi
|
||||
_SSF_EMU_DIR="$GAME_DIR/emulators"
|
||||
_SSF_ADDED_ANY=0
|
||||
# realpaths already registered — so a fixed-name symlink
|
||||
# (ES-DE.AppImage, RetroArch.AppImage, Dolphin_Emulator.AppImage) and the
|
||||
# real versioned file it points at don't both end up as separate Steam
|
||||
# tiles for the same emulator.
|
||||
_SSF_SEEN=""
|
||||
_ssf_add_appimage() {
|
||||
# $1 = filename inside emulators/, $2 = display name (optional —
|
||||
# falls back to steam-add-nonsteam-game's own filename-derived default).
|
||||
local _file="$1" _name="${2:-}" _real
|
||||
[ -f "$_SSF_EMU_DIR/$_file" ] || return 1
|
||||
_real=$(readlink -f "$_SSF_EMU_DIR/$_file")
|
||||
case " $_SSF_SEEN " in *" $_real "*) return 1 ;; esac
|
||||
_SSF_SEEN="$_SSF_SEEN $_real"
|
||||
if [ -n "$_name" ]; then
|
||||
"$0" steam-add-nonsteam-game "$_file" "$_name"
|
||||
else
|
||||
"$0" steam-add-nonsteam-game "$_file"
|
||||
fi
|
||||
}
|
||||
|
||||
# Fixed-name symlinks first, so their friendly display names win over
|
||||
# the versioned real filename each one points at (see the emulator
|
||||
# download step in setup.sh for why these symlinks exist).
|
||||
_ssf_add_appimage "ES-DE.AppImage" "EmulationStation (ES-DE)" && _SSF_ADDED_ANY=1
|
||||
_ssf_add_appimage "RetroArch.AppImage" "RetroArch" && _SSF_ADDED_ANY=1
|
||||
_ssf_add_appimage "Dolphin_Emulator.AppImage" "Dolphin (GameCube/Wii)" && _SSF_ADDED_ANY=1
|
||||
|
||||
# Everything else already downloaded into emulators/ — Cemu, Azahar,
|
||||
# PCSX2, and any future emulator this list doesn't yet special-case by
|
||||
# name. The realpath dedupe above skips the Dolphin/ES-DE/RetroArch
|
||||
# symlinks' own real targets when this glob reaches them.
|
||||
shopt -s nullglob
|
||||
for _appimg in "$_SSF_EMU_DIR"/*.AppImage; do
|
||||
_base=$(basename "$_appimg")
|
||||
case "${_base,,}" in
|
||||
cemu*) _ssf_add_appimage "$_base" "Cemu (Wii U)" && _SSF_ADDED_ANY=1 ;;
|
||||
azahar*) _ssf_add_appimage "$_base" "Azahar (3DS)" && _SSF_ADDED_ANY=1 ;;
|
||||
pcsx2*) _ssf_add_appimage "$_base" "PCSX2 (PS2)" && _SSF_ADDED_ANY=1 ;;
|
||||
*) _ssf_add_appimage "$_base" && _SSF_ADDED_ANY=1 ;;
|
||||
esac
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
if [ "$_SSF_ADDED_ANY" = 0 ]; then
|
||||
echo "No emulator AppImages found in $_SSF_EMU_DIR yet — download them first: sudo ./setup.sh wolf"
|
||||
fi
|
||||
|
||||
if [ "$_SSF_ADDED_ANY" = 1 ]; then
|
||||
echo ""
|
||||
echo "Cores/shaders/overlays live in the same retroarch/ directory the esde/retroarch apps"
|
||||
echo "already use (shared mount — nothing new to configure there). If you haven't already:"
|
||||
echo " ./manage.sh cores all (downloads every libretro core + shaders/overlays/database)"
|
||||
fi
|
||||
;;
|
||||
cemu-clone-controller)
|
||||
# Clones a WORKING Cemu controller mapping onto a new device slot,
|
||||
# skipping Cemu's own Input Settings dialog entirely for that slot.
|
||||
@@ -4178,7 +4543,7 @@ CLONEPY
|
||||
fi
|
||||
echo "Reading live controller GUIDs from $_CSC_CONTAINER..."
|
||||
|
||||
_CSC_PROBE=$(docker exec -u retro "$_CSC_CONTAINER" python3 - << 'PROBEPY'
|
||||
_CSC_PROBE=$(docker exec -i -u retro "$_CSC_CONTAINER" python3 - << 'PROBEPY'
|
||||
import ctypes, ctypes.util, sys
|
||||
|
||||
libname = ctypes.util.find_library("SDL2") or "libSDL2-2.0.so.0"
|
||||
@@ -4327,6 +4692,9 @@ SYNCPY
|
||||
echo " ./manage.sh install-completion - Enable tab-completion for this script"
|
||||
echo " ./manage.sh steam-add-nonsteam-game [name] [display name]"
|
||||
echo " - Add an emulator (emulators/) as a non-Steam game, no GUI needed"
|
||||
echo " ./manage.sh steam-setup-frontends - Wait for Steam sign-in (QR code via Moonlight), then add"
|
||||
echo " EVERY downloaded emulator AppImage (ES-DE, RetroArch,"
|
||||
echo " Cemu, Azahar, PCSX2, Dolphin, ...) to Steam"
|
||||
echo " ./manage.sh cemu-clone-controller [slot 0-3] [uuid] [display name]"
|
||||
echo " - Clone a working Cemu controller mapping onto a new device slot"
|
||||
echo " ./manage.sh cemu-sync-controllers - Auto-detect connected controllers via SDL and clone mappings onto all of them"
|
||||
@@ -4391,7 +4759,26 @@ CATALOG = {
|
||||
# (see below) — without it, an emulator AppImage (Cemu, etc.)
|
||||
# added as a non-Steam game (./manage.sh steam-add-nonsteam-game)
|
||||
# has no file to actually point Exe at from inside this container.
|
||||
f'{games}/emulators:/home/retro/Applications:rw'],
|
||||
f'{games}/emulators:/home/retro/Applications:rw',
|
||||
# Same roms/saves/bios/retro-home/retroarch mounts as esde/
|
||||
# retroarch below — without these, a standalone ES-DE or
|
||||
# RetroArch AppImage added here as a non-Steam game (same
|
||||
# mechanism as Cemu above) would see none of the ROMs, cores,
|
||||
# save states, BIOS files, or ES-DE's own settings/custom
|
||||
# systems (TI-99, Wii U AntiMicroX command) that the esde/
|
||||
# retroarch containers already have — it'd start from a
|
||||
# completely empty config instead of reusing what's already
|
||||
# set up. Every path here is the exact same host directory
|
||||
# those two containers mount, just also visible from Steam.
|
||||
f'{games}/roms:/ROMs:rw',
|
||||
f'{games}/saves:/mnt/games/saves:rw',
|
||||
f'{games}/media:/media:rw',
|
||||
f'{games}/bios:/home/retro/bioses:rw',
|
||||
f'{games}/retro-home:/home/retro/.config:rw',
|
||||
f'{games}/retro-home-data:/home/retro/.local/share:rw',
|
||||
f'{games}/retroarch:/home/retro/.config/retroarch:rw',
|
||||
f'{games}/esde-custom-systems:/home/retro/ES-DE/custom_systems:rw',
|
||||
f'{games}/esde-settings:/home/retro/ES-DE/settings:rw'],
|
||||
env=['PROTON_LOG=1', 'RUN_SWAY=true',
|
||||
'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*'],
|
||||
cap_add=['SYS_ADMIN', 'SYS_NICE', 'SYS_PTRACE', 'NET_RAW', 'MKNOD', 'NET_ADMIN'],
|
||||
@@ -4715,6 +5102,35 @@ PYEOF
|
||||
# Hand the folder back to the real user
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WOLF_DIR"
|
||||
|
||||
# ── Optional: auto-wire already-downloaded emulators into Steam ──────────
|
||||
# Steam's own shortcuts.vdf (and the userdata/<id>/ dir it lives under)
|
||||
# only exists once Steam has actually been signed into at least once —
|
||||
# meaningless on a brand-new install, since Steam hasn't even been opened
|
||||
# in Moonlight yet at this point in a first run. But on a RERUN of this
|
||||
# module (sudo ./setup.sh wolf again, after you've since signed in),
|
||||
# every AppImage already sitting in emulators/ can go straight into
|
||||
# Steam's library with no extra manual step. No blocking here: this is
|
||||
# a quick, one-shot check for a Steam profile that already exists — the
|
||||
# actual up-to-10-minutes sign-in wait lives in
|
||||
# 'manage.sh steam-setup-frontends' itself (re-invoked below, not
|
||||
# duplicated), and only ever triggers there if this check somehow raced
|
||||
# against a sign-in that hadn't finished landing on disk yet.
|
||||
if echo "$_APP_KEYS" | grep -qw steam; then
|
||||
local _WOLF_ANY_STEAM_UID=""
|
||||
_WOLF_ANY_STEAM_UID=$(find "$WOLF_STATE_DIR" -maxdepth 2 -type d -name Steam 2>/dev/null \
|
||||
| while IFS= read -r _sd; do ls "$_sd/.steam/steam/userdata/" 2>/dev/null | head -1; done | head -1)
|
||||
if [ -n "$_WOLF_ANY_STEAM_UID" ]; then
|
||||
log_info "Steam is already signed in — adding downloaded emulators to Steam's library..."
|
||||
(cd "$WOLF_DIR" && ./manage.sh steam-setup-frontends) \
|
||||
|| log_warning "Couldn't auto-add emulators to Steam — run manually: cd $WOLF_DIR && ./manage.sh steam-setup-frontends"
|
||||
else
|
||||
log_info "Once you've signed into Steam via Moonlight, run this to add every downloaded"
|
||||
log_info "emulator (Cemu, ES-DE, RetroArch, etc.) to Steam's library — or just re-run"
|
||||
log_info "'sudo ./setup.sh wolf' after signing in and it'll be done automatically:"
|
||||
log_info " cd $WOLF_DIR && ./manage.sh steam-setup-frontends"
|
||||
fi
|
||||
fi
|
||||
|
||||
local ALL_IPS
|
||||
ALL_IPS=$(ip -4 addr show | grep -oP '(?<=inet )\d+\.\d+\.\d+\.\d+(?=/)' | grep -v '^127\.')
|
||||
|
||||
@@ -4819,7 +5235,16 @@ PYEOF
|
||||
echo " Online together → each player launches their own session,"
|
||||
echo " all connect to the same game server"
|
||||
echo ""
|
||||
echo "Manage: cd $WOLF_DIR && ./manage.sh {start|stop|restart|logs|status|pin|update|apps|reorder|add-web|ge-proton|fix-ea-game}"
|
||||
echo " 4 controllers, one game (e.g. Cemu/Wii U)? Steam Input tells identical"
|
||||
echo " controllers apart by device path, not just SDL GUID — add the emulator"
|
||||
echo " to Steam as a non-Steam game instead of launching it from ES-DE:"
|
||||
echo " ./manage.sh steam-setup-frontends # adds every downloaded emulator AppImage to Steam"
|
||||
echo " ./manage.sh cemu-clone-controller / cemu-sync-controllers"
|
||||
echo " For the ES-DE/RetroArch Wolf apps directly, force distinct virtual pad"
|
||||
echo " types per slot instead: ./manage.sh controllers (see README.md)"
|
||||
echo ""
|
||||
echo "Manage: cd $WOLF_DIR && ./manage.sh {start|stop|restart|logs|status|update|cores|reorder|add-web|backup|ge-proton|fix-ea-game|controllers|steam-add-nonsteam-game|steam-setup-frontends|pin}"
|
||||
echo " ./manage.sh install-completion # tab-complete every command above"
|
||||
echo ""
|
||||
echo "── EA GAMES (Battlefront II, etc.) ───────────────────"
|
||||
echo ""
|
||||
@@ -4898,10 +5323,33 @@ cd $WOLF_DIR
|
||||
Run \`./manage.sh install-completion\` once, then re-open your shell (or
|
||||
\`source ~/.bashrc\`). After that, \`./manage.sh <TAB><TAB>\` lists all commands.
|
||||
|
||||
## Adding an emulator to Steam as a non-Steam game
|
||||
Steam's own "Add a Non-Steam Game" is a Big Picture file-browser flow —
|
||||
usable through Moonlight, but slow to click through for something you're
|
||||
scripting or repeating. This does the same thing directly:
|
||||
## Getting downloaded emulators into Steam — the easy way
|
||||
Downloading an emulator AppImage (\`sudo ./setup.sh wolf\`'s Cemu/Azahar/
|
||||
PCSX2/Dolphin/ES-DE/RetroArch prompts) only puts the file in \`emulators/\`
|
||||
— it doesn't touch Steam's own library on its own, since that step runs
|
||||
before Steam has even been signed into. Once you've signed into Steam via
|
||||
Moonlight, one command adds everything already downloaded:
|
||||
\`\`\`bash
|
||||
cd $WOLF_DIR && ./manage.sh steam-setup-frontends
|
||||
\`\`\`
|
||||
This scans \`emulators/\` and adds every AppImage found (Cemu, Azahar,
|
||||
PCSX2, Dolphin, ES-DE, RetroArch — whichever prompts you said yes to) as
|
||||
a Steam non-Steam game in one pass; see the section below for exactly how
|
||||
it waits for sign-in. **You don't even need to run this yourself on a
|
||||
rerun** — \`sudo ./setup.sh wolf\` run again after you've signed into Steam
|
||||
detects the signed-in profile automatically and adds everything for you
|
||||
as the last step, no extra command needed. It's still worth knowing the
|
||||
command directly for downloading one more emulator later without
|
||||
re-running the whole installer.
|
||||
|
||||
## Adding a single emulator to Steam as a non-Steam game
|
||||
\`steam-add-nonsteam-game\` is the one-off building block
|
||||
\`steam-setup-frontends\` above calls for each emulator it finds — reach
|
||||
for it directly when you only want to (re-)add one specific file, e.g.
|
||||
after manually dropping in an AppImage the setup script doesn't know
|
||||
about. Steam's own "Add a Non-Steam Game" is a Big Picture file-browser
|
||||
flow — usable through Moonlight, but slow to click through for something
|
||||
you're scripting or repeating. This does the same thing directly:
|
||||
\`\`\`bash
|
||||
cd $WOLF_DIR && ./manage.sh steam-add-nonsteam-game cemu
|
||||
\`\`\`
|
||||
@@ -4924,10 +5372,124 @@ controllers per player slot even when the physical controllers are
|
||||
identical models (the same case \`./manage.sh controllers\` exists to work
|
||||
around for ES-DE). Add Cemu here, then in Steam's own Big Picture
|
||||
Controller Settings you can see and assign each detected controller
|
||||
individually. This hasn't been confirmed live yet against Wolf's
|
||||
container-created virtual controllers specifically (real hardware behaves
|
||||
this way; whether Steam Input sees Wolf's virtual joypads the same way is
|
||||
still to be tested) — worth trying before assuming it works.
|
||||
individually.
|
||||
|
||||
**Controller doesn't respond at all once added to Steam?** Confirmed live:
|
||||
this is Steam Input, not the emulator — by default Steam applies its own
|
||||
controller configuration to *every* shortcut, Steam or non-Steam alike,
|
||||
and a freshly-added non-Steam game with no configuration picked yet can
|
||||
end up with no usable mapping at all rather than passing raw input
|
||||
through. Fix from Steam's own Big Picture UI: select the shortcut → the
|
||||
controller icon / **Manage Game** → **Controller Options**, and either
|
||||
pick a **Gamepad** template (closest to "pass it through as a normal
|
||||
joystick", needed for ES-DE/RetroArch's own native SDL input handling to
|
||||
see it at all) or turn **Steam Input** off for that one shortcut entirely
|
||||
if you don't need the per-device assignment this section is about in the
|
||||
first place. This is a one-time, per-shortcut setting Steam remembers —
|
||||
not something this installer can preconfigure from the command line, since
|
||||
it's stored in Steam's own (separate, Steam Cloud-synced) controller
|
||||
config rather than \`shortcuts.vdf\`.
|
||||
|
||||
**Opens at roughly half the screen instead of fullscreen?** Also confirmed
|
||||
live, and now worked around automatically as of the version of this repo
|
||||
that added the point below — \`steam-add-nonsteam-game\`/
|
||||
\`steam-setup-frontends\` route the launch through a small wrapper
|
||||
(\`emulators/steam-fullscreen-wrap\`) instead of pointing Steam's \`Exe\`
|
||||
directly at the AppImage. The underlying cause: a non-Steam game launched
|
||||
from Steam is a *second* top-level window inside Wolf's single-app Steam
|
||||
Sway session, and Sway's own kiosk config only auto-fullscreens the ONE
|
||||
window it expects (Steam's own) — anything launched from inside Steam
|
||||
opens at whatever default size it requests instead. The wrapper launches
|
||||
the real binary, then repeatedly asks Sway to fullscreen whatever currently
|
||||
has focus for a few seconds after launch (idempotent — harmless if it's
|
||||
already fullscreen). **This has not been confirmed live against a real
|
||||
Wolf Steam session yet** — if \`swaymsg\` isn't reachable from inside that
|
||||
exact container, the wrapper's fullscreen loop just fails silently and the
|
||||
window opens at its old default size, same as before this existed; it's
|
||||
safe to try either way since the underlying launch itself is unaffected.
|
||||
Re-run \`steam-add-nonsteam-game\`/\`steam-setup-frontends\` for anything
|
||||
already added before this existed — it replaces the old direct-launch
|
||||
shortcut with the wrapped one automatically, no duplicate tile left behind.
|
||||
|
||||
**An added emulator's tile appears in Steam but crashes on launch instead
|
||||
of opening at all** (as opposed to the half-screen case above, where it
|
||||
does open): that's a different problem — something about the AppImage
|
||||
itself failing to run inside the Steam container specifically, since the
|
||||
same binary launching fine through the \`esde\`/\`desktop\` Wolf apps rules out
|
||||
the AppImage being broken outright. Worth checking before assuming it's
|
||||
unfixable:
|
||||
\`\`\`bash
|
||||
CONTAINER=\$(docker ps --format '{{.Names}}' | grep -i WolfSteam | head -1)
|
||||
docker exec "\$CONTAINER" /home/retro/Applications/<the-appimage-file> ; echo "exit: \$?"
|
||||
\`\`\`
|
||||
Run directly like this (no wrapper, no Steam involved) the real error
|
||||
usually prints straight to the terminal — a missing shared library, a
|
||||
FUSE/AppImage mount failure (\`dlopen(): error loading libfuse.so.2\`),
|
||||
or similar. That output is what actually narrows down the fix.
|
||||
|
||||
## Running ES-DE and/or RetroArch through Steam instead of their own Wolf apps
|
||||
The same Steam Input reasoning above applies beyond just Cemu: ES-DE and
|
||||
RetroArch both ship their own official standalone Linux AppImages
|
||||
(separate from the \`esde\`/\`retroarch\` Wolf catalog apps this installer
|
||||
already runs), and adding one of those to Steam gets you the same
|
||||
per-device controller assignment for every system it covers, not just
|
||||
Cemu. \`sudo ./setup.sh wolf\` offers to download both (opt-in, default
|
||||
no) right after the Cemu step — ES-DE's from its GitLab releases (it
|
||||
isn't on GitHub), RetroArch's from \`hizzlekizzle/RetroArch-AppImage\` (a
|
||||
well-regarded third-party nightly build — libretro.org's own buildbot
|
||||
doesn't publish through an API this installer can automate against).
|
||||
Both land in \`emulators/\` under a fixed name (\`ES-DE.AppImage\` /
|
||||
\`RetroArch.AppImage\`) regardless of the real release asset's own
|
||||
filename, so Steam's non-Steam-game matching finds them reliably.
|
||||
|
||||
They reuse the exact same \`roms/\`, \`saves/\`, \`bios/\`, \`retro-home\`, and
|
||||
\`retroarch\` (cores/shaders/overlays) directories the \`esde\`/\`retroarch\`
|
||||
containers already use — the \`steam\` Wolf app mounts all of the same
|
||||
paths, so nothing needs re-downloading or re-scraping just because it's
|
||||
now also reachable from Steam. If you haven't already populated cores:
|
||||
\`\`\`bash
|
||||
cd $WOLF_DIR && ./manage.sh cores all
|
||||
\`\`\`
|
||||
|
||||
Finishing the Steam side needs one thing that can't be scripted — Steam
|
||||
Guard's QR-code sign-in requires a phone approving a prompt — so this
|
||||
polls for it instead of trying to script past it:
|
||||
\`\`\`bash
|
||||
cd $WOLF_DIR && ./manage.sh steam-setup-frontends
|
||||
\`\`\`
|
||||
Starts Wolf if it isn't already up, checks whether Steam's already
|
||||
signed in (proceeds immediately if so), otherwise prints the QR-code
|
||||
steps and waits (up to 10 minutes) for sign-in to complete, then adds
|
||||
every AppImage already sitting in \`emulators/\` — not just ES-DE and
|
||||
RetroArch, but Cemu/Azahar/PCSX2/Dolphin too — as a Steam non-Steam game,
|
||||
re-using \`steam-add-nonsteam-game\` above rather than duplicating its
|
||||
shortcuts.vdf-writing logic. Safe to re-run any time (e.g. if it timed
|
||||
out waiting, or you downloaded another emulator since) — already-added
|
||||
shortcuts are updated in place, not duplicated.
|
||||
|
||||
**Returning to Steam from ES-DE, without a second instance:** if ES-DE
|
||||
is running as a Steam non-Steam game (via the AppImage above, not the
|
||||
separate \`esde\` Wolf app), Steam is the parent process the whole
|
||||
time — quitting ES-DE drops you back into the same still-running Steam
|
||||
Big Picture session rather than starting a new one. This only works for
|
||||
the AppImage-in-Steam path; the standalone \`esde\` Wolf app is a
|
||||
completely separate container, and Wolf has no supported way to hand off
|
||||
from one running app to another mid-session (switching apps means
|
||||
closing the Moonlight session and reconnecting to the other one).
|
||||
|
||||
**Multiple devices, same Steam account, same controller mappings?**
|
||||
Worth knowing before relying on it: Wolf gives each *paired client* its
|
||||
own separate Steam container/home directory (confirmed against this
|
||||
repo's own \`_steam_home()\` — it searches across multiple
|
||||
\`.../Steam\` directories, not just one), so a second device connecting
|
||||
to Wolf doesn't reuse the first device's Steam install and has to sign
|
||||
in separately the first time. Once it's signed into the *same* Steam
|
||||
account, Valve's own account-level Steam Cloud config sync should
|
||||
replicate your Steam Input controller bindings across those separate
|
||||
local installs (the same mechanism that syncs bindings between a Steam
|
||||
Deck and a gaming PC) — but that's a Steam-account feature, not
|
||||
something Wolf or this installer controls, and hasn't been confirmed
|
||||
live in this specific setup.
|
||||
|
||||
## Multiple controllers (same game/emulator can't tell them apart)
|
||||
**Symptom:** two or more controllers connected through the same Moonlight
|
||||
|
||||
Reference in New Issue
Block a user