Storage step: say "not connected", and allow custom paths

A registered drive that is unplugged rendered through the same path as any
other candidate — a "needs care" badge, "free of" with no numbers on either
side, an empty meter. To a first-time installer that reads as two broken disks
the scan turned up, with nothing tying the card back to a drive they registered
and later unplugged. Say "not connected", name the path, and draw no meter: a
meter with nothing in it is a claim about free space nobody measured. The same
locations are withheld from the dropdowns, since the wizard cannot stat a
directory on a drive that is absent.

Both dropdowns now end in "Custom path…", for a NAS mount or an LVM volume the
disk heuristics never rank as a candidate. Validation goes through
validateStep(3) rather than a disabled button: the apply side already refuses a
relative or system path, but its refusal is to fall back to the system disk,
and that is indistinguishable from having chosen the system disk on purpose.

A typed path is not a registered location, so setup_apply registers it via
storageAdd — which is what keeps the empty-directory admission rule and the
fitness checks in play — named after its basename, so it reads as "nas" rather
than "location-3" in the placement menus.

libreportal-storage: accept the name the listing prints. remove matched id and
path only, so `remove location-3` failed against a row displayed as
location-3. Root-owned helper changed, so footprint_version 10 -> 11.

Expose window.setupWizard: the instance was local to a promise in the
orchestrator and unreachable from the console or a test.

lp-storage-custom-test drives the step in a browser. Two holes it found in the
tests themselves, both the shape it exists to catch — a check whose failure
mode is to not run:

  - It counted the cards that say "not connected" and asserted over those.
    Turn the feature off and the count is zero, every() over an empty list is
    true, and the block passed having checked nothing. The expectation now
    comes from the feed.

  - Both browser tests exited 0 whenever the page returned nothing. Under sudo,
    where chromium will not start, they reported PASS having asserted nothing.
    They now probe with `lp-shot --url` and curl: if the WebUI answers HTTP the
    browser is the only thing that can have broken, and that is a failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-29 03:09:25 +01:00
parent 03d4788a2c
commit fd0a0fd08c
10 changed files with 369 additions and 16 deletions

View File

@ -109,6 +109,10 @@ class SystemOrchestrator {
return new Promise((resolve, reject) => {
// Create and show setup wizard
const setupWizard = new SetupWizard();
// A handle on the running wizard, for the headless tests and for anyone
// debugging a step from the console. The instance is otherwise local to
// this promise and unreachable once show() returns.
window.setupWizard = setupWizard;
setupWizard.initialize(this.setupDetector, async () => {
try {
// Setup completed, continue with normal loading

View File

@ -1421,6 +1421,7 @@ body.setup-wizard-open .custom-select-popup { z-index: 10001; }
.setup-storage-choice {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 10px;
}
@ -1445,3 +1446,18 @@ body.setup-wizard-open .custom-select-popup { z-index: 10001; }
margin-top: 4px;
word-break: break-all;
}
/* A custom storage path that cannot be used says so under the box, rather
than being quietly swapped for the system disk on the apply side. Its own
flex line, offset to sit under the input rather than under the label. */
.setup-storage-choice-custom { padding-left: 132px; }
.setup-storage-choice-err {
flex: 0 0 100%;
margin: -4px 0 0;
font-size: 0.82em;
color: #ff9b8f;
}
.setup-storage-choice-err:empty { display: none; }
.setup-storage-choice input.is-invalid {
border-color: rgba(255, 120, 100, 0.75);
}

View File

@ -5,6 +5,10 @@
// /api/setup/save which fans out into separate tasks per app, then this UI
// hands off to the tasks page focused on the first task.
// Marker for "I will type a path" in the two storage dropdowns. Not a valid
// path itself, so it can never be mistaken for one.
const CUSTOM_PATH = '__custom__';
class SetupWizard {
constructor() {
this.container = null;
@ -608,10 +612,17 @@ class SetupWizard {
_storageCard(c, key, opts) {
const o = opts || {};
const refused = c.verdict === 'refuse';
// A registered location whose drive is not currently mounted is a different
// thing from one that has a problem: nothing is wrong with it, it is simply
// not here. Calling that "needs care" next to a blank size and an empty
// meter told a new user nothing at all — the row looked like a drive that
// had failed to load rather than one that is unplugged.
const offline = c.state === 'unmounted';
const badge = o.system
? '<span class="setup-storage-badge setup-storage-badge-ok">system</span>'
: (refused ? '<span class="setup-storage-badge setup-storage-badge-bad">can\u2019t be used</span>'
: (c.verdict === 'warn' ? '<span class="setup-storage-badge setup-storage-badge-warn">needs care</span>' : ''));
: (offline ? '<span class="setup-storage-badge setup-storage-badge-warn">not connected</span>'
: (refused ? '<span class="setup-storage-badge setup-storage-badge-bad">can\u2019t be used</span>'
: (c.verdict === 'warn' ? '<span class="setup-storage-badge setup-storage-badge-warn">needs care</span>' : '')));
// A registered location has a name the user chose; an unregistered
// candidate only has a path.
@ -623,8 +634,10 @@ class SetupWizard {
<span class="setup-app-body">
<span class="setup-app-name">${this.escapeHtml(title)} ${badge}</span>
<span class="setup-app-desc">
${this.escapeHtml(c.free)} free of ${this.escapeHtml(c.size)}${c.removable ? ' &middot; removable' : ''}
${this._storageMeter(c)}
${offline
? `Not mounted right now &middot; ${this.escapeHtml(c.path || '')}`
: `${this.escapeHtml(c.free)} free of ${this.escapeHtml(c.size)}${c.removable ? ' &middot; removable' : ''}
${this._storageMeter(c)}`}
</span>
</span>
<button type="button" class="setup-storage-details" data-storage-details="${this.escapeHtml(key)}">Details</button>
@ -635,8 +648,14 @@ class SetupWizard {
_storageChoices() {
const opts = [{ value: 'primary', label: this._primaryLabel() }];
this.storageCandidates
.filter(c => c.verdict !== 'refuse')
.forEach(c => opts.push({ value: c.path, label: c.path }));
// Not an unmounted one: choosing it would put app data on a bare
// mountpoint, which is the failure this whole feature exists to avoid.
.filter(c => c.verdict !== 'refuse' && c.state !== 'unmounted')
.forEach(c => opts.push({ value: c.path, label: c.name ? `${c.name} (${c.path})` : c.path }));
// Somewhere the scan did not find: a NAS mount, an LVM volume, a directory
// on a drive already in use. The scan lists whole filesystems, so anything
// that is a path rather than a disk had no way in before this.
opts.push({ value: CUSTOM_PATH, label: 'Custom path\u2026' });
return opts;
}
@ -687,14 +706,23 @@ class SetupWizard {
return;
}
// A value that is not one of the offered options is a path the user typed,
// so the control has to come back up showing Custom path rather than
// silently snapping to the first entry.
const isCustom = (v) => !!v && v !== 'primary' && !opts.some(o => o.value === v);
const row = (id, label, tip, value) => `
<div class="setup-storage-choice">
<span class="setup-storage-choice-label">${label}
<span class="setup-tooltip" tabindex="0" data-tip="${this.escapeHtml(tip)}">?</span>
</span>
<select id="${id}" class="form-control">
${opts.map(o => `<option value="${this.escapeHtml(o.value)}"${o.value === value ? ' selected' : ''}>${this.escapeHtml(o.label)}</option>`).join('')}
${opts.map(o => `<option value="${this.escapeHtml(o.value)}"${(o.value === value || (o.value === CUSTOM_PATH && isCustom(value))) ? ' selected' : ''}>${this.escapeHtml(o.label)}</option>`).join('')}
</select>
</div>
<div class="setup-storage-choice setup-storage-choice-custom" id="${id}-custom-row" style="${isCustom(value) ? '' : 'display:none;'}">
<input type="text" id="${id}-custom" class="form-control"
placeholder="/mnt/nas/libreportal" value="${isCustom(value) ? this.escapeHtml(value) : ''}">
<span class="setup-storage-choice-err" id="${id}-custom-err"></span>
</div>`;
box.innerHTML =
@ -706,14 +734,61 @@ class SetupWizard {
this.storageDefault) +
'<div class="setup-storage-choice-msg" id="sw-storage-system-msg" style="display:none;"></div>';
box.querySelector('#sw-storage-system').addEventListener('change', (e) => {
this.storageSystemChoice = e.target.value;
this.renderStorageSystemMsg();
});
box.querySelector('#sw-storage-apps').addEventListener('change', (e) => {
this.storageDefault = e.target.value;
});
// Choosing "Custom path" reveals the input and holds the value at empty
// until something is typed — the sentinel must never reach the payload.
const wire = (id, apply) => {
const sel = box.querySelector(`#${id}`);
const row = box.querySelector(`#${id}-custom-row`);
const inp = box.querySelector(`#${id}-custom`);
if (!sel) return;
sel.addEventListener('change', (e) => {
const custom = e.target.value === CUSTOM_PATH;
if (row) row.style.display = custom ? '' : 'none';
apply(custom ? (inp && inp.value.trim()) || '' : e.target.value);
if (custom && inp) inp.focus();
this._syncStorageNav();
});
if (inp) inp.addEventListener('input', () => {
if (sel.value === CUSTOM_PATH) apply(inp.value.trim());
this._syncStorageNav();
});
};
wire('sw-storage-system', (v) => { this.storageSystemChoice = v; this.renderStorageSystemMsg(); });
wire('sw-storage-apps', (v) => { this.storageDefault = v; });
this.renderStorageSystemMsg();
this._syncStorageNav();
}
// A half-typed custom path must not be able to reach the payload. The apply
// side would refuse it and fall back to the system disk, and a silent
// fallback is indistinguishable from having chosen the system disk on
// purpose — so block Continue and say why instead.
_customPathProblem(v) {
if (!v) return 'Enter a full path, starting with /';
if (!v.startsWith('/')) return 'Use a full path, starting with /';
if (/\s/.test(v)) return 'Paths with spaces are not supported';
if (v === '/' || /^\/(boot|dev|etc|proc|run|sys|usr|bin|sbin|lib|lib64|var)(\/|$)/.test(v)) {
return 'Pick a path outside the system directories';
}
return '';
}
// Returns the first problem found, and paints it beside the offending input.
_syncStorageNav() {
if (!this.container) return '';
let blocked = '';
['sw-storage-system', 'sw-storage-apps'].forEach((id) => {
const sel = this.container.querySelector('#' + id);
const inp = this.container.querySelector('#' + id + '-custom');
const err = this.container.querySelector('#' + id + '-custom-err');
if (!sel || !inp) return;
const problem = sel.value === CUSTOM_PATH
? this._customPathProblem(inp.value.trim()) : '';
if (err) err.textContent = problem;
inp.classList.toggle('is-invalid', !!problem);
if (problem) blocked = problem;
});
return blocked;
}
// Moving LibrePortal's own tree re-bakes the root-owned helpers, the systemd
@ -1327,6 +1402,12 @@ class SetupWizard {
}
}
}
// 3 = Storage. Only a typed custom path can be invalid; the scanned
// options all came from the backend.
if (idx === 3) {
const problem = this._syncStorageNav();
if (problem) return problem;
}
// 6 = Recommended (Storage 3, Backups 4 and Import 5 shifted this along).
if (idx === 6) {
const traefikBox = this.container.querySelector('input[data-app="traefik"]');

View File

@ -540,6 +540,65 @@ default-layout host onto it means *every* app takes the stage-and-move branch,
and staging (disk 1) and destination (disk 2) are different devices, so
`app-adopt` exercises its copy path rather than `mv`.
## 12.6 — The Storage step, after watching someone read it
Two things about the wizard's Storage step were wrong in a way only a fresh
pair of eyes catches.
**A registered drive that is unplugged.** `storage list` reports it as
`unmounted`, and the step rendered that through the same path as every other
candidate: a yellow "needs care" badge, "free of" with no numbers either side,
and an empty meter. To someone installing for the first time that reads as *the
scan found two broken disks* — there is nothing on the card connecting it to a
drive they registered earlier and have since unplugged. It now says **not
connected**, names the path, and draws no meter, because a meter with nothing in
it is a claim about free space that was never measured.
The same locations are also withheld from the two dropdowns. The wizard cannot
stat a directory on a drive that is absent, so it cannot promise an app placed
there would have anywhere to write.
**Custom paths.** The dropdowns offered only what the scan turned up, which is
wrong for a NAS mount, an LVM volume, or anything else the disk heuristics do
not rank as a candidate. Both now end in *Custom path…*, revealing a text box.
The validation lives in `validateStep(3)` rather than in a disabled button. The
apply side already refuses a relative or system path — but its refusal is to
fall back to the system disk, and *that is indistinguishable from having chosen
the system disk on purpose*. This is the failure shape this project keeps
having to fix (§10, and the compose-guard and `app-data-remove` bugs before it),
so the step blocks and says which rule the path broke.
A path typed here is not a registered location, so `setup_apply.sh` registers it
through `storageAdd` before writing `CFG_STORAGE_DEFAULT`. Going through
`storageAdd` rather than writing the path straight into the config is what keeps
the empty-directory admission rule and the fitness checks in play — the
alternative silently adopts a directory full of someone else's data. It is named
after its own basename, so it reads as `nas` in the placement menus rather than
`location-3`.
`libreportal storage remove` also learned to accept the name the listing prints.
It matched on id and path only, so `remove location-3` failed against a row the
table displayed as `location-3`.
### What the test found about the tests
`lp-storage-custom-test` drives the step in a real browser. Two things it caught
about itself are worth recording, because both are the same shape as the bugs it
exists to prevent — a check whose failure mode is to not run.
It first counted "how many cards say *not connected*" and asserted things about
those. Disabling the feature makes that count zero, `every()` over an empty list
is true, and the whole block passed while asserting nothing. The expected count
now comes from the feed (`storageCandidates` in state `unmounted`), so removing
the rendering is a failure rather than an empty set.
And both browser tests skipped — exit 0 — whenever the page returned nothing.
Run under `sudo`, where chromium refuses to start, they reported PASS having
checked nothing at all. They now probe the WebUI with `lp-shot --url` and curl:
if it answers HTTP then the browser is the only thing that can have broken, and
that is a failure, not a skip.
## 13. Open questions
1. ~~**Naming.** "Storage location" vs "backup location" in the same UI~~**resolved (2026-08-24):** build the Disks view (§7.1). The device becomes the organising concept and the two registries become *roles* on it, so the user never has to hold the distinction to understand their own hardware. Registries stay separate underneath.

View File

@ -134,7 +134,7 @@ command_symlink="/usr/local/bin/libreportal"
# `update apply` runs as the manager and CANNOT rewrite root-owned files, so a bump
# tells the updater the new release needs a root re-install (which re-bakes them).
# Recorded at install in $lp_lib_dir/.footprint_version. See docs/contributing/development.md.
footprint_version=10
footprint_version=11
footprint_marker="$lp_lib_dir/.footprint_version"
# Directories — three independently-relocatable roots (see scripts/source/paths.sh

View File

@ -23,6 +23,16 @@ fail=0
chk(){ if [[ "$2" == "$3" ]]; then echo " ok $1"; else echo " FAIL $1: got '$2' want '$3'"; fail=1; fi; }
command -v jq >/dev/null 2>&1 || { echo " SKIP jq not installed"; exit 0; }
# A skip must not be able to stand in for a pass. If the WebUI answers HTTP
# then the browser is the only thing that can have failed, and that is a
# failure — skipping there is how this test reported success under sudo, where
# chromium refuses to start at all.
lp_reachable() {
local u; u=$("$SHOT" --url 2>/dev/null) || return 1
[[ -n "$u" ]] || return 1
curl -fsS -o /dev/null --max-time 5 "$u" 2>/dev/null
}
read -r -d '' DRIVE <<'JS'
const DUMMY = 'dummy-not-a-real-secret-0000';
const out = {};
@ -139,7 +149,10 @@ JS
OUT=$("$SHOT" --eval "/?step=4" "$DRIVE" 2>/dev/null)
if [[ -z "$OUT" ]] || ! jq -e . >/dev/null 2>&1 <<< "$OUT"; then
echo " SKIP no WebUI reachable, or the step did not load"
if lp_reachable; then
echo " FAIL the WebUI is up but the step returned nothing (browser failed?)"; exit 1
fi
echo " SKIP no WebUI reachable"
exit 0
fi
if [[ "$(jq -r '.error // ""' <<< "$OUT")" != "" ]]; then

View File

@ -14,6 +14,7 @@ Environment:
LP_SHOT_URL base URL of the WebUI (default: auto-detected, else http://localhost:3179)
LP_SHOT_VIEWPORT WIDTHxHEIGHT (default 1440x900)
--eval ROUTE JS run JS in the page and print the result; no screenshot
--url print the base URL that would be used, and exit
LP_SHOT_EVAL JS run in the page before capture — open a dialog, pick a
tab, expand a row. Awaited, so async handlers finish.
LP_SHOT_SCALE device pixel ratio (default 2 — that's the "crisp")
@ -369,6 +370,14 @@ def main():
#
# lp-shot --token -> the raw cookie VALUE
# lp-shot --cookie-js -> a document.cookie assignment to paste/eval
# --url prints the base URL that would be used, and nothing else. Tests
# probe it with curl to tell "the WebUI is down, skip" apart from "the
# WebUI is up and the browser broke" — a browser test that skips on both
# reports success while asserting nothing.
if len(sys.argv) > 1 and sys.argv[1] == "--url":
print(base_url())
sys.exit(0)
if len(sys.argv) > 1 and sys.argv[1] in ("--token", "--cookie-js"):
token, src = mint_token()
if os.environ.get("LP_SHOT_VERBOSE"):

View File

@ -0,0 +1,138 @@
#!/bin/bash
# Drive the wizard's Storage step in a real browser.
#
# scripts/dev/lp-storage-custom-test # needs a running WebUI
#
# Two things this guards.
#
# A location whose drive is absent used to render as a warn card — "needs care",
# "free of" with no numbers, an empty meter. A new user has no way to read that
# as "this is a drive you registered and it is unplugged", so it looked like the
# scan had found two broken disks. It must now say it is not connected, name the
# path, and draw no meter.
#
# And a typed custom path must never reach the payload half-typed. The apply
# side refuses a relative or system path and falls back to the system disk —
# a fallback that looks exactly like having chosen the system disk on purpose,
# which is the failure shape this project keeps having to fix. validateStep
# has to block it at the step instead.
#
# One page load: the whole interaction runs in a single `lp-shot --eval`.
REPO="$(cd "$(dirname "$0")/../.." && pwd)"
SHOT="$REPO/scripts/dev/lp-shot"
fail=0
chk(){ if [[ "$2" == "$3" ]]; then echo " ok $1"; else echo " FAIL $1: got '$2' want '$3'"; fail=1; fi; }
command -v jq >/dev/null 2>&1 || { echo " SKIP jq not installed"; exit 0; }
# A skip must not be able to stand in for a pass. If the WebUI answers HTTP
# then the browser is the only thing that can have failed, and that is a
# failure — skipping there is how this test reported success under sudo, where
# chromium refuses to start at all.
lp_reachable() {
local u; u=$("$SHOT" --url 2>/dev/null) || return 1
[[ -n "$u" ]] || return 1
curl -fsS -o /dev/null --max-time 5 "$u" 2>/dev/null
}
read -r -d '' DRIVE <<'JS'
const out = {};
const w = window.setupWizard;
if (!w) return JSON.stringify({ error: 'wizard handle missing' });
const fire = (el, ev) => el.dispatchEvent(new Event(ev, { bubbles: true }));
const sel = document.querySelector('#sw-storage-apps');
const inp = document.querySelector('#sw-storage-apps-custom');
const row = document.querySelector('#sw-storage-apps-custom-row');
const err = document.querySelector('#sw-storage-apps-custom-err');
if (!sel || !inp || !row) return JSON.stringify({ error: 'storage step not rendered' });
// An unmounted location must not be offered as somewhere to put app data:
// the wizard cannot check its free space or write to it.
// How many SHOULD be offline comes from the feed, not from the rendering.
// Counting the cards that say "not connected" would make every assertion
// below vacuous the moment the feature stopped working.
const cards = Array.from(document.querySelectorAll('.setup-app-card, .setup-storage-card'));
const expected = (w.storageCandidates || []).filter(c => c.state === 'unmounted');
out.expectedOffline = expected.length;
const offline = cards.filter(c =>
expected.some(e => e.path && c.textContent.includes(e.path)));
out.offlineCards = offline.length;
out.offlineSayNotConnected = offline.length > 0
&& offline.every(c => /not connected/i.test(c.textContent));
out.offlineSaysNotMounted = offline.length > 0 && offline.every(c => /not mounted right now/i.test(c.textContent));
out.offlineNamesPath = offline.length > 0 && offline.every(c => /·\s*\//.test(c.textContent));
out.offlineHasNoMeter = offline.length > 0 && offline.every(c => !c.querySelector('.setup-storage-meter'));
out.offlineSaysNeedsCare = offline.some(c => /needs care/i.test(c.textContent));
out.offlineOffered = Array.from(sel.options)
.some(o => offline.some(c => o.value && c.textContent.includes(o.value)));
out.hasCustomOption = Array.from(sel.options).some(o => o.value === '__custom__');
out.rowHiddenAtRest = row.style.display === 'none';
sel.value = '__custom__'; fire(sel, 'change');
out.rowShownOnPick = row.style.display !== 'none';
out.emptyBlocks = !!w.validateStep(3);
const probe = (v) => { inp.value = v; fire(inp, 'input');
return { blocks: !!w.validateStep(3),
marked: inp.classList.contains('is-invalid'),
said: (err && err.textContent) || '' }; };
out.relative = probe('mnt/nas');
out.systemDir = probe('/etc/libreportal');
out.root = probe('/');
out.spaces = probe('/mnt/my disk');
out.good = probe('/mnt/nas/apps');
out.goodStored = w.storageDefault;
// Back to a scanned option: never blocked, and the error clears.
sel.value = 'primary'; fire(sel, 'change');
out.primaryBlocks = !!w.validateStep(3);
out.primaryStored = w.storageDefault;
return JSON.stringify(out);
JS
J=$("$SHOT" --eval "/?step=3" "$DRIVE" 2>/dev/null | tr "'" '"' | sed 's/\bTrue\b/true/g; s/\bFalse\b/false/g')
if [[ -z "$J" ]]; then
if lp_reachable; then
echo " FAIL the WebUI is up but the page returned nothing (browser failed?)"; exit 1
fi
echo " SKIP no WebUI reachable"; exit 0
fi
g(){ echo "$J" | jq -r "$1" 2>/dev/null; }
if [[ "$(g '.error // empty')" != "" ]]; then echo " FAIL $(g .error)"; exit 1; fi
echo "unmounted locations"
if [[ "$(g .expectedOffline)" == "0" ]]; then
echo " SKIP no unmounted location registered — nothing to check"
else
chk "every one has a card" "$(g .offlineCards)" "$(g .expectedOffline)"
chk "badged 'not connected'" "$(g .offlineSayNotConnected)" true
chk "say they are not mounted" "$(g .offlineSaysNotMounted)" true
chk "name the path" "$(g .offlineNamesPath)" true
chk "draw no free-space meter" "$(g .offlineHasNoMeter)" true
chk "do not say 'needs care'" "$(g .offlineSaysNeedsCare)" false
chk "are not offered as a target" "$(g .offlineOffered)" false
fi
echo "custom path"
chk "the option exists" "$(g .hasCustomOption)" true
chk "input hidden until picked" "$(g .rowHiddenAtRest)" true
chk "input shown once picked" "$(g .rowShownOnPick)" true
chk "empty blocks the step" "$(g .emptyBlocks)" true
chk "relative path blocks" "$(g .relative.blocks)" true
chk "relative path is marked" "$(g .relative.marked)" true
chk "relative path says why" "$(g '.relative.said != ""')" true
chk "system dir blocks" "$(g .systemDir.blocks)" true
chk "/ blocks" "$(g .root.blocks)" true
chk "path with spaces blocks" "$(g .spaces.blocks)" true
chk "an absolute path passes" "$(g .good.blocks)" false
chk "and is not marked" "$(g .good.marked)" false
chk "and its error is cleared" "$(g '.good.said == ""')" true
chk "and it is what gets stored" "$(g .goodStored)" /mnt/nas/apps
chk "a scanned option never blocks" "$(g .primaryBlocks)" false
chk "and replaces the custom value" "$(g .primaryStored)" primary
[[ $fail -eq 0 ]] && echo "storage custom-path test: OK"
exit $fail

View File

@ -102,6 +102,29 @@ setupApplyConfig()
if declare -f storageLocationName >/dev/null 2>&1; then
default_name=$(storageLocationName "$storage_default" 2>/dev/null) || default_name=""
fi
# A path typed into the wizard's "Custom path" box is not a registered
# location yet, so the lookup above finds nothing. Register it — through
# storageAdd, because that is what enforces the empty-directory
# admission rule and the fitness checks; writing the path straight into
# the config would skip both and is how an app ends up on a directory
# root never agreed to own.
if [[ -z "$default_name" || "$default_name" == "default" ]] \
&& [[ "$storage_default" == /* ]] \
&& declare -f storageAdd >/dev/null 2>&1; then
# Named after the directory, so it reads as "nas" in the app
# placement menus rather than "location-3".
local custom_name
custom_name=$(basename -- "$storage_default" | tr -c 'a-zA-Z0-9-' '-' | sed 's/^-*//; s/-*$//')
[[ -z "$custom_name" ]] && custom_name="custom"
if storageAdd "$storage_default" "$custom_name" >/dev/null 2>&1; then
storageCacheReset 2>/dev/null || true
default_name=$(storageLocationName "$storage_default" 2>/dev/null) || default_name=""
[[ -n "$default_name" ]] && isSuccessful "Registered '$storage_default' as a storage location."
else
isNotice "Could not use '$storage_default' — it must be an empty directory outside the system paths."
fi
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'"

View File

@ -413,6 +413,16 @@ remove() {
if [[ "$_id" == "$want" || "${_path%/}" == "${want%/}" ]]; then
found_path="${_path%/}"; found_id="$_id"; break
fi
# Also match the name the listing prints, since that is what a person
# reading the table will type. Includes the "location-N" fallback the
# marker records when a location was added without one.
if [[ -f "${_path%/}/$MARKER" ]]; then
local _name
_name=$(sed -n 's/^name=//p' "${_path%/}/$MARKER" 2>/dev/null | head -1)
if [[ -n "$_name" && "$_name" == "$want" ]]; then
found_path="${_path%/}"; found_id="$_id"; break
fi
fi
done < "$REGISTRY"
[[ -n "$found_id" ]] || { _err "no such location: $want"; return 1; }