3539 lines
113 KiB
JavaScript
3539 lines
113 KiB
JavaScript
(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 = '<tr><td colspan="4">No Q-Apps available.</td></tr>';
|
|
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 };
|
|
}
|
|
})();
|