feat(install): choose system and app-data disks independently; theme the dropdown
Two fixes. The wizard's "new apps store their data on" dropdown was a bare native <select>. The OS draws that popup and ignores our CSS, which is why it came out as stock white chrome — the WebUI already solves this with custom-select.js, which enhances any select.form-control into a themed button and list. It just needed the class. And the installer now asks for the two roots independently rather than only app data. I had argued one question was simpler, and for a desktop it is — the control plane is ~20 MB and moving it gains nothing. But on a small board with an 8 GB eMMC and a USB SSD you want both moved, and there was no way to say so without knowing the flags exist. Still one disk list and two short questions; each is skipped if its flag was already passed. Fixed a bug the test caught immediately: _initAskDisk returns the chosen path on stdout, and it was printing the prompt there too, so the question text became part of the answer — the system root ended up named after its own prompt. Prompts go to stderr now, stdout is the return channel. Verified every combination under a pty: both default, apps only, both moved, system only, and invalid-then-valid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
8d41b98497
commit
3034eaf4c7
@ -295,11 +295,7 @@ body.setup-wizard-open {
|
|||||||
|
|
||||||
.setup-field input[type=text],
|
.setup-field input[type=text],
|
||||||
.setup-field input[type=email],
|
.setup-field input[type=email],
|
||||||
/* The storage default's select is outside .setup-field (it is a row, not a
|
.setup-field select {
|
||||||
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%;
|
width: 100%;
|
||||||
background: rgba(var(--text-rgb), 0.06);
|
background: rgba(var(--text-rgb), 0.06);
|
||||||
border: 1px solid rgba(var(--text-rgb), 0.12);
|
border: 1px solid rgba(var(--text-rgb), 0.12);
|
||||||
@ -316,8 +312,7 @@ body.setup-wizard-open {
|
|||||||
|
|
||||||
.setup-field input[type=text]:focus,
|
.setup-field input[type=text]:focus,
|
||||||
.setup-field input[type=email]:focus,
|
.setup-field input[type=email]:focus,
|
||||||
.setup-field select:focus,
|
.setup-field select:focus {
|
||||||
.setup-storage-default select:focus {
|
|
||||||
outline: none;
|
outline: none;
|
||||||
background: rgba(var(--text-rgb), 0.10);
|
background: rgba(var(--text-rgb), 0.10);
|
||||||
border-color: rgba(var(--accent-rgb), 0.55);
|
border-color: rgba(var(--accent-rgb), 0.55);
|
||||||
@ -1390,4 +1385,9 @@ body.setup-wizard-open .eo-modal { z-index: 10000; }
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
.setup-storage-default-label { font-size: 0.92em; opacity: 0.9; }
|
.setup-storage-default-label { font-size: 0.92em; opacity: 0.9; }
|
||||||
.setup-storage-default select { flex: 1; min-width: 180px; }
|
/* custom-select.js replaces the native popup (the OS draws that one and
|
||||||
|
ignores our CSS) with a themed button + list. It enhances any
|
||||||
|
select.form-control, so the class is what makes this match the wizard's
|
||||||
|
other dropdowns rather than showing stock white chrome. */
|
||||||
|
.setup-storage-default select,
|
||||||
|
.setup-storage-default .custom-select { flex: 1; min-width: 180px; }
|
||||||
|
|||||||
@ -593,7 +593,7 @@ class SetupWizard {
|
|||||||
box.style.display = '';
|
box.style.display = '';
|
||||||
box.innerHTML = `
|
box.innerHTML = `
|
||||||
<span class="setup-storage-default-label">New apps store their data on</span>
|
<span class="setup-storage-default-label">New apps store their data on</span>
|
||||||
<select id="sw-storage-default-select">
|
<select id="sw-storage-default-select" class="form-control">
|
||||||
${opts.map(o => `<option value="${this.escapeHtml(o.value)}"${o.value === this.storageDefault ? ' selected' : ''}>${this.escapeHtml(o.label)}</option>`).join('')}
|
${opts.map(o => `<option value="${this.escapeHtml(o.value)}"${o.value === this.storageDefault ? ' selected' : ''}>${this.escapeHtml(o.label)}</option>`).join('')}
|
||||||
</select>`;
|
</select>`;
|
||||||
|
|
||||||
|
|||||||
@ -26,23 +26,29 @@ login), installs LibrePortal, and prints the WebUI address + a generated passwor
|
|||||||
|
|
||||||
### Put data where you want it (separate disks, external drives)
|
### Put data where you want it (separate disks, external drives)
|
||||||
|
|
||||||
**The installer just asks.** If it finds a second drive, it offers it for app
|
**The installer just asks.** If it finds another drive, it offers it before
|
||||||
data before installing anything:
|
installing anything — one list, two independent questions:
|
||||||
|
|
||||||
```
|
```
|
||||||
Where should app data live?
|
Where should LibrePortal keep things?
|
||||||
|
|
||||||
1) This disk (default) 911.9G 808.4G free
|
1) This disk (default) 911.9G 808.4G free
|
||||||
2) /mnt/bigdisk 3.6T 3.6T free
|
2) /mnt/bigdisk 3.6T 3.6T free
|
||||||
|
|
||||||
LibrePortal itself stays on this disk either way — only app data moves.
|
LibrePortal itself — settings, database, logs. Around 20 MB, and it stays small.
|
||||||
|
Choose [1]:
|
||||||
|
|
||||||
|
App data — everything your apps store. This is the one that grows.
|
||||||
Choose [1]:
|
Choose [1]:
|
||||||
```
|
```
|
||||||
|
|
||||||
|
They're separate on purpose. On a desktop you usually only move the second. On
|
||||||
|
a small board with an 8 GB eMMC and a USB SSD you move both.
|
||||||
|
|
||||||
It picks a subdirectory on the drive you choose, never the mount point itself,
|
It picks a subdirectory on the drive you choose, never the mount point itself,
|
||||||
and skips the question entirely when there is nothing else to choose, when you
|
and skips a question when the matching flag was already passed — or the whole
|
||||||
passed `--containers-dir`, or when running unattended.
|
thing when there is nothing else to choose, no terminal, or you're running
|
||||||
|
unattended.
|
||||||
|
|
||||||
The three roots below are still there for scripted installs:
|
The three roots below are still there for scripted installs:
|
||||||
|
|
||||||
|
|||||||
95
init.sh
95
init.sh
@ -188,32 +188,38 @@ libreportalDerivePaths() {
|
|||||||
}
|
}
|
||||||
libreportalDerivePaths
|
libreportalDerivePaths
|
||||||
|
|
||||||
# Interactive disk picker for app data.
|
# Interactive disk picker, in the shape an OS installer asks it.
|
||||||
#
|
#
|
||||||
# The --containers-dir flag has existed for a while, but someone running the
|
# --system-dir/--containers-dir have existed for a while and nobody running the
|
||||||
# curl|bash installer never learns it exists — so a box with a 4 TB disk sitting
|
# curl|bash installer ever learned they existed, so a box with a 4 TB disk beside
|
||||||
# next to a small system SSD quietly put everything on the SSD. This asks, once,
|
# a small system SSD quietly put everything on the SSD.
|
||||||
# in the shape an OS installer asks it.
|
#
|
||||||
|
# Two roots, asked independently, because they are genuinely different choices:
|
||||||
|
# the control plane is ~20 MB and never grows, while app data is what becomes
|
||||||
|
# terabytes. On a normal desktop only the second matters; on an SBC with a small
|
||||||
|
# eMMC and a USB SSD you want both moved. Backups stay on --backups-dir, which
|
||||||
|
# is a scripted-install concern rather than a first-run one.
|
||||||
#
|
#
|
||||||
# Deliberately narrow:
|
# Deliberately narrow:
|
||||||
# * app data only. The control plane is ~20 MB and never grows; the thing
|
# * one disk list, two short questions. Three questions nobody can answer at
|
||||||
# worth placing is the data. Offering three roots would be three questions
|
# that moment is worse than a flag they never see.
|
||||||
# to answer badly.
|
|
||||||
# * skipped entirely when unattended, when --containers-dir was passed, when
|
|
||||||
# there is no TTY, or when there is nothing else to choose. A prompt with
|
|
||||||
# one answer is not a question.
|
|
||||||
# * a SUBDIRECTORY on the chosen disk, never its mount point — that keeps the
|
# * a SUBDIRECTORY on the chosen disk, never its mount point — that keeps the
|
||||||
# "root only ever takes an empty directory" rule intact and leaves anything
|
# "root only ever takes an empty directory" rule intact and leaves anything
|
||||||
# already on the disk alone.
|
# already on the disk alone.
|
||||||
|
# * candidates exclude anything on the same filesystem as /, since placing
|
||||||
|
# data there gains nothing.
|
||||||
#
|
#
|
||||||
# Self-contained (findmnt only), like the rest of init.sh: scripts/ is not
|
# Skipped when unattended, with no TTY, when the matching flag was already
|
||||||
|
# passed, or when the scan finds nothing else — a prompt with one possible
|
||||||
|
# answer is not a question. Self-contained (findmnt only): scripts/ is not
|
||||||
# necessarily loadable this early.
|
# necessarily loadable this early.
|
||||||
initPickContainersDir()
|
initPickRoots()
|
||||||
{
|
{
|
||||||
[[ "$init_unattended_mode" == true ]] && return 0
|
[[ "$init_unattended_mode" == true ]] && return 0
|
||||||
[[ -n "${LP_CONTAINERS_DIR_EXPLICIT:-}" ]] && return 0
|
|
||||||
[[ -t 0 && -t 1 ]] || return 0
|
[[ -t 0 && -t 1 ]] || return 0
|
||||||
command -v findmnt >/dev/null 2>&1 || return 0
|
command -v findmnt >/dev/null 2>&1 || return 0
|
||||||
|
# Nothing left to ask?
|
||||||
|
[[ -n "${LP_SYSTEM_DIR_EXPLICIT:-}" && -n "${LP_CONTAINERS_DIR_EXPLICIT:-}" ]] && return 0
|
||||||
|
|
||||||
local sys_dev; sys_dev=$(stat -c '%d' -- / 2>/dev/null)
|
local sys_dev; sys_dev=$(stat -c '%d' -- / 2>/dev/null)
|
||||||
local -a paths=() labels=()
|
local -a paths=() labels=()
|
||||||
@ -239,7 +245,7 @@ initPickContainersDir()
|
|||||||
squashfs|overlay|overlay2|aufs|tmpfs|devtmpfs|ramfs|iso9660|udf|vfat|exfat|ntfs|ntfs3|msdos|fuseblk|"") continue ;;
|
squashfs|overlay|overlay2|aufs|tmpfs|devtmpfs|ramfs|iso9660|udf|vfat|exfat|ntfs|ntfs3|msdos|fuseblk|"") continue ;;
|
||||||
esac
|
esac
|
||||||
dev=$(stat -c '%d' -- "$target" 2>/dev/null)
|
dev=$(stat -c '%d' -- "$target" 2>/dev/null)
|
||||||
[[ -n "$dev" && "$dev" == "$sys_dev" ]] && continue # same disk as / — no gain
|
[[ -n "$dev" && "$dev" == "$sys_dev" ]] && continue
|
||||||
paths+=("$target")
|
paths+=("$target")
|
||||||
labels+=("$(printf '%-24s %-8s %s free' "$target" "${size:-?}" "${avail:-?}")")
|
labels+=("$(printf '%-24s %-8s %s free' "$target" "${size:-?}" "${avail:-?}")")
|
||||||
done < <(findmnt -Pno TARGET,SOURCE,FSTYPE,SIZE,AVAIL 2>/dev/null)
|
done < <(findmnt -Pno TARGET,SOURCE,FSTYPE,SIZE,AVAIL 2>/dev/null)
|
||||||
@ -251,7 +257,7 @@ initPickContainersDir()
|
|||||||
root_avail=$(findmnt -no AVAIL --target / 2>/dev/null | tail -1)
|
root_avail=$(findmnt -no AVAIL --target / 2>/dev/null | tail -1)
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
isHeader "Where should app data live?"
|
isHeader "Where should LibrePortal keep things?"
|
||||||
echo ""
|
echo ""
|
||||||
printf ' %s %-24s %-8s %s free\n' "1)" "This disk (default)" "${root_size:-?}" "${root_avail:-?}"
|
printf ' %s %-24s %-8s %s free\n' "1)" "This disk (default)" "${root_size:-?}" "${root_avail:-?}"
|
||||||
local i
|
local i
|
||||||
@ -259,22 +265,43 @@ initPickContainersDir()
|
|||||||
printf ' %s %s\n' "$((i + 2)))" "${labels[$i]}"
|
printf ' %s %s\n' "$((i + 2)))" "${labels[$i]}"
|
||||||
done
|
done
|
||||||
echo ""
|
echo ""
|
||||||
echo " LibrePortal itself stays on this disk either way — only app data moves."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
local choice=""
|
# Ask for one root. Echoes the chosen mount point on STDOUT, or nothing for
|
||||||
isQuestion "Choose [1]:"
|
# "this disk". Everything the human reads goes to stderr — stdout is the
|
||||||
read -r choice
|
# return channel here, and prompting on it makes the prompt part of the
|
||||||
echo ""
|
# answer.
|
||||||
[[ -z "$choice" || "$choice" == "1" ]] && return 0
|
_initAskDisk() {
|
||||||
if ! [[ "$choice" =~ ^[0-9]+$ ]] || (( choice < 2 || choice > ${#paths[@]} + 1 )); then
|
local prompt="$1" choice=""
|
||||||
isNotice "Not a listed option — using this disk."
|
echo "$prompt" >&2
|
||||||
return 0
|
isQuestion "Choose [1]:" >&2
|
||||||
|
read -r choice
|
||||||
|
echo "" >&2
|
||||||
|
[[ -z "$choice" || "$choice" == "1" ]] && return 0
|
||||||
|
if ! [[ "$choice" =~ ^[0-9]+$ ]] || (( choice < 2 || choice > ${#paths[@]} + 1 )); then
|
||||||
|
isNotice "Not a listed option — using this disk." >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf '%s' "${paths[$((choice - 2))]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
local chosen
|
||||||
|
if [[ -z "${LP_SYSTEM_DIR_EXPLICIT:-}" ]]; then
|
||||||
|
chosen=$(_initAskDisk "LibrePortal itself — settings, database, logs. Around 20 MB, and it stays small.")
|
||||||
|
if [[ -n "$chosen" ]]; then
|
||||||
|
LP_SYSTEM_DIR="${chosen%/}/libreportal-system"
|
||||||
|
isSuccessful "LibrePortal will live in $LP_SYSTEM_DIR"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local chosen="${paths[$((choice - 2))]}"
|
if [[ -z "${LP_CONTAINERS_DIR_EXPLICIT:-}" ]]; then
|
||||||
LP_CONTAINERS_DIR="${chosen%/}/libreportal-containers"
|
chosen=$(_initAskDisk "App data — everything your apps store. This is the one that grows.")
|
||||||
isSuccessful "App data will live in $LP_CONTAINERS_DIR"
|
if [[ -n "$chosen" ]]; then
|
||||||
|
LP_CONTAINERS_DIR="${chosen%/}/libreportal-containers"
|
||||||
|
isSuccessful "App data will live in $LP_CONTAINERS_DIR"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
unset -f _initAskDisk
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -365,7 +392,7 @@ for ((i=1; i<=$#; i++)); do
|
|||||||
# Relocatable roots (=form keeps the single-token shift logic). Validated
|
# Relocatable roots (=form keeps the single-token shift logic). Validated
|
||||||
# by libreportalValidatePaths before any folder is created. Can also be set
|
# by libreportalValidatePaths before any folder is created. Can also be set
|
||||||
# via the LP_*_DIR environment.
|
# via the LP_*_DIR environment.
|
||||||
--system-dir=*) LP_SYSTEM_DIR="${!i#*=}"; ((init_shift_count++)) ;;
|
--system-dir=*) LP_SYSTEM_DIR="${!i#*=}"; LP_SYSTEM_DIR_EXPLICIT=1; ((init_shift_count++)) ;;
|
||||||
--containers-dir=*) LP_CONTAINERS_DIR="${!i#*=}"; LP_CONTAINERS_DIR_EXPLICIT=1; ((init_shift_count++)) ;;
|
--containers-dir=*) LP_CONTAINERS_DIR="${!i#*=}"; LP_CONTAINERS_DIR_EXPLICIT=1; ((init_shift_count++)) ;;
|
||||||
--backups-dir=*) LP_BACKUPS_DIR="${!i#*=}"; ((init_shift_count++)) ;;
|
--backups-dir=*) LP_BACKUPS_DIR="${!i#*=}"; ((init_shift_count++)) ;;
|
||||||
--manager-user=*) LP_MANAGER_USER="${!i#*=}"; ((init_shift_count++)) ;;
|
--manager-user=*) LP_MANAGER_USER="${!i#*=}"; ((init_shift_count++)) ;;
|
||||||
@ -2000,10 +2027,10 @@ if [[ $EUID -ne 0 ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
else
|
else
|
||||||
if [[ "$param1" == "init" ]]; then
|
if [[ "$param1" == "init" ]]; then
|
||||||
# Ask where app data goes before anything is validated or created.
|
# Ask where things go before anything is validated or created.
|
||||||
# No-op when unattended, when --containers-dir was passed, or when
|
# No-op when unattended, when the flags were passed, or when there
|
||||||
# there is only one possible answer.
|
# is only one possible answer.
|
||||||
initPickContainersDir
|
initPickRoots
|
||||||
libreportalDerivePaths
|
libreportalDerivePaths
|
||||||
|
|
||||||
# Validate the chosen install roots before creating/baking anything.
|
# Validate the chosen install roots before creating/baking anything.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user