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 = ` +
+ LibrePortal is set to move to ${this.esc(this.pending.target)} + Run this in a terminal on the server. This page will follow it and reload itself. + ${this.esc(this.pending.cmd)} +
+
+ + +
`; + document.body.appendChild(el); + this.banner = el; + + el.querySelector('[data-act="copy"]').addEventListener('click', async (e) => { + const b = e.currentTarget; + try { await navigator.clipboard.writeText(this.pending.cmd); b.textContent = 'Copied'; } + catch { + // http:// on a LAN is not a secure context, which is most installs. + const code = el.querySelector('code'); + const r = document.createRange(); r.selectNodeContents(code); + const s = window.getSelection(); s.removeAllRanges(); s.addRange(r); + b.textContent = 'Press \u2318/Ctrl+C'; + } + setTimeout(() => { b.textContent = 'Copy'; }, 2500); + }); + + el.querySelector('[data-act="dismiss"]').addEventListener('click', () => { + clearInterval(this.timer); + RelocateWatcher.clear(); + el.remove(); + }); + } + + done() { + RelocateWatcher.clear(); + if (this.banner) { + this.banner.querySelector('.lp-relocate-text').innerHTML = + 'Moved.Reloading\u2026'; + this.banner.querySelector('.lp-relocate-actions').remove(); + } + // Straight to the homepage: whatever route this was, it was a route on the + // old install. + setTimeout(() => { window.location.href = '/'; }, 1200); + } + + esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] + )); + } +} + +window.RelocateWatcher = RelocateWatcher; diff --git a/containers/libreportal/frontend/core/setup/css/setup-wizard.css b/containers/libreportal/frontend/core/setup/css/setup-wizard.css index 8ae7750..b6c7704 100755 --- a/containers/libreportal/frontend/core/setup/css/setup-wizard.css +++ b/containers/libreportal/frontend/core/setup/css/setup-wizard.css @@ -1461,3 +1461,61 @@ body.setup-wizard-open .custom-select-popup { z-index: 10001; } .setup-storage-choice input.is-invalid { border-color: rgba(255, 120, 100, 0.75); } + +/* The relocate command, beside a copy button. Not a wrapped 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" <