(function () {
const root = document.getElementById('qortal-qapps-root');
if (!root) {
return;
}
let qapps = [];
try {
const parsed = JSON.parse(root.dataset.qapps || '[]');
if (Array.isArray(parsed)) {
qapps = parsed;
}
} catch (error) {
qapps = [];
}
const enabled = root.dataset.qappsEnabled === '1';
const browserEnabled = root.dataset.qappsBrowserEnabled === '1';
const browserAddress = root.dataset.qappsBrowserAddress || '';
const urlParams = new URLSearchParams(window.location.search);
const debugFromQuery = urlParams.get('debug') === '1' || urlParams.get('qapps_debug') === '1';
const debugFromStorage = window.localStorage && window.localStorage.getItem('qortal_qapps_debug') === '1';
const debugEnabled = root.dataset.qappsDebugEnabled === '1' || debugFromQuery || debugFromStorage;
const nodeUrl = root.dataset.qortalNodeUrl || '';
const gatewayUrl = root.dataset.qortalGatewayUrl || '';
const gatewayProxyTemplate = root.dataset.gatewayProxyUrl || '';
const viewerRendererConfigured = Boolean(nodeUrl || gatewayUrl);
const qappsRequestUrl = root.dataset.qappsRequestUrl || '';
const qappsApproveUrl = root.dataset.qappsApproveUrl || '';
const qappsPermissionsUrl = root.dataset.qappsPermissionsUrl || '';
const qappsUnlockUrl = root.dataset.qappsUnlockUrl || '';
const qappsUnlockStatusUrl = root.dataset.qappsUnlockStatusUrl || '';
const defaultApprovalMode = (function () {
const rawMode = String(root.dataset.qappsDefaultApprovalMode || '').trim();
if (rawMode === 'session') {
return 'type_minutes';
}
if (rawMode === 'scope') {
return 'type_always';
}
if (rawMode === 'app') {
return 'app_always';
}
if (rawMode === 'type_minutes' || rawMode === 'type_always' || rawMode === 'app_always') {
return rawMode;
}
return 'once';
})();
const defaultApprovalTempMinutes = (function () {
const parsed = Number(String(root.dataset.qappsDefaultApprovalTempMinutes || '10').trim());
if (!Number.isFinite(parsed)) {
return 10;
}
return Math.max(1, Math.min(Math.floor(parsed), 1440));
})();
const defaultApprovalUnlock10Min = root.dataset.qappsDefaultApprovalUnlock10Min === '1';
const defaultUnlockSession20Min = root.dataset.qappsDefaultUnlockSession20Min === '1';
const userMappingsUrl = root.dataset.userMappingsUrl || '';
const nextcloudPublicUrl = root.dataset.nextcloudPublicUrl || '';
const settingsPath = root.dataset.settingsPath || '/settings/user/qortal_integration';
const emptyCard = document.getElementById('qortal-qapps-empty');
const browserCard = document.getElementById('qortal-qapps-browser');
const browserAddressEl = document.getElementById('qortal-qapps-browser-address');
const browserButton = document.getElementById('qortal-qapps-open-browser');
const listCard = document.getElementById('qortal-qapps-list');
const listBody = document.getElementById('qortal-qapps-body');
const warningCard = document.getElementById('qortal-qapps-warning');
const settingsLink = document.getElementById('qortal-qapps-settings-link');
const viewerCard = document.getElementById('qortal-qapps-viewer');
const viewerAddress = document.getElementById('qortal-qapps-viewer-address');
const viewerAddressInputWrap = root.querySelector('.qortal-viewer-address-input');
const viewerAddressInput = document.getElementById('qortal-qapps-viewer-address-input');
const viewerAddressGo = document.getElementById('qortal-qapps-viewer-address-go');
const viewerLoadingProgress = document.getElementById('qortal-qapps-loading-progress');
const viewerLoadingOverlay = document.getElementById('qortal-qapps-loading-overlay');
const viewerLoadingStatus = document.getElementById('qortal-qapps-loading-status');
const viewerLoadingMeta = document.getElementById('qortal-qapps-loading-meta');
const viewerLoadingProgressBar = document.getElementById('qortal-qapps-loading-progress-bar');
const viewerFrame = document.getElementById('qortal-qapps-frame');
const viewerFrameWrap = document.getElementById('qortal-qapps-frame-wrap');
const viewerClose = document.getElementById('qortal-qapps-close');
const viewerOpenExternal = document.getElementById('qortal-qapps-open-external');
const viewerBack = document.getElementById('qortal-qapps-back');
const viewerReload = document.getElementById('qortal-qapps-reload');
const mobileMenuToggle = document.getElementById('qortal-qapps-mobile-menu');
const viewerMenuToggle = document.getElementById('qortal-qapps-menu');
const pageHeader = root.querySelector('.qortal-page-header');
const debugCard = document.getElementById('qortal-qapps-debug');
const debugBody = document.getElementById('qortal-qapps-debug-body');
const debugCopy = document.getElementById('qortal-qapps-debug-copy');
const debugClear = document.getElementById('qortal-qapps-debug-clear');
const debugFloat = document.getElementById('qortal-qapps-debug-float');
const debugMinimize = document.getElementById('qortal-qapps-debug-minimize');
const approvalModal = document.getElementById('qortal-qapps-approval');
const approvalAppEl = document.getElementById('qortal-approval-app');
const approvalActionEl = document.getElementById('qortal-approval-action');
const approvalTempMinutesEl = document.getElementById('qortal-approval-temp-minutes');
const approvalPassword = document.getElementById('qortal-approval-password');
const approvalTtl = document.getElementById('qortal-approval-ttl');
const approvalPolicyStatusEl = document.getElementById('qortal-approval-policy-status');
const approvalWalletStatusEl = document.getElementById('qortal-approval-wallet-status');
const approvalUnlockFieldsEl = document.getElementById('qortal-approval-unlock-fields');
const approvalConfirm = document.getElementById('qortal-approval-confirm');
const approvalCancel = document.getElementById('qortal-approval-cancel');
const approvalError = document.getElementById('qortal-approval-error');
const unlockModal = document.getElementById('qortal-qapps-unlock');
const unlockAppEl = document.getElementById('qortal-unlock-app');
const unlockActionEl = document.getElementById('qortal-unlock-action');
const unlockPassword = document.getElementById('qortal-unlock-password');
const unlockSession = document.getElementById('qortal-unlock-session');
const unlockConfirm = document.getElementById('qortal-unlock-confirm');
const unlockCancel = document.getElementById('qortal-unlock-cancel');
const unlockError = document.getElementById('qortal-unlock-error');
const nameRequiredModal = document.getElementById('qortal-qapps-name-required');
const nameRequiredDashboardLink = document.getElementById('qortal-qapps-name-required-dashboard');
const nameRequiredContinue = document.getElementById('qortal-qapps-name-required-continue');
const nameRequiredCancel = document.getElementById('qortal-qapps-name-required-cancel');
const debugEntries = [];
const debugLimit = 200;
const qdnLoadingRetryDelayMs = 4000;
const qdnLoadingReloadFallbackDelayMs = 7000;
const qdnLoadingRetryMaxAttempts = 240;
const qdnLoadingMarkers = [
'files are being retrieved from the qortal data network',
'this page will refresh automatically when the content becomes available',
'initializing',
'initialising'
];
const qdnStatusTitles = {
NOT_STARTED: 'Preparing resource...',
PUBLISHED: 'Published, download not started',
DOWNLOADING: 'Downloading from QDN...',
DOWNLOADED: 'Downloaded, preparing content...',
BUILDING: 'Building content...',
MISSING_DATA: 'Missing data, waiting for peers...',
BUILD_FAILED: 'Build failed. Retrying...',
NOT_PUBLISHED: 'Resource not found',
UNSUPPORTED: 'Unsupported request',
BLOCKED: 'Content is blocked',
};
const qdnStatusNumericMap = {
1: 'PUBLISHED',
2: 'NOT_PUBLISHED',
3: 'DOWNLOADING',
4: 'DOWNLOADED',
5: 'BUILDING',
6: 'READY',
7: 'MISSING_DATA',
8: 'BUILD_FAILED',
9: 'UNSUPPORTED',
10: 'BLOCKED',
};
const mobileMenuClass = 'qortal-mobile-menu-open';
const viewerOpenClass = 'qortal-viewer-open';
const approvalScopeAliases = {
PUBLISH_MULTIPLE_QDN_RESOURCES: 'PUBLISH_QDN_RESOURCE',
};
let currentWalletId = '';
let currentWalletAddress = '';
let currentQappAddress = '';
let currentViewerAddress = '';
let approvalContext = null;
let activeApprovalPolicyMode = defaultApprovalMode;
const walletLockState = {
state: 'unknown',
expiresAt: null,
};
const temporaryApprovalRules = [];
let qdnLoadingRetryTimer = null;
let qdnLoadingRetryAttempts = 0;
let qdnLoadingStatusFailures = 0;
let qdnLoadingLastSnapshot = '';
let currentQdnResourceTarget = null;
const nameLookupCacheTtlMs = 60 * 1000;
let nameLookupCache = {
address: '',
hasName: null,
checkedAt: 0,
};
function clearQdnLoadingProgress() {
if (viewerLoadingProgress) {
viewerLoadingProgress.textContent = '';
viewerLoadingProgress.classList.add('qortal-hidden');
}
if (viewerLoadingStatus) {
viewerLoadingStatus.textContent = '';
}
if (viewerLoadingMeta) {
viewerLoadingMeta.textContent = '';
}
if (viewerLoadingProgressBar) {
viewerLoadingProgressBar.style.width = '0%';
}
if (viewerLoadingOverlay) {
viewerLoadingOverlay.classList.add('qortal-hidden');
}
}
function coerceFiniteNumber(value) {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
}
function getQdnStatusLabel(status, description) {
if (typeof description === 'string' && description.trim() !== '') {
return description.trim();
}
const normalizedStatus = String(status || '').trim().toUpperCase();
if (normalizedStatus && qdnStatusTitles[normalizedStatus]) {
return qdnStatusTitles[normalizedStatus];
}
return 'Loading from QDN...';
}
function updateQdnLoadingProgress(status, percentLoaded, localChunkCount, totalChunkCount, description) {
const normalizedStatus = String(status || '').toUpperCase();
if (normalizedStatus === 'READY') {
clearQdnLoadingProgress();
return;
}
const boundedPercent = coerceFiniteNumber(percentLoaded);
const bounded =
boundedPercent !== null ? Math.max(0, Math.min(100, boundedPercent)) : null;
if (!viewerLoadingProgress) {
return;
}
let toolbarLabel = 'Loading...';
if (bounded !== null) {
toolbarLabel = 'Loading ' + bounded.toFixed(1) + '%';
}
if (
localChunkCount !== null
&& localChunkCount !== undefined
&& Number.isFinite(localChunkCount)
&& String(toolbarLabel).indexOf('%') === -1
) {
toolbarLabel += ' (' + localChunkCount + ' chunks)';
}
viewerLoadingProgress.textContent = toolbarLabel;
viewerLoadingProgress.classList.remove('qortal-hidden');
if (viewerLoadingOverlay) {
viewerLoadingOverlay.classList.remove('qortal-hidden');
}
if (viewerLoadingStatus) {
const statusLabel = getQdnStatusLabel(normalizedStatus, description);
viewerLoadingStatus.textContent =
bounded !== null ? statusLabel + ' ' + bounded.toFixed(1) + '%' : statusLabel;
}
if (viewerLoadingProgressBar) {
viewerLoadingProgressBar.style.width = (bounded !== null ? bounded : 0) + '%';
}
if (viewerLoadingMeta) {
const local = coerceFiniteNumber(localChunkCount);
const total = coerceFiniteNumber(totalChunkCount);
if (local !== null && total !== null && total > 0) {
viewerLoadingMeta.textContent = 'Chunks ' + local + ' / ' + total;
} else if (local !== null) {
viewerLoadingMeta.textContent = local + ' chunks downloaded';
} else {
viewerLoadingMeta.textContent = '';
}
}
}
function clearQdnLoadingRetry(reason) {
if (qdnLoadingRetryTimer) {
window.clearTimeout(qdnLoadingRetryTimer);
qdnLoadingRetryTimer = null;
logDebug('info', 'QDN loading auto-retry stopped', {
reason: reason || 'cleared',
attempts: qdnLoadingRetryAttempts,
});
}
qdnLoadingStatusFailures = 0;
qdnLoadingLastSnapshot = '';
clearQdnLoadingProgress();
}
function safeDecodeURIComponent(value) {
if (typeof value !== 'string') {
return '';
}
try {
return decodeURIComponent(value);
} catch (_error) {
return value;
}
}
function stripQueryAndHash(value) {
return String(value || '').split('#')[0].split('?')[0];
}
function parseQdnResourceTarget(address) {
const normalized = normalizeBrowserAddress(address || '');
if (!normalized || !normalized.toLowerCase().startsWith('qortal://')) {
return null;
}
const rawPath = stripQueryAndHash(normalized.slice('qortal://'.length)).replace(/^\/+/, '');
if (!rawPath) {
return null;
}
const segments = rawPath
.split('/')
.filter(function (segment) {
return Boolean(segment);
})
.map(safeDecodeURIComponent);
if (segments.length < 2) {
return null;
}
const service = String(segments[0] || '').trim().toUpperCase();
const name = String(segments[1] || '').trim();
if (!service || !name) {
return null;
}
const identifierCandidate = segments.length > 2 ? String(segments[2] || '').trim() : '';
return {
service,
name,
identifierCandidate,
};
}
function buildQdnStatusUrls(target) {
if (!target || !target.service || !target.name) {
return [];
}
const encodeSegmentsLocally = !gatewayProxyTemplate;
const paths = [];
const urls = [];
const pushPath = function (segments) {
const encoded = segments
.filter(function (segment) {
return typeof segment === 'string' && segment.trim() !== '';
})
.map(function (segment) {
const value = segment.trim();
return encodeSegmentsLocally ? encodeURIComponent(value) : value;
})
.join('/');
if (!encoded || paths.includes(encoded)) {
return;
}
paths.push(encoded);
};
const pushUrl = function (baseUrl, withBuildParam) {
if (!baseUrl) {
return;
}
const built = withBuildParam
? baseUrl + (baseUrl.indexOf('?') === -1 ? '?' : '&') + 'build=true'
: baseUrl;
if (!urls.includes(built)) {
urls.push(built);
}
};
const base = ['arbitrary', 'resource', 'status', target.service, target.name];
if (target.identifierCandidate) {
pushPath(base.concat([target.identifierCandidate]));
}
pushPath(base);
paths.forEach(function (path) {
const baseUrl = openGatewayPath(path);
// Prefer build=true (progress/status from loading flow), but fall back to plain status.
pushUrl(baseUrl, true);
pushUrl(baseUrl, false);
});
return urls.filter(function (url) {
return Boolean(url);
});
}
function isLikelyStatus404(result) {
if (!result) {
return false;
}
if (result.status !== 404) {
return false;
}
const body = result.json;
if (!body || typeof body !== 'object') {
return true;
}
const message =
(typeof body.message === 'string' ? body.message : '')
|| (typeof body.error === 'string' ? body.error : '');
return message.toLowerCase().includes('not found') || message === '';
}
async function probeQdnResourceProperties(target) {
if (!target || !target.service || !target.name) {
return { ok: false };
}
const identifier = target.identifierCandidate || 'default';
const path = `arbitrary/resource/properties/${target.service}/${target.name}/${identifier}`;
const baseUrl = openGatewayPath(path);
if (!baseUrl) {
return { ok: false };
}
try {
const result = await fetchJsonWithTimeout(baseUrl + (baseUrl.indexOf('?') === -1 ? '?' : '&') + 'build=true', 4500);
if (!result.ok) {
return { ok: false };
}
// properties endpoint is mostly used to trigger/build; return generic downloading state.
return {
ok: true,
status: 'BUILDING',
description: 'Initializing and building content...',
percentLoaded: null,
localChunkCount: null,
totalChunkCount: null,
url: baseUrl,
};
} catch (_error) {
return { ok: false };
}
}
async function probeQdnResourceStatus(target) {
const urls = buildQdnStatusUrls(target);
let sawStatus404 = false;
for (let i = 0; i < urls.length; i += 1) {
const url = urls[i];
try {
const result = await fetchJsonWithTimeout(url, 4500);
if (!result.ok || !result.json) {
if (isLikelyStatus404(result)) {
sawStatus404 = true;
}
continue;
}
const parsed = parseQdnStatusPayload(result.json);
if (!parsed) {
continue;
}
return {
ok: true,
status: String(parsed.status || '').toUpperCase(),
description: parsed.description || '',
percentLoaded: coerceFiniteNumber(parsed.percentLoaded),
localChunkCount: coerceFiniteNumber(parsed.localChunkCount),
totalChunkCount: coerceFiniteNumber(parsed.totalChunkCount),
url,
};
} catch (_error) {
// Try the next candidate URL.
}
}
if (sawStatus404) {
logDebug('warn', 'QDN status endpoint returned 404 variants; falling back to properties probe', {
service: target.service,
name: target.name,
identifier: target.identifierCandidate || '',
});
const propertiesResult = await probeQdnResourceProperties(target);
if (propertiesResult.ok) {
return propertiesResult;
}
}
return { ok: false };
}
function parseQdnStatusCandidate(candidate) {
if (!candidate || typeof candidate !== 'object') {
return null;
}
const nestedStatus =
candidate.status && typeof candidate.status === 'object' ? candidate.status : null;
let statusCode = '';
const rawCandidates = [
candidate.id,
candidate.status,
nestedStatus ? nestedStatus.id : null,
nestedStatus ? nestedStatus.status : null,
nestedStatus ? nestedStatus.name : null,
];
for (let i = 0; i < rawCandidates.length; i += 1) {
const value = rawCandidates[i];
if (typeof value === 'string' && value.trim() !== '') {
statusCode = value.trim().toUpperCase();
break;
}
}
if (!statusCode) {
const numericValue = coerceFiniteNumber(candidate.status)
|| (nestedStatus ? coerceFiniteNumber(nestedStatus.value) : null);
if (numericValue !== null && qdnStatusNumericMap[Math.floor(numericValue)]) {
statusCode = qdnStatusNumericMap[Math.floor(numericValue)];
}
}
if (!statusCode) {
return null;
}
let localChunkCount = coerceFiniteNumber(candidate.localChunkCount);
if (localChunkCount === null && nestedStatus) {
localChunkCount = coerceFiniteNumber(nestedStatus.localChunkCount);
}
let totalChunkCount = coerceFiniteNumber(candidate.totalChunkCount);
if (totalChunkCount === null && nestedStatus) {
totalChunkCount = coerceFiniteNumber(nestedStatus.totalChunkCount);
}
let percentLoaded = coerceFiniteNumber(candidate.percentLoaded);
if (percentLoaded === null && nestedStatus) {
percentLoaded = coerceFiniteNumber(nestedStatus.percentLoaded);
}
if (percentLoaded === null && localChunkCount !== null && totalChunkCount !== null && totalChunkCount > 0) {
percentLoaded = (localChunkCount / totalChunkCount) * 100;
}
return {
status: statusCode,
description:
(typeof candidate.description === 'string' && candidate.description.trim() !== '' ? candidate.description.trim() : '')
|| (nestedStatus && typeof nestedStatus.description === 'string' ? nestedStatus.description.trim() : '')
|| (typeof candidate.title === 'string' ? candidate.title.trim() : ''),
percentLoaded,
localChunkCount,
totalChunkCount,
};
}
function parseQdnStatusPayload(payload) {
const visited = new Set();
const queue = [payload];
let depth = 0;
while (queue.length > 0 && depth < 16) {
const candidate = queue.shift();
depth += 1;
if (!candidate || typeof candidate !== 'object') {
continue;
}
if (visited.has(candidate)) {
continue;
}
visited.add(candidate);
const parsed = parseQdnStatusCandidate(candidate);
if (parsed) {
return parsed;
}
if (candidate.data && typeof candidate.data === 'object') {
queue.push(candidate.data);
}
if (candidate.result && typeof candidate.result === 'object') {
queue.push(candidate.result);
}
if (candidate.status && typeof candidate.status === 'object') {
queue.push(candidate.status);
}
}
return null;
}
async function fetchJsonWithTimeout(url, timeoutMs) {
const canAbort = typeof window.AbortController === 'function';
const controller = canAbort ? new window.AbortController() : null;
let timeoutId = null;
if (controller && timeoutMs > 0) {
timeoutId = window.setTimeout(function () {
controller.abort();
}, timeoutMs);
}
try {
const response = await fetch(url, {
method: 'GET',
signal: controller ? controller.signal : undefined,
});
const rawText = await response.text();
let json = null;
if (rawText) {
try {
json = JSON.parse(rawText);
} catch (_error) {
json = null;
}
}
return {
ok: response.ok,
status: response.status,
json,
};
} finally {
if (timeoutId) {
window.clearTimeout(timeoutId);
}
}
}
function detectQdnLoadingInterstitial() {
if (!viewerFrame) {
return { inspectable: false, detected: false };
}
let doc = null;
try {
doc = viewerFrame.contentDocument;
} catch (_error) {
return { inspectable: false, detected: false };
}
if (!doc || !doc.body) {
return { inspectable: true, detected: false };
}
const bodyText = (doc.body.innerText || doc.body.textContent || '').toLowerCase();
const titleText = String(doc.title || '').toLowerCase();
const combinedText = bodyText + '\n' + titleText;
const detected = qdnLoadingMarkers.some(function (marker) {
return combinedText.includes(marker);
});
return { inspectable: true, detected };
}
function reloadViewerFrame() {
if (!viewerFrame) {
return false;
}
try {
if (viewerFrame.contentWindow) {
viewerFrame.contentWindow.location.reload();
return true;
}
} catch (_error) {
// fallback below
}
try {
if (viewerFrame.src) {
viewerFrame.src = viewerFrame.src;
return true;
}
} catch (_error) {
return false;
}
return false;
}
async function runQdnLoadingRetryTick() {
if (qdnLoadingRetryAttempts >= qdnLoadingRetryMaxAttempts) {
logDebug('warn', 'QDN loading auto-retry max attempts reached', {
attempts: qdnLoadingRetryAttempts,
});
return;
}
if (!viewerCard || viewerCard.classList.contains('qortal-hidden')) {
clearQdnLoadingRetry('viewer_hidden');
return;
}
const detection = detectQdnLoadingInterstitial();
if (!detection.inspectable) {
clearQdnLoadingRetry('cross_origin_or_uninspectable');
return;
}
let prefetchedStatusResult = null;
if (!detection.detected && currentQdnResourceTarget) {
prefetchedStatusResult = await probeQdnResourceStatus(currentQdnResourceTarget);
if (prefetchedStatusResult.ok) {
updateQdnLoadingProgress(
prefetchedStatusResult.status,
prefetchedStatusResult.percentLoaded,
prefetchedStatusResult.localChunkCount,
prefetchedStatusResult.totalChunkCount,
prefetchedStatusResult.description
);
if (prefetchedStatusResult.status === 'READY') {
const shouldForceRefresh =
qdnLoadingRetryAttempts > 0
|| (viewerLoadingOverlay && !viewerLoadingOverlay.classList.contains('qortal-hidden'));
if (shouldForceRefresh) {
logDebug('info', 'QDN status ready after loading flow, forcing viewer refresh', {
attempt: qdnLoadingRetryAttempts,
});
clearQdnLoadingRetry('status_ready_refresh');
qdnLoadingRetryAttempts = 0;
reloadViewerFrame();
return;
}
clearQdnLoadingRetry('content_loaded');
qdnLoadingRetryAttempts = 0;
return;
}
} else {
clearQdnLoadingRetry('content_loaded');
qdnLoadingRetryAttempts = 0;
return;
}
}
if (!detection.detected && !prefetchedStatusResult) {
clearQdnLoadingRetry('content_loaded');
qdnLoadingRetryAttempts = 0;
return;
}
updateQdnLoadingProgress('DOWNLOADING', null, null, null, '');
qdnLoadingRetryAttempts += 1;
let shouldReload = true;
let delayMs = qdnLoadingRetryDelayMs;
if (currentQdnResourceTarget) {
const statusResult = prefetchedStatusResult || await probeQdnResourceStatus(currentQdnResourceTarget);
if (statusResult.ok) {
qdnLoadingStatusFailures = 0;
const statusSnapshot =
statusResult.status + ':'
+ (statusResult.percentLoaded !== null ? statusResult.percentLoaded : '')
+ ':'
+ (statusResult.localChunkCount !== null ? statusResult.localChunkCount : '');
if (
statusSnapshot !== qdnLoadingLastSnapshot
|| qdnLoadingRetryAttempts % 8 === 0
) {
logDebug('info', 'QDN loading status', {
attempt: qdnLoadingRetryAttempts,
status: statusResult.status,
percentLoaded: statusResult.percentLoaded,
localChunkCount: statusResult.localChunkCount,
totalChunkCount: statusResult.totalChunkCount,
});
qdnLoadingLastSnapshot = statusSnapshot;
}
updateQdnLoadingProgress(
statusResult.status,
statusResult.percentLoaded,
statusResult.localChunkCount,
statusResult.totalChunkCount,
statusResult.description
);
if (statusResult.status === 'READY') {
logDebug('info', 'QDN resource ready, refreshing viewer', {
attempt: qdnLoadingRetryAttempts,
});
reloadViewerFrame();
return;
}
shouldReload = false;
} else {
qdnLoadingStatusFailures += 1;
delayMs = qdnLoadingReloadFallbackDelayMs;
if (qdnLoadingStatusFailures <= 3) {
shouldReload = false;
logDebug('warn', 'QDN status probe failed, waiting before fallback reload', {
attempt: qdnLoadingRetryAttempts,
failures: qdnLoadingStatusFailures,
});
}
}
}
if (shouldReload) {
logDebug('info', 'QDN loading detected, retrying load', {
attempt: qdnLoadingRetryAttempts,
delayMs,
mode: currentQdnResourceTarget ? 'fallback_reload' : 'reload',
});
reloadViewerFrame();
}
scheduleQdnLoadingRetry(delayMs);
}
function scheduleQdnLoadingRetry(delayOverrideMs) {
if (qdnLoadingRetryTimer) {
return;
}
if (qdnLoadingRetryAttempts >= qdnLoadingRetryMaxAttempts) {
logDebug('warn', 'QDN loading auto-retry max attempts reached', {
attempts: qdnLoadingRetryAttempts,
});
return;
}
const nextDelay =
typeof delayOverrideMs === 'number' && Number.isFinite(delayOverrideMs)
? Math.max(1000, Math.floor(delayOverrideMs))
: qdnLoadingRetryDelayMs;
qdnLoadingRetryTimer = window.setTimeout(function () {
qdnLoadingRetryTimer = null;
runQdnLoadingRetryTick().catch(function (error) {
logDebug('warn', 'QDN loading retry tick failed', {
error: error && error.message ? error.message : String(error),
});
scheduleQdnLoadingRetry(qdnLoadingReloadFallbackDelayMs);
});
}, nextDelay);
}
function fallbackApprovalScope(requestType) {
return approvalScopeAliases[requestType] || requestType || '';
}
function normalizeApprovalScope(scope, requestType) {
const candidate = typeof scope === 'string' ? scope.trim() : '';
if (!candidate) {
return fallbackApprovalScope(requestType);
}
if (requestType && approvalScopeAliases[requestType] && candidate === requestType) {
return approvalScopeAliases[requestType];
}
return candidate;
}
function sanitizeDebugValue(value) {
if (!value || typeof value !== 'object') {
return value;
}
if (Array.isArray(value)) {
return value.map(sanitizeDebugValue);
}
const clone = {};
for (const key in value) {
if (!Object.prototype.hasOwnProperty.call(value, key)) {
continue;
}
const lower = key.toLowerCase();
if (
lower.includes('seed') ||
lower.includes('password') ||
lower.includes('secret') ||
lower.includes('backup')
) {
clone[key] = '[redacted]';
continue;
}
clone[key] = sanitizeDebugValue(value[key]);
}
return clone;
}
function logDebug(level, message, details) {
if (!debugEnabled || !debugBody) {
return;
}
const timestamp = new Date().toISOString();
const entry = {
time: timestamp,
level,
message,
details: sanitizeDebugValue(details),
};
debugEntries.push(entry);
if (debugEntries.length > debugLimit) {
debugEntries.shift();
}
const lines = debugEntries.map(function (item) {
const suffix = item.details !== undefined ? ' ' + JSON.stringify(item.details) : '';
const level = item.level.toUpperCase();
return '[' + item.time + '] ' + level + ': ' + item.message + suffix;
});
debugBody.textContent = lines.join('\n');
debugBody.scrollTop = debugBody.scrollHeight;
}
if (debugFromQuery && window.localStorage) {
window.localStorage.setItem('qortal_qapps_debug', '1');
}
function detectTheme() {
const docEl = document.documentElement;
const body = document.body;
const dataTheme = (docEl && docEl.dataset ? docEl.dataset.theme : '') || (body && body.dataset ? body.dataset.theme : '');
if (dataTheme) {
return dataTheme;
}
const classList = (docEl && docEl.classList ? docEl.classList : body && body.classList ? body.classList : null);
if (classList) {
if (classList.contains('theme-dark') || classList.contains('dark') || classList.contains('theme--dark') || classList.contains('nc-dark')) {
return 'dark';
}
if (classList.contains('theme-light') || classList.contains('light') || classList.contains('theme--light') || classList.contains('nc-light')) {
return 'light';
}
}
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
}
function detectLanguage() {
return (document.documentElement && document.documentElement.lang) || navigator.language || '';
}
function appendThemeParams(url) {
if (!url) {
return url;
}
try {
const u = new URL(url, window.location.origin);
if (!u.searchParams.has('theme')) {
u.searchParams.set('theme', detectTheme());
}
const lang = detectLanguage();
if (lang && !u.searchParams.has('lang')) {
u.searchParams.set('lang', lang);
}
return u.toString();
} catch (error) {
return url;
}
}
function encodeGatewayPath(path) {
return path
.split('/')
.map(function (segment) {
return encodeURIComponent(segment);
})
.join('/');
}
function buildProxyUrl(path) {
if (!gatewayProxyTemplate) {
return '';
}
if (gatewayProxyTemplate.includes('__PATH__')) {
return gatewayProxyTemplate.replace('__PATH__', encodeGatewayPath(path));
}
return gatewayProxyTemplate + encodeURIComponent(path);
}
function isUpstreamRootPath(path) {
const normalized = String(path || '').replace(/^\/+/, '');
return /^(render|arbitrary|names|addresses|transactions|blocks|crosschain|groups|chat|lists|assets|at|admin|peers|utils|apps|payments|polls|stats|bootstrap|developer|websockets)(\/|$)/i.test(normalized);
}
function toUpstreamProxyPath(path, preferRender) {
const normalized = String(path || '').replace(/^\/+/, '');
if (!normalized) {
return '';
}
if (preferRender && nodeUrl && !isUpstreamRootPath(normalized)) {
return 'render/' + normalized;
}
return normalized;
}
function resolveGatewayAddress(address, ensureTrailingSlash) {
if (!address) {
return '';
}
let path = address.trim();
if (/^https?:\/\//i.test(path)) {
return appendThemeParams(path);
}
if (path.toLowerCase().startsWith('qortal://')) {
path = path.slice('qortal://'.length);
}
path = path.replace(/^\/+/, '');
if (ensureTrailingSlash && path && !path.endsWith('/')) {
path += '/';
}
const upstreamPath = toUpstreamProxyPath(path, true);
if (gatewayProxyTemplate) {
if (!viewerRendererConfigured) {
return '';
}
return appendThemeParams(buildProxyUrl(upstreamPath));
}
const directBase = nodeUrl ? nodeUrl.replace(/\/$/, '') : gatewayUrl.replace(/\/$/, '');
if (!directBase) {
return '';
}
const base = directBase.replace(/\/$/, '');
return appendThemeParams(base + '/' + upstreamPath);
}
function normalizeBrowserAddress(address) {
const raw = String(address || '').trim();
if (!raw) {
return '';
}
if (!raw.toLowerCase().startsWith('qortal://')) {
return raw;
}
const afterScheme = raw.slice('qortal://'.length).replace(/^\/+/, '');
if (!afterScheme) {
return raw;
}
// Browser convenience: qortal://Name -> qortal://WEBSITE/Name
if (!afterScheme.includes('/')) {
return 'qortal://WEBSITE/' + afterScheme;
}
return raw;
}
function deriveSafeReloadAddress(address) {
const normalized = normalizeBrowserAddress(address || '');
if (!normalized || !normalized.toLowerCase().startsWith('qortal://')) {
return normalized;
}
const rawPath = stripQueryAndHash(normalized.slice('qortal://'.length)).replace(/^\/+/, '');
if (!rawPath) {
return normalized;
}
const segments = rawPath
.split('/')
.filter(function (segment) {
return Boolean(segment);
})
.map(safeDecodeURIComponent);
if (segments.length < 2) {
return normalized;
}
const service = String(segments[0] || '').trim().toUpperCase();
const name = String(segments[1] || '').trim();
if (!service || !name) {
return normalized;
}
if (service === 'APP') {
return 'qortal://APP/' + name;
}
return normalized;
}
function setViewerAddressDisplay(address) {
const value = String(address || '').trim();
currentViewerAddress = value;
if (viewerAddress) {
viewerAddress.textContent = value;
}
if (viewerAddressInput) {
viewerAddressInput.value = value;
}
}
function parseQortalAddressFromViewerUrl(url) {
if (!url) {
return '';
}
try {
const parsed = new URL(url, window.location.origin);
const gatewayMarker = '/apps/qortal_integration/gateway/';
const markerIndex = parsed.pathname.indexOf(gatewayMarker);
if (markerIndex < 0) {
return '';
}
let upstreamPath = parsed.pathname
.slice(markerIndex + gatewayMarker.length)
.replace(/^\/+/, '');
if (!upstreamPath) {
return '';
}
if (upstreamPath.toLowerCase().startsWith('render/')) {
upstreamPath = upstreamPath.slice('render/'.length);
}
const decodedPath = upstreamPath
.split('/')
.map(function (segment) {
return safeDecodeURIComponent(segment);
})
.join('/');
return normalizeBrowserAddress('qortal://' + decodedPath + parsed.search + parsed.hash);
} catch (_error) {
return '';
}
}
function getNavigationAddressFromPayload(payload) {
if (!payload || typeof payload !== 'object') {
return '';
}
const candidates = [
payload.qortalLink,
payload.address,
payload.url,
payload.href,
payload.path,
payload.value,
];
for (let i = 0; i < candidates.length; i += 1) {
const candidate = candidates[i];
if (typeof candidate !== 'string' || candidate.trim() === '') {
continue;
}
const trimmed = candidate.trim();
if (trimmed.toLowerCase().startsWith('qortal://')) {
return normalizeBrowserAddress(trimmed);
}
if (trimmed.startsWith('/')) {
return normalizeBrowserAddress('qortal://' + trimmed.replace(/^\/+/, ''));
}
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
const parsed = parseQortalAddressFromViewerUrl(trimmed);
if (parsed) {
return parsed;
}
} else {
return normalizeBrowserAddress('qortal://' + trimmed.replace(/^\/+/, ''));
}
}
return '';
}
function syncViewerAddressFromFrame() {
if (!viewerFrame || !viewerFrame.contentWindow) {
return '';
}
try {
const href = viewerFrame.contentWindow.location.href;
const parsed = parseQortalAddressFromViewerUrl(href);
if (parsed) {
setViewerAddressDisplay(parsed);
return parsed;
}
} catch (_error) {
// ignore cross-origin access failures
}
return '';
}
function copyText(value) {
const text = String(value || '').trim();
if (!text) {
return Promise.resolve(false);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text).then(function () {
return true;
}).catch(function () {
return false;
});
}
return Promise.resolve(false);
}
function getDashboardRegisterUrl() {
const base = (nextcloudPublicUrl || (window.location.origin + (typeof OC !== 'undefined' && OC.webroot ? OC.webroot : '')))
.replace(/\/$/, '');
return base + '/apps/qortal_integration/account?highlight=register-name';
}
async function postBrokerQortalRequest(requestType, payload) {
if (!qappsRequestUrl) {
throw new Error('Q-Apps request endpoint is not configured');
}
const headers = { 'Content-Type': 'application/json' };
if (typeof OC !== 'undefined' && OC.requestToken) {
headers.requesttoken = OC.requestToken;
}
const response = await fetch(qappsRequestUrl, {
method: 'POST',
headers,
body: JSON.stringify({
requestType,
payload: payload || {},
}),
});
const json = await response.json();
if (!response.ok || !json || json.ok === false || json.error) {
throw new Error((json && json.error) || 'qortal_request_failed');
}
return json.data !== undefined ? json.data : json;
}
function parsePrimaryNameResult(resultPayload) {
if (typeof resultPayload === 'string') {
return resultPayload.trim();
}
if (Array.isArray(resultPayload)) {
return resultPayload.length > 0 ? parsePrimaryNameResult(resultPayload[0]) : '';
}
if (resultPayload && typeof resultPayload === 'object') {
return String(
resultPayload.name
|| resultPayload.primaryName
|| (resultPayload.data && resultPayload.data.name)
|| ''
).trim();
}
return '';
}
async function hasRegisteredName(address) {
const normalizedAddress = String(address || '').trim();
if (!normalizedAddress) {
return true;
}
const now = Date.now();
if (
nameLookupCache.address === normalizedAddress
&& typeof nameLookupCache.hasName === 'boolean'
&& (now - nameLookupCache.checkedAt) <= nameLookupCacheTtlMs
) {
return nameLookupCache.hasName;
}
let hasName = false;
try {
const primaryName = parsePrimaryNameResult(
await postBrokerQortalRequest('GET_PRIMARY_NAME', { address: normalizedAddress })
);
if (primaryName) {
hasName = true;
} else {
const namesResult = await postBrokerQortalRequest('GET_ACCOUNT_NAMES', { address: normalizedAddress });
if (Array.isArray(namesResult)) {
hasName = namesResult.some(function (entry) {
if (typeof entry === 'string') {
return entry.trim() !== '';
}
if (entry && typeof entry === 'object') {
return String(entry.name || '').trim() !== '';
}
return false;
});
}
}
} catch (error) {
logDebug('warn', 'Registered-name check failed; allowing access', {
address: normalizedAddress,
error: error && error.message ? error.message : String(error),
});
hasName = true;
}
nameLookupCache = {
address: normalizedAddress,
hasName,
checkedAt: now,
};
return hasName;
}
function showRegisteredNameRequiredModal() {
return new Promise(function (resolve) {
if (!nameRequiredModal || !nameRequiredDashboardLink || !nameRequiredContinue || !nameRequiredCancel) {
resolve('continue');
return;
}
const dashboardUrl = getDashboardRegisterUrl();
nameRequiredDashboardLink.href = dashboardUrl;
nameRequiredModal.classList.remove('qortal-hidden');
const cleanup = function () {
nameRequiredDashboardLink.onclick = null;
nameRequiredContinue.onclick = null;
nameRequiredCancel.onclick = null;
nameRequiredModal.classList.add('qortal-hidden');
};
nameRequiredDashboardLink.onclick = function (event) {
if (event) {
event.preventDefault();
}
cleanup();
resolve('dashboard');
};
nameRequiredContinue.onclick = function () {
cleanup();
resolve('continue');
};
nameRequiredCancel.onclick = function () {
cleanup();
resolve('cancel');
};
});
}
async function ensureRegisteredNameBeforeOpening() {
let effectiveAddress = String(currentWalletAddress || '').trim();
if (!effectiveAddress && currentWalletId && userMappingsUrl) {
await refreshUserMapping();
effectiveAddress = String(currentWalletAddress || '').trim();
}
if (!effectiveAddress) {
return true;
}
const hasName = await hasRegisteredName(effectiveAddress);
if (hasName) {
return true;
}
const decision = await showRegisteredNameRequiredModal();
if (decision === 'dashboard') {
window.location.href = getDashboardRegisterUrl();
return false;
}
return decision === 'continue';
}
function setMobileMenuOpen(open, shouldResize) {
const isOpen = Boolean(open);
root.classList.toggle(mobileMenuClass, isOpen);
if (mobileMenuToggle) {
mobileMenuToggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
}
if (viewerMenuToggle) {
viewerMenuToggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
}
if (shouldResize) {
resizeViewerFrame();
}
}
function setViewerOpen(open) {
const isOpen = Boolean(open);
root.classList.toggle(viewerOpenClass, isOpen);
if (pageHeader) {
pageHeader.style.display = isOpen ? 'none' : '';
}
}
async function openAddress(address, name) {
if (!address || !viewerCard || !viewerFrame || !viewerAddress) {
return;
}
const allowedToOpen = await ensureRegisteredNameBeforeOpening();
if (!allowedToOpen) {
return;
}
const normalizedAddress = normalizeBrowserAddress(address);
const resolved = resolveGatewayAddress(normalizedAddress, true);
if (!resolved) {
window.alert('Qortal node or gateway URL is not configured. Ask an admin to configure rendering.');
return;
}
currentQappAddress = normalizedAddress;
currentQdnResourceTarget = parseQdnResourceTarget(normalizedAddress);
logDebug('info', 'Opening Q-App', { address: normalizedAddress, resolved });
clearQdnLoadingRetry('open_address');
qdnLoadingRetryAttempts = 0;
qdnLoadingStatusFailures = 0;
qdnLoadingLastSnapshot = '';
updateQdnLoadingProgress('NOT_STARTED', 0, null, null, '');
setViewerAddressDisplay(normalizedAddress);
if (viewerAddressInputWrap) {
viewerAddressInputWrap.classList.remove('qortal-hidden');
}
if (viewerAddress) {
viewerAddress.classList.remove('qortal-hidden');
}
viewerFrame.src = resolved;
viewerCard.classList.remove('qortal-hidden');
setViewerOpen(true);
setMobileMenuOpen(false, false);
resizeViewerFrame();
if (debugCard && debugEnabled) {
const docked = window.localStorage && window.localStorage.getItem('qortal_qapps_debug_dock') === '1';
debugCard.classList.toggle('qortal-debug-floating', !docked);
if (!debugCard.classList.contains('qortal-debug-minimized')) {
debugCard.classList.remove('qortal-hidden');
}
}
if (viewerOpenExternal) {
viewerOpenExternal.textContent = 'Copy link';
viewerOpenExternal.onclick = async function () {
const fromFrame = syncViewerAddressFromFrame();
const linkToCopy = fromFrame || currentViewerAddress || currentQappAddress || normalizedAddress;
const copied = await copyText(linkToCopy);
viewerOpenExternal.textContent = copied ? 'Copied' : 'Copy failed';
window.setTimeout(function () {
viewerOpenExternal.textContent = 'Copy link';
}, 1200);
};
}
if (viewerClose) {
viewerClose.onclick = function () {
clearQdnLoadingRetry('viewer_closed');
qdnLoadingRetryAttempts = 0;
currentQdnResourceTarget = null;
setViewerOpen(false);
setMobileMenuOpen(false, false);
viewerFrame.src = 'about:blank';
viewerCard.classList.add('qortal-hidden');
};
}
if (viewerBack) {
viewerBack.onclick = function () {
try {
if (viewerFrame.contentWindow) {
viewerFrame.contentWindow.history.back();
}
} catch (_err) {
// ignore cross-origin issues
}
};
}
if (viewerReload) {
viewerReload.onclick = function () {
const preferredAddress =
deriveSafeReloadAddress(currentViewerAddress || currentQappAddress || '');
if (preferredAddress && preferredAddress.toLowerCase().startsWith('qortal://')) {
logDebug('info', 'Reload routed to safe reopen', {
currentViewerAddress: currentViewerAddress || '',
currentQappAddress: currentQappAddress || '',
reloadAddress: preferredAddress,
});
openAddress(preferredAddress, 'Qortal Browser');
return;
}
try {
if (viewerFrame.contentWindow) {
viewerFrame.contentWindow.location.reload();
}
} catch (_err) {
// ignore cross-origin issues
}
};
}
}
function resizeViewerFrame() {
if (!viewerFrameWrap) {
return;
}
const rect = viewerFrameWrap.getBoundingClientRect();
const rootRect = root.getBoundingClientRect();
const viewportHeight =
(window.visualViewport && window.visualViewport.height) ||
window.innerHeight ||
document.documentElement.clientHeight ||
0;
const viewportWidth =
(window.visualViewport && window.visualViewport.width) ||
window.innerWidth ||
document.documentElement.clientWidth ||
0;
const isMobile = viewportWidth && viewportWidth <= 780;
if (!isMobile && root.classList.contains(mobileMenuClass)) {
setMobileMenuOpen(false, false);
}
const padding = isMobile ? 8 : 16;
let nextHeight = Math.floor(viewportHeight - rect.top - padding);
if (root.classList.contains(viewerOpenClass) && Number.isFinite(rootRect.bottom)) {
const containerPadding = isMobile ? 4 : 8;
const containerBoundHeight = Math.floor(rootRect.bottom - rect.top - containerPadding);
if (Number.isFinite(containerBoundHeight) && containerBoundHeight > 0) {
nextHeight = Math.min(nextHeight, containerBoundHeight);
}
}
if (isMobile) {
const maxMobile = Math.floor(viewportHeight * 0.85);
nextHeight = Math.min(nextHeight, maxMobile);
}
const minHeight = isMobile ? Math.max(160, Math.floor(viewportHeight * 0.45)) : 360;
nextHeight = Math.max(minHeight, nextHeight);
viewerFrameWrap.style.height = nextHeight + 'px';
}
function renderList() {
if (!listBody) {
return;
}
listBody.innerHTML = '';
if (!enabled || qapps.length === 0) {
listBody.innerHTML = '
| No Q-Apps available. |
';
return;
}
qapps.forEach(function (entry) {
const row = document.createElement('tr');
const nameCell = document.createElement('td');
nameCell.textContent = entry.name || '';
const addressCell = document.createElement('td');
const addressCode = document.createElement('code');
addressCode.textContent = entry.address || '';
addressCell.appendChild(addressCode);
const descCell = document.createElement('td');
descCell.textContent = entry.description || '';
const actionCell = document.createElement('td');
const launchButton = document.createElement('button');
launchButton.className = 'button';
launchButton.textContent = 'Launch';
launchButton.disabled = !viewerRendererConfigured;
if (!viewerRendererConfigured) {
launchButton.title = 'Qortal node or gateway URL is not configured.';
}
launchButton.addEventListener('click', function () {
openAddress(entry.address || '', entry.name || '');
});
actionCell.appendChild(launchButton);
row.appendChild(nameCell);
row.appendChild(addressCell);
row.appendChild(descCell);
row.appendChild(actionCell);
listBody.appendChild(row);
});
}
if (emptyCard) {
emptyCard.style.display = enabled || browserEnabled ? 'none' : 'block';
}
if (browserCard) {
if (browserEnabled && browserAddress) {
browserCard.style.display = 'block';
if (browserAddressEl) {
browserAddressEl.textContent = browserAddress;
}
if (browserButton) {
browserButton.addEventListener('click', function () {
openAddress(browserAddress, 'Qortal Browser');
});
}
} else {
browserCard.style.display = 'none';
}
}
if (listCard) {
listCard.style.display = enabled ? 'block' : 'none';
}
if (settingsLink) {
const base = (nextcloudPublicUrl || (window.location.origin + (typeof OC !== 'undefined' && OC.webroot ? OC.webroot : '')))
.replace(/\/$/, '');
settingsLink.href = base + settingsPath;
}
if (nameRequiredDashboardLink) {
nameRequiredDashboardLink.href = getDashboardRegisterUrl();
}
async function refreshUserMapping() {
if (!warningCard || !userMappingsUrl) {
return;
}
try {
const headers = {};
if (typeof OC !== 'undefined' && OC.requestToken) {
headers.requesttoken = OC.requestToken;
}
const response = await fetch(userMappingsUrl, { method: 'GET', headers });
const payload = await response.json();
let mappings = [];
if (payload && Array.isArray(payload.mappings)) {
mappings = payload.mappings;
} else if (payload && payload.data && Array.isArray(payload.data.mappings)) {
mappings = payload.data.mappings;
}
let selected = null;
const hasWallet = mappings.some(function (entry) {
if (!entry || typeof entry !== 'object') {
return false;
}
if (!entry.walletId) {
return false;
}
if (entry.status && entry.status !== 'linked') {
return false;
}
if (!selected) {
selected = entry;
}
return true;
});
if (!selected) {
selected = mappings.find(function (entry) {
return entry && entry.walletId;
});
}
if (!selected) {
selected = mappings.find(function (entry) {
return entry && entry.qortalAddress;
});
}
currentWalletId = selected && selected.walletId ? String(selected.walletId) : '';
currentWalletAddress = selected && selected.qortalAddress ? String(selected.qortalAddress) : '';
if (nameLookupCache.address !== currentWalletAddress) {
nameLookupCache = {
address: '',
hasName: null,
checkedAt: 0,
};
}
if (currentWalletId) {
await refreshWalletLockState(currentWalletId);
} else {
markWalletUnknown();
}
warningCard.classList.toggle('qortal-hidden', hasWallet);
} catch (error) {
warningCard.classList.add('qortal-hidden');
markWalletUnknown();
}
}
function openFromQuery() {
const params = new URLSearchParams(window.location.search);
const raw = params.get('qapp') || params.get('app');
if (!raw) {
return;
}
const target = decodeURIComponent(raw);
const match = qapps.find(function (entry) {
return entry && (entry.address === target || entry.name === target);
});
if (match) {
openAddress(match.address || '', match.name || '');
}
}
renderList();
Promise.resolve(refreshUserMapping()).finally(function () {
openFromQuery();
});
if (debugCard) {
debugCard.classList.toggle('qortal-hidden', !debugEnabled);
}
if (debugCard && debugEnabled) {
const docked = window.localStorage && window.localStorage.getItem('qortal_qapps_debug_dock') === '1';
debugCard.classList.toggle('qortal-debug-floating', !docked);
if (debugFloat) {
debugFloat.textContent = docked ? 'Float Panel' : 'Dock Panel';
debugFloat.addEventListener('click', function () {
const isFloating = debugCard.classList.contains('qortal-debug-floating');
debugCard.classList.toggle('qortal-debug-floating', !isFloating);
const nowDocked = isFloating;
if (window.localStorage) {
window.localStorage.setItem('qortal_qapps_debug_dock', nowDocked ? '1' : '0');
}
debugFloat.textContent = nowDocked ? 'Float Panel' : 'Dock Panel';
});
}
if (debugMinimize) {
debugMinimize.textContent = debugCard.classList.contains('qortal-debug-minimized') ? 'Expand' : 'Minimize';
debugMinimize.addEventListener('click', function () {
const isMinimized = debugCard.classList.toggle('qortal-debug-minimized');
debugMinimize.textContent = isMinimized ? 'Expand' : 'Minimize';
});
}
}
if (debugCopy) {
debugCopy.addEventListener('click', function () {
if (!debugBody) return;
const text = debugBody.textContent || '';
if (!text) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(function () {
/* ignore */
});
}
});
}
if (debugClear) {
debugClear.addEventListener('click', function () {
debugEntries.length = 0;
if (debugBody) {
debugBody.textContent = 'Debug panel enabled.';
}
});
}
window.addEventListener('resize', resizeViewerFrame);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', resizeViewerFrame);
}
function toggleMobileMenu() {
setMobileMenuOpen(!root.classList.contains(mobileMenuClass), true);
}
setViewerOpen(Boolean(viewerCard && !viewerCard.classList.contains('qortal-hidden')));
setMobileMenuOpen(root.classList.contains(mobileMenuClass), false);
if (mobileMenuToggle) {
mobileMenuToggle.addEventListener('click', toggleMobileMenu);
}
if (viewerMenuToggle) {
viewerMenuToggle.addEventListener('click', toggleMobileMenu);
}
if (viewerAddressGo && viewerAddressInput) {
viewerAddressGo.addEventListener('click', function () {
const next = viewerAddressInput.value.trim();
if (!next) {
return;
}
openAddress(next, 'Qortal Browser');
});
viewerAddressInput.addEventListener('keydown', function (event) {
if (event.key === 'Enter') {
event.preventDefault();
viewerAddressGo.click();
}
});
}
if (viewerFrame) {
viewerFrame.addEventListener('load', async function () {
syncViewerAddressFromFrame();
if (!viewerCard || viewerCard.classList.contains('qortal-hidden')) {
clearQdnLoadingRetry('viewer_hidden');
return;
}
const detection = detectQdnLoadingInterstitial();
if (!detection.inspectable) {
clearQdnLoadingRetry('cross_origin_or_uninspectable');
return;
}
if (detection.detected) {
updateQdnLoadingProgress('DOWNLOADING', null, null, null, '');
scheduleQdnLoadingRetry();
return;
}
if (currentQdnResourceTarget) {
const statusResult = await probeQdnResourceStatus(currentQdnResourceTarget);
if (statusResult.ok && statusResult.status !== 'READY') {
updateQdnLoadingProgress(
statusResult.status,
statusResult.percentLoaded,
statusResult.localChunkCount,
statusResult.totalChunkCount,
statusResult.description
);
scheduleQdnLoadingRetry();
return;
}
if (statusResult.ok && statusResult.status === 'READY') {
const shouldForceRefresh =
qdnLoadingRetryAttempts > 0
|| (viewerLoadingOverlay && !viewerLoadingOverlay.classList.contains('qortal-hidden'));
if (shouldForceRefresh) {
logDebug('info', 'QDN status ready on frame load, forcing viewer refresh', {
attempt: qdnLoadingRetryAttempts,
});
clearQdnLoadingRetry('status_ready_refresh');
qdnLoadingRetryAttempts = 0;
reloadViewerFrame();
return;
}
}
}
clearQdnLoadingRetry('content_loaded');
qdnLoadingRetryAttempts = 0;
});
}
function originFromUrl(url) {
try {
return new URL(url).origin;
} catch (error) {
return '';
}
}
const allowedOrigins = new Set();
const gatewayOrigin = originFromUrl(gatewayUrl);
if (gatewayOrigin) {
allowedOrigins.add(gatewayOrigin);
}
allowedOrigins.add(window.location.origin);
function buildBridgePayload(message) {
if (!message || typeof message !== 'object') {
return {};
}
if (message.payload && typeof message.payload === 'object') {
return message.payload;
}
const copy = Object.assign({}, message);
delete copy.action;
delete copy.requestedHandler;
delete copy.payload;
delete copy.requestId;
delete copy.isExtension;
return copy;
}
function normalizeBridgePayload(requestType, payload) {
if (!payload || typeof payload !== 'object') {
return {};
}
const normalized = Object.assign({}, payload);
const pickBase64Value = function (value) {
if (!value || typeof value !== 'object') {
return '';
}
if (typeof value.base64 === 'string' && value.base64.trim() !== '') {
return value.base64;
}
if (typeof value.data64 === 'string' && value.data64.trim() !== '') {
return value.data64;
}
return '';
};
const listActions = new Set(['GET_LIST_ITEMS', 'ADD_LIST_ITEMS', 'DELETE_LIST_ITEM']);
if (listActions.has(requestType)) {
if (
(normalized.listName === undefined || normalized.listName === null || normalized.listName === '')
&& normalized.list_name !== undefined
&& normalized.list_name !== null
&& normalized.list_name !== ''
) {
normalized.listName = normalized.list_name;
}
}
if (requestType === 'PUBLISH_QDN_RESOURCE') {
const payloadBase64 = pickBase64Value(normalized);
if (
(normalized.data === undefined || normalized.data === null || normalized.data === '')
&& payloadBase64 !== ''
) {
normalized.data = payloadBase64;
}
if (
(normalized.uploadType === undefined || normalized.uploadType === null || normalized.uploadType === '')
&& payloadBase64 !== ''
) {
normalized.uploadType = 'base64';
}
}
if (requestType === 'PUBLISH_MULTIPLE_QDN_RESOURCES' && Array.isArray(normalized.resources)) {
normalized.resources = normalized.resources.map(function (resource) {
if (!resource || typeof resource !== 'object') {
return resource;
}
const resourceCopy = Object.assign({}, resource);
const resourceBase64 = pickBase64Value(resourceCopy);
if (
(resourceCopy.data === undefined || resourceCopy.data === null || resourceCopy.data === '')
&& resourceBase64 !== ''
) {
resourceCopy.data = resourceBase64;
}
if (
(resourceCopy.uploadType === undefined || resourceCopy.uploadType === null || resourceCopy.uploadType === '')
&& resourceBase64 !== ''
) {
resourceCopy.uploadType = 'base64';
}
return resourceCopy;
});
}
const cryptoActions = new Set([
'ENCRYPT_DATA',
'DECRYPT_DATA',
'ENCRYPT_QORTAL_GROUP_DATA',
'DECRYPT_QORTAL_GROUP_DATA'
]);
if (cryptoActions.has(requestType)) {
if (
(normalized.data === undefined || normalized.data === null || normalized.data === '')
&& typeof normalized.data64 === 'string'
&& normalized.data64.trim() !== ''
) {
normalized.data = normalized.data64;
}
if (
(normalized.encryptedData === undefined || normalized.encryptedData === null || normalized.encryptedData === '')
&& typeof normalized.encryptedData64 === 'string'
&& normalized.encryptedData64.trim() !== ''
) {
normalized.encryptedData = normalized.encryptedData64;
}
}
return normalized;
}
function buildGatewayPath(payload) {
if (!payload || typeof payload !== 'object') {
return '';
}
const service = payload.service || payload.type || 'WEBSITE';
const name = payload.name;
if (!name) {
return '';
}
let path = String(service).replace(/^\/+/, '') + '/' + String(name).replace(/^\/+/, '');
if (payload.identifier) {
path += '/' + String(payload.identifier).replace(/^\/+/, '');
}
if (payload.path) {
const extra = String(payload.path);
path += extra.startsWith('/') ? extra : '/' + extra;
}
return path;
}
function openGatewayPath(path) {
if (!path) {
return '';
}
const normalized = path.replace(/^\/+/, '');
const upstreamPath = toUpstreamProxyPath(normalized, true);
if (!viewerRendererConfigured) {
return '';
}
if (gatewayProxyTemplate) {
return buildProxyUrl(upstreamPath);
}
if (nodeUrl) {
return nodeUrl.replace(/\/$/, '') + '/' + upstreamPath;
}
if (gatewayUrl) {
return gatewayUrl.replace(/\/$/, '') + '/' + upstreamPath;
}
return '';
}
function postBridgeError(port, errorMessage) {
if (!port) {
return;
}
port.postMessage({
result: null,
error: {
error: errorMessage,
message: errorMessage,
},
});
}
function normalizeApprovalMode(mode) {
const normalized = String(mode || '').trim();
if (normalized === 'session') {
return 'type_minutes';
}
if (normalized === 'scope') {
return 'type_always';
}
if (normalized === 'app') {
return 'app_always';
}
if (normalized === 'type_minutes' || normalized === 'type_always' || normalized === 'app_always') {
return normalized;
}
return 'once';
}
function normalizeApprovalMinutes(value) {
const parsed = Number(String(value || '').trim());
if (!Number.isFinite(parsed)) {
return defaultApprovalTempMinutes;
}
return Math.max(1, Math.min(Math.floor(parsed), 1440));
}
function upsertTemporaryApprovalRule(entry) {
const normalizedRequestType = String(entry.requestType || '').trim();
const normalizedScope = String(entry.scope || '').trim();
const normalizedApp = String(entry.app || '').trim();
if (!normalizedRequestType || !normalizedScope) {
return;
}
const expiresAt = Number(entry.expiresAt || 0);
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
return;
}
for (let i = temporaryApprovalRules.length - 1; i >= 0; i -= 1) {
const candidate = temporaryApprovalRules[i];
if (!candidate || candidate.expiresAt <= Date.now()) {
temporaryApprovalRules.splice(i, 1);
continue;
}
if (
candidate.requestType === normalizedRequestType
&& candidate.scope === normalizedScope
&& candidate.app === normalizedApp
) {
candidate.expiresAt = expiresAt;
return;
}
}
temporaryApprovalRules.push({
requestType: normalizedRequestType,
scope: normalizedScope,
app: normalizedApp,
expiresAt,
});
}
function findTemporaryApprovalRule(requestType, scope, app) {
const normalizedRequestType = String(requestType || '').trim();
const normalizedScope = String(scope || '').trim();
const normalizedApp = String(app || '').trim();
const now = Date.now();
for (let i = temporaryApprovalRules.length - 1; i >= 0; i -= 1) {
const candidate = temporaryApprovalRules[i];
if (!candidate || candidate.expiresAt <= now) {
temporaryApprovalRules.splice(i, 1);
continue;
}
if (candidate.requestType !== normalizedRequestType) {
continue;
}
if (candidate.scope !== normalizedScope) {
continue;
}
if (candidate.app !== normalizedApp) {
continue;
}
return candidate;
}
return null;
}
function approvalModeLabel(mode) {
switch (normalizeApprovalMode(mode)) {
case 'type_minutes':
return 'Allow this request type for X minutes';
case 'type_always':
return 'Always allow this request type';
case 'app_always':
return 'Always allow all request types for this Q-App';
default:
return 'Allow only this request';
}
}
function updateApprovalPolicyStatus(selectedMode) {
if (!approvalPolicyStatusEl) {
return;
}
approvalPolicyStatusEl.textContent =
'Policy currently active in this tab: ' +
approvalModeLabel(activeApprovalPolicyMode) +
'. This request: ' +
approvalModeLabel(selectedMode);
}
function markWalletLocked() {
walletLockState.state = 'locked';
walletLockState.expiresAt = null;
if (approvalModal && !approvalModal.classList.contains('qortal-hidden')) {
updateApprovalWalletStatus();
}
}
function markWalletUnknown() {
walletLockState.state = 'unknown';
walletLockState.expiresAt = null;
if (approvalModal && !approvalModal.classList.contains('qortal-hidden')) {
updateApprovalWalletStatus();
}
}
function markWalletUnlocked(ttlSeconds) {
walletLockState.state = 'unlocked';
if (typeof ttlSeconds === 'number' && Number.isFinite(ttlSeconds) && ttlSeconds > 0) {
walletLockState.expiresAt = Date.now() + (ttlSeconds * 1000);
if (approvalModal && !approvalModal.classList.contains('qortal-hidden')) {
updateApprovalWalletStatus();
}
return;
}
walletLockState.expiresAt = null;
if (approvalModal && !approvalModal.classList.contains('qortal-hidden')) {
updateApprovalWalletStatus();
}
}
function getWalletLockStatusPresentation() {
if (walletLockState.state === 'locked') {
return {
text: 'Wallet lock: LOCKED (password required)',
className: 'qortal-wallet-status-locked',
showUnlockFields: true,
};
}
if (walletLockState.state === 'unlocked') {
if (walletLockState.expiresAt && walletLockState.expiresAt <= Date.now()) {
walletLockState.state = 'locked';
walletLockState.expiresAt = null;
return {
text: 'Wallet lock: LOCKED (previous unlock expired)',
className: 'qortal-wallet-status-locked',
showUnlockFields: true,
};
}
if (walletLockState.expiresAt && walletLockState.expiresAt > Date.now()) {
const remainingMinutes = Math.max(1, Math.ceil((walletLockState.expiresAt - Date.now()) / 60000));
return {
text: 'Wallet lock: UNLOCKED for about ' + remainingMinutes + ' min',
className: 'qortal-wallet-status-ok',
showUnlockFields: false,
};
}
return {
text: 'Wallet lock: UNLOCKED',
className: 'qortal-wallet-status-ok',
showUnlockFields: false,
};
}
return {
text: 'Wallet lock: unknown (password may be required)',
className: 'qortal-wallet-status-unknown',
showUnlockFields: false,
};
}
function updateApprovalWalletStatus() {
const status = getWalletLockStatusPresentation();
if (approvalWalletStatusEl) {
approvalWalletStatusEl.textContent = status.text;
approvalWalletStatusEl.classList.remove(
'qortal-wallet-status-ok',
'qortal-wallet-status-locked',
'qortal-wallet-status-unknown'
);
approvalWalletStatusEl.classList.add(status.className);
}
if (approvalUnlockFieldsEl) {
approvalUnlockFieldsEl.classList.toggle('qortal-hidden', !status.showUnlockFields);
}
if (!status.showUnlockFields) {
if (approvalPassword) {
approvalPassword.value = '';
}
if (approvalTtl) {
approvalTtl.checked = defaultApprovalUnlock10Min;
}
}
return status;
}
function parseUnlockStatus(value) {
let candidate = value;
for (let i = 0; i < 4; i += 1) {
if (!candidate || typeof candidate !== 'object' || candidate.data === undefined || candidate.data === null) {
break;
}
if (typeof candidate.data !== 'object') {
break;
}
candidate = candidate.data;
}
if (!candidate || typeof candidate !== 'object' || typeof candidate.unlocked !== 'boolean') {
return null;
}
const unlocked = candidate.unlocked;
const rawExpires = candidate.expiresAt !== undefined ? candidate.expiresAt : candidate.expires_at;
let expiresAtMs = null;
if (typeof rawExpires === 'number' && Number.isFinite(rawExpires)) {
if (rawExpires > 0) {
expiresAtMs = rawExpires < 10_000_000_000 ? rawExpires * 1000 : rawExpires;
}
} else if (typeof rawExpires === 'string' && rawExpires.trim() !== '') {
const trimmed = rawExpires.trim();
if (/^\d+$/.test(trimmed)) {
const parsed = Number(trimmed);
if (Number.isFinite(parsed) && parsed > 0) {
expiresAtMs = parsed < 10_000_000_000 ? parsed * 1000 : parsed;
}
} else {
const parsedDate = Date.parse(trimmed);
if (!Number.isNaN(parsedDate) && parsedDate > 0) {
expiresAtMs = parsedDate;
}
}
}
return { unlocked, expiresAtMs };
}
async function refreshWalletLockState(walletId) {
const id = String(walletId || '').trim();
if (!id || !qappsUnlockStatusUrl) {
markWalletUnknown();
return false;
}
const headers = {};
if (typeof OC !== 'undefined' && OC.requestToken) {
headers.requesttoken = OC.requestToken;
}
let response;
try {
const joiner = qappsUnlockStatusUrl.indexOf('?') === -1 ? '?' : '&';
response = await fetch(
qappsUnlockStatusUrl + joiner + 'walletId=' + encodeURIComponent(id),
{ method: 'GET', headers }
);
} catch (error) {
logDebug('warn', 'Wallet unlock status fetch failed', {
walletId: id,
error: error && error.message ? error.message : String(error),
});
markWalletUnknown();
return false;
}
let payload = null;
try {
payload = await response.json();
} catch (_error) {
payload = null;
}
if (!response.ok || (payload && payload.error)) {
logDebug('warn', 'Wallet unlock status request failed', {
walletId: id,
status: response.status,
error: payload && payload.error ? payload.error : 'wallet_unlock_status_failed',
});
markWalletUnknown();
return false;
}
const parsed = parseUnlockStatus(payload);
if (!parsed) {
markWalletUnknown();
return false;
}
if (!parsed.unlocked) {
markWalletLocked();
return true;
}
if (parsed.expiresAtMs && parsed.expiresAtMs > Date.now()) {
const ttlSeconds = Math.max(1, Math.floor((parsed.expiresAtMs - Date.now()) / 1000));
markWalletUnlocked(ttlSeconds);
return true;
}
markWalletUnlocked(undefined);
return true;
}
function showApprovalError(message) {
if (!approvalError) {
return;
}
approvalError.textContent = message;
approvalError.classList.toggle('qortal-hidden', !message);
}
function showUnlockError(message) {
if (!unlockError) {
return;
}
unlockError.textContent = message;
unlockError.classList.toggle('qortal-hidden', !message);
}
function showApprovalModal(context) {
return new Promise(function (resolve) {
if (!approvalModal || !approvalConfirm || !approvalCancel) {
resolve({ action: 'cancel' });
return;
}
approvalContext = context || null;
if (approvalAppEl) {
approvalAppEl.textContent = (context && context.app) || currentQappAddress || 'Unknown';
}
if (approvalActionEl) {
const normalizedScope = normalizeApprovalScope(
context && context.scope ? context.scope : '',
context && context.requestType ? context.requestType : ''
);
approvalActionEl.textContent = normalizedScope || (context && context.requestType) || 'Unknown';
}
if (approvalPassword) {
approvalPassword.value = '';
}
if (approvalTtl) {
approvalTtl.checked = defaultApprovalUnlock10Min;
}
const normalizedDefaultMode = normalizeApprovalMode(defaultApprovalMode);
const defaultModeInput = document.querySelector(
'input[name="qortal-approval-mode"][value="' + normalizedDefaultMode + '"]'
);
if (defaultModeInput) {
defaultModeInput.checked = true;
} else {
const fallbackModeInput = document.querySelector('input[name="qortal-approval-mode"][value="once"]');
if (fallbackModeInput) {
fallbackModeInput.checked = true;
}
}
if (approvalTempMinutesEl) {
approvalTempMinutesEl.value = String(defaultApprovalTempMinutes);
}
const walletStatus = updateApprovalWalletStatus();
updateApprovalPolicyStatus(normalizedDefaultMode);
const modeInputs = document.querySelectorAll('input[name="qortal-approval-mode"]');
modeInputs.forEach(function (input) {
input.onchange = function () {
const selectedMode = normalizeApprovalMode(input.value || 'once');
updateApprovalPolicyStatus(selectedMode);
if (approvalTempMinutesEl) {
approvalTempMinutesEl.disabled = selectedMode !== 'type_minutes';
}
};
});
if (approvalTempMinutesEl) {
approvalTempMinutesEl.disabled = normalizedDefaultMode !== 'type_minutes';
}
showApprovalError('');
approvalModal.classList.remove('qortal-hidden');
if (walletStatus.showUnlockFields && approvalPassword) {
approvalPassword.focus();
approvalPassword.onkeydown = function (event) {
if (event.key !== 'Enter') {
return;
}
event.preventDefault();
if (approvalConfirm && !approvalConfirm.disabled) {
approvalConfirm.click();
}
};
} else if (approvalConfirm) {
approvalConfirm.focus();
}
logDebug('info', 'Approval modal shown', { requestType: context && context.requestType, app: context && context.app });
approvalConfirm.onclick = function () {
const choice = document.querySelector('input[name="qortal-approval-mode"]:checked');
const mode = normalizeApprovalMode(choice ? choice.value : 'once');
const unlockFieldsVisible = approvalUnlockFieldsEl && !approvalUnlockFieldsEl.classList.contains('qortal-hidden');
const passwordValue = approvalPassword ? approvalPassword.value.trim() : '';
const ttlSeconds = unlockFieldsVisible && approvalTtl && approvalTtl.checked ? 600 : undefined;
if (unlockFieldsVisible && !passwordValue) {
showApprovalError('Wallet password is required because the wallet is locked.');
return;
}
logDebug('info', 'Approval confirm clicked', { mode });
resolve({
action: mode,
password: unlockFieldsVisible ? passwordValue : '',
ttlSeconds,
tempMinutes: normalizeApprovalMinutes(approvalTempMinutesEl ? approvalTempMinutesEl.value : defaultApprovalTempMinutes),
});
};
approvalCancel.onclick = function () {
logDebug('info', 'Approval cancelled');
closeApprovalModal();
resolve({ action: 'cancel' });
};
});
}
function showUnlockModal(context) {
return new Promise(function (resolve) {
if (!unlockModal || !unlockConfirm || !unlockCancel || !unlockPassword) {
resolve({ action: 'cancel' });
return;
}
if (unlockAppEl) {
unlockAppEl.textContent = (context && context.app) || currentQappAddress || 'Unknown';
}
if (unlockActionEl) {
unlockActionEl.textContent = (context && context.requestType) || 'Unknown';
}
unlockPassword.value = '';
if (unlockSession) {
unlockSession.checked = defaultUnlockSession20Min;
}
showUnlockError('');
unlockModal.classList.remove('qortal-hidden');
unlockPassword.focus();
unlockPassword.onkeydown = function (event) {
if (event.key !== 'Enter') {
return;
}
event.preventDefault();
if (unlockConfirm && !unlockConfirm.disabled) {
unlockConfirm.click();
}
};
unlockConfirm.onclick = function () {
const password = unlockPassword.value.trim();
if (!password) {
showUnlockError('Wallet password is required.');
return;
}
const ttlSeconds = unlockSession && unlockSession.checked ? 1200 : undefined;
resolve({ action: 'unlock', password, ttlSeconds });
};
unlockCancel.onclick = function () {
closeUnlockModal();
resolve({ action: 'cancel' });
};
});
}
function closeApprovalModal() {
if (!approvalModal) {
return;
}
approvalModal.classList.add('qortal-hidden');
approvalContext = null;
showApprovalError('');
if (approvalPassword) {
approvalPassword.value = '';
approvalPassword.onkeydown = null;
}
if (approvalTtl) {
approvalTtl.checked = defaultApprovalUnlock10Min;
}
if (approvalTempMinutesEl) {
approvalTempMinutesEl.value = String(defaultApprovalTempMinutes);
approvalTempMinutesEl.disabled = false;
}
const modeInputs = document.querySelectorAll('input[name="qortal-approval-mode"]');
modeInputs.forEach(function (input) {
input.onchange = null;
});
if (approvalConfirm) {
approvalConfirm.disabled = false;
approvalConfirm.textContent = 'Approve';
}
if (approvalCancel) {
approvalCancel.disabled = false;
}
}
function closeUnlockModal() {
if (!unlockModal) {
return;
}
unlockModal.classList.add('qortal-hidden');
showUnlockError('');
if (unlockConfirm) {
unlockConfirm.disabled = false;
unlockConfirm.textContent = 'Unlock';
}
if (unlockCancel) {
unlockCancel.disabled = false;
}
if (unlockPassword) {
unlockPassword.value = '';
unlockPassword.onkeydown = null;
}
}
async function approveOnce(requestType, payload, context) {
if (!qappsApproveUrl) {
throw new Error('Approval endpoint is not configured');
}
const walletId = context && context.walletId ? context.walletId : currentWalletId;
const scope = normalizeApprovalScope(context && context.scope ? context.scope : '', requestType);
if (!walletId) {
throw new Error('walletId is not linked');
}
if (!scope) {
throw new Error('scope is missing');
}
const headers = { 'Content-Type': 'application/json' };
if (typeof OC !== 'undefined' && OC.requestToken) {
headers.requesttoken = OC.requestToken;
}
let response;
try {
response = await fetch(qappsApproveUrl, {
method: 'POST',
headers,
body: JSON.stringify({
walletId,
scope,
app: currentQappAddress,
requestType,
}),
});
} catch (error) {
const message = error && error.message ? error.message : 'Failed to fetch';
throw new Error(`approval_fetch_failed (${scope}): ${message}`);
}
const json = await response.json();
if (!response.ok || json.ok === false || json.error) {
throw new Error(json.error || 'approval_failed');
}
const token = json.data && json.data.approvalToken ? json.data.approvalToken : json.approvalToken;
if (!token) {
throw new Error('approval_token_missing');
}
return token;
}
async function runApprovedRequestWithFallbacks(requestType, payload, token, context, headers, decision, walletId) {
const approvedScopes = new Set();
const initialScope = normalizeApprovalScope(context && context.scope ? context.scope : '', requestType);
if (initialScope) {
approvedScopes.add(initialScope);
}
const runRequest = async function (approvalToken) {
const retryPayload = Object.assign({}, payload, { approvalToken });
const retryResponse = await fetch(qappsRequestUrl, {
method: 'POST',
headers,
body: JSON.stringify({
requestType,
payload: retryPayload,
}),
});
const retryJson = await retryResponse.json();
return { retryResponse, retryJson };
};
let attempt = 0;
while (attempt < 4) {
const { retryResponse, retryJson } = await runRequest(token);
if (!retryResponse.ok || retryJson.ok === false || retryJson.error) {
const retryError = retryJson.error || 'approval_failed';
logDebug('warn', 'Approval retry failed', { requestType, error: retryError });
if (typeof retryError === 'string' && retryError.includes('wallet_locked')) {
markWalletLocked();
if (decision && decision.password) {
await unlockWallet(walletId, decision.password, decision.ttlSeconds);
} else {
await ensureWalletUnlocked(walletId, requestType);
}
token = await approveOnce(requestType, payload, context);
attempt += 1;
continue;
}
if (typeof retryError === 'string' && retryError.includes('approval_required')) {
const retryApprovalContext = extractApprovalContext(retryError, retryJson.details, requestType);
const requiredScope = normalizeApprovalScope(retryApprovalContext.scope, requestType);
if (requiredScope && !approvedScopes.has(requiredScope)) {
logDebug('info', 'Supplemental approval required', { requestType, scope: requiredScope });
token = await approveOnce(requestType, payload, {
scope: requiredScope,
walletId: retryApprovalContext.walletId || walletId,
});
approvedScopes.add(requiredScope);
attempt += 1;
continue;
}
}
return { ok: false, error: retryError };
}
const retryResult = retryJson.data !== undefined ? retryJson.data : retryJson;
return { ok: true, result: retryResult };
}
return { ok: false, error: 'approval_failed' };
}
async function setPermission(mode, requestType, scopeOverride, walletIdOverride) {
if (!qappsPermissionsUrl) {
throw new Error('Permissions endpoint is not configured');
}
const effectiveWalletId = walletIdOverride || currentWalletId;
if (!effectiveWalletId) {
throw new Error('walletId is not linked');
}
const normalizedMode = normalizeApprovalMode(mode);
const modeValue = 'persistent';
const contextScope = normalizeApprovalScope(
scopeOverride || (approvalContext && approvalContext.scope ? approvalContext.scope : ''),
requestType
);
const scopeValue = contextScope || fallbackApprovalScope(requestType);
const headers = { 'Content-Type': 'application/json' };
if (typeof OC !== 'undefined' && OC.requestToken) {
headers.requesttoken = OC.requestToken;
}
async function postPermission(permissionPayload) {
logDebug('info', 'Setting permission', {
mode: permissionPayload.mode,
scope: permissionPayload.scope,
app: permissionPayload.app || '',
requestType: permissionPayload.requestType || '',
});
let response;
try {
response = await fetch(qappsPermissionsUrl, {
method: 'POST',
headers,
body: JSON.stringify(permissionPayload),
});
} catch (error) {
const message = error && error.message ? error.message : 'Failed to fetch';
throw new Error('permission_fetch_failed: ' + message);
}
const text = await response.text();
let json = null;
if (text) {
try {
json = JSON.parse(text);
} catch (_error) {
json = null;
}
}
if (!response.ok) {
const detail = json && json.error ? json.error : ('HTTP ' + response.status);
throw new Error('permission_set_failed: ' + detail);
}
if (json && (json.ok === false || json.error)) {
throw new Error(json.error || 'permission_set_failed');
}
return json || { ok: true };
}
if (normalizedMode === 'app_always') {
if (!currentQappAddress) {
throw new Error('app context is missing');
}
const appWidePayload = {
walletId: effectiveWalletId,
scope: currentQappAddress,
mode: modeValue,
app: currentQappAddress,
};
let appWideSet = false;
let appWideError = null;
try {
await postPermission(appWidePayload);
appWideSet = true;
} catch (primaryError) {
appWideError = primaryError;
logDebug('warn', 'App-wide permission set failed, trying app+type fallback', {
requestType,
error: primaryError && primaryError.message ? primaryError.message : String(primaryError),
});
}
// Compatibility: some external-auth versions accept app-wide permissions
// but still enforce request scope checks. Also set app+scope when available.
if (scopeValue) {
const fallbackPayload = {
walletId: effectiveWalletId,
scope: scopeValue,
mode: modeValue,
app: currentQappAddress,
...(requestType ? { requestType } : {}),
};
try {
return await postPermission(fallbackPayload);
} catch (fallbackError) {
if (appWideSet) {
logDebug('warn', 'App+scope permission set failed after app-wide success', {
requestType,
scope: scopeValue,
error: fallbackError && fallbackError.message ? fallbackError.message : String(fallbackError),
});
return { ok: true };
}
throw fallbackError;
}
}
if (appWideSet) {
return { ok: true };
}
throw appWideError || new Error('scope is missing');
}
if (normalizedMode === 'type_always') {
if (!scopeValue) {
throw new Error('scope is missing');
}
return await postPermission({
walletId: effectiveWalletId,
scope: scopeValue,
mode: modeValue,
requestType,
});
}
throw new Error('permission mode not supported');
}
async function runPermissionRequestWithFallbacks(mode, requestType, payload, headers, decision, walletId, context) {
const grantedScopes = new Set();
const initialScope = normalizeApprovalScope(context && context.scope ? context.scope : '', requestType);
if (initialScope) {
grantedScopes.add(initialScope);
}
let attempt = 0;
while (attempt < 4) {
const retryResponse = await fetch(qappsRequestUrl, {
method: 'POST',
headers,
body: JSON.stringify({
requestType,
payload,
}),
});
const retryJson = await retryResponse.json();
if (!retryResponse.ok || retryJson.ok === false || retryJson.error) {
const retryError = retryJson.error || 'approval_failed';
logDebug('warn', 'Permission retry failed', { requestType, error: retryError });
if (typeof retryError === 'string' && retryError.includes('wallet_locked')) {
markWalletLocked();
if (decision && decision.password) {
await unlockWallet(walletId, decision.password, decision.ttlSeconds);
} else {
await ensureWalletUnlocked(walletId, requestType);
}
attempt += 1;
continue;
}
if (typeof retryError === 'string' && retryError.includes('approval_required')) {
const retryApprovalContext = extractApprovalContext(retryError, retryJson.details, requestType);
const requiredScope = normalizeApprovalScope(retryApprovalContext.scope, requestType);
if (requiredScope && !grantedScopes.has(requiredScope)) {
await setPermission(mode, requestType, requiredScope, retryApprovalContext.walletId || walletId);
grantedScopes.add(requiredScope);
attempt += 1;
continue;
}
}
return { ok: false, error: retryError };
}
const retryResult = retryJson.data !== undefined ? retryJson.data : retryJson;
return { ok: true, result: retryResult };
}
return { ok: false, error: 'approval_failed' };
}
async function tryTemporaryApproval(requestType, payload, approvalCtx, headers) {
const walletId = approvalCtx && approvalCtx.walletId ? approvalCtx.walletId : currentWalletId;
const scope = normalizeApprovalScope(approvalCtx && approvalCtx.scope ? approvalCtx.scope : '', requestType);
if (!walletId || !scope || !currentQappAddress) {
return null;
}
const activeRule = findTemporaryApprovalRule(requestType, scope, currentQappAddress);
if (!activeRule) {
return null;
}
logDebug('info', 'Applying temporary approval rule', {
requestType,
scope,
app: currentQappAddress,
expiresAt: activeRule.expiresAt,
});
try {
const token = await approveOnce(requestType, payload, approvalCtx);
const retryOutcome = await runApprovedRequestWithFallbacks(
requestType,
payload,
token,
approvalCtx,
headers,
{ action: 'type_minutes', password: '', ttlSeconds: undefined },
walletId
);
return retryOutcome;
} catch (error) {
logDebug('warn', 'Temporary approval auto-flow failed', {
requestType,
error: error && error.message ? error.message : String(error),
});
return null;
}
}
async function unlockWallet(walletId, password, ttlSeconds) {
if (!qappsUnlockUrl) {
throw new Error('Unlock endpoint is not configured');
}
const headers = { 'Content-Type': 'application/json' };
if (typeof OC !== 'undefined' && OC.requestToken) {
headers.requesttoken = OC.requestToken;
}
const response = await fetch(qappsUnlockUrl, {
method: 'POST',
headers,
body: JSON.stringify({
walletId,
password,
...(ttlSeconds ? { ttlSeconds } : {}),
}),
});
const json = await response.json();
if (!response.ok || json.ok === false || json.error) {
markWalletLocked();
throw new Error(json.error || 'wallet_unlock_failed');
}
markWalletUnlocked(typeof ttlSeconds === 'number' ? ttlSeconds : undefined);
return json;
}
async function ensureWalletUnlocked(walletId, requestType) {
const decision = await showUnlockModal({ requestType, app: currentQappAddress, walletId });
if (decision.action === 'cancel') {
throw new Error('wallet_locked');
}
if (unlockConfirm) {
unlockConfirm.disabled = true;
unlockConfirm.textContent = 'Unlocking...';
}
if (unlockCancel) {
unlockCancel.disabled = true;
}
try {
await unlockWallet(walletId, decision.password, decision.ttlSeconds);
closeUnlockModal();
} catch (unlockErr) {
showUnlockError(unlockErr.message || 'wallet_unlock_failed');
if (unlockConfirm) {
unlockConfirm.disabled = false;
unlockConfirm.textContent = 'Unlock';
}
if (unlockCancel) {
unlockCancel.disabled = false;
}
throw unlockErr;
}
}
function extractApprovalContext(errorMessage, details, requestType) {
const context = {
scope: '',
walletId: '',
};
function captureFromValue(value, depth) {
if (depth > 3 || value === null || value === undefined) {
return;
}
if (typeof value === 'string') {
if (!context.scope) {
const scopeMatch = value.match(/"scope"\s*:\s*"([^"]+)"/);
if (scopeMatch && scopeMatch[1]) {
context.scope = scopeMatch[1];
}
}
if (!context.walletId) {
const walletMatch = value.match(/"walletId"\s*:\s*"([^"]+)"/);
if (walletMatch && walletMatch[1]) {
context.walletId = walletMatch[1];
}
}
const jsonStart = value.indexOf('{');
const jsonEnd = value.lastIndexOf('}');
if (jsonStart !== -1 && jsonEnd !== -1 && jsonEnd > jsonStart) {
try {
const parsed = JSON.parse(value.slice(jsonStart, jsonEnd + 1));
captureFromValue(parsed, depth + 1);
} catch (_err) {
// ignore parse errors
}
}
return;
}
if (typeof value !== 'object') {
return;
}
if (!context.scope && typeof value.scope === 'string') {
context.scope = value.scope;
}
if (!context.walletId && typeof value.walletId === 'string') {
context.walletId = value.walletId;
}
if (value.detail !== undefined) {
captureFromValue(value.detail, depth + 1);
}
if (value.data !== undefined) {
captureFromValue(value.data, depth + 1);
}
if (value.error !== undefined) {
captureFromValue(value.error, depth + 1);
}
}
captureFromValue(details, 0);
captureFromValue(errorMessage, 0);
context.scope = normalizeApprovalScope(context.scope, requestType);
if (!context.walletId) {
context.walletId = currentWalletId || '';
}
return context;
}
function isWalletLockedError(errorMessage, details) {
if (typeof errorMessage === 'string' && errorMessage.includes('wallet_locked')) {
return true;
}
const detail = details && details.data && details.data.detail ? details.data.detail : null;
if (detail && typeof detail === 'object' && detail.error === 'wallet_locked') {
return true;
}
if (details && typeof details === 'object' && details.error === 'wallet_locked') {
return true;
}
return false;
}
window.addEventListener('message', async function (event) {
const message = event && event.data ? event.data : null;
if (!message || typeof message.action !== 'string') {
return;
}
const hasPort = Boolean(event && event.ports && event.ports[0]);
if (viewerFrame && event.source && viewerFrame.contentWindow && event.source !== viewerFrame.contentWindow) {
logDebug('warn', 'Ignored message from unknown source', { action: message.action });
return;
}
if (event.origin && !allowedOrigins.has(event.origin)) {
logDebug('warn', 'Ignored message from disallowed origin', { origin: event.origin, action: message.action });
return;
}
const requestType = message.action;
const payload = normalizeBridgePayload(requestType, buildBridgePayload(message));
if (requestType === 'SET_TAB' && message.requestedHandler === 'UI') {
const path = buildGatewayPath(payload);
const url = openGatewayPath(path);
if (url) {
logDebug('info', 'SET_TAB opening', { path, url });
window.open(url, '_blank', 'noopener');
}
if (event.source && typeof event.source.postMessage === 'function') {
event.source.postMessage(
{
action: 'SET_TAB_SUCCESS',
requestedHandler: 'UI',
payload: {
name: payload.name,
},
},
event.origin || '*'
);
}
return;
}
if (!hasPort && requestType === 'SET_TAB_SUCCESS') {
return;
}
if (!hasPort) {
if (requestType === 'NAVIGATION_HISTORY') {
const addressFromPayload = getNavigationAddressFromPayload(payload);
if (addressFromPayload) {
setViewerAddressDisplay(addressFromPayload);
logDebug('info', 'Navigation history update (no port)', { requestType, address: addressFromPayload });
} else {
const frameAddress = syncViewerAddressFromFrame();
logDebug('info', 'Navigation history update (no port)', frameAddress ? { requestType, address: frameAddress } : { requestType });
}
return;
}
logDebug('warn', 'Missing message port', { requestType });
return;
}
const port = event.ports[0];
if (!requestType) {
postBridgeError(port, 'requestType is required');
return;
}
if (requestType === 'QDN_RESOURCE_DISPLAYED') {
port.postMessage({ result: true, error: null });
return;
}
if (requestType === 'LINK_TO_QDN_RESOURCE') {
const path = buildGatewayPath(payload);
const url = openGatewayPath(path);
if (url) {
logDebug('info', 'LINK_TO_QDN_RESOURCE opening', { path, url });
window.open(url, '_blank', 'noopener');
}
port.postMessage({ result: true, error: null });
return;
}
if (requestType === 'OPEN_NEW_TAB') {
const qortalLink =
(typeof payload.qortalLink === 'string' && payload.qortalLink.trim()) ||
(typeof payload.link === 'string' && payload.link.trim()) ||
(typeof payload.url === 'string' && payload.url.trim()) ||
(typeof payload.href === 'string' && payload.href.trim()) ||
'';
if (!qortalLink) {
postBridgeError(port, 'qortalLink is required');
return;
}
logDebug('info', 'OPEN_NEW_TAB routed to integrated viewer', { qortalLink });
openAddress(qortalLink, 'Qortal Browser');
port.postMessage({ result: true, error: null });
return;
}
try {
const gatewayResult = await handleGatewayRequest(requestType, payload);
if (gatewayResult.handled) {
if (gatewayResult.error) {
logDebug('error', 'Gateway request failed', { requestType, error: gatewayResult.error });
postBridgeError(port, gatewayResult.error);
} else {
logDebug('info', 'Gateway request handled', { requestType, result: gatewayResult.data });
port.postMessage({ result: gatewayResult.data, error: null });
}
return;
}
if (!qappsRequestUrl) {
logDebug('error', 'Q-Apps request URL missing', { requestType });
postBridgeError(port, 'Q-Apps request endpoint is not configured');
return;
}
const headers = { 'Content-Type': 'application/json' };
if (typeof OC !== 'undefined' && OC.requestToken) {
headers.requesttoken = OC.requestToken;
}
logDebug('info', 'Broker qortalRequest start', { requestType, payload });
const response = await fetch(qappsRequestUrl, {
method: 'POST',
headers,
body: JSON.stringify({
requestType,
payload,
}),
});
const json = await response.json();
if (!response.ok || json.ok === false || json.error) {
const errorMessage = json.error || 'qortal_request_failed';
const approvalContext = extractApprovalContext(errorMessage, json.details, requestType);
if (isWalletLockedError(errorMessage, json.details)) {
markWalletLocked();
const walletId = approvalContext.walletId || payload.walletId || currentWalletId;
logDebug('info', 'Wallet locked', { requestType, walletId });
const decision = await showUnlockModal({ requestType, app: currentQappAddress, walletId });
if (decision.action === 'cancel') {
postBridgeError(port, 'wallet_locked');
closeUnlockModal();
return;
}
if (unlockConfirm) {
unlockConfirm.disabled = true;
unlockConfirm.textContent = 'Unlocking...';
}
if (unlockCancel) {
unlockCancel.disabled = true;
}
try {
await unlockWallet(walletId, decision.password, decision.ttlSeconds);
const retryResponse = await fetch(qappsRequestUrl, {
method: 'POST',
headers,
body: JSON.stringify({
requestType,
payload,
}),
});
const retryJson = await retryResponse.json();
if (!retryResponse.ok || retryJson.ok === false || retryJson.error) {
const retryError = retryJson.error || 'qortal_request_failed';
if (typeof retryError === 'string' && retryError.includes('approval_required')) {
const retryApprovalContext = extractApprovalContext(retryError, retryJson.details, requestType);
logDebug('info', 'Approval required after unlock', { requestType });
const temporaryOutcome = await tryTemporaryApproval(
requestType,
payload,
retryApprovalContext,
headers
);
if (temporaryOutcome && temporaryOutcome.ok) {
logDebug('info', 'Broker qortalRequest ok', { requestType, result: temporaryOutcome.result });
activeApprovalPolicyMode = 'type_minutes';
port.postMessage({ result: temporaryOutcome.result, error: null });
closeUnlockModal();
return;
}
closeUnlockModal();
await refreshWalletLockState(retryApprovalContext.walletId || currentWalletId);
const decisionAfterUnlock = await showApprovalModal({
requestType,
payload,
app: currentQappAddress,
scope: retryApprovalContext.scope,
walletId: retryApprovalContext.walletId,
});
if (decisionAfterUnlock.action === 'cancel') {
postBridgeError(port, 'approval_cancelled');
closeApprovalModal();
return;
}
if (approvalConfirm) {
approvalConfirm.disabled = true;
approvalConfirm.textContent = 'Approving...';
}
if (approvalCancel) {
approvalCancel.disabled = true;
}
try {
if (decisionAfterUnlock.action === 'once' || decisionAfterUnlock.action === 'type_minutes') {
if (decisionAfterUnlock.action === 'type_minutes') {
const temporaryScope = normalizeApprovalScope(retryApprovalContext.scope, requestType);
upsertTemporaryApprovalRule({
requestType,
scope: temporaryScope,
app: currentQappAddress,
expiresAt: Date.now() + (decisionAfterUnlock.tempMinutes * 60 * 1000),
});
}
const token = await approveOnce(requestType, payload, retryApprovalContext);
const retryOutcome = await runApprovedRequestWithFallbacks(
requestType,
payload,
token,
retryApprovalContext,
headers,
decisionAfterUnlock,
retryApprovalContext.walletId || currentWalletId
);
if (!retryOutcome.ok) {
showApprovalError(retryOutcome.error || 'approval_failed');
postBridgeError(port, retryOutcome.error || 'qortal_request_failed');
closeApprovalModal();
return;
}
logDebug('info', 'Broker qortalRequest ok', { requestType, result: retryOutcome.result });
if (decisionAfterUnlock.action === 'type_minutes') {
activeApprovalPolicyMode = 'type_minutes';
}
port.postMessage({ result: retryOutcome.result, error: null });
closeApprovalModal();
return;
}
if (
decisionAfterUnlock.action === 'type_always' ||
decisionAfterUnlock.action === 'app_always'
) {
const walletIdAfterUnlock = retryApprovalContext.walletId || currentWalletId;
await setPermission(decisionAfterUnlock.action, requestType, retryApprovalContext.scope, walletIdAfterUnlock);
const permissionOutcome = await runPermissionRequestWithFallbacks(
decisionAfterUnlock.action,
requestType,
payload,
headers,
decisionAfterUnlock,
walletIdAfterUnlock,
retryApprovalContext
);
if (!permissionOutcome.ok) {
showApprovalError(permissionOutcome.error || 'approval_failed');
postBridgeError(port, permissionOutcome.error || 'qortal_request_failed');
closeApprovalModal();
return;
}
activeApprovalPolicyMode = decisionAfterUnlock.action;
logDebug('info', 'Broker qortalRequest ok', { requestType, result: permissionOutcome.result });
port.postMessage({ result: permissionOutcome.result, error: null });
closeApprovalModal();
return;
}
} finally {
if (approvalConfirm) {
approvalConfirm.disabled = false;
approvalConfirm.textContent = 'Approve';
}
if (approvalCancel) {
approvalCancel.disabled = false;
}
}
}
showUnlockError(retryError || 'wallet_unlock_failed');
postBridgeError(port, retryError || 'wallet_unlock_failed');
closeUnlockModal();
return;
}
const retryResult = retryJson.data !== undefined ? retryJson.data : retryJson;
logDebug('info', 'Broker qortalRequest ok', { requestType, result: retryResult });
port.postMessage({ result: retryResult, error: null });
closeUnlockModal();
return;
} catch (unlockErr) {
showUnlockError(unlockErr.message || 'wallet_unlock_failed');
logDebug('error', 'Wallet unlock failed', { requestType, error: unlockErr.message || 'wallet_unlock_failed' });
postBridgeError(port, unlockErr.message || 'wallet_unlock_failed');
if (unlockConfirm) {
unlockConfirm.disabled = false;
unlockConfirm.textContent = 'Unlock';
}
if (unlockCancel) {
unlockCancel.disabled = false;
}
closeUnlockModal();
return;
}
}
if (typeof errorMessage === 'string' && errorMessage.includes('approval_required')) {
logDebug('info', 'Approval required', { requestType, context: approvalContext });
const temporaryOutcome = await tryTemporaryApproval(requestType, payload, approvalContext, headers);
if (temporaryOutcome && temporaryOutcome.ok) {
logDebug('info', 'Broker qortalRequest ok', { requestType, result: temporaryOutcome.result });
activeApprovalPolicyMode = 'type_minutes';
port.postMessage({ result: temporaryOutcome.result, error: null });
return;
}
await refreshWalletLockState(approvalContext.walletId || currentWalletId);
const decision = await showApprovalModal({
requestType,
payload,
app: currentQappAddress,
scope: approvalContext.scope,
walletId: approvalContext.walletId,
});
if (decision.action === 'cancel') {
postBridgeError(port, 'approval_cancelled');
closeApprovalModal();
return;
}
if (approvalConfirm) {
approvalConfirm.disabled = true;
approvalConfirm.textContent = 'Approving...';
}
if (approvalCancel) {
approvalCancel.disabled = true;
}
try {
const walletId = approvalContext.walletId || currentWalletId;
if (decision.password) {
await unlockWallet(walletId, decision.password, decision.ttlSeconds);
}
if (decision.action === 'once' || decision.action === 'type_minutes') {
if (decision.action === 'type_minutes') {
const temporaryScope = normalizeApprovalScope(approvalContext.scope, requestType);
upsertTemporaryApprovalRule({
requestType,
scope: temporaryScope,
app: currentQappAddress,
expiresAt: Date.now() + (decision.tempMinutes * 60 * 1000),
});
}
let token;
try {
token = await approveOnce(requestType, payload, approvalContext);
} catch (error) {
const msg = error && error.message ? error.message : String(error || 'approval_failed');
if (msg.includes('wallet_locked')) {
markWalletLocked();
if (decision.password) {
await unlockWallet(walletId, decision.password, decision.ttlSeconds);
} else {
await ensureWalletUnlocked(walletId, requestType);
}
token = await approveOnce(requestType, payload, approvalContext);
} else {
throw error;
}
}
const retryOutcome = await runApprovedRequestWithFallbacks(
requestType,
payload,
token,
approvalContext,
headers,
decision,
walletId
);
if (!retryOutcome.ok) {
showApprovalError(retryOutcome.error || 'approval_failed');
postBridgeError(port, retryOutcome.error || 'qortal_request_failed');
if (approvalConfirm) {
approvalConfirm.disabled = false;
approvalConfirm.textContent = 'Approve';
}
if (approvalCancel) {
approvalCancel.disabled = false;
}
closeApprovalModal();
return;
}
logDebug('info', 'Broker qortalRequest ok', { requestType, result: retryOutcome.result });
if (decision.action === 'type_minutes') {
activeApprovalPolicyMode = 'type_minutes';
}
port.postMessage({ result: retryOutcome.result, error: null });
closeApprovalModal();
return;
}
if (decision.action === 'type_always' || decision.action === 'app_always') {
try {
await setPermission(decision.action, requestType, approvalContext.scope, walletId);
} catch (error) {
const msg = error && error.message ? error.message : String(error || 'permission_set_failed');
if (msg.includes('wallet_locked')) {
markWalletLocked();
if (decision.password) {
await unlockWallet(walletId, decision.password, decision.ttlSeconds);
} else {
await ensureWalletUnlocked(walletId, requestType);
}
await setPermission(decision.action, requestType, approvalContext.scope, walletId);
} else {
throw error;
}
}
const permissionOutcome = await runPermissionRequestWithFallbacks(
decision.action,
requestType,
payload,
headers,
decision,
walletId,
approvalContext
);
if (!permissionOutcome.ok) {
showApprovalError(permissionOutcome.error || 'approval_failed');
postBridgeError(port, permissionOutcome.error || 'qortal_request_failed');
closeApprovalModal();
return;
}
activeApprovalPolicyMode = decision.action;
logDebug('info', 'Broker qortalRequest ok', { requestType, result: permissionOutcome.result });
port.postMessage({ result: permissionOutcome.result, error: null });
closeApprovalModal();
return;
}
} catch (approvalError) {
showApprovalError(approvalError.message || 'approval_failed');
logDebug('error', 'Approval failed', { requestType, error: approvalError.message || 'approval_failed' });
postBridgeError(port, approvalError.message || 'approval_failed');
if (approvalConfirm) {
approvalConfirm.disabled = false;
approvalConfirm.textContent = 'Approve';
}
if (approvalCancel) {
approvalCancel.disabled = false;
}
closeApprovalModal();
return;
}
}
logDebug('error', 'Broker qortalRequest failed', { requestType, error: json.error || 'qortal_request_failed' });
postBridgeError(port, json.error || 'qortal_request_failed');
return;
}
let resultPayload = json.data !== undefined ? json.data : json;
if (requestType === 'GET_PRIMARY_NAME' && typeof resultPayload !== 'string') {
if (Array.isArray(resultPayload)) {
resultPayload = resultPayload.length > 0 ? resultPayload[0] : '';
} else if (resultPayload && typeof resultPayload === 'object') {
resultPayload =
resultPayload.name ||
resultPayload.primaryName ||
(resultPayload.data && resultPayload.data.name) ||
'';
} else if (resultPayload === null || resultPayload === undefined) {
resultPayload = '';
}
}
logDebug('info', 'Broker qortalRequest ok', { requestType, result: resultPayload });
port.postMessage({ result: resultPayload, error: null });
} catch (error) {
logDebug('error', 'qortalRequest exception', { requestType, error: error.message || 'qortal_request_failed' });
postBridgeError(port, error.message || 'qortal_request_failed');
}
});
function appendQuery(params, key, value) {
if (value === undefined || value === null || value === '') {
return;
}
if (Array.isArray(value)) {
value.forEach(function (item) {
appendQuery(params, key, item);
});
return;
}
const normalized = typeof value === 'boolean' ? String(Boolean(value)) : String(value);
params.append(key, normalized);
}
function buildGatewayApiUrl(path, params) {
const base = openGatewayPath(path);
if (!base) {
return '';
}
if (!params) {
return base;
}
const qs = params.toString();
if (!qs) {
return base;
}
return base + (base.includes('?') ? '&' : '?') + qs;
}
function parseGatewayResponse(text) {
if (text === '') {
return null;
}
try {
return JSON.parse(text);
} catch (error) {
return text;
}
}
async function fetchGateway(url) {
logDebug('info', 'Gateway fetch', { url });
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json,text/plain,*/*',
},
});
const text = await response.text();
const parsed = parseGatewayResponse(text);
if (!response.ok) {
const message = parsed && parsed.error ? parsed.error : 'gateway_request_failed';
logDebug('error', 'Gateway fetch failed', { url, status: response.status, message });
throw new Error(message);
}
logDebug('info', 'Gateway fetch ok', { url, status: response.status });
return parsed;
}
async function handleGatewayRequest(requestType, payload) {
const params = new URLSearchParams();
let path = '';
switch (requestType) {
case 'IS_USING_PUBLIC_NODE':
return { handled: true, data: false };
case 'GET_ACCOUNT_DATA':
if (!payload.address) return { handled: true, error: 'address is required' };
path = `addresses/${payload.address}`;
break;
case 'GET_ACCOUNT_NAMES':
if (!payload.address) return { handled: true, error: 'address is required' };
path = `names/address/${payload.address}`;
break;
case 'SEARCH_NAMES':
path = 'names/search';
appendQuery(params, 'query', payload.query);
appendQuery(params, 'prefix', payload.prefix);
appendQuery(params, 'limit', payload.limit);
appendQuery(params, 'offset', payload.offset);
appendQuery(params, 'reverse', payload.reverse);
break;
case 'GET_NAME_DATA':
if (!payload.name) return { handled: true, error: 'name is required' };
path = `names/${payload.name}`;
break;
case 'LIST_QDN_RESOURCES':
path = 'arbitrary/resources';
appendQuery(params, 'service', payload.service);
appendQuery(params, 'name', payload.name);
appendQuery(params, 'identifier', payload.identifier);
appendQuery(params, 'default', payload.default);
appendQuery(params, 'includestatus', payload.includeStatus);
appendQuery(params, 'includemetadata', payload.includeMetadata);
appendQuery(params, 'namefilter', payload.nameListFilter);
appendQuery(params, 'followedonly', payload.followedOnly);
appendQuery(params, 'excludeblocked', payload.excludeBlocked);
appendQuery(params, 'limit', payload.limit);
appendQuery(params, 'offset', payload.offset);
appendQuery(params, 'reverse', payload.reverse);
break;
case 'SEARCH_QDN_RESOURCES':
path = 'arbitrary/resources/search';
appendQuery(params, 'service', payload.service);
appendQuery(params, 'query', payload.query);
appendQuery(params, 'identifier', payload.identifier);
appendQuery(params, 'name', payload.name);
appendQuery(params, 'name', payload.names);
appendQuery(params, 'keywords', payload.keywords);
appendQuery(params, 'title', payload.title);
appendQuery(params, 'description', payload.description);
appendQuery(params, 'prefix', payload.prefix);
appendQuery(params, 'exactmatchnames', payload.exactMatchNames);
appendQuery(params, 'default', payload.default);
appendQuery(params, 'mode', payload.mode);
appendQuery(params, 'minlevel', payload.minLevel);
appendQuery(params, 'includestatus', payload.includeStatus);
appendQuery(params, 'includemetadata', payload.includeMetadata);
appendQuery(params, 'namefilter', payload.nameListFilter);
appendQuery(params, 'followedonly', payload.followedOnly);
appendQuery(params, 'excludeblocked', payload.excludeBlocked);
appendQuery(params, 'before', payload.before);
appendQuery(params, 'after', payload.after);
appendQuery(params, 'limit', payload.limit);
appendQuery(params, 'offset', payload.offset);
appendQuery(params, 'reverse', payload.reverse);
break;
case 'FETCH_QDN_RESOURCE':
if (!payload.service || !payload.name) return { handled: true, error: 'service and name are required' };
path = `arbitrary/${payload.service}/${payload.name}`;
if (payload.identifier) {
path += `/${payload.identifier}`;
}
appendQuery(params, 'filepath', payload.filepath);
appendQuery(params, 'rebuild', payload.rebuild);
appendQuery(params, 'encoding', payload.encoding);
break;
case 'GET_QDN_RESOURCE_STATUS':
if (!payload.service || !payload.name) return { handled: true, error: 'service and name are required' };
path = `arbitrary/resource/status/${payload.service}/${payload.name}`;
if (payload.identifier) {
path += `/${payload.identifier}`;
}
appendQuery(params, 'build', payload.build);
break;
case 'GET_QDN_RESOURCE_METADATA':
if (!payload.service || !payload.name) return { handled: true, error: 'service and name are required' };
path = `arbitrary/metadata/${payload.service}/${payload.name}/${payload.identifier || 'default'}`;
break;
case 'GET_QDN_RESOURCE_PROPERTIES':
if (!payload.service || !payload.name) return { handled: true, error: 'service and name are required' };
path = `arbitrary/resource/properties/${payload.service}/${payload.name}/${payload.identifier || 'default'}`;
break;
case 'GET_QDN_RESOURCE_URL':
if (!payload.service || !payload.name) return { handled: true, error: 'service and name are required' };
path = `arbitrary/resource/status/${payload.service}/${payload.name}`;
if (payload.identifier) {
path += `/${payload.identifier}`;
}
appendQuery(params, 'build', payload.build);
{
const statusUrl = buildGatewayApiUrl(path, params);
if (!statusUrl) return { handled: true, error: 'Gateway URL is not configured' };
const status = await fetchGateway(statusUrl);
const parsedStatus = parseQdnStatusPayload(status);
const statusCode = parsedStatus ? parsedStatus.status : '';
const totalChunkCount = parsedStatus
? parsedStatus.totalChunkCount
: coerceFiniteNumber(status && status.totalChunkCount);
if (
!status
|| statusCode === 'NOT_PUBLISHED'
|| statusCode === 'UNSUPPORTED'
|| statusCode === 'BLOCKED'
|| (!statusCode && totalChunkCount !== null && totalChunkCount <= 0)
) {
return { handled: true, error: 'Resource does not exist' };
}
const resourcePath = `${payload.service}/${payload.name}`
+ (payload.identifier ? `/${payload.identifier}` : '')
+ (payload.path ? `/${String(payload.path).replace(/^\/+/, '')}` : '');
const url = openGatewayPath(resourcePath);
return { handled: true, data: url };
}
default:
return { handled: false };
}
const url = buildGatewayApiUrl(path, params);
if (!url) {
return { handled: true, error: 'Gateway URL is not configured' };
}
const data = await fetchGateway(url);
return { handled: true, data };
}
})();