The main sweep — ~260 call sites across ~100 files move from string
concatenation on a single root to appDir/storageAppDirs/storageAppConfigs.
On a single-root install the resolved paths are identical, so this is a
no-op until a location is registered.
Enumerators were the interesting half. `for d in "$containers_dir"/*/`
appears in the menus, the registry/artifact scanners and the DNS setup —
and a shell glob cannot list a rootless 751 tree at all, which is the
same bug config_find_file.sh already documents in a comment. Routing them
through storageAppDirs (which enumerates as the owning user) fixes that
alongside the multi-root work.
Three places needed judgement rather than substitution:
db_app_scan.sh deletes database rows and port allocations for apps whose
folder is missing, and reaps "empty" app dirs. With a storage location
unmounted, every app on it looks exactly like that. Each of those
branches now gates on appStorageAvailable first — an app on an unplugged
drive is skipped with a notice, never deleted.
instance_create.sh rewrites cloned hooks so an instance touches its own
directory instead of the base app's. Its sed matched ${containers_dir}<type>,
which this sweep just replaced with $(appDir <type>) — so it would have
silently stopped redirecting, and an instance would have written to the
original's files (the adguard auth adapter case its own comment warns
about). Now matches both appDir forms, verified against bare, quoted,
unrelated-app, legacy and prose cases.
peer_shell/peer_pull streamed and extracted relative to the primary root.
Both now use the app's own root, and peer_shell keeps a single-root
fallback since it runs as a restricted SSH shell with no LibrePortal env.
Also fixes a pre-existing bug found on the way: webui_app_config.sh
tested "$containers_dir/frontend/data/last_update", one level short of the
real tree under the libreportal app dir, so the WebUI refresh trigger
after a config update has never once fired.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
289 lines
12 KiB
Bash
Executable File
289 lines
12 KiB
Bash
Executable File
#!/bin/bash
|
|
|
|
# Build dashy's conf.yml from the same source of truth the WebUI uses
|
|
# for its URL buttons: apps-services.json. Each row in that file is a
|
|
# single (service, link) pair; the user picks which URLs to surface via
|
|
# CFG_DASHY_SHORTCUTS (CSV of "<service.name>:<link_index>" IDs, set by
|
|
# the Manage Shortcuts tool). Items are grouped by their app's category
|
|
# (read from the app's `# Category :` header).
|
|
appDashyUpdateConf()
|
|
{
|
|
local conf_file="$(appDir dashy)/etc/conf.yml"
|
|
local services_json="$(webuiDir)/frontend/data/apps/generated/apps-services.json"
|
|
local icons_src_dir="$(webuiDir)/frontend/icons/apps"
|
|
|
|
# Don't check the apps DB here — appUpdateSpecifics calls us at
|
|
# install step 6, BEFORE databaseInstallApp (step 8) writes the
|
|
# row, so dockerCheckAppInstalled would say not_installed and bail.
|
|
# Look at the actual docker container instead — if the container
|
|
# exists or the install dir is present, generate the conf.
|
|
if ! runFileOp docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qE '^(dashy|dashy-service)$' \
|
|
&& [[ ! -d "$(appDir dashy)" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
isHeader "Dashy Config Generation"
|
|
|
|
# Always seed a minimal conf so dashy never starts against an
|
|
# empty user-data dir (which would 500 on / with "Configuration
|
|
# could not be loaded"). Both branches below — the no-services
|
|
# bootstrap and the full render — will overwrite this.
|
|
_dashyWriteSkeleton() {
|
|
local install_name="${CFG_INSTALL_NAME:-LibrePortal}"
|
|
runFileOp mkdir -p "$(dirname "$conf_file")"
|
|
runFileWrite "$conf_file" <<EOF
|
|
---
|
|
pageInfo:
|
|
title: Dashy - LibrePortal - ${install_name}
|
|
description: Welcome to your LibrePortal Dashy dashboard!
|
|
navLinks:
|
|
- title: Dashy GitHub
|
|
path: http://github.com/Lissy93/dashy
|
|
- title: Dashy Documentation
|
|
path: http://dashy.to/docs
|
|
|
|
appConfig:
|
|
theme: Nord-Frost
|
|
|
|
sections: []
|
|
EOF
|
|
runFileOp chown "$docker_install_user:$docker_install_user" "$conf_file" 2>/dev/null || true
|
|
}
|
|
|
|
if ! command -v jq >/dev/null 2>&1; then
|
|
isError "jq is required for dashy config generation but is not installed."
|
|
# Still seed a skeleton so dashy can boot.
|
|
[[ -f "$conf_file" ]] || _dashyWriteSkeleton
|
|
return 1
|
|
fi
|
|
if [[ ! -f "$services_json" ]]; then
|
|
isNotice "$services_json not found yet — emitting minimal dashy conf so the container can boot. Run the Manage Shortcuts tool to populate it."
|
|
_dashyWriteSkeleton
|
|
return 0
|
|
fi
|
|
|
|
local original_md5=""
|
|
[[ -f "$conf_file" ]] && original_md5=$(runFileOp md5sum "$conf_file" 2>/dev/null | awk '{print $1}')
|
|
|
|
runFileOp mkdir -p "$(dirname "$conf_file")"
|
|
|
|
# Build the selected-id set (empty CFG = include every URL).
|
|
local _selected_set=""
|
|
if [[ -n "${CFG_DASHY_SHORTCUTS:-}" ]]; then
|
|
_selected_set="|${CFG_DASHY_SHORTCUTS//,/|}|"
|
|
fi
|
|
_isSelected() {
|
|
[[ -z "$_selected_set" ]] && return 0
|
|
[[ "$_selected_set" == *"|$1|"* ]]
|
|
}
|
|
|
|
# Resolve an app's category from its install script header, with a
|
|
# cache so we don't re-read the same file once per URL.
|
|
declare -A _cat_cache=()
|
|
_appCategory() {
|
|
local app="$1"
|
|
if [[ -n "${_cat_cache[$app]+x}" ]]; then
|
|
printf '%s' "${_cat_cache[$app]}"
|
|
return
|
|
fi
|
|
local cat=""
|
|
local sh="${install_containers_dir}/${app}/${app}.sh"
|
|
if [[ -f "$sh" ]]; then
|
|
cat=$(awk -F ': ' '/# Category :/{print $2; exit}' "$sh" \
|
|
| tr -d '\r' | sed 's/[[:space:]]*$//')
|
|
fi
|
|
[[ -z "$cat" ]] && cat="Other"
|
|
_cat_cache[$app]="$cat"
|
|
printf '%s' "$cat"
|
|
}
|
|
|
|
# Resolve display title + description from the app's .config so the
|
|
# dashboard tile reads "Jellyfin / Media server" instead of the raw
|
|
# service slug.
|
|
declare -A _title_cache=() _desc_cache=()
|
|
_appMeta() {
|
|
local app="$1"
|
|
if [[ -z "${_title_cache[$app]+x}" ]]; then
|
|
local cfg="${install_containers_dir}/${app}/${app}.config"
|
|
local upper="${app^^}"
|
|
local t="" d=""
|
|
if [[ -f "$cfg" ]]; then
|
|
t=$(grep -E "^CFG_${upper}_TITLE=" "$cfg" | cut -d= -f2- | sed 's/^"//; s/"$//')
|
|
d=$(grep -E "^CFG_${upper}_DESCRIPTION=" "$cfg" | cut -d= -f2- | sed 's/^"//; s/"$//')
|
|
fi
|
|
[[ -z "$t" ]] && t="$app"
|
|
_title_cache[$app]="$t"
|
|
_desc_cache[$app]="$d"
|
|
fi
|
|
}
|
|
|
|
# Stream every (service, link) pair as TSV:
|
|
# id <tab> app <tab> label <tab> url <tab> traefik <tab> login
|
|
# Mirrors window.expandServiceLinks(): use links[] when present;
|
|
# otherwise fall back to the service-level externalURL (or build
|
|
# one from serverIP+externalPort).
|
|
local rows
|
|
rows=$(jq -r '
|
|
.services
|
|
| map(select(.buttonEnabled != false))
|
|
| map(
|
|
. as $s
|
|
| (
|
|
if (.links | type) == "array" and (.links | length) > 0
|
|
then .links | to_entries | map(
|
|
{
|
|
id: ($s.name + ":" + (.key|tostring)),
|
|
app: $s.app,
|
|
label: (.value.label // $s.buttonText // $s.name),
|
|
url: (.value.externalURL // .value.internalURL // ""),
|
|
traefik: ($s.traefikManaged == true),
|
|
login: ($s.loginRequired == true)
|
|
})
|
|
else [{
|
|
id: ($s.name + ":0"),
|
|
app: $s.app,
|
|
label: ($s.buttonText // $s.name),
|
|
url: ($s.externalURL // ("http://" + ($s.serverIP // "localhost") + ":" + ($s.externalPort|tostring))),
|
|
traefik: ($s.traefikManaged == true),
|
|
login: ($s.loginRequired == true)
|
|
}]
|
|
end
|
|
)
|
|
)
|
|
| flatten
|
|
| map(select(.url != "" and .url != null))
|
|
| .[]
|
|
| [.id, .app, .label, .url, (.traefik|tostring), (.login|tostring)] | @tsv
|
|
' "$services_json")
|
|
|
|
# Group selected rows by category in a stable order.
|
|
declare -A _cat_buckets=()
|
|
declare -a _cat_order=()
|
|
local _id _app _label _url _traefik _login
|
|
local IFS_BAK="$IFS"
|
|
while IFS=$'\t' read -r _id _app _label _url _traefik _login; do
|
|
[[ -z "$_id" ]] && continue
|
|
_isSelected "$_id" || continue
|
|
# Skip dashy URLs themselves to avoid a recursive shortcut.
|
|
[[ "$_app" == "dashy" ]] && continue
|
|
local cat
|
|
cat=$(_appCategory "$_app")
|
|
if [[ -z "${_cat_buckets[$cat]+x}" ]]; then
|
|
_cat_buckets[$cat]=""
|
|
_cat_order+=("$cat")
|
|
fi
|
|
# Prefix items under their parent app with an icon name dashy
|
|
# understands (`hl-<app>`). YAML-escape via printf %q-style: we
|
|
# only need to handle quotes in label/desc; URLs are URI-safe.
|
|
local _label_safe="${_label//\"/\\\"}"
|
|
_cat_buckets[$cat]+=$'\t'"${_app}"$'\x01'"${_label_safe}"$'\x01'"${_url}"$'\x01'"${_traefik}"$'\x01'"${_login}"$'\n'
|
|
done <<< "$rows"
|
|
IFS="$IFS_BAK"
|
|
|
|
# Header.
|
|
local install_name="${CFG_INSTALL_NAME:-LibrePortal}"
|
|
local theme="${CFG_DASHY_THEME:-Nord-Frost}"
|
|
local page_title="${CFG_DASHY_PAGE_TITLE:-}"
|
|
local page_desc="${CFG_DASHY_PAGE_DESCRIPTION:-}"
|
|
local layout="${CFG_DASHY_LAYOUT:-auto}"
|
|
local icon_size="${CFG_DASHY_ICON_SIZE:-medium}"
|
|
local open_target="${CFG_DASHY_OPEN_TARGET:-newtab}"
|
|
local status_check="${CFG_DASHY_STATUS_CHECK:-false}"
|
|
[[ -z "$page_title" ]] && page_title="Dashy - LibrePortal - ${install_name}"
|
|
[[ -z "$page_desc" ]] && page_desc="Welcome to your LibrePortal Dashy dashboard!"
|
|
|
|
runFileWrite "$conf_file" <<EOF
|
|
---
|
|
pageInfo:
|
|
title: ${page_title}
|
|
description: ${page_desc}
|
|
navLinks:
|
|
- title: Dashy GitHub
|
|
path: http://github.com/Lissy93/dashy
|
|
- title: Dashy Documentation
|
|
path: http://dashy.to/docs
|
|
|
|
appConfig:
|
|
theme: ${theme}
|
|
layout: ${layout}
|
|
iconSize: ${icon_size}
|
|
defaultOpeningMethod: ${open_target}
|
|
statusCheck: ${status_check}
|
|
|
|
sections:
|
|
EOF
|
|
|
|
# Reference icons via absolute URLs back to the LibrePortal WebUI's
|
|
# public icon server (the same URL that renders these icons inside
|
|
# the WebUI itself). This sidesteps two problems with the previous
|
|
# local-mirror approach:
|
|
# 1. Dashy's Vue icon resolver doesn't treat bare `/path.svg`
|
|
# as a fetchable image — it routes through its own icon
|
|
# pipeline which expects `item-icons/foo.svg` or `hl-foo`.
|
|
# 2. The `./etc:/app/user-data` mount only serves files via
|
|
# dashy's express middleware, not via its icon pipeline.
|
|
# Pulling ed_base from apps-services.json keeps it in sync with
|
|
# whatever IP/port LibrePortal is currently on.
|
|
local ed_base
|
|
ed_base=$(jq -r '.services[] | select(.app == "libreportal") | .externalURL' "$services_json" 2>/dev/null | head -1)
|
|
[[ -z "$ed_base" || "$ed_base" == "null" ]] && ed_base="http://localhost:3719"
|
|
|
|
local _total_items=0
|
|
local cat
|
|
for cat in "${_cat_order[@]}"; do
|
|
printf -- "- name: %s\n icon: fas fa-cube\n items:\n" "$cat" \
|
|
| runFileWrite -a "$conf_file"
|
|
local IFS_BAK="$IFS"
|
|
local entry
|
|
while IFS= read -r entry; do
|
|
entry="${entry#$'\t'}"
|
|
[[ -z "$entry" ]] && continue
|
|
local app label url traefik login
|
|
IFS=$'\x01' read -r app label url traefik login <<< "$entry"
|
|
[[ -z "$url" ]] && continue
|
|
_appMeta "$app"
|
|
local title="${_title_cache[$app]}"
|
|
local desc="${_desc_cache[$app]}"
|
|
# If the app exposes multiple URLs, prefix the per-URL
|
|
# label so users can tell them apart on the tile.
|
|
local tile_title="$title"
|
|
if [[ -n "$label" && "$label" != "$title" ]]; then
|
|
tile_title="${title} — ${label}"
|
|
fi
|
|
local tile_desc="$desc"
|
|
[[ -z "$tile_desc" ]] && tile_desc="$label"
|
|
# Pick an icon: prefer the synced local SVG (always works
|
|
# for our installed apps), fall back to dashy's homelab
|
|
# bundle for anything we couldn't sync.
|
|
# Absolute URL to the LibrePortal WebUI's icon endpoint.
|
|
# Falls back to dashy's bundled `hl-<app>` set if the SVG
|
|
# isn't present (covers third-party apps not shipped with
|
|
# an LibrePortal icon).
|
|
local icon_ref
|
|
if [[ -f "${icons_src_dir}/${app}.svg" ]]; then
|
|
icon_ref="${ed_base}/icons/apps/${app}.svg"
|
|
else
|
|
icon_ref="hl-${app}"
|
|
fi
|
|
printf -- " - title: %s\n description: %s\n icon: %s\n url: %s\n statusCheck: %s\n target: %s\n" \
|
|
"$tile_title" "$tile_desc" "$icon_ref" "$url" "$status_check" "$open_target" \
|
|
| runFileWrite -a "$conf_file"
|
|
_total_items=$((_total_items + 1))
|
|
done <<< "${_cat_buckets[$cat]}"
|
|
IFS="$IFS_BAK"
|
|
done
|
|
|
|
runFileOp chown "$docker_install_user:$docker_install_user" "$conf_file" 2>/dev/null || true
|
|
|
|
local updated_md5=$(runFileOp md5sum "$conf_file" 2>/dev/null | awk '{print $1}')
|
|
if [[ "$original_md5" != "$updated_md5" ]]; then
|
|
isNotice "Dashy config changed — restarting container..."
|
|
runFileOp docker restart dashy-service >/dev/null 2>&1 || runFileOp docker restart dashy >/dev/null 2>&1 || true
|
|
local _cat_label="categories"
|
|
[[ ${#_cat_order[@]} -eq 1 ]] && _cat_label="category"
|
|
isSuccessful "Restarted dashy (${#_cat_order[@]} ${_cat_label}, ${_total_items} URL(s))."
|
|
else
|
|
isSuccessful "No changes to dashy config (${_total_items} URL(s) selected)."
|
|
fi
|
|
}
|