Spaces:
Running
Running
k0880k commited on
Commit ·
71fa6eb
1
Parent(s): d7a25e9
fix(migration): keep preflight controls on canonical first-load pipeline
Browse files- core/migration_ui_stability.py +71 -84
core/migration_ui_stability.py
CHANGED
|
@@ -2,19 +2,11 @@
|
|
| 2 |
|
| 3 |
Older migration UI layers still exist as implementation machinery, but they may
|
| 4 |
not infer roles, auto-select a Joomla runtime, or issue their own plans when a
|
| 5 |
-
selector changes.
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
that site's database proof immediately. This is *not* a migration preflight and
|
| 11 |
-
never chooses a role from option population; it only opens a fresh DB connection
|
| 12 |
-
using the already-registered node configuration so an idle MySQL session cannot
|
| 13 |
-
be mistaken for missing credentials.
|
| 14 |
-
|
| 15 |
-
This final script pass also keeps fleet population placeholder-first and narrows
|
| 16 |
-
connectivity observers so rendering status cannot recursively trigger probes.
|
| 17 |
-
Server-side live-write gates are untouched.
|
| 18 |
"""
|
| 19 |
from __future__ import annotations
|
| 20 |
|
|
@@ -32,19 +24,16 @@ _REGISTERED_SITE_RECONNECT = r"""
|
|
| 32 |
(() => {
|
| 33 |
if (window.__signalmeshRegisteredSiteReconnect) return;
|
| 34 |
window.__signalmeshRegisteredSiteReconnect = true;
|
| 35 |
-
|
| 36 |
const bind = () => {
|
| 37 |
const src = document.querySelector('#mg-src');
|
| 38 |
const dst = document.querySelector('#mg-dst');
|
| 39 |
if (!src || !dst) return false;
|
| 40 |
-
|
| 41 |
const apiBase = (typeof API_BASE !== 'undefined') ? API_BASE : location.pathname.replace(/\/[^/]*$/, '');
|
| 42 |
const carried = () => (typeof sessionHeader === 'function' ? sessionHeader() : '');
|
| 43 |
const phase = document.querySelector('#mg-phase');
|
| 44 |
const msg = document.querySelector('#mg-msg');
|
| 45 |
const adminNote = document.querySelector('#mg-admin-creds .muted');
|
| 46 |
if (adminNote) adminNote.textContent = 'Joomla administrator only — not database credentials. Used only to install missing software on the target.';
|
| 47 |
-
|
| 48 |
const show = (text, bad=false) => {
|
| 49 |
if (phase) phase.textContent = text;
|
| 50 |
if (bad && msg) {
|
|
@@ -52,44 +41,33 @@ _REGISTERED_SITE_RECONNECT = r"""
|
|
| 52 |
else msg.textContent = text;
|
| 53 |
}
|
| 54 |
};
|
| 55 |
-
|
| 56 |
async function reconnect(role, sel) {
|
| 57 |
const site = String(sel.value || '').trim();
|
| 58 |
if (!site) return;
|
| 59 |
const seq = Number(sel.dataset.smReconnectSeq || 0) + 1;
|
| 60 |
sel.dataset.smReconnectSeq = String(seq);
|
| 61 |
show(`Reconnecting ${site}…`);
|
| 62 |
-
|
| 63 |
-
const
|
| 64 |
-
q.set(role, site);
|
| 65 |
-
const headers = {};
|
| 66 |
-
if (carried()) headers['X-Kyklos-Session'] = carried();
|
| 67 |
-
|
| 68 |
try {
|
| 69 |
-
const r = await fetch(apiBase + '/api/migration-suite/state?' + q.toString(), {
|
| 70 |
-
credentials: 'same-origin', headers, cache: 'no-store'
|
| 71 |
-
});
|
| 72 |
const body = await r.json().catch(() => ({}));
|
| 73 |
if (Number(sel.dataset.smReconnectSeq) !== seq) return;
|
| 74 |
if (!r.ok) throw new Error(body?.detail || body?.message || `Reconnect failed (${r.status})`);
|
| 75 |
-
|
| 76 |
const p = body?.[role] || null;
|
| 77 |
const ok = role === 'source'
|
| 78 |
? !!(p?.read_proven && p?.inventory_proven && p?.joomla)
|
| 79 |
: !!(p?.read_proven && p?.inventory_proven && p?.write_proven && p?.joomla);
|
| 80 |
if (!ok) {
|
| 81 |
-
const detail = (p?.checks || []).filter(x => !x.ok).map(x => x.detail).filter(Boolean).join(' · ')
|
| 82 |
-
|| p?.error || 'Joomla database proof did not pass.';
|
| 83 |
throw new Error(detail);
|
| 84 |
}
|
| 85 |
-
|
| 86 |
show(`${site} reconnected · Joomla ${p.joomla || '?'} · ${p.database || 'database ready'}`);
|
| 87 |
} catch (e) {
|
| 88 |
if (Number(sel.dataset.smReconnectSeq) !== seq) return;
|
| 89 |
show(`Registered site ${site} could not reconnect: ${e.message || String(e)}`, true);
|
| 90 |
}
|
| 91 |
}
|
| 92 |
-
|
| 93 |
for (const [role, sel] of [['source', src], ['target', dst]]) {
|
| 94 |
if (sel.dataset.smReconnectBound === '1') continue;
|
| 95 |
sel.dataset.smReconnectBound = '1';
|
|
@@ -97,7 +75,6 @@ _REGISTERED_SITE_RECONNECT = r"""
|
|
| 97 |
}
|
| 98 |
return true;
|
| 99 |
};
|
| 100 |
-
|
| 101 |
if (!bind()) {
|
| 102 |
const obs = new MutationObserver(() => { if (bind()) obs.disconnect(); });
|
| 103 |
obs.observe(document.documentElement, {childList:true, subtree:true});
|
|
@@ -110,69 +87,78 @@ _SAFE_MGFILL = r"""
|
|
| 110 |
window.mgFill = function(sites) {
|
| 111 |
const rows = Array.isArray(sites) ? sites : [];
|
| 112 |
for (const [selector, role] of [['#mg-src','source'], ['#mg-dst','target']]) {
|
| 113 |
-
const sel = document.querySelector(selector);
|
| 114 |
-
if (!sel) continue;
|
| 115 |
sel.innerHTML = '';
|
| 116 |
-
const ph = document.createElement('option');
|
| 117 |
-
ph.value = '';
|
| 118 |
-
ph.textContent = `— connect/select ${role} —`;
|
| 119 |
-
sel.append(ph);
|
| 120 |
for (const row of rows) {
|
| 121 |
const value = typeof row === 'string' ? row : (row?.site || row?.id || '');
|
| 122 |
if (!value) continue;
|
| 123 |
-
const opt = document.createElement('option');
|
| 124 |
-
opt.value = value;
|
| 125 |
-
opt.textContent = value;
|
| 126 |
-
sel.append(opt);
|
| 127 |
}
|
| 128 |
sel.value = '';
|
| 129 |
}
|
| 130 |
-
const box = document.querySelector('#migratebox');
|
| 131 |
-
if (box) box.classList.add('sm-pipeline-superseded');
|
| 132 |
};
|
| 133 |
})();
|
| 134 |
"""
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
def _remove_legacy_triggers() -> None:
|
| 138 |
-
"""
|
| 139 |
-
suite._SCRIPT = suite._SCRIPT.replace(
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
)
|
| 144 |
-
suite._SCRIPT = suite._SCRIPT.replace(
|
| 145 |
-
"if(!version)version=body.desired_target_joomla||'';",
|
| 146 |
-
"// Desired Joomla is explicit in the guided pipeline; never auto-adopt it here.",
|
| 147 |
-
1,
|
| 148 |
-
)
|
| 149 |
-
suite._SCRIPT = re.sub(
|
| 150 |
-
r"\n\s*const obs=new MutationObserver\(\(\)=>\{if\(src\?\.options\.length&&dst\?\.options\.length\)load\(\)\.catch\(\(\)=>\{\}\);\}\);if\(src&&dst\)\{obs\.observe\(src,\{childList:true\}\);obs\.observe\(dst,\{childList:true\}\);\}\n",
|
| 151 |
-
"\n",
|
| 152 |
-
suite._SCRIPT,
|
| 153 |
-
count=1,
|
| 154 |
-
)
|
| 155 |
-
|
| 156 |
-
# Strip the two anonymous explicit-selector listeners independently so this
|
| 157 |
-
# remains correct whether the source script indents them or a test fixture
|
| 158 |
-
# does not. The guided pipeline owns migration planning, but a separate
|
| 159 |
-
# reconnect-only listener is appended below for the historical selectors.
|
| 160 |
-
unified._UNIFIED_JS = unified._UNIFIED_JS.replace(
|
| 161 |
-
"src.addEventListener('change',()=>fetchPlan().catch(e=>setMsg(e.message,true)));",
|
| 162 |
-
"// Guided migration owns SOURCE changes.",
|
| 163 |
-
1,
|
| 164 |
-
)
|
| 165 |
-
unified._UNIFIED_JS = unified._UNIFIED_JS.replace(
|
| 166 |
-
"dst.addEventListener('change',()=>fetchPlan().catch(e=>setMsg(e.message,true)));",
|
| 167 |
-
"// Guided migration owns TARGET changes.",
|
| 168 |
-
1,
|
| 169 |
-
)
|
| 170 |
-
unified._UNIFIED_JS = re.sub(
|
| 171 |
-
r"\n\s*// loadFleet/mgFill populate the selects asynchronously\.[\s\S]*?if\(src\.options\.length>1&&dst\.options\.length>1\)fetchPlan\(\)\.catch\(\(\)=>\{\}\);\n",
|
| 172 |
-
"\n",
|
| 173 |
-
unified._UNIFIED_JS,
|
| 174 |
-
count=1,
|
| 175 |
-
)
|
| 176 |
if "__signalmeshRegisteredSiteReconnect" not in unified._UNIFIED_JS:
|
| 177 |
unified._UNIFIED_JS += "\n" + _REGISTERED_SITE_RECONNECT
|
| 178 |
|
|
@@ -184,10 +170,11 @@ def _install_safe_fleet_fill() -> None:
|
|
| 184 |
|
| 185 |
def install() -> None:
|
| 186 |
global _INSTALLED
|
| 187 |
-
if _INSTALLED:
|
| 188 |
-
return
|
| 189 |
_remove_legacy_triggers()
|
| 190 |
_install_safe_fleet_fill()
|
| 191 |
if _OLD_CONNECTIVITY in suite._SCRIPT:
|
| 192 |
suite._SCRIPT = suite._SCRIPT.replace(_OLD_CONNECTIVITY, _NEW_CONNECTIVITY, 1)
|
|
|
|
|
|
|
| 193 |
_INSTALLED = True
|
|
|
|
| 2 |
|
| 3 |
Older migration UI layers still exist as implementation machinery, but they may
|
| 4 |
not infer roles, auto-select a Joomla runtime, or issue their own plans when a
|
| 5 |
+
selector changes. The guided route controller is the sole browser owner.
|
| 6 |
+
|
| 7 |
+
This final pass also exposes a persistent preflight control on the canonical
|
| 8 |
+
pipeline. Preflight is diagnostic/read-only and must never be hidden behind a
|
| 9 |
+
failed live migration.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
from __future__ import annotations
|
| 12 |
|
|
|
|
| 24 |
(() => {
|
| 25 |
if (window.__signalmeshRegisteredSiteReconnect) return;
|
| 26 |
window.__signalmeshRegisteredSiteReconnect = true;
|
|
|
|
| 27 |
const bind = () => {
|
| 28 |
const src = document.querySelector('#mg-src');
|
| 29 |
const dst = document.querySelector('#mg-dst');
|
| 30 |
if (!src || !dst) return false;
|
|
|
|
| 31 |
const apiBase = (typeof API_BASE !== 'undefined') ? API_BASE : location.pathname.replace(/\/[^/]*$/, '');
|
| 32 |
const carried = () => (typeof sessionHeader === 'function' ? sessionHeader() : '');
|
| 33 |
const phase = document.querySelector('#mg-phase');
|
| 34 |
const msg = document.querySelector('#mg-msg');
|
| 35 |
const adminNote = document.querySelector('#mg-admin-creds .muted');
|
| 36 |
if (adminNote) adminNote.textContent = 'Joomla administrator only — not database credentials. Used only to install missing software on the target.';
|
|
|
|
| 37 |
const show = (text, bad=false) => {
|
| 38 |
if (phase) phase.textContent = text;
|
| 39 |
if (bad && msg) {
|
|
|
|
| 41 |
else msg.textContent = text;
|
| 42 |
}
|
| 43 |
};
|
|
|
|
| 44 |
async function reconnect(role, sel) {
|
| 45 |
const site = String(sel.value || '').trim();
|
| 46 |
if (!site) return;
|
| 47 |
const seq = Number(sel.dataset.smReconnectSeq || 0) + 1;
|
| 48 |
sel.dataset.smReconnectSeq = String(seq);
|
| 49 |
show(`Reconnecting ${site}…`);
|
| 50 |
+
const q = new URLSearchParams(); q.set(role, site);
|
| 51 |
+
const headers = {}; if (carried()) headers['X-Kyklos-Session'] = carried();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
try {
|
| 53 |
+
const r = await fetch(apiBase + '/api/migration-suite/state?' + q.toString(), {credentials:'same-origin', headers, cache:'no-store'});
|
|
|
|
|
|
|
| 54 |
const body = await r.json().catch(() => ({}));
|
| 55 |
if (Number(sel.dataset.smReconnectSeq) !== seq) return;
|
| 56 |
if (!r.ok) throw new Error(body?.detail || body?.message || `Reconnect failed (${r.status})`);
|
|
|
|
| 57 |
const p = body?.[role] || null;
|
| 58 |
const ok = role === 'source'
|
| 59 |
? !!(p?.read_proven && p?.inventory_proven && p?.joomla)
|
| 60 |
: !!(p?.read_proven && p?.inventory_proven && p?.write_proven && p?.joomla);
|
| 61 |
if (!ok) {
|
| 62 |
+
const detail = (p?.checks || []).filter(x => !x.ok).map(x => x.detail).filter(Boolean).join(' · ') || p?.error || 'Joomla database proof did not pass.';
|
|
|
|
| 63 |
throw new Error(detail);
|
| 64 |
}
|
|
|
|
| 65 |
show(`${site} reconnected · Joomla ${p.joomla || '?'} · ${p.database || 'database ready'}`);
|
| 66 |
} catch (e) {
|
| 67 |
if (Number(sel.dataset.smReconnectSeq) !== seq) return;
|
| 68 |
show(`Registered site ${site} could not reconnect: ${e.message || String(e)}`, true);
|
| 69 |
}
|
| 70 |
}
|
|
|
|
| 71 |
for (const [role, sel] of [['source', src], ['target', dst]]) {
|
| 72 |
if (sel.dataset.smReconnectBound === '1') continue;
|
| 73 |
sel.dataset.smReconnectBound = '1';
|
|
|
|
| 75 |
}
|
| 76 |
return true;
|
| 77 |
};
|
|
|
|
| 78 |
if (!bind()) {
|
| 79 |
const obs = new MutationObserver(() => { if (bind()) obs.disconnect(); });
|
| 80 |
obs.observe(document.documentElement, {childList:true, subtree:true});
|
|
|
|
| 87 |
window.mgFill = function(sites) {
|
| 88 |
const rows = Array.isArray(sites) ? sites : [];
|
| 89 |
for (const [selector, role] of [['#mg-src','source'], ['#mg-dst','target']]) {
|
| 90 |
+
const sel = document.querySelector(selector); if (!sel) continue;
|
|
|
|
| 91 |
sel.innerHTML = '';
|
| 92 |
+
const ph = document.createElement('option'); ph.value = ''; ph.textContent = `— connect/select ${role} —`; sel.append(ph);
|
|
|
|
|
|
|
|
|
|
| 93 |
for (const row of rows) {
|
| 94 |
const value = typeof row === 'string' ? row : (row?.site || row?.id || '');
|
| 95 |
if (!value) continue;
|
| 96 |
+
const opt = document.createElement('option'); opt.value = value; opt.textContent = value; sel.append(opt);
|
|
|
|
|
|
|
|
|
|
| 97 |
}
|
| 98 |
sel.value = '';
|
| 99 |
}
|
| 100 |
+
const box = document.querySelector('#migratebox'); if (box) box.classList.add('sm-pipeline-superseded');
|
|
|
|
| 101 |
};
|
| 102 |
})();
|
| 103 |
"""
|
| 104 |
|
| 105 |
+
_PERSISTENT_PREFLIGHT = r"""
|
| 106 |
+
(() => {
|
| 107 |
+
if (window.__signalmeshPersistentPreflight) return;
|
| 108 |
+
window.__signalmeshPersistentPreflight = true;
|
| 109 |
+
const apiBase = () => (typeof API_BASE !== 'undefined') ? API_BASE : location.pathname.replace(/\/[^/]*$/, '');
|
| 110 |
+
const carried = () => (typeof sessionHeader === 'function' ? sessionHeader() : '');
|
| 111 |
+
const selected = role => document.querySelector(`[data-role-select="${role}"]`)?.value || document.querySelector(`[data-step="${role}"] .sm-pipe-choice b`)?.textContent?.trim() || '';
|
| 112 |
+
const version = () => document.querySelector('#sm-target-version')?.value || document.querySelector('[data-step="version"] .sm-pipe-choice b')?.textContent?.replace(/^Joomla\s+/,'').trim() || '';
|
| 113 |
+
|
| 114 |
+
function ensure() {
|
| 115 |
+
const root = document.querySelector('#sm-guided-pipeline');
|
| 116 |
+
if (!root) return;
|
| 117 |
+
let box = document.querySelector('#sm-persistent-preflight');
|
| 118 |
+
if (!box) {
|
| 119 |
+
box = document.createElement('section');
|
| 120 |
+
box.id = 'sm-persistent-preflight';
|
| 121 |
+
box.className = 'sm-pipe-step active';
|
| 122 |
+
box.innerHTML = `<div class="sm-pipe-num">P</div><div class="sm-pipe-body"><div class="sm-pipe-head"><h3>Preflight</h3><span class="sm-pipe-status wait" data-pf-status>AVAILABLE</span></div><div class="sm-sub">Read-only proof. Never migrates data. Select source + target; desired Joomla runtime is optional until runtime-contract validation.</div><div class="sm-actions"><button class="primary" data-pf-run>Run preflight</button></div><div data-pf-out class="sm-pipe-evidence">Waiting for source and target selection.</div></div>`;
|
| 123 |
+
root.append(box);
|
| 124 |
+
}
|
| 125 |
+
const btn = box.querySelector('[data-pf-run]');
|
| 126 |
+
if (btn && btn.dataset.bound !== '1') {
|
| 127 |
+
btn.dataset.bound = '1';
|
| 128 |
+
btn.onclick = async () => {
|
| 129 |
+
const source = selected('source'), target = selected('target'), target_version = version();
|
| 130 |
+
const out = box.querySelector('[data-pf-out]'), stat = box.querySelector('[data-pf-status]');
|
| 131 |
+
if (!source || !target) { out.textContent = 'Choose and prove both Source and Target first.'; return; }
|
| 132 |
+
btn.disabled = true; stat.textContent = 'RUNNING'; out.textContent = 'Proving connectivity, inventory, target write authority, component data mapping, extension requirements, compatibility, and runtime contract…';
|
| 133 |
+
try {
|
| 134 |
+
const q = new URLSearchParams({source,target}); if (target_version) q.set('target_version',target_version);
|
| 135 |
+
const headers = {}; if (carried()) headers['X-Kyklos-Session'] = carried();
|
| 136 |
+
const r = await fetch(apiBase() + '/api/migration-suite/state?' + q.toString(), {credentials:'same-origin',headers,cache:'no-store'});
|
| 137 |
+
const body = await r.json().catch(()=>({}));
|
| 138 |
+
if (!r.ok) throw new Error(body?.detail || body?.message || `Preflight failed (${r.status})`);
|
| 139 |
+
const blockers = body?.migration?.blockers || [];
|
| 140 |
+
const req = body?.requirements || {};
|
| 141 |
+
const totals = body?.migration?.database?.totals || {};
|
| 142 |
+
stat.textContent = blockers.length ? 'BLOCKED' : 'PROVEN';
|
| 143 |
+
out.innerHTML = `<div><b>${blockers.length ? 'Preflight found blockers' : 'Preflight passed'}</b></div><div>${Number(totals.rows_to_move||0)} client rows planned · ${Number(body?.migration?.extensions?.required||0)} extension roots</div><div>source ${req.source?'✓':'✗'} · target ${req.target?'✓':'✗'} · target write ${req.target_write?'✓':'✗'} · runtime ${target_version?(req.target_version?'✓':'✗'):'not selected yet'}</div>${blockers.map(b=>`<div class="bad">✗ ${String(b.message||b.kind||'blocker')}</div>`).join('')}`;
|
| 144 |
+
} catch(e) { stat.textContent='FAILED'; out.textContent=e.message||String(e); }
|
| 145 |
+
finally { btn.disabled=false; }
|
| 146 |
+
};
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
ensure();
|
| 150 |
+
const obs = new MutationObserver(ensure); obs.observe(document.documentElement,{childList:true,subtree:true});
|
| 151 |
+
})();
|
| 152 |
+
"""
|
| 153 |
+
|
| 154 |
|
| 155 |
def _remove_legacy_triggers() -> None:
|
| 156 |
+
suite._SCRIPT = suite._SCRIPT.replace("src?.addEventListener('change',()=>{version='';load().catch(showError)});dst?.addEventListener('change',()=>{version='';load().catch(showError)});", "// Guided migration owns selector decisions and preflight.", 1)
|
| 157 |
+
suite._SCRIPT = suite._SCRIPT.replace("if(!version)version=body.desired_target_joomla||'';", "// Desired Joomla is explicit in the guided pipeline; never auto-adopt it here.", 1)
|
| 158 |
+
suite._SCRIPT = re.sub(r"\n\s*const obs=new MutationObserver\(\(\)=>\{if\(src\?\.options\.length&&dst\?\.options\.length\)load\(\)\.catch\(\(\)=>\{\}\);\}\);if\(src&&dst\)\{obs\.observe\(src,\{childList:true\}\);obs\.observe\(dst,\{childList:true\}\);\}\n", "\n", suite._SCRIPT, count=1)
|
| 159 |
+
unified._UNIFIED_JS = unified._UNIFIED_JS.replace("src.addEventListener('change',()=>fetchPlan().catch(e=>setMsg(e.message,true)));", "// Guided migration owns SOURCE changes.", 1)
|
| 160 |
+
unified._UNIFIED_JS = unified._UNIFIED_JS.replace("dst.addEventListener('change',()=>fetchPlan().catch(e=>setMsg(e.message,true)));", "// Guided migration owns TARGET changes.", 1)
|
| 161 |
+
unified._UNIFIED_JS = re.sub(r"\n\s*// loadFleet/mgFill populate the selects asynchronously\.[\s\S]*?if\(src\.options\.length>1&&dst\.options\.length>1\)fetchPlan\(\)\.catch\(\(\)=>\{\}\);\n", "\n", unified._UNIFIED_JS, count=1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
if "__signalmeshRegisteredSiteReconnect" not in unified._UNIFIED_JS:
|
| 163 |
unified._UNIFIED_JS += "\n" + _REGISTERED_SITE_RECONNECT
|
| 164 |
|
|
|
|
| 170 |
|
| 171 |
def install() -> None:
|
| 172 |
global _INSTALLED
|
| 173 |
+
if _INSTALLED: return
|
|
|
|
| 174 |
_remove_legacy_triggers()
|
| 175 |
_install_safe_fleet_fill()
|
| 176 |
if _OLD_CONNECTIVITY in suite._SCRIPT:
|
| 177 |
suite._SCRIPT = suite._SCRIPT.replace(_OLD_CONNECTIVITY, _NEW_CONNECTIVITY, 1)
|
| 178 |
+
if "__signalmeshPersistentPreflight" not in suite._SCRIPT:
|
| 179 |
+
suite._SCRIPT += "\n" + _PERSISTENT_PREFLIGHT
|
| 180 |
_INSTALLED = True
|