appwrite/app/views/install/installer/js/modules/progress.js

992 lines
38 KiB
JavaScript

(() => {
const {
INSTALLATION_STEPS,
TIMINGS,
getBodyDataset,
isUpgradeMode,
STEP_IDS,
STATUS,
SSE_EVENTS
} = window.InstallerStepsContext;
const {
formState,
applyLockPayload,
applyBodyDefaults,
setInstallLock,
getInstallLock,
clearInstallLock,
isInstallLocked,
syncInstallLockFlag,
getStoredInstallId,
storeInstallId,
clearInstallId
} = window.InstallerStepsState || {};
const { extractHostname, isLocalHost, isIPAddress } = window.InstallerStepsValidation || {};
const { generateSecretKey } = window.InstallerStepsUI || {};
const { showToast } = window.InstallerToast || {};
let activeInstall = null;
let unloadGuard = null;
let sseSessionDetails = null;
const csrfToken = document.querySelector('meta[name="appwrite-installer-csrf"]')?.getAttribute('content') || '';
const withCsrfHeader = (headers = {}) => {
if (!csrfToken) {
return headers;
}
return { ...headers, 'X-Appwrite-Installer-CSRF': csrfToken };
};
const showCsrfToast = () => {
showToast?.({
status: 'error',
title: 'Session expired',
description: 'Refresh the page and try again.',
dismissible: true
});
};
const validateInstallRequest = async () => {
try {
const response = await fetch('/install/validate', {
method: 'POST',
headers: withCsrfHeader({
'Content-Type': 'application/json'
})
});
if (!response.ok) {
showCsrfToast();
return false;
}
const data = await response.json().catch(() => ({}));
if (!data?.success) {
showCsrfToast();
return false;
}
return true;
} catch (error) {
showCsrfToast();
return false;
}
};
const setUnloadGuard = (enabled) => {
if (!enabled && unloadGuard) {
window.removeEventListener('beforeunload', unloadGuard);
unloadGuard = null;
return;
}
if (enabled && !unloadGuard) {
unloadGuard = (event) => {
event.preventDefault();
event.returnValue = '';
return '';
};
window.addEventListener('beforeunload', unloadGuard);
}
};
const cleanupInstallFlow = () => {
if (activeInstall?.controller) {
activeInstall.controller.abort();
if (activeInstall.pollTimer) {
clearInterval(activeInstall.pollTimer);
}
if (activeInstall.fallbackTimer) {
clearTimeout(activeInstall.fallbackTimer);
}
activeInstall = null;
}
stopSyncedSpinnerRotation();
setUnloadGuard(false);
};
const getStepDefinition = (id) => INSTALLATION_STEPS.find((step) => step.id === id);
const getProgressLabel = (step, status, message) => {
if (!step) return message || '';
if (status === STATUS.ERROR) {
const normalized = normalizeInstallError(message || '');
return normalized.summary || 'Installation failed.';
}
if (status === STATUS.COMPLETED) return step.done;
return step.inProgress;
};
const updateInstallRow = (row, step, status, message) => {
if (!row || !step) return;
row.dataset.status = status;
row.dataset.step = step.id;
if (status !== STATUS.ERROR) {
row.classList.remove('is-open');
const toggle = row.querySelector('[data-install-toggle]');
if (toggle) {
toggle.setAttribute('aria-expanded', 'false');
}
}
const label = getProgressLabel(step, status, message);
const text = row.querySelector('[data-install-text]');
if (text) {
if (text.textContent !== label) {
text.classList.remove('is-enter');
text.textContent = label;
text.classList.add('is-enter');
requestAnimationFrame(() => {
text.classList.remove('is-enter');
});
}
}
// Show/hide "Navigate to Console" button for account setup errors
const consoleBtn = row.querySelector('[data-install-console]');
if (consoleBtn) {
const shouldShow = step.id === STEP_IDS.ACCOUNT_SETUP && status === STATUS.ERROR;
consoleBtn.classList.toggle('is-hidden', !shouldShow);
}
};
const normalizeInstallError = (message) => {
const text = String(message || '').trim();
if (!text) {
return { summary: '', details: '' };
}
const colonIndex = text.indexOf(':');
if (colonIndex > 0 && colonIndex < 80) {
const summary = text.slice(0, colonIndex).trim();
const details = text.slice(colonIndex + 1).trim();
return { summary, details };
}
if (text.length > 180) {
return { summary: text.slice(0, 180).trim() + '…', details: text };
}
return { summary: text, details: '' };
};
let spinnerAnimationFrame = null;
const stopSyncedSpinnerRotation = () => {
if (spinnerAnimationFrame) {
cancelAnimationFrame(spinnerAnimationFrame);
spinnerAnimationFrame = null;
}
};
const startSyncedSpinnerRotation = (container) => {
stopSyncedSpinnerRotation();
if (!container) return;
let startTime = null;
const animate = (timestamp) => {
if (!startTime) startTime = timestamp;
const elapsed = timestamp - startTime;
const rotation = ((elapsed / 1000) * 360 * 1.5) % 360;
container.style.setProperty('--spinner-rotation', `${rotation}deg`);
spinnerAnimationFrame = requestAnimationFrame(animate);
};
spinnerAnimationFrame = requestAnimationFrame(animate);
};
const updateInstallErrorDetails = (row, error) => {
if (!row) return;
const traceNode = row.querySelector('[data-install-trace]');
const normalized = normalizeInstallError(error?.message || '');
const output = error?.output || '';
const trace = error?.trace || '';
const detailChunks = [];
if (normalized.details) detailChunks.push(normalized.details);
if (output) detailChunks.push(output);
if (trace) detailChunks.push(trace);
const detailText = detailChunks.join('\n\n');
if (traceNode) {
traceNode.textContent = detailText;
traceNode.style.display = detailText ? 'block' : 'none';
}
};
const createInstallRow = (template, step) => {
const fragment = template.content.cloneNode(true);
const row = fragment.querySelector('.install-row');
if (!row) return null;
const toggle = row.querySelector('[data-install-toggle]');
const setOpenState = (isOpen) => {
row.classList.toggle('is-open', isOpen);
if (toggle) {
toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
}
};
const toggleRow = () => {
if (!row.dataset.status || row.dataset.status !== STATUS?.ERROR) {
return;
}
setOpenState(!row.classList.contains('is-open'));
};
row.addEventListener('click', (event) => {
if (event.target.closest('[data-install-retry]')) {
return;
}
if (event.target.closest('[data-install-toggle]')) {
return;
}
if (event.target.closest('.install-row-details')) {
return;
}
toggleRow();
});
if (toggle) {
toggle.addEventListener('click', (event) => {
event.stopPropagation();
toggleRow();
});
}
updateInstallRow(row, step, STATUS.IN_PROGRESS);
return row;
};
const generateInstallId = () => {
if (window.crypto?.randomUUID) {
return window.crypto.randomUUID();
}
const bytes = new Uint8Array(16);
window.crypto.getRandomValues(bytes);
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
};
const buildRedirectUrl = (protocol) => {
const dataset = getBodyDataset?.() ?? {};
const rawDomain = (formState?.appDomain || dataset.defaultAppDomain || '').trim();
if (!rawDomain) return '';
const httpPort = (formState?.httpPort || dataset.defaultHttpPort || '').trim();
const httpsPort = (formState?.httpsPort || dataset.defaultHttpsPort || '').trim();
const hasPort = rawDomain.includes(':') || rawDomain.startsWith('[');
let host = rawDomain;
const hostForProtocol = extractHostname?.(rawDomain);
const normalizedHost = hostForProtocol?.toLowerCase?.() ?? '';
if (hostForProtocol === '0.0.0.0') {
host = rawDomain.replace('0.0.0.0', 'localhost');
} else if (normalizedHost === 'traefik') {
host = rawDomain.replace(hostForProtocol, 'localhost');
}
const port = protocol === 'https' ? httpsPort : httpPort;
const defaultPort = protocol === 'https' ? '443' : '80';
if (!hasPort && port && port !== defaultPort) {
host = `${host}:${port}`;
}
return `${protocol}://${host}`;
};
const canUseHttps = () => {
const dataset = getBodyDataset?.() ?? {};
const rawDomain = (formState?.appDomain || dataset.defaultAppDomain || '').trim();
const httpsPort = (formState?.httpsPort || dataset.defaultHttpsPort || '').trim();
if (!httpsPort || httpsPort === '0') return false;
const hostname = extractHostname?.(rawDomain)?.toLowerCase?.() ?? '';
return !isLocalHost?.(hostname) && !isIPAddress?.(hostname);
};
const pollCertificate = async (domain, port, maxAttempts, intervalMs) => {
for (let i = 0; i < maxAttempts; i++) {
try {
const response = await fetch(`/install/certificate?domain=${encodeURIComponent(domain)}&port=${encodeURIComponent(port)}`);
if (response.ok) {
const data = await response.json();
if (data.ready) return true;
}
} catch {
// Installer server may have shut down
}
if (i < maxAttempts - 1) {
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
}
return false;
};
const redirectToApp = (protocol) => {
const url = buildRedirectUrl(protocol);
if (!url) return;
fetch('/install/shutdown', { method: 'POST', headers: withCsrfHeader() }).catch(() => {});
window.location.href = url;
};
const notifyInstallComplete = (installId, session) => {
if (!installId) return Promise.resolve();
const payload = { installId };
const sessionSecret = session?.sessionSecret || session?.secret;
const sessionId = session?.sessionId || session?.id;
const sessionExpire = session?.sessionExpire || session?.expire;
if (sessionSecret) {
payload.sessionSecret = sessionSecret;
}
if (sessionId) {
payload.sessionId = sessionId;
}
if (sessionExpire) {
payload.sessionExpire = sessionExpire;
}
return fetch('/install/complete', {
method: 'POST',
headers: withCsrfHeader({
'Content-Type': 'application/json'
}),
body: JSON.stringify(payload)
}).catch(() => {});
};
const buildInstallPayload = (installId) => {
const normalizedSecret = (formState?.opensslKey || '').trim();
if (!normalizedSecret && generateSecretKey && !isUpgradeMode?.()) {
formState.opensslKey = generateSecretKey();
}
const normalizedDomain = (formState?.appDomain || '').trim() || 'localhost';
const normalizedHttpPort = (formState?.httpPort || '').trim() || '80';
const normalizedHttpsPort = (formState?.httpsPort || '').trim() || '443';
const normalizedEmail = (formState?.emailCertificates || '').trim();
const normalizedAssistantKey = (formState?.assistantOpenAIKey || '').trim();
const normalizedAccountEmail = (formState?.accountEmail || '').trim();
const normalizedAccountPassword = (formState?.accountPassword || '').trim();
return {
installId,
httpPort: normalizedHttpPort,
httpsPort: normalizedHttpsPort,
database: formState?.database || 'mongodb',
appDomain: normalizedDomain,
emailCertificates: normalizedEmail,
opensslKey: (formState?.opensslKey || '').trim(),
assistantOpenAIKey: normalizedAssistantKey,
accountEmail: normalizedAccountEmail,
accountPassword: normalizedAccountPassword
};
};
const fetchInstallStatus = async (installId) => {
if (!installId) return null;
const response = await fetch(`/install/status?installId=${encodeURIComponent(installId)}`, {
cache: 'no-store'
});
if (!response.ok) return null;
const json = await response.json();
return json.progress || null;
};
const readEventStream = async (stream, onEvent) => {
const reader = stream.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
try {
const processEvent = (rawEvent) => {
if (!rawEvent) return;
const lines = rawEvent.split('\n');
let eventName = 'message';
let data = '';
lines.forEach((line) => {
if (line.startsWith('event:')) {
eventName = line.replace('event:', '').trim();
} else if (line.startsWith('data:')) {
data += line.replace('data:', '').trim();
}
});
if (data) {
try {
const parsed = JSON.parse(data);
onEvent(eventName, parsed);
} catch (error) {
onEvent(eventName, { message: data });
}
}
};
while (true) {
const { value, done } = await reader.read();
if (done) {
buffer = buffer.replace(/\r\n/g, '\n');
if (buffer.trim()) {
processEvent(buffer);
}
break;
}
buffer += decoder.decode(value, { stream: true });
buffer = buffer.replace(/\r\n/g, '\n');
let separatorIndex = buffer.indexOf('\n\n');
while (separatorIndex !== -1) {
const rawEvent = buffer.slice(0, separatorIndex);
buffer = buffer.slice(separatorIndex + 2);
processEvent(rawEvent);
separatorIndex = buffer.indexOf('\n\n');
}
}
} finally {
try {
reader.releaseLock();
} catch (error) {}
}
};
const initStep5 = (root) => {
if (!root) return;
let resolvedProtocol = 'http';
if (activeInstall?.controller) {
activeInstall.controller.abort();
}
if (activeInstall?.pollTimer) {
clearInterval(activeInstall.pollTimer);
}
if (activeInstall?.fallbackTimer) {
clearTimeout(activeInstall.fallbackTimer);
}
activeInstall = null;
const list = root.querySelector('[data-install-list]');
const template = root.querySelector('#install-row-template');
if (!list || !template) return;
startSyncedSpinnerRotation(list);
list.innerHTML = '';
const rowsById = new Map();
const progressState = new Map();
syncInstallLockFlag?.();
applyLockPayload?.();
applyBodyDefaults?.();
const ensureRow = (step) => {
if (!step) return null;
if (rowsById.has(step.id)) {
return rowsById.get(step.id);
}
const row = createInstallRow(template, step);
if (!row) return null;
row.classList.add('is-entering');
list.appendChild(row);
row.getBoundingClientRect();
requestAnimationFrame(() => {
row.classList.remove('is-entering');
});
rowsById.set(step.id, row);
return row;
};
const installPanel = root.querySelector('.install-panel');
let panelHeightCleanup = null;
const animatePanelHeight = (mutate) => {
if (!installPanel) {
mutate();
return;
}
if (panelHeightCleanup) {
panelHeightCleanup();
panelHeightCleanup = null;
}
const currentHeight = installPanel.getBoundingClientRect().height;
installPanel.style.height = `${currentHeight}px`;
installPanel.getBoundingClientRect();
mutate();
const nextHeight = installPanel.getBoundingClientRect().height;
if (currentHeight === nextHeight) {
installPanel.style.height = '';
return;
}
installPanel.style.height = `${currentHeight}px`;
installPanel.getBoundingClientRect();
installPanel.style.height = `${nextHeight}px`;
const cleanup = () => {
installPanel.style.height = '';
installPanel.removeEventListener('transitionend', onEnd);
};
const onEnd = (event) => {
if (event.propertyName === 'height') {
cleanup();
}
};
panelHeightCleanup = cleanup;
installPanel.addEventListener('transitionend', onEnd);
};
const renderProgress = () => {
animatePanelHeight(() => {
const visibleSteps = [];
for (const step of INSTALLATION_STEPS) {
const state = progressState.get(step.id);
if (!state) break;
visibleSteps.push(step);
}
visibleSteps.forEach((step) => {
const state = progressState.get(step.id);
if (!state) return;
const row = ensureRow(step);
if (row) {
updateInstallRow(row, step, state.status || STATUS.IN_PROGRESS, state.message);
if (state.status === STATUS?.ERROR) {
updateInstallErrorDetails(row, {
message: state.message,
trace: state.details?.trace,
output: state.details?.output
});
}
}
});
});
};
const firstStep = INSTALLATION_STEPS[0];
if (firstStep) {
progressState.set(firstStep.id, {
status: STATUS.IN_PROGRESS,
message: firstStep.inProgress
});
}
renderProgress();
const applyProgress = (payload) => {
const step = getStepDefinition(payload.step) || {
id: payload.step,
inProgress: payload.message || payload.step,
done: payload.message || payload.step
};
if (step.id === STEP_IDS.ACCOUNT_SETUP && payload.details?.sessionSecret) {
sseSessionDetails = payload.details;
}
progressState.set(step.id, {
status: payload.status || STATUS.IN_PROGRESS,
message: payload.message,
details: payload.details
});
renderProgress();
if (activeInstall) {
activeInstall.lastEventAt = Date.now();
if (payload.status === STATUS.ERROR) {
if (activeInstall.pollTimer) {
clearInterval(activeInstall.pollTimer);
activeInstall.pollTimer = null;
}
if (activeInstall.fallbackTimer) {
clearTimeout(activeInstall.fallbackTimer);
activeInstall.fallbackTimer = null;
}
}
}
scheduleFallback();
};
const handleProgress = (payload) => {
if (!payload || !payload.step) return;
const existingState = progressState.get(payload.step);
if (existingState && existingState.status === STATUS.COMPLETED && payload.status === STATUS.IN_PROGRESS) {
return;
}
const step = getStepDefinition(payload.step) || {
id: payload.step,
inProgress: payload.message || payload.step,
done: payload.message || payload.step
};
if (payload.status === STATUS.IN_PROGRESS) {
const currentIndex = INSTALLATION_STEPS.findIndex((candidate) => candidate.id === step.id);
if (currentIndex > 0) {
for (let i = 0; i < currentIndex; i += 1) {
const previousStep = INSTALLATION_STEPS[i];
const previousState = progressState.get(previousStep.id);
if (previousState && previousState.status !== STATUS.COMPLETED) {
progressState.set(previousStep.id, {
status: STATUS.COMPLETED,
message: previousStep.done,
details: previousState.details
});
}
}
}
}
applyProgress(payload);
};
const applySnapshot = (snapshot) => {
if (!snapshot || !snapshot.steps) return;
INSTALLATION_STEPS.forEach((step) => {
const detail = snapshot.steps[step.id];
if (!detail) return;
progressState.set(step.id, {
status: detail.status,
message: detail.message,
details: snapshot.details?.[step.id]
});
});
renderProgress();
};
const checkAllCompleted = () => {
const allDone = INSTALLATION_STEPS.every((step) => {
const state = progressState.get(step.id);
return state && state.status === STATUS.COMPLETED;
});
if (!allDone) return;
const accountState = progressState.get(STEP_IDS.ACCOUNT_SETUP);
const sessionDetails = sseSessionDetails || accountState?.details;
finalizeInstall();
startSslCheck(sessionDetails);
};
const startPolling = () => {
if (!activeInstall || activeInstall.pollTimer) return;
activeInstall.pollTimer = setInterval(async () => {
if (!activeInstall || activeInstall.completed) return;
const snapshot = await fetchInstallStatus(activeInstall.installId);
if (snapshot) {
applySnapshot(snapshot);
checkAllCompleted();
}
}, TIMINGS?.installPollInterval ?? 0);
};
const scheduleFallback = () => {
if (!activeInstall) return;
if (activeInstall.fallbackTimer) {
clearTimeout(activeInstall.fallbackTimer);
}
activeInstall.fallbackTimer = setTimeout(() => {
if (!activeInstall) return;
startPolling();
}, TIMINGS?.installFallbackDelay ?? 0);
};
const finalizeInstall = () => {
if (!activeInstall) return;
activeInstall.completed = true;
if (activeInstall.pollTimer) {
clearInterval(activeInstall.pollTimer);
}
if (activeInstall.fallbackTimer) {
clearTimeout(activeInstall.fallbackTimer);
}
stopSyncedSpinnerRotation();
setUnloadGuard(false);
};
const SSL_STEP = {
id: STEP_IDS.SSL_CERTIFICATE,
inProgress: 'Generating SSL certificate...',
done: 'SSL certificate verified'
};
const REDIRECT_STEP = {
id: STEP_IDS.REDIRECT,
inProgress: 'Redirecting to console...',
done: 'Redirecting to console...'
};
const showRedirectStep = (sessionDetails, protocol) => {
animatePanelHeight(() => {
progressState.set(REDIRECT_STEP.id, {
status: STATUS.IN_PROGRESS,
message: REDIRECT_STEP.inProgress
});
const row = ensureRow(REDIRECT_STEP);
if (row) {
updateInstallRow(row, REDIRECT_STEP, STATUS.IN_PROGRESS, REDIRECT_STEP.inProgress);
}
});
startSyncedSpinnerRotation(list);
notifyInstallComplete(activeInstall?.installId, sessionDetails).finally(() => {
setTimeout(() => redirectToApp(protocol), TIMINGS?.redirectDelay ?? 0);
});
};
const startSslCheck = (sessionDetails) => {
if (!canUseHttps()) {
showRedirectStep(sessionDetails, 'http');
return;
}
animatePanelHeight(() => {
progressState.set(SSL_STEP.id, {
status: STATUS.IN_PROGRESS,
message: SSL_STEP.inProgress
});
const row = ensureRow(SSL_STEP);
if (row) {
updateInstallRow(row, SSL_STEP, STATUS.IN_PROGRESS, SSL_STEP.inProgress);
}
});
startSyncedSpinnerRotation(list);
const dataset = getBodyDataset?.() ?? {};
const rawDomain = (formState?.appDomain || dataset.defaultAppDomain || '').trim();
const httpsPort = (formState?.httpsPort || dataset.defaultHttpsPort || '443').trim();
const domain = extractHostname?.(rawDomain) || rawDomain;
pollCertificate(domain, httpsPort, 15, 2000).then((ready) => {
stopSyncedSpinnerRotation();
const certMessage = ready ? SSL_STEP.done : 'Certificate pending';
animatePanelHeight(() => {
progressState.set(SSL_STEP.id, {
status: STATUS.COMPLETED,
message: certMessage
});
const row = ensureRow(SSL_STEP);
if (row) {
updateInstallRow(row, SSL_STEP, STATUS.COMPLETED, certMessage);
}
});
resolvedProtocol = ready ? 'https' : 'http';
showRedirectStep(sessionDetails, resolvedProtocol);
});
};
const startInstallStream = async (installId, options = {}) => {
const isValid = await validateInstallRequest();
if (!isValid) {
return;
}
activeInstall = {
installId,
controller: new AbortController(),
lastEventAt: Date.now(),
pollTimer: null,
fallbackTimer: null,
completed: false
};
const payload = buildInstallPayload(installId);
if (options.retryStep) {
payload.retryStep = options.retryStep;
}
setInstallLock?.(installId, payload);
setUnloadGuard(true);
try {
scheduleFallback();
const response = await fetch('/install', {
method: 'POST',
headers: withCsrfHeader({
'Content-Type': 'application/json',
'Accept': 'text/event-stream'
}),
body: JSON.stringify(payload),
signal: activeInstall.controller.signal
});
if (!response.ok || !response.body) {
let errorMessage = null;
try {
const contentType = response.headers.get('Content-Type') || '';
if (contentType.includes('application/json')) {
const data = await response.json();
errorMessage = data?.message || null;
}
} catch (error) {
errorMessage = null;
}
if (errorMessage) {
handleProgress({
step: STEP_IDS.CONFIG_FILES,
status: STATUS.ERROR,
message: errorMessage
});
finalizeInstall();
return;
}
startPolling();
return;
}
await readEventStream(response.body, (event, data) => {
if (!activeInstall) return;
if (event === SSE_EVENTS.INSTALL_ID && data?.installId) {
activeInstall.installId = data.installId;
storeInstallId?.(data.installId);
return;
}
if (event === SSE_EVENTS.PROGRESS) {
handleProgress(data);
return;
}
if (event === SSE_EVENTS.DONE) {
// Mark every step as completed (preserving details
// from earlier progress events, e.g. session info).
INSTALLATION_STEPS.forEach((step) => {
const existing = progressState.get(step.id);
if (!existing || (existing.status !== STATUS.COMPLETED && existing.status !== STATUS.ERROR)) {
progressState.set(step.id, {
status: STATUS.COMPLETED,
message: step.done,
details: existing?.details
});
}
});
renderProgress();
// If any step ended in error (e.g. account creation
// failed), stay on the progress screen so the user can
// see the error and choose to retry or navigate to the
// console manually — don't auto-redirect.
const hasErrors = INSTALLATION_STEPS.some((step) => {
const state = progressState.get(step.id);
return state && state.status === STATUS.ERROR;
});
if (hasErrors) {
finalizeInstall();
return;
}
const accountState = progressState.get(STEP_IDS.ACCOUNT_SETUP);
const sessionDetails = sseSessionDetails || accountState?.details;
finalizeInstall();
startSslCheck(sessionDetails);
return;
}
if (event === SSE_EVENTS.ERROR) {
if (data?.message) {
const existingError = Array.from(progressState.values()).some((state) => state?.status === STATUS.ERROR);
if (data.step || !existingError) {
let targetStep = data.step;
if (!targetStep) {
for (const candidate of INSTALLATION_STEPS) {
const state = progressState.get(candidate.id);
if (!state || state.status !== STATUS.COMPLETED) {
targetStep = candidate.id;
break;
}
}
}
handleProgress({
step: targetStep || STEP_IDS.CONFIG_FILES,
status: STATUS.ERROR,
message: data.message,
details: data.details
});
}
}
finalizeInstall();
}
});
if (activeInstall && !activeInstall.completed) {
// Stream ended without a "done" event (e.g. browser
// throttled the background tab). Check if we're done.
checkAllCompleted();
if (!activeInstall?.completed) {
startPolling();
}
}
} catch (error) {
if (!activeInstall || activeInstall.controller.signal.aborted) {
return;
}
startPolling();
}
};
const resumeInstall = async (installId) => {
const snapshot = await fetchInstallStatus(installId);
if (!snapshot) return false;
activeInstall = {
installId,
controller: new AbortController(),
lastEventAt: Date.now(),
pollTimer: null,
fallbackTimer: null,
completed: false
};
applySnapshot(snapshot);
startPolling();
setUnloadGuard(true);
return true;
};
const resetProgressFrom = (stepId) => {
const index = INSTALLATION_STEPS.findIndex((step) => step.id === stepId);
if (index === -1) return;
INSTALLATION_STEPS.slice(index).forEach((step) => {
progressState.delete(step.id);
const row = rowsById.get(step.id);
if (row && row.parentNode) {
row.parentNode.removeChild(row);
}
rowsById.delete(step.id);
});
};
const retryInstallStep = (stepId) => {
if (!stepId) return;
if (activeInstall?.controller) {
activeInstall.controller.abort();
}
if (activeInstall?.pollTimer) {
clearInterval(activeInstall.pollTimer);
}
if (activeInstall?.fallbackTimer) {
clearTimeout(activeInstall.fallbackTimer);
}
resetProgressFrom(stepId);
const step = getStepDefinition(stepId);
progressState.set(stepId, {
status: STATUS.IN_PROGRESS,
message: step?.inProgress || 'Retrying...'
});
const row = ensureRow(step);
if (row) {
updateInstallRow(row, step, STATUS.IN_PROGRESS, step.inProgress || 'Retrying...');
}
const installId = activeInstall?.installId || getInstallLock?.()?.installId || generateInstallId();
storeInstallId?.(installId);
startInstallStream(installId, { retryStep: stepId });
};
list.addEventListener('click', (event) => {
const consoleButton = event.target.closest('[data-install-console]');
const retryButton = event.target.closest('[data-install-retry]');
if (consoleButton) {
redirectToApp(resolvedProtocol);
return;
}
if (retryButton) {
const row = retryButton.closest('.install-row');
const stepId = row?.dataset.step;
retryInstallStep(stepId);
}
});
// When the user switches back to this tab, check if installation
// finished while the tab was in the background.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && activeInstall && !activeInstall.completed) {
checkAllCompleted();
}
});
const lock = getInstallLock?.();
const existingInstallId = lock?.installId || getStoredInstallId?.();
if (existingInstallId) {
resumeInstall(existingInstallId).then((resumed) => {
if (!resumed) {
clearInstallId?.();
clearInstallLock?.();
const newInstallId = generateInstallId();
storeInstallId?.(newInstallId);
startInstallStream(newInstallId);
}
});
} else {
const newInstallId = generateInstallId();
storeInstallId?.(newInstallId);
startInstallStream(newInstallId);
}
};
window.InstallerStepsProgress = {
initStep5,
cleanupInstallFlow,
validateInstallRequest
};
})();