diff --git a/containers/libreportal/frontend/core/boot/js/system-orchestrator.js b/containers/libreportal/frontend/core/boot/js/system-orchestrator.js index 34f28a9..7bb31af 100755 --- a/containers/libreportal/frontend/core/boot/js/system-orchestrator.js +++ b/containers/libreportal/frontend/core/boot/js/system-orchestrator.js @@ -129,7 +129,12 @@ class SystemOrchestrator { // Handle normal loading sequence async handleNormalLoading() { - + // A relocation the user was told to run may still be outstanding, on this + // page load or any later one. Started here rather than at setup, because + // the move happens in a terminal minutes or days afterwards and the tab + // that showed the command is usually long gone. + try { RelocateWatcher.start(); } catch { /* never block loading for this */ } + // Wait longer for all scripts to fully load await new Promise(resolve => setTimeout(resolve, 2500)); @@ -346,3 +351,129 @@ if (document.readyState === 'loading') { await window.systemOrchestrator.initialize(); })(); } + +// Follow a pending `libreportal-relocate` and reload when it lands. +// +// Relocating LibrePortal's own tree cannot be a WebUI action: it re-bakes the +// paths inside every root-owned helper, and those are baked at install exactly +// so the manager cannot redirect a privileged operation by editing something +// it owns. A helper that re-baked the others from a caller-supplied path would +// hand the manager the whole trust boundary. So a human runs it with real +// root, and libreportal-relocate is deliberately absent from the manager's +// sudoers. +// +// What is left to us is the part that was actually annoying: being told to run +// a command and then having no idea whether it worked. This keeps the command +// to hand, notices the move landing, and follows it — so the last step of a +// relocation is the page coming back on its own rather than a manual refresh. +class RelocateWatcher { + static KEY = 'lp.pendingRelocate'; + + static pending() { + try { return JSON.parse(localStorage.getItem(RelocateWatcher.KEY) || 'null'); } + catch { return null; } + } + + static clear() { + try { localStorage.removeItem(RelocateWatcher.KEY); } catch {} + } + + static start() { + const p = RelocateWatcher.pending(); + if (!p || !p.target) return; + new RelocateWatcher(p).run(); + } + + constructor(pending) { + this.pending = pending; + this.banner = null; + } + + async run() { + // Already done — the user ran it before this page loaded. + if (await this.landed()) { RelocateWatcher.clear(); return; } + this.render(); + // Every 5s. The move is a human typing a command and then a file copy, so + // there is nothing to be gained by asking faster, and the feed is + // regenerated on a schedule anyway. + this.timer = setInterval(async () => { + if (await this.landed()) { + clearInterval(this.timer); + this.done(); + } + }, 5000); + } + + // The move has landed when the host reports its system dir as the target. + // Deliberately NOT "the server restarted": an ordinary container restart + // would look identical, and announcing a relocation that never happened is + // worse than saying nothing. + async landed() { + try { + const r = await fetch('/data/system/storage.json', { cache: 'no-store' }); + if (!r.ok) return false; + const d = await r.json(); + const now = String(d.system_dir || '').replace(/\/$/, ''); + return !!now && now === String(this.pending.target).replace(/\/$/, ''); + } catch { + // Unreachable is the expected middle of a relocation, not a failure. + return false; + } + } + + render() { + const el = document.createElement('div'); + el.className = 'lp-relocate-banner'; + el.innerHTML = ` +
${this.esc(this.pending.cmd)}
+ on its own
+ line: the point is that it can be taken in one action. */
+.setup-cmd-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 6px 0 4px;
+}
+.setup-cmd-row code {
+ flex: 1;
+ min-width: 0;
+ word-break: break-all;
+}
+
+/* Shown on any page while a relocation is outstanding, so the command survives
+ the tab that produced it. */
+.lp-relocate-banner {
+ position: fixed;
+ left: 50%;
+ bottom: 18px;
+ transform: translateX(-50%);
+ z-index: 9998;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ max-width: min(920px, calc(100vw - 32px));
+ padding: 12px 16px;
+ border-radius: 12px;
+ border: 1px solid rgba(255, 190, 60, 0.34);
+ background: rgba(20, 40, 62, 0.96);
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
+ color: #e8f2fb;
+ font-size: 0.9rem;
+}
+.lp-relocate-text { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
+.lp-relocate-text span { opacity: 0.8; font-size: 0.85em; }
+.lp-relocate-text code {
+ margin-top: 3px;
+ padding: 4px 7px;
+ border-radius: 6px;
+ background: rgba(0, 0, 0, 0.32);
+ word-break: break-all;
+}
+.lp-relocate-actions { display: flex; gap: 8px; flex-shrink: 0; }
+.lp-relocate-actions button {
+ padding: 6px 12px;
+ border-radius: 8px;
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ background: rgba(255, 255, 255, 0.08);
+ color: inherit;
+ cursor: pointer;
+ font-size: 0.85em;
+}
+.lp-relocate-actions button:hover { background: rgba(255, 255, 255, 0.14); }
+@media (max-width: 640px) {
+ .lp-relocate-banner { flex-direction: column; align-items: stretch; }
+}
diff --git a/containers/libreportal/frontend/core/setup/js/setup-wizard.js b/containers/libreportal/frontend/core/setup/js/setup-wizard.js
index 3d77b01..3d0b755 100755
--- a/containers/libreportal/frontend/core/setup/js/setup-wizard.js
+++ b/containers/libreportal/frontend/core/setup/js/setup-wizard.js
@@ -29,7 +29,7 @@ class SetupWizard {
// backup already answers all three, and asking would invite someone to
// contradict what is about to be restored over their answer.
this.stepNames = ['Start', 'Experience', 'Identity', 'Domains', 'Storage', 'Backups', 'Import', 'Recommended', 'Metrics',
- 'Source', 'Contents', 'Rebuild'];
+ 'Backup', 'Contents', 'Rebuild'];
this.stepIcons = ['\u{1F9ED}', '\u{1F331}', '\u{1FA90}', '\u{1F6F0}\u{FE0F}', '\u{1F4BE}', '\u{1F6DF}', '\u{1F4E6}', '\u{1F6E1}\u{FE0F}', '\u{1F4CA}',
'\u{1F5C4}\u{FE0F}', '\u{1F50D}', '\u{267B}\u{FE0F}'];
// 'new' | 'restore'. Chosen on Start; everything downstream reads it.
@@ -84,7 +84,7 @@ class SetupWizard {
// disjoint: a restore is not asked for an install name, domains or an app
// list, because the backup answers all three and asking would invite the
// user to contradict what is about to be written over their answer.
- const RESTORE_ONLY = ['Source', 'Contents', 'Rebuild'];
+ const RESTORE_ONLY = ['Backup', 'Contents', 'Rebuild'];
if (name === 'Start') return true;
if (RESTORE_ONLY.includes(name)) return this.installMode === 'restore';
if (this.installMode === 'restore') return false;
@@ -412,27 +412,28 @@ class SetupWizard {
-
+
- Where is your backup?
- ?
-
+ Backup
- Your backups live in a repository: a folder on a disk, or a
- remote server. Not a single file.
+ Where is it? Backups live in a repository \u2014 a folder on a
+ disk, or a remote server. Not a single file.
-
- Password
-
+
+
+
+
+
+
+
-
- This unlocks the repository. It is handed to the host through a
- one-shot reference, so it never lands in a task or a log.
-
-
-
+
+
@@ -442,7 +443,8 @@ class SetupWizard {
- What is in this backup
+ Contents
+ What this backup would bring back.
Go back a step and read the backup first.
@@ -452,7 +454,8 @@ class SetupWizard {
- Rebuild this server
+ Rebuild
+ What is about to happen, and in what order.
@@ -615,6 +618,7 @@ class SetupWizard {
unregistered.filter(c => !seen.has((c.path || '').replace(/\/$/, ''))));
this.storageSystem = data.system || null;
this.storagePrimary = data.primary || '';
+ this.storagePrimarySystemDir = data.system_dir || '';
} catch (e) {
console.log('[setup] storage scan unavailable:', e.message);
this.storageCandidates = [];
@@ -997,42 +1001,79 @@ class SetupWizard {
// --- restore branch -------------------------------------------------------
- // The "where is your backup" fields. Same backend types the Backup page
- // offers, rendered here rather than reusing the destination dialog: that
- // dialog is for a place to WRITE backups and carries retention, scheduling
- // and an enable toggle, none of which mean anything for a repository you are
- // only going to read.
+ // The repository fields.
+ //
+ // Laid out the way every other field in the wizard is — label with a
+ // tooltip, then an icon beside the input — rather than the label-left row
+ // the Storage step uses. That row suits a column of dropdowns; a form of
+ // typed values in the middle of a wizard that looks nothing like the rest of
+ // it just reads as unfinished.
+ //
+ // Rendered here rather than reusing the Backup page's destination dialog:
+ // that dialog is for a place to WRITE backups and carries retention,
+ // scheduling and an enable toggle, none of which mean anything for a
+ // repository you are only going to read.
renderRestoreSource() {
const box = this.container.querySelector('#sw-rs-fields');
if (!box || box.dataset.rendered === '1') return;
box.dataset.rendered = '1';
- const row = (label, id, ph, type) => `
-
- ${label}
-
+ const field = (id, label, tip, icon, ph, type) => `
+
+
+
+
+
+
`;
box.innerHTML = `
-
- Kind
-
+
+
+
+
+
+
` +
- row('Path', 'sw-rs-path', '/mnt/usb/libreportal-backups', 'local') +
- row('SSH user', 'sw-rs-ssh-user', 'backups', 'sftp') +
- row('SSH host', 'sw-rs-ssh-host', 'nas.example.com', 'sftp') +
- row('Remote path', 'sw-rs-ssh-path', '/srv/libreportal', 'sftp') +
- row('URL', 'sw-rs-uri', 'rest:https://backup.example.com/', 'rest') +
- row('Bucket URI', 'sw-rs-s3uri', 's3:s3.amazonaws.com/my-bucket', 's3') +
- row('Access key ID', 'sw-rs-s3id', 'AKIA…', 's3') +
- row('Bucket URI', 'sw-rs-b2uri', 'b2:my-bucket', 'b2') +
- row('Account ID', 'sw-rs-b2id', '', 'b2');
+ field('sw-rs-path', 'Folder',
+ "The repository folder itself \u2014 the one containing config, data/ and snapshots/, not the folder above it.",
+ '\u{1F4C1}', '/mnt/usb/libreportal-backups', 'local') +
+ field('sw-rs-ssh-user', 'SSH user',
+ 'The account used to reach the server over SSH.',
+ '\u{1F464}', 'backups', 'sftp') +
+ field('sw-rs-ssh-host', 'SSH host',
+ 'Hostname or IP of the server holding the repository.',
+ '\u{1F5A5}\u{FE0F}', 'nas.example.com', 'sftp') +
+ field('sw-rs-ssh-path', 'Remote folder',
+ 'Path to the repository on that server.',
+ '\u{1F4C1}', '/srv/libreportal-backups', 'sftp') +
+ field('sw-rs-uri', 'Server URL',
+ 'The full repository URL, as restic writes it.',
+ '\u{1F517}', 'rest:https://backup.example.com/', 'rest') +
+ field('sw-rs-s3uri', 'Bucket URL',
+ 'The bucket holding the repository, as restic writes it.',
+ '\u{1FAA3}', 's3:s3.amazonaws.com/my-bucket', 's3') +
+ field('sw-rs-s3id', 'Access key ID',
+ 'The key ID for that bucket. Its secret goes in the password field below.',
+ '\u{1F511}', 'AKIA\u2026', 's3') +
+ field('sw-rs-b2uri', 'Bucket',
+ 'The B2 bucket holding the repository.',
+ '\u{1FAA3}', 'b2:my-bucket', 'b2') +
+ field('sw-rs-b2id', 'Account ID',
+ 'Your B2 account or application key ID.',
+ '\u{1F194}', '', 'b2');
const sync = () => {
const type = (this.container.querySelector('#sw-rs-type') || {}).value || 'local';
@@ -1370,10 +1411,47 @@ class SetupWizard {
msg.innerHTML = '';
return;
}
+ const cmd = `sudo libreportal-relocate --system-dir=${this.storageSystemTarget || (this.storageSystemChoice + '/libreportal-system')}`;
msg.style.display = '';
- msg.innerHTML = `Moving LibrePortal itself needs root, so it happens outside the WebUI.
- Finish setup, then run:
- sudo libreportal-relocate --system-dir=${this.escapeHtml(this.storageSystemTarget || (this.storageSystemChoice + '/libreportal-system'))}`;
+ // Not a button, and it cannot be one. Relocating re-bakes the paths inside
+ // every root-owned helper, and those paths are baked at install precisely
+ // so the manager cannot redirect a privileged operation by editing
+ // something it owns. A helper that re-baked the others from a
+ // caller-supplied path would hand the manager the whole trust boundary
+ // those helpers exist to defend — so libreportal-relocate is deliberately
+ // not in the manager's sudoers, and the WebUI can only ever tell you the
+ // command. See scripts/system/libreportal-relocate.
+ //
+ // What the WebUI CAN do is make running it painless: copy it in one click,
+ // and then watch for the move landing and follow it to the new install.
+ msg.innerHTML = `Moving LibrePortal itself needs real root, so it happens in a terminal
+ rather than here. Finish setup, then run:
+
+ ${this.escapeHtml(cmd)}
+
+
+ This page will follow it and reload itself when the move finishes.`;
+
+ const btn = msg.querySelector('#sw-relocate-copy');
+ if (btn) {
+ btn.addEventListener('click', async () => {
+ try {
+ await navigator.clipboard.writeText(cmd);
+ btn.textContent = 'Copied';
+ } catch {
+ // Clipboard access needs a secure context, and a LAN install on
+ // http:// is not one — which is most of them. Select the text so
+ // the usual keyboard copy still works rather than failing silently.
+ const el = msg.querySelector('#sw-relocate-cmd');
+ if (el) {
+ const r = document.createRange(); r.selectNodeContents(el);
+ const s = window.getSelection(); s.removeAllRanges(); s.addRange(r);
+ }
+ btn.textContent = 'Press \u2318/Ctrl+C';
+ }
+ setTimeout(() => { btn.textContent = 'Copy'; }, 2500);
+ });
+ }
}
// Backups: one question, asked at setup rather than left to be discovered.
@@ -2425,6 +2503,20 @@ class SetupWizard {
}));
} catch { /* sessionStorage may be unavailable in private mode */ }
+ // The relocate command is shown on a step the user is about to navigate
+ // away from, so it has to outlive the wizard. Persisted rather than
+ // sessioned: the move happens in a terminal, possibly after closing the
+ // tab, and losing the command at that point means going to look it up.
+ try {
+ if (this.installMode !== 'restore' && this.storageSystemTarget) {
+ localStorage.setItem('lp.pendingRelocate', JSON.stringify({
+ target: this.storageSystemTarget,
+ from: this.storagePrimarySystemDir || '',
+ cmd: `sudo libreportal-relocate --system-dir=${this.storageSystemTarget}`
+ }));
+ }
+ } catch { /* private mode */ }
+
if (typeof this.onComplete === 'function') {
try { this.onComplete(); } catch (err) { console.error('[setup] onComplete threw:', err); }
}
diff --git a/docs/roadmap/first-run-restore.md b/docs/roadmap/first-run-restore.md
index 5e1ba8c..52a9256 100644
--- a/docs/roadmap/first-run-restore.md
+++ b/docs/roadmap/first-run-restore.md
@@ -487,7 +487,7 @@ of **two disjoint step sets**:
```
new Start -> Experience -> Identity -> Domains -> Storage
-> Backups -> Import -> Recommended -> (Metrics)
- restore Start -> Source -> Contents -> Rebuild
+ restore Start -> Backup -> Contents -> Rebuild
```
Disjoint on purpose. A restore is never asked for an install name, domains or
@@ -496,7 +496,7 @@ to type an answer that is about to be written over, which is worse than not
asking. The test asserts non-overlap in *both* directions rather than only that
the restore steps appear.
-**Source** collects the repository the same way the Backup page does, minus
+**Backup** collects the repository the same way the Backup page does, minus
everything that only means something for a place you WRITE to: no retention, no
schedule, no enable toggle. The password leaves through the one-shot
`secret:` channel and is cleared from the DOM; the test asserts the value
diff --git a/docs/roadmap/storage-locations.md b/docs/roadmap/storage-locations.md
index a514738..8315801 100644
--- a/docs/roadmap/storage-locations.md
+++ b/docs/roadmap/storage-locations.md
@@ -631,6 +631,49 @@ 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.
+## 12.7 — The relocate hand-off, and why it stays a command
+
+Asked directly: why is moving LibrePortal itself not just a button that runs at
+the end of setup and reloads the page?
+
+Because of the thing the whole privilege model rests on. Every root-owned
+helper has its paths **baked in at install** (§3), specifically so the manager
+cannot redirect a privileged operation by editing something it owns. Relocating
+re-bakes those paths. A helper that re-baked the other helpers from a
+caller-supplied path would hand the manager the entire trust boundary those
+helpers exist to defend — the manager picks the path, the path becomes where
+privileged chowns land. `libreportal-relocate` is therefore deliberately absent
+from the manager's sudoers, in the same category as uninstall, and
+`scripts/system/libreportal-relocate` says so at the top.
+
+Narrowing it does not rescue it. "Only allow targets already in the root-owned
+storage registry" sounds bounded until you notice the manager can *add* to that
+registry — `libreportal-storage add` is in its sudoers, by design. The only
+safe source for that path is root, and root is not present when the wizard
+runs: `initPickRoots` asks "where should LibrePortal keep things?" during
+install, as root, before anything is created — which is the friction-free
+answer, and why choosing the disk up front is always better than moving later.
+
+What *was* fixable is the part that actually annoyed: being handed a command
+with no idea whether it worked.
+
+- The command now comes with a **Copy** button. Clipboard access needs a secure
+ context and a LAN install on `http://` is not one, which is most of them — so
+ the fallback selects the text and says which keys to press, rather than
+ failing silently.
+- The pending move is persisted to `localStorage`, not session state. The move
+ happens in a terminal minutes or days later, usually after the tab that
+ showed the command is gone.
+- A **watcher** on every page load shows the outstanding command and polls for
+ the move landing, then reloads to the homepage — whatever route you were on
+ belonged to the old install.
+
+"Landed" means *the host reports its system dir as the target*, which is why
+`system_dir` was added to `storage.json`. Deliberately not "the server went
+away and came back": an ordinary container restart is indistinguishable from
+that, and announcing a relocation that never happened is worse than saying
+nothing.
+
## 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.
diff --git a/scripts/dev/lp-restore-wizard-test b/scripts/dev/lp-restore-wizard-test
index 79acf93..a2703e5 100755
--- a/scripts/dev/lp-restore-wizard-test
+++ b/scripts/dev/lp-restore-wizard-test
@@ -50,12 +50,23 @@ read -r -d '' DRIVE <<'JS'
// Disjoint in both directions, apart from Start itself.
const NEW_ONLY = ['Experience', 'Identity', 'Domains', 'Storage', 'Backups', 'Import', 'Recommended', 'Metrics'];
- const RESTORE_ONLY = ['Source', 'Contents', 'Rebuild'];
+ const RESTORE_ONLY = ['Backup', 'Contents', 'Rebuild'];
out.restoreLeaksNewStep = out.restoreSteps.some(s => NEW_ONLY.includes(s));
out.newLeaksRestoreStep = out.newSteps.some(s => RESTORE_ONLY.includes(s));
out.startAlwaysShown = out.newSteps[0] === 'Start' && out.restoreSteps[0] === 'Start';
// The source form offers every backend, and shows only the chosen one.
+ // The progress bar said "Source" while the heading said "Where is your
+ // backup?", which read as two different steps. They have to agree.
+ const sec = document.querySelector('.setup-step[data-step="9"] .setup-section-title');
+ out.titleMatchesStepName = !!sec && sec.textContent.trim() === 'Backup';
+ // Fields laid out like the rest of the wizard: label with a tooltip, and an
+ // icon beside the input — not the label-left rows the Storage step uses.
+ out.fieldsHaveIcons = document.querySelectorAll('#sw-rs-fields .setup-field-icon').length > 0;
+ out.fieldsHaveTooltips = document.querySelectorAll('#sw-rs-fields .setup-tooltip').length > 0;
+ out.passwordHasIcon = !!document.querySelector('#sw-rs-pass')
+ ?.closest('.setup-input-row')?.querySelector('.setup-field-icon');
+
out.kinds = Array.from(document.querySelectorAll('#sw-rs-type option')).map(o => o.value);
const groupsFor = (t) => {
const sel = $('#sw-rs-type'); sel.value = t; fire(sel, 'change');
@@ -163,10 +174,15 @@ chk "picking restore switches mode" "$(g .mode)" restore
chk "Start shows in both" "$(g .startAlwaysShown)" true
chk "restore shows no install steps" "$(g .restoreLeaksNewStep)" false
chk "new shows no restore steps" "$(g .newLeaksRestoreStep)" false
-chk "restore step set" "$(g '.restoreSteps | join(",")')" "Start,Source,Contents,Rebuild"
+chk "restore step set" "$(g '.restoreSteps | join(",")')" "Start,Backup,Contents,Rebuild"
+
+chk "step name matches its title" "$(g .titleMatchesStepName)" true
echo "the backup source form"
chk "every backend offered" "$(g '.kinds | join(",")')" "local,sftp,rest,s3,b2"
+chk "fields carry icons" "$(g .fieldsHaveIcons)" true
+chk "fields carry tooltips" "$(g .fieldsHaveTooltips)" true
+chk "so does the password field" "$(g .passwordHasIcon)" true
chk "local shows only its own" "$(g .localShowsOnlyLocal)" true
chk "sftp shows only its own" "$(g .sftpShowsOnlySftp)" true
chk "empty path refused" "$(g .emptyPathRefused)" true
diff --git a/scripts/dev/lp-storage-step-test b/scripts/dev/lp-storage-step-test
index 27564e9..0d92293 100755
--- a/scripts/dev/lp-storage-step-test
+++ b/scripts/dev/lp-storage-step-test
@@ -143,6 +143,51 @@ read -r -d '' DRIVE <<'JS'
out.staleAfterDriveChange = $('#sw-path-apps').value;
out.staleWant = other;
}
+ // --- the relocate hand-off ---
+ // Moving LibrePortal's own tree cannot be a WebUI action: it re-bakes the
+ // paths inside every root helper, and those are baked at install so the
+ // manager cannot redirect a privileged operation. So the wizard prints a
+ // command — and the watcher makes that bearable by noticing the move
+ // landing and following it.
+ //
+ // "Landed" must mean the host REPORTS the new system dir, never "the server
+ // restarted": an ordinary container restart looks identical, and announcing
+ // a relocation that never happened is worse than saying nothing.
+ const W = window.RelocateWatcher;
+ out.watcherExists = !!W;
+ if (W) {
+ out.notLandedForAnUnrelatedPath =
+ await new W({ target: '/mnt/__nope/libreportal-system', cmd: 'x' }).landed();
+ const feed = await (await fetch('/data/system/storage.json', { cache: 'no-store' })).json();
+ out.feedReportsSystemDir = !!feed.system_dir;
+ out.landsWhenFeedMatches =
+ await new W({ target: feed.system_dir, cmd: 'x' }).landed();
+ out.trailingSlashTolerated =
+ await new W({ target: feed.system_dir + '/', cmd: 'x' }).landed();
+
+ localStorage.removeItem('lp.pendingRelocate');
+ W.start();
+ out.silentWhenNothingPending = !document.querySelector('.lp-relocate-banner');
+
+ localStorage.setItem('lp.pendingRelocate', JSON.stringify({
+ target: '/mnt/__nope/libreportal-system',
+ cmd: 'sudo libreportal-relocate --system-dir=/mnt/__nope/libreportal-system' }));
+ W.start();
+ await new Promise(r => setTimeout(r, 300));
+ const b = document.querySelector('.lp-relocate-banner');
+ out.bannerCarriesTheCommand = !!b && b.textContent.includes('libreportal-relocate --system-dir=/mnt/__nope');
+ out.bannerOffersCopy = !!b && !!b.querySelector('[data-act="copy"]');
+ if (b) b.remove();
+
+ // Already done before this page loaded: clear, do not nag.
+ localStorage.setItem('lp.pendingRelocate', JSON.stringify({ target: feed.system_dir, cmd: 'x' }));
+ W.start();
+ await new Promise(r => setTimeout(r, 300));
+ out.clearsWhenAlreadyLanded =
+ !document.querySelector('.lp-relocate-banner') && !localStorage.getItem('lp.pendingRelocate');
+ localStorage.removeItem('lp.pendingRelocate');
+ }
+
return JSON.stringify(out);
JS
@@ -203,5 +248,16 @@ else
fi
fi
+echo "the relocate hand-off"
+chk "the watcher is loaded" "$(g .watcherExists)" true
+chk "the feed reports the system dir" "$(g .feedReportsSystemDir)" true
+chk "an unrelated path has not landed" "$(g .notLandedForAnUnrelatedPath)" false
+chk "landed when the feed matches" "$(g .landsWhenFeedMatches)" true
+chk "a trailing slash is tolerated" "$(g .trailingSlashTolerated)" true
+chk "silent when nothing is pending" "$(g .silentWhenNothingPending)" true
+chk "the banner carries the command" "$(g .bannerCarriesTheCommand)" true
+chk "and offers to copy it" "$(g .bannerOffersCopy)" true
+chk "clears when already landed" "$(g .clearsWhenAlreadyLanded)" true
+
[[ $fail -eq 0 ]] && echo "storage step test: OK"
exit $fail
diff --git a/scripts/webui/data/generators/system/webui_storage_candidates.sh b/scripts/webui/data/generators/system/webui_storage_candidates.sh
index dc02c9a..2ebbde1 100644
--- a/scripts/webui/data/generators/system/webui_storage_candidates.sh
+++ b/scripts/webui/data/generators/system/webui_storage_candidates.sh
@@ -157,6 +157,7 @@ webuiGenerateStorageCandidates()
cat > "$tmp" <