diff --git a/configs/general/general_basic b/configs/general/general_basic
index 57ce1c6..d329ab8 100755
--- a/configs/general/general_basic
+++ b/configs/general/general_basic
@@ -5,3 +5,4 @@
CFG_INSTALL_NAME=Change-Me # Installation Name - The name for your LibrePortal instance
CFG_TIMEZONE=Etc/UTC # Container Timezone - Timezone handed to app containers; scheduled tasks follow the host clock
CFG_INSTALL_LEVEL=beginner # Experience Level - How much technical detail the WebUI shows [beginner:Beginner — simple|advanced:Advanced — show everything]
+CFG_STORAGE_DEFAULT=primary # Default App Storage - Where a newly installed app keeps its data, unless that app says otherwise. Change an individual app from its own config, or with `libreportal app move` [primary:System disk]
diff --git a/containers/libreportal/frontend/core/setup/css/setup-wizard.css b/containers/libreportal/frontend/core/setup/css/setup-wizard.css
index 716ea45..8604f06 100755
--- a/containers/libreportal/frontend/core/setup/css/setup-wizard.css
+++ b/containers/libreportal/frontend/core/setup/css/setup-wizard.css
@@ -295,7 +295,11 @@ body.setup-wizard-open {
.setup-field input[type=text],
.setup-field input[type=email],
-.setup-field select {
+/* The storage default's select is outside .setup-field (it is a row, not a
+ labelled field), so it would otherwise render as a raw native dropdown —
+ white box, wrong font — against the glass panel. Share the styling. */
+.setup-field select,
+.setup-storage-default select {
width: 100%;
background: rgba(var(--text-rgb), 0.06);
border: 1px solid rgba(var(--text-rgb), 0.12);
@@ -312,7 +316,8 @@ body.setup-wizard-open {
.setup-field input[type=text]:focus,
.setup-field input[type=email]:focus,
-.setup-field select:focus {
+.setup-field select:focus,
+.setup-storage-default select:focus {
outline: none;
background: rgba(var(--text-rgb), 0.10);
border-color: rgba(var(--accent-rgb), 0.55);
@@ -1374,3 +1379,15 @@ body.setup-wizard-open .eo-modal { z-index: 10000; }
height: 1px;
background: rgba(255, 255, 255, 0.16);
}
+
+/* "New apps store their data on [ … ]" — one row, and only rendered once a
+ second drive is ticked, so it never appears as furniture. */
+.setup-storage-default {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin-top: 12px;
+}
+.setup-storage-default-label { font-size: 0.92em; opacity: 0.9; }
+.setup-storage-default select { flex: 1; min-width: 180px; }
diff --git a/containers/libreportal/frontend/core/setup/js/setup-wizard.js b/containers/libreportal/frontend/core/setup/js/setup-wizard.js
index 8ec11b6..3e6dafe 100755
--- a/containers/libreportal/frontend/core/setup/js/setup-wizard.js
+++ b/containers/libreportal/frontend/core/setup/js/setup-wizard.js
@@ -32,6 +32,8 @@ class SetupWizard {
this.selectedStorage = [];
// Paths the user asked us to make permanent in /etc/fstab.
this.fstabWanted = [];
+ // Which drive new apps default to: 'primary' or a ticked drive's path.
+ this.storageDefault = 'primary';
this.installLevel = 'beginner';
this.totalSteps = this._effectiveTotalSteps();
this.domainCount = 0; // tracked dynamically as the user adds rows
@@ -237,6 +239,7 @@ class SetupWizard {
?
+
@@ -542,6 +545,13 @@ class SetupWizard {
});
});
+ // Ticking a drive changes whether "where do new apps go?" is a real
+ // question, so re-evaluate it on every change.
+ list.querySelectorAll('[data-storage-path]').forEach((cb) => {
+ cb.addEventListener('change', () => this.renderStorageDefault());
+ });
+ this.renderStorageDefault();
+
if (note) {
// Only the single-disk case says anything: with drives listed, the cards
// and their badges already carry it, and a standing explanatory line
@@ -552,6 +562,46 @@ class SetupWizard {
}
}
+ // "Where do new apps go?" — asked only when it is actually a question, i.e.
+ // once at least one extra drive is ticked. With nothing ticked there is one
+ // possible answer and a control would be furniture.
+ //
+ // This sets a DEFAULT, not a per-app placement: apps ship
+ // CFG__STORAGE=default, which means "follow the global setting", so one
+ // choice here places every app installed afterwards. Individual apps can
+ // still be pinned, and moved later with `libreportal app move`.
+ renderStorageDefault() {
+ const box = this.container.querySelector('#sw-storage-default');
+ if (!box) return;
+
+ const ticked = this.collectStorage();
+ if (!ticked.length) {
+ box.style.display = 'none';
+ box.innerHTML = '';
+ this.storageDefault = 'primary';
+ return;
+ }
+
+ // Keep a previous choice if that drive is still ticked.
+ if (this.storageDefault !== 'primary' && !ticked.includes(this.storageDefault)) {
+ this.storageDefault = 'primary';
+ }
+
+ const opts = [{ value: 'primary', label: 'System disk' }]
+ .concat(ticked.map(p => ({ value: p, label: p })));
+
+ box.style.display = '';
+ box.innerHTML = `
+ New apps store their data on
+ `;
+
+ box.querySelector('select').addEventListener('change', (e) => {
+ this.storageDefault = e.target.value;
+ });
+ }
+
// Details modal — the technical spec, every check with its full explanation,
// and (when the drive isn't in fstab) the offer to make it permanent.
//
@@ -1064,7 +1114,9 @@ class SetupWizard {
// the CLI and the root helper, which validate independently.
storage: this.collectStorage(),
// Only for drives that are actually being registered.
- storage_fstab: this.fstabWanted.filter(p => this.collectStorage().includes(p))
+ storage_fstab: this.fstabWanted.filter(p => this.collectStorage().includes(p)),
+ // 'primary', or the path of a drive also present in `storage`.
+ storage_default: (this.storageDefault !== 'primary' && this.collectStorage().includes(this.storageDefault)) ? this.storageDefault : 'primary'
};
// Apply the experience choice to the WebUI immediately so the next
diff --git a/scripts/dev/lp-storage-default-test b/scripts/dev/lp-storage-default-test
new file mode 100755
index 0000000..8088996
--- /dev/null
+++ b/scripts/dev/lp-storage-default-test
@@ -0,0 +1,36 @@
+#!/bin/bash
+# Covers CFG_STORAGE_DEFAULT's three states: a named location wins, "primary"
+# pins to the install-time root, and "default" (what every app template ships)
+# inherits the global setting — which is what lets one choice at setup place
+# every future app without editing 37 configs.
+R="$(cd "$(dirname "$0")/../.." && pwd)"
+B=$(mktemp -d); trap 'rm -rf "$B"' EXIT
+export LP_SYSTEM_DIR="$B/sys" LP_CONTAINERS_DIR="$B/primary" LP_BACKUPS_DIR="$B/bk"
+export LP_STORAGE_REGISTRY="$B/storage.roots"
+mkdir -p "$B/primary" "$B/big" "$B/sys/configs"
+printf '2\t%s\t0:0\tuuid2\n' "$B/big" > "$B/storage.roots"
+: > "$B/big/.libreportal-storage"
+source "$R/scripts/source/paths.sh"
+CFG_STORAGE_LOC_2_NAME=bigdisk
+fail=0
+chk(){ [[ "$2" == "$3" ]] && echo " ok $1" || { echo " FAIL $1: got '$2' want '$3'"; fail=1; }; }
+
+echo "--- global default = primary (out of the box) ---"
+CFG_STORAGE_DEFAULT=primary; storageCacheReset
+chk "app with no opinion" "$(appDir newapp)" "$B/primary/newapp"
+
+echo "--- global default = bigdisk ---"
+CFG_STORAGE_DEFAULT=bigdisk; storageCacheReset
+chk "app inherits the default" "$(appDir newapp)" "$B/big/newapp"
+CFG_NEWAPP_STORAGE=default; storageCacheReset
+chk "explicit 'default' inherits too" "$(appDir newapp)" "$B/big/newapp"
+CFG_NEWAPP_STORAGE=primary; storageCacheReset
+chk "explicit 'primary' overrides" "$(appDir newapp)" "$B/primary/newapp"
+CFG_NEWAPP_STORAGE=bigdisk; storageCacheReset
+chk "explicit name wins" "$(appDir newapp)" "$B/big/newapp"
+
+echo "--- a default naming a location that was removed ---"
+CFG_STORAGE_DEFAULT=ghostdisk; unset CFG_NEWAPP_STORAGE; storageCacheReset
+chk "falls back, never refuses" "$(appDir newapp)" "$B/primary/newapp"
+
+echo; [[ $fail -eq 0 ]] && echo "ALL PASS" || echo "FAILURES"; exit $fail
diff --git a/scripts/setup/setup_apply.sh b/scripts/setup/setup_apply.sh
index d36a8f6..d3f6c55 100644
--- a/scripts/setup/setup_apply.sh
+++ b/scripts/setup/setup_apply.sh
@@ -26,6 +26,7 @@ setupApplyConfig()
local domains_json=$(echo "$payload" | jq -c '.domains // []')
local storage_json=$(echo "$payload" | jq -c '.storage // []')
local storage_fstab_json=$(echo "$payload" | jq -c '.storage_fstab // []')
+ local storage_default=$(echo "$payload" | jq -r '.storage_default // "primary"')
if [[ -n "$install_name" ]]; then
updateConfigOption "CFG_INSTALL_NAME" "$install_name"
@@ -88,6 +89,24 @@ setupApplyConfig()
done
fi
+ # Where newly installed apps keep their data. Stored as a location NAME, not
+ # a path: app configs ship CFG__STORAGE=default meaning "follow this",
+ # so one choice at setup places every app installed afterwards without
+ # touching 37 configs. Resolved AFTER registration, since the name only
+ # exists once the location has been accepted.
+ if [[ -n "$storage_default" && "$storage_default" != "primary" && "$storage_default" != "null" ]]; then
+ local default_name=""
+ if declare -f storageLocationName >/dev/null 2>&1; then
+ default_name=$(storageLocationName "$storage_default" 2>/dev/null) || default_name=""
+ fi
+ if [[ -n "$default_name" && "$default_name" != "default" ]]; then
+ updateConfigOption "CFG_STORAGE_DEFAULT" "$default_name"
+ isSuccessful "New apps will store their data on '$default_name'"
+ else
+ isNotice "Could not resolve '$storage_default' to a storage location — new apps will use the system disk."
+ fi
+ fi
+
local domains_count=$(echo "$domains_json" | jq -r 'length')
if [[ "$domains_count" -gt 0 ]]; then
local i=0
diff --git a/scripts/source/paths.sh b/scripts/source/paths.sh
index 51ff4cd..433f01c 100644
--- a/scripts/source/paths.sh
+++ b/scripts/source/paths.sh
@@ -203,7 +203,10 @@ pathIsContainerData()
storageLocationPath()
{
local want="$1"
- [[ -z "$want" || "$want" == "default" ]] && { primaryRoot; return 0; }
+ # "primary" names the install-time root explicitly. "default" is accepted
+ # here for callers that resolve a concrete value, but at APP level it means
+ # "inherit the global default" — see _appDirIntended.
+ [[ -z "$want" || "$want" == "default" || "$want" == "primary" ]] && { primaryRoot; return 0; }
local _id _path _rest name_var
if [[ -r "$lp_storage_registry" ]]; then
@@ -355,11 +358,26 @@ _appDirIntended()
local slug="$1"
local key="CFG_${slug^^}_STORAGE"
local want="${!key:-}"
+
+ # Three states, and the distinction matters:
+ # this app goes there, whatever the global default says
+ # primary this app goes on the install-time root, explicitly
+ # default no opinion — follow CFG_STORAGE_DEFAULT
+ #
+ # Templates ship "default", so a box configured with a big second disk
+ # picks it up for every new app without editing 37 configs; an app that
+ # was deliberately placed keeps its placement.
+ if [[ -z "$want" || "$want" == "default" ]]; then
+ want="${CFG_STORAGE_DEFAULT:-primary}"
+ fi
+
local path
- if [[ -n "$want" ]] && path=$(storageLocationPath "$want"); then
+ if path=$(storageLocationPath "$want"); then
printf '%s' "$path"
return 0
fi
+ # Named a location that no longer exists — fall back rather than refuse, so
+ # a removed disk can't make an app un-installable.
primaryRoot
}