(function () { const root = document.getElementById("qortal-qapps-root"); if (!root) { return; } let qapps = []; const normalizeQappIconMode = function (value) { const normalized = String(value || "") .trim() .toLowerCase(); if (normalized === "custom" || normalized === "name") { return normalized; } return "auto"; }; try { const parsed = JSON.parse(root.dataset.qapps || "[]"); if (Array.isArray(parsed)) { qapps = parsed .filter(function (entry) { return ( entry && typeof entry === "object" && typeof entry.address === "string" && entry.address.trim() !== "" ); }) .map(function (entry) { return { name: typeof entry.name === "string" ? entry.name.trim() : "", address: String(entry.address || "").trim(), description: typeof entry.description === "string" ? entry.description.trim() : "", iconMode: normalizeQappIconMode(entry.iconMode), iconUrl: typeof entry.iconUrl === "string" ? entry.iconUrl.trim() : "", iconFromName: typeof entry.iconFromName === "string" ? entry.iconFromName.trim() : typeof entry.iconSourceName === "string" ? entry.iconSourceName.trim() : "", }; }); } } 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 embedMode = root.dataset.embedMode === "1" || urlParams.get("embed") === "1"; const embedChromeless = embedMode && (urlParams.get("chromeless") === "1" || urlParams.get("embed_chrome") === "0"); const embedLockQapp = embedMode && urlParams.get("lock_qapp") === "1"; const embedRequestedQapp = normalizeBrowserAddress( decodeURIComponent(urlParams.get("qapp") || urlParams.get("app") || "") ); 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 advancedUnlockSettingsEnabled = root.dataset.qappsAdvancedUnlockSettingsEnabled === "1"; const defaultApprovalMode = (function () { if (!advancedUnlockSettingsEnabled) { return "app_always"; } 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 || "120").trim() ); if (!Number.isFinite(parsed)) { return 120; } const value = Math.floor(parsed); const allowed = [60, 120, 180, 360, 720, 1440, 7200]; return allowed.includes(value) ? value : 120; })(); const defaultApprovalUnlock10Min = advancedUnlockSettingsEnabled && root.dataset.qappsDefaultApprovalUnlock10Min === "1"; const defaultUnlockSession20Min = advancedUnlockSettingsEnabled && root.dataset.qappsDefaultUnlockSession20Min === "1"; const userMappingsUrl = root.dataset.userMappingsUrl || ""; const nextcloudPublicUrl = root.dataset.nextcloudPublicUrl || ""; const settingsPath = root.dataset.settingsPath || "/settings/user/qortal_integration"; const sovereignMode = root.dataset.sovereignMode === "full_qortal" ? "full_qortal" : "powered"; const isPoweredMode = sovereignMode === "powered"; const effectiveDebugEnabled = !isPoweredMode && debugEnabled; const modeCopy = isPoweredMode ? { title: "NuQloud Apps", help: "Launch approved NuQloud Apps from inside Nextcloud.", emptyNote: "App access is not enabled yet. Ask an admin to enable it.", browserTitle: "Full NuQloud Browser", browserNote: "Open the full NuQloud browser for unrestricted app access.", warningNote: "No linked wallet is available for your account yet. You can browse apps, but authenticated actions will not work until you create or attach a wallet.", settingsButton: "Open Integration Settings", nameRequiredTitle: "Distributed Identity Setup Needed", nameRequiredContinue: "Continue in View-Only Mode", listTitle: "Available NuQloud Apps", listNote: "Launch approved NuQloud Apps from this list, or use Full Browser mode for unrestricted navigation.", listEmpty: "No apps available.", nodeMissingAlert: "Node or gateway URL is not configured. Ask an admin to configure rendering.", launchDisabledTitle: "Node or gateway URL is not configured.", browserName: "NuQloud Browser", requestEndpointMissing: "Apps request endpoint is not configured", approvalTitle: advancedUnlockSettingsEnabled ? "Authorize Request" : "Continue in NuQloud App", approvalNote: advancedUnlockSettingsEnabled ? "Choose how this request should be handled. Broker/API session auth is automatic while you are logged into Nextcloud." : "Continue with this app action. Enter your account password only if your account is currently locked.", unlockTitle: advancedUnlockSettingsEnabled ? "Unlock Wallet" : "Unlock to Continue", unlockNote: advancedUnlockSettingsEnabled ? "Your wallet is locked. Enter your wallet password to continue." : "Enter your account password to continue in this app.", loadingLabel: "Loading from decentralized network...", loadingFromQdn: "Loading from decentralized network...", accountLabel: "NuQloud decentralized account", unitLabel: "publish credits", costLabel: "125 publish credits", createAccountStep: "Use the 'Create Account' button on the dashboard.", fundingStep: "Request initial publish credits from your admins or CHD, or purchase publish credits on the dashboard.", nameStep: "Use 125 publish credits to register your distributed identity name on the network.", prereqOutro: "Then you will have full access to distributed authentication, communications, backups, and data features.", mobileSwitchNote: 'Open "{name}" here? This will replace your current app in this view.', mobileLeaveNote: 'Leave your current app and open "{name}" here? Tap OK to open anyway or Cancel to stay.', } : { title: "Q-Apps", help: "Launch approved Q-Apps from inside Nextcloud.", emptyNote: "Q-Apps access is not enabled yet. Ask an admin to enable it.", browserTitle: "Full Qortal Browser", browserNote: "Open the full Qortal browser for unrestricted Q-App access.", warningNote: "No Qortal account is linked to your Nextcloud user yet. You can browse Q-Apps, but authentication-required actions will not work until you create or attach a Qortal account.", settingsButton: "Open Qortal Settings", nameRequiredTitle: "Distributed Identity Setup Needed", nameRequiredContinue: "Continue in View-Only Mode", listTitle: "Available Q-Apps", listNote: "Launch approved Q-Apps from this list, or use Full Browser mode for unrestricted navigation.", listEmpty: "No Q-Apps available.", nodeMissingAlert: "Qortal node or gateway URL is not configured. Ask an admin to configure rendering.", launchDisabledTitle: "Qortal node or gateway URL is not configured.", browserName: "Qortal Browser", requestEndpointMissing: "Q-Apps request endpoint is not configured", approvalTitle: advancedUnlockSettingsEnabled ? "Authorize Qortal Request" : "Continue in Q-App", approvalNote: advancedUnlockSettingsEnabled ? "Choose how this Qortal request should be handled. Broker/API session auth is automatic while you are logged into Nextcloud." : "Continue with this Q-App action. Enter your wallet password only if your wallet is currently locked.", unlockTitle: advancedUnlockSettingsEnabled ? "Unlock Qortal Wallet" : "Unlock to Continue", unlockNote: advancedUnlockSettingsEnabled ? "Your wallet is locked. Enter your wallet password to continue." : "Enter your wallet password to continue in this app.", loadingLabel: "Loading from QDN...", loadingFromQdn: "Loading from QDN...", accountLabel: "Qortal account", unitLabel: "QORT", costLabel: "1.25 QORT", createAccountStep: "Use the 'Create Account' button on the dashboard.", fundingStep: "Request initial QORT from your admins/CHD, or purchase publish credits/QORT on the dashboard.", nameStep: "Use 1.25 QORT to register your distributed identity name on the network.", prereqOutro: "Then you will have full access to distributed authentication, communications, backups, and QDN data features.", mobileSwitchNote: 'Open "{name}" here? This will replace your current app in this view.', mobileLeaveNote: 'Leave your current app and open "{name}" here? Tap OK to open anyway or Cancel to stay.', }; const emptyCard = document.getElementById("qortal-qapps-empty"); const titleEl = document.getElementById("qortal-qapps-title"); const helpEl = document.getElementById("qortal-qapps-help"); const emptyNoteEl = document.getElementById("qortal-qapps-empty-note"); const browserCard = document.getElementById("qortal-qapps-browser"); const browserTitleEl = document.getElementById("qortal-qapps-browser-title"); const browserNoteEl = document.getElementById("qortal-qapps-browser-note"); const browserAddressEl = document.getElementById( "qortal-qapps-browser-address" ); const browserButton = document.getElementById("qortal-qapps-open-browser"); const listCard = document.getElementById("qortal-qapps-list"); const listTitleEl = document.getElementById("qortal-qapps-list-title"); const listNoteEl = document.getElementById("qortal-qapps-list-note"); const galleryEl = document.getElementById("qortal-qapps-gallery"); const listBody = document.getElementById("qortal-qapps-body"); const warningCard = document.getElementById("qortal-qapps-warning"); const warningNoteEl = document.getElementById("qortal-qapps-warning-note"); 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 viewerNodeStats = document.getElementById( "qortal-qapps-viewer-node-stats" ); const viewerPeersPill = document.getElementById( "qortal-qapps-viewer-peers-pill" ); const viewerPeersValue = document.getElementById( "qortal-qapps-viewer-peers-value" ); const viewerDataPeersPill = document.getElementById( "qortal-qapps-viewer-data-peers-pill" ); const viewerDataPeersValue = document.getElementById( "qortal-qapps-viewer-data-peers-value" ); 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 viewerMobileRefresh = document.getElementById("qortal-qapps-mobile-refresh"); 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 debugResizeHandle = document.getElementById( "qortal-qapps-debug-resize-handle" ); 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 approvalTitleEl = document.getElementById( "qortal-qapps-approval-title" ); const approvalNoteEl = document.getElementById("qortal-qapps-approval-note"); 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 unlockTitleEl = document.getElementById("qortal-qapps-unlock-title"); const unlockNoteEl = document.getElementById("qortal-qapps-unlock-note"); 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 nameRequiredTitleEl = document.getElementById( "qortal-qapps-name-required-title" ); const nameRequiredNote1El = document.getElementById( "qortal-qapps-name-required-note-1" ); const nameRequiredStepsEl = document.getElementById( "qortal-qapps-name-required-steps" ); const nameRequiredStep1El = document.getElementById( "qortal-qapps-name-required-step-1" ); const nameRequiredStep2El = document.getElementById( "qortal-qapps-name-required-step-2" ); const nameRequiredStep3El = document.getElementById( "qortal-qapps-name-required-step-3" ); const nameRequiredNote2El = document.getElementById( "qortal-qapps-name-required-note-2" ); 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 switchModal = document.getElementById("qortal-qapps-switch-modal"); const switchModalNoteEl = document.getElementById( "qortal-qapps-switch-modal-note" ); const switchModalOpenHereButton = document.getElementById( "qortal-qapps-switch-open-here" ); const switchModalOpenNewTabButton = document.getElementById( "qortal-qapps-switch-open-new-tab" ); const switchModalCancelButton = document.getElementById( "qortal-qapps-switch-cancel" ); const debugPanelDockStorageKey = "qortal_qapps_debug_dock"; const debugPanelHeightStorageKey = "qortal_qapps_debug_height"; const debugPanelDefaultHeight = 280; const debugPanelMinHeight = 160; const debugPanelCompactRatio = 0.25; const debugPanelPeekHeight = 18; let debugPanelExpandedHeight = debugPanelDefaultHeight; let debugPanelResizeObserver = null; let debugDockHoverSuppressed = false; if (embedMode) { root.classList.add("qortal-embed-mode"); if (embedChromeless) { root.classList.add("qortal-embed-chromeless"); } } 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: isPoweredMode ? "Downloading from decentralized network..." : "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 temporaryApprovalStorageKey = "qortal_qapps_temp_rules_v1"; const temporaryApprovalRules = loadTemporaryApprovalRules(); let qdnLoadingRetryTimer = null; let qdnLoadingRetryAttempts = 0; let qdnLoadingStatusFailures = 0; let qdnLoadingLastSnapshot = ""; let currentQdnResourceTarget = null; let viewerNodeStatusPollTimer = null; let viewerNodeStatusLastFetchAt = 0; let switchModalResolver = null; const nameLookupCacheTtlMs = 60 * 1000; let nameLookupCache = { address: "", hasName: null, checkedAt: 0, }; let balanceLookupCache = { address: "", balanceQort: null, checkedAt: 0, }; function applyModeCopy() { if (titleEl) { titleEl.textContent = modeCopy.title; } if (helpEl) { helpEl.textContent = modeCopy.help; } if (emptyNoteEl) { emptyNoteEl.textContent = modeCopy.emptyNote; } if (browserTitleEl) { browserTitleEl.textContent = modeCopy.browserTitle; } if (browserNoteEl) { browserNoteEl.textContent = modeCopy.browserNote; } if (warningNoteEl) { warningNoteEl.textContent = modeCopy.warningNote; } if (settingsLink) { settingsLink.textContent = modeCopy.settingsButton; } if (nameRequiredTitleEl) { nameRequiredTitleEl.textContent = modeCopy.nameRequiredTitle; } if (nameRequiredContinue) { nameRequiredContinue.textContent = modeCopy.nameRequiredContinue; } if (listTitleEl) { listTitleEl.textContent = modeCopy.listTitle; } if (listNoteEl) { listNoteEl.textContent = modeCopy.listNote; } if (approvalTitleEl) { approvalTitleEl.textContent = modeCopy.approvalTitle; } if (approvalNoteEl) { approvalNoteEl.textContent = modeCopy.approvalNote; } if (unlockTitleEl) { unlockTitleEl.textContent = modeCopy.unlockTitle; } if (unlockNoteEl) { unlockNoteEl.textContent = modeCopy.unlockNote; } root.setAttribute("data-mode-active", sovereignMode); } function approvalConfirmLabel(showUnlockFields) { if (advancedUnlockSettingsEnabled) { return "Approve"; } return showUnlockFields ? "Unlock and Continue" : "Continue"; } function approvalBusyLabel(showUnlockFields) { if (advancedUnlockSettingsEnabled) { return "Approving..."; } return showUnlockFields ? "Unlocking..." : "Continuing..."; } function unlockConfirmLabel() { return "Unlock"; } 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 setViewerNodeStatPillState(element, state) { if (!element) { return; } element.classList.remove( "qortal-node-stat-ok", "qortal-node-stat-warn", "qortal-node-stat-error" ); if (state === "ok") { element.classList.add("qortal-node-stat-ok"); } else if (state === "warn") { element.classList.add("qortal-node-stat-warn"); } else if (state === "error") { element.classList.add("qortal-node-stat-error"); } } function setViewerNodeStatusDisplay(peers, dataPeers, unavailable) { if (!viewerNodeStats) { return; } if (viewerPeersValue) { viewerPeersValue.textContent = unavailable ? "!" : peers !== null ? String(peers) : "—"; } if (viewerDataPeersValue) { viewerDataPeersValue.textContent = unavailable ? "!" : dataPeers !== null ? String(dataPeers) : "—"; } if (unavailable) { setViewerNodeStatPillState(viewerPeersPill, "error"); setViewerNodeStatPillState(viewerDataPeersPill, "error"); return; } setViewerNodeStatPillState( viewerPeersPill, peers === null ? null : peers < 3 ? "error" : "ok" ); setViewerNodeStatPillState( viewerDataPeersPill, dataPeers === null ? null : dataPeers < 3 ? "error" : "ok" ); } function findNodeStatusField(source, keys, depth) { if (!source || typeof source !== "object") { return null; } if (!Array.isArray(keys) || keys.length === 0) { return null; } const limit = typeof depth === "number" && depth > 0 ? depth : 3; const queue = [{ value: source, level: 0 }]; const seen = new Set(); while (queue.length > 0) { const next = queue.shift(); if (!next || !next.value || typeof next.value !== "object") { continue; } if (seen.has(next.value)) { continue; } seen.add(next.value); for (let i = 0; i < keys.length; i += 1) { const key = keys[i]; if (Object.prototype.hasOwnProperty.call(next.value, key)) { return next.value[key]; } } if (next.level >= limit) { continue; } Object.keys(next.value).forEach(function (childKey) { const child = next.value[childKey]; if (child && typeof child === "object") { queue.push({ value: child, level: next.level + 1 }); } }); } return null; } function parseNodeStatusInt(value) { const parsed = coerceFiniteNumber(value); if (parsed === null) { return null; } return Math.max(0, Math.floor(parsed)); } function stopViewerNodeStatusPolling() { if (viewerNodeStatusPollTimer) { window.clearInterval(viewerNodeStatusPollTimer); viewerNodeStatusPollTimer = null; } } async function refreshViewerNodeStatus(force) { if (!viewerCard || viewerCard.classList.contains("qortal-hidden")) { return; } const now = Date.now(); if ( !force && viewerNodeStatusLastFetchAt && now - viewerNodeStatusLastFetchAt < 8000 ) { return; } viewerNodeStatusLastFetchAt = now; try { const result = await postBrokerQortalRequest("GET_NODE_STATUS", {}); const peers = parseNodeStatusInt( findNodeStatusField( result, [ "peers", "peerCount", "numberOfConnections", "connectedPeers", "connections", ], 4 ) ); const dataPeers = parseNodeStatusInt( findNodeStatusField( result, [ "numberOfDataConnections", "dataPeers", "dataPeerCount", "numberOfDataPeers", "connectedDataPeers", ], 4 ) ); setViewerNodeStatusDisplay(peers, dataPeers, false); } catch (_error) { setViewerNodeStatusDisplay(null, null, true); } } function startViewerNodeStatusPolling() { if (!viewerNodeStats) { return; } stopViewerNodeStatusPolling(); refreshViewerNodeStatus(true).catch(function () { // ignored }); viewerNodeStatusPollTimer = window.setInterval(function () { refreshViewerNodeStatus(false).catch(function () { // ignored }); }, 20000); } 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 modeCopy.loadingFromQdn; } function isTerminalQdnStatus(status) { const normalizedStatus = String(status || "") .trim() .toUpperCase(); return ( normalizedStatus === "NOT_PUBLISHED" || normalizedStatus === "UNSUPPORTED" || normalizedStatus === "BLOCKED" ); } function updateQdnLoadingProgress( status, percentLoaded, localChunkCount, totalChunkCount, description ) { const normalizedStatus = String(status || "").toUpperCase(); if (normalizedStatus === "READY") { clearQdnLoadingProgress(); return; } const localCount = coerceFiniteNumber(localChunkCount); const totalCount = coerceFiniteNumber(totalChunkCount); // Some QDN status responses expose per-chunk percent (0..100) without aggregate totals, // which causes visible 0/100 flicker while multiple chunks download. In that case, // prefer an overall "loading/chunks" indicator instead of a percent bar. const shouldUsePercent = normalizedStatus === "READY" || (totalCount !== null && totalCount > 0); const boundedPercent = shouldUsePercent ? coerceFiniteNumber(percentLoaded) : null; const bounded = boundedPercent !== null ? Math.max(0, Math.min(100, boundedPercent)) : null; if (!viewerLoadingProgress) { return; } let toolbarLabel = modeCopy.loadingLabel; if (bounded !== null) { toolbarLabel = "Loading " + bounded.toFixed(1) + "%"; } if (localCount !== null && String(toolbarLabel).indexOf("%") === -1) { toolbarLabel += " (" + localCount + " 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 = localCount; const total = totalCount; 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; let terminalMissingResult = null; 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; } const normalizedStatus = String(parsed.status || "").toUpperCase(); if ( normalizedStatus === "NOT_PUBLISHED" || normalizedStatus === "UNSUPPORTED" || normalizedStatus === "BLOCKED" ) { // Deep links inside a Q-App can look like an "identifier" on first probe candidate. // Keep trying alternate status URLs (especially without identifier) before concluding missing. if (!terminalMissingResult) { terminalMissingResult = { ok: true, status: normalizedStatus, description: parsed.description || "", percentLoaded: coerceFiniteNumber(parsed.percentLoaded), localChunkCount: coerceFiniteNumber(parsed.localChunkCount), totalChunkCount: coerceFiniteNumber(parsed.totalChunkCount), url, }; } continue; } return { ok: true, status: normalizedStatus, description: parsed.description || "", percentLoaded: coerceFiniteNumber(parsed.percentLoaded), localChunkCount: coerceFiniteNumber(parsed.localChunkCount), totalChunkCount: coerceFiniteNumber(parsed.totalChunkCount), url, }; } catch (_error) { // Try the next candidate URL. } } if (terminalMissingResult) { return terminalMissingResult; } 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) { if (isTerminalQdnStatus(prefetchedStatusResult.status)) { clearQdnLoadingRetry("content_loaded_terminal_status"); qdnLoadingRetryAttempts = 0; return; } updateQdnLoadingProgress( prefetchedStatusResult.status, prefetchedStatusResult.percentLoaded, prefetchedStatusResult.localChunkCount, prefetchedStatusResult.totalChunkCount, prefetchedStatusResult.description ); if (prefetchedStatusResult.status === "READY") { 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") { if (detection.detected) { logDebug( "info", "QDN resource ready while loading interstitial is visible, refreshing viewer", { attempt: qdnLoadingRetryAttempts, } ); reloadViewerFrame(); } else { clearQdnLoadingRetry("content_loaded_ready"); qdnLoadingRetryAttempts = 0; } 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; } const spendingRequestTypes = new Set([ "SIGN_TRANSACTION", "REGISTER_NAME", "UPDATE_NAME", "SELL_NAME", "CANCEL_SELL_NAME", "BUY_NAME", "SEND_COIN", "SEND_COIN_MULTIPLE", "DEPLOY_AT", "CREATE_POLL", "VOTE_ON_POLL", "CREATE_TRADE_BUY_ORDER", "CANCEL_TRADE_BUY_ORDER", "CREATE_ASSET_ORDER", "CANCEL_ASSET_ORDER", "TRANSFER_ASSET", "PUBLISH_QDN_RESOURCE", "PUBLISH_MULTIPLE_QDN_RESOURCES", "ADD_LIST_ITEMS", "DELETE_LIST_ITEM", ]); function isLikelySpendingRequestType(requestType) { const normalized = String(requestType || "") .trim() .toUpperCase(); if (!normalized) { return false; } if (spendingRequestTypes.has(normalized)) { return true; } if (normalized.startsWith("PUBLISH_") || normalized.startsWith("SEND_")) { return true; } if (normalized.includes("TRADE") || normalized.includes("BUY_ORDER")) { return true; } return false; } function isHighRiskConfirmationRequired(error, json) { if (error === "high_risk_confirmation_required") { return true; } return Boolean( json && typeof json.error === "string" && json.error === "high_risk_confirmation_required" ); } function highRiskConfirmationMessage(requestType, details) { const info = details && typeof details === "object" ? details : {}; const lines = [ "NuQloud blocked this request for an extra safety check.", "", "Action: " + String(info.requestType || requestType || "Q-App request"), ]; if (info.reason) { lines.push("Reason: " + String(info.reason)); } if (info.amount) { lines.push("Amount: " + String(info.amount)); } if (info.recipient) { lines.push("Recipient: " + String(info.recipient)); } if (info.txCount) { lines.push("Estimated transactions: " + String(info.txCount)); } if (info.feeCredits) { lines.push( "Estimated publish fees: " + String(info.feeCredits) + " credits" + (info.feeQort ? " (" + String(info.feeQort) + " QORT)" : "") ); } lines.push("", "Continue only if you expected this request."); return lines.join("\n"); } async function postQappsRequestWithHighRiskConfirmation( requestType, payload, headers ) { let workingPayload = Object.assign({}, payload || {}); const postOnce = async function () { const response = await fetchWithRetry( qappsRequestUrl, { method: "POST", headers, body: JSON.stringify({ requestType, payload: workingPayload, }), }, 2, 250 ); const json = await response.json(); return { response, json }; }; let outcome = await postOnce(); if ( !outcome.response.ok || outcome.json.ok === false || outcome.json.error ) { const errorMessage = outcome.json.error || "qortal_request_failed"; if (isHighRiskConfirmationRequired(errorMessage, outcome.json)) { const details = outcome.json && outcome.json.details ? outcome.json.details : {}; const token = details && typeof details.confirmationToken === "string" ? details.confirmationToken : ""; if (!token) { return { response: outcome.response, json: { error: "high_risk_confirmation_unavailable", details }, payload: workingPayload, }; } const accepted = window.confirm( highRiskConfirmationMessage(requestType, details) ); if (!accepted) { return { response: outcome.response, json: { error: "high_risk_confirmation_cancelled", details }, payload: workingPayload, }; } workingPayload = Object.assign({}, workingPayload, { highRiskConfirmationToken: token, }); outcome = await postOnce(); } } return { response: outcome.response, json: outcome.json, payload: workingPayload, }; } function extractSignedTransactionPayload(value, depth) { const currentDepth = typeof depth === "number" ? depth : 0; if (currentDepth > 6 || value === null || value === undefined) { return ""; } if (typeof value === "string") { const trimmed = value.trim(); if (!trimmed) { return ""; } if ( (trimmed[0] === "{" || trimmed[0] === "[") && trimmed.length <= 500000 ) { try { const parsed = JSON.parse(trimmed); const parsedValue = extractSignedTransactionPayload( parsed, currentDepth + 1 ); if (parsedValue) { return parsedValue; } } catch (_error) { // Ignore JSON parse failure and treat as plain payload. } } return trimmed; } if (Array.isArray(value)) { for (let i = 0; i < value.length; i += 1) { const candidate = extractSignedTransactionPayload( value[i], currentDepth + 1 ); if (candidate) { return candidate; } } return ""; } if (typeof value !== "object") { return ""; } const candidateKeys = [ "signedTransaction", "signedTransactionBytes", "transactionBytes", "signedBytes", "bytes", "data", "result", "payload", "transaction", ]; for (let i = 0; i < candidateKeys.length; i += 1) { const key = candidateKeys[i]; if (!Object.prototype.hasOwnProperty.call(value, key)) { continue; } const candidate = extractSignedTransactionPayload( value[key], currentDepth + 1 ); if (candidate) { return candidate; } } for (const key in value) { if (!Object.prototype.hasOwnProperty.call(value, key)) { continue; } const nested = extractSignedTransactionPayload( value[key], currentDepth + 1 ); if (nested) { return nested; } } return ""; } function normalizeQappsResultPayload(requestType, resultPayload) { if ( requestType === "GET_PRIMARY_NAME" && typeof resultPayload !== "string" ) { if (Array.isArray(resultPayload)) { return resultPayload.length > 0 ? resultPayload[0] : ""; } if (resultPayload && typeof resultPayload === "object") { return ( resultPayload.name || resultPayload.primaryName || (resultPayload.data && resultPayload.data.name) || "" ); } if (resultPayload === null || resultPayload === undefined) { return ""; } } if (requestType === "SIGN_TRANSACTION") { const signedPayload = extractSignedTransactionPayload(resultPayload, 0); if (signedPayload) { return signedPayload; } } return resultPayload; } 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 (!effectiveDebugEnabled || !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; } function getViewportHeight() { return ( (window.visualViewport && window.visualViewport.height) || window.innerHeight || document.documentElement.clientHeight || 0 ); } function clampDebugPanelHeight(value) { const numeric = Math.round(Number(value)); if (!Number.isFinite(numeric)) { return debugPanelDefaultHeight; } return Math.max(debugPanelMinHeight, numeric); } function readStoredDebugPanelHeight() { if (!window.localStorage) { return debugPanelExpandedHeight; } const parsed = Number( window.localStorage.getItem(debugPanelHeightStorageKey) ); return clampDebugPanelHeight(parsed); } function applyDebugPanelSizing() { if (!debugCard) { return; } const compactHeight = Math.max( 120, Math.round(debugPanelExpandedHeight * debugPanelCompactRatio) ); debugCard.style.setProperty( "--qortal-debug-panel-height", debugPanelExpandedHeight + "px" ); debugCard.style.setProperty( "--qortal-debug-panel-compact-height", compactHeight + "px" ); debugCard.style.setProperty( "--qortal-debug-panel-peek-height", debugPanelPeekHeight + "px" ); } function setDebugPanelExpandedHeight(nextHeight, persist) { debugPanelExpandedHeight = clampDebugPanelHeight(nextHeight); applyDebugPanelSizing(); if (persist && window.localStorage) { window.localStorage.setItem( debugPanelHeightStorageKey, String(debugPanelExpandedHeight) ); } } function setDebugDockHidden(hidden, persist) { if (!debugCard) { return; } debugCard.classList.toggle("qortal-debug-docked-hidden", Boolean(hidden)); if (!hidden) { releaseDebugDockHoverSuppression(); } if (persist && window.localStorage) { window.localStorage.setItem(debugPanelDockStorageKey, hidden ? "1" : "0"); } updateDebugPanelControls(); } function suppressDebugDockHover() { if (!debugCard) { return; } debugDockHoverSuppressed = true; debugCard.classList.add("qortal-debug-dock-suppressed"); } function releaseDebugDockHoverSuppression() { if (!debugCard) { return; } debugDockHoverSuppressed = false; debugCard.classList.remove("qortal-debug-dock-suppressed"); } function syncDebugPanelState() { if (!debugCard || !effectiveDebugEnabled) { return; } setDebugPanelExpandedHeight(readStoredDebugPanelHeight(), false); setDebugDockHidden( window.localStorage && window.localStorage.getItem(debugPanelDockStorageKey) === "1", false ); updateDebugPanelControls(); } function updateDebugPanelControls() { if (!debugCard) { return; } if (debugMinimize) { const isMinimized = debugCard.classList.contains( "qortal-debug-minimized" ); const minimizeLabel = isMinimized ? "Expand debug panel" : "Minimize debug panel"; debugMinimize.textContent = "−"; debugMinimize.setAttribute("title", minimizeLabel); debugMinimize.setAttribute("aria-label", minimizeLabel); debugMinimize.setAttribute("aria-pressed", isMinimized ? "true" : "false"); } if (debugFloat) { const isDockHidden = debugCard.classList.contains( "qortal-debug-docked-hidden" ); const dockLabel = isDockHidden ? "Keep debug panel open" : "Auto-hide debug panel"; debugFloat.textContent = isDockHidden ? "⇧" : "⤓"; debugFloat.setAttribute("title", dockLabel); debugFloat.setAttribute("aria-label", dockLabel); debugFloat.setAttribute("aria-pressed", isDockHidden ? "true" : "false"); } } 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(); let fromQortalScheme = false; let suffix = ""; if (/^https?:\/\//i.test(path)) { return appendThemeParams(path); } if (path.toLowerCase().startsWith("qortal://")) { fromQortalScheme = true; path = path.slice("qortal://".length); } if (fromQortalScheme) { const queryIndex = path.indexOf("?"); const hashIndex = path.indexOf("#"); let splitIndex = -1; if (queryIndex >= 0 && hashIndex >= 0) { splitIndex = Math.min(queryIndex, hashIndex); } else if (queryIndex >= 0) { splitIndex = queryIndex; } else if (hashIndex >= 0) { splitIndex = hashIndex; } if (splitIndex >= 0) { suffix = path.slice(splitIndex); path = path.slice(0, splitIndex); } } path = path.replace(/^\/+/, ""); if (ensureTrailingSlash && path && !path.endsWith("/")) { let shouldAppendSlash = true; if (fromQortalScheme) { const basePath = stripQueryAndHash(path); const segments = basePath.split("/").filter(function (segment) { return Boolean(segment); }); // Preserve SPA/app sub-routes like qortal://APP/Q-Assets/assets (no forced trailing slash). // Only force a slash for root-style QDN addresses (service + name only). if (segments.length > 2) { shouldAppendSlash = false; } } if (shouldAppendSlash) { path += "/"; } } const upstreamPath = toUpstreamProxyPath(path, true); if (gatewayProxyTemplate) { if (!viewerRendererConfigured) { return ""; } return appendThemeParams(buildProxyUrl(upstreamPath) + suffix); } const directBase = nodeUrl ? nodeUrl.replace(/\/$/, "") : gatewayUrl.replace(/\/$/, ""); if (!directBase) { return ""; } const base = directBase.replace(/\/$/, ""); return appendThemeParams(base + "/" + upstreamPath + suffix); } function normalizeBrowserAddress(address) { const raw = String(address || "").trim(); if (!raw) { return ""; } if (!raw.toLowerCase().startsWith("qortal://")) { return raw; } const splitIndex = (function () { const queryIndex = raw.indexOf("?"); const hashIndex = raw.indexOf("#"); if (queryIndex === -1) { return hashIndex; } if (hashIndex === -1) { return queryIndex; } return Math.min(queryIndex, hashIndex); })(); const base = splitIndex >= 0 ? raw.slice(0, splitIndex) : raw; const suffix = splitIndex >= 0 ? raw.slice(splitIndex) : ""; const afterScheme = base.slice("qortal://".length).replace(/^\/+/, ""); if (!afterScheme) { return raw; } // Browser convenience: qortal://Name -> qortal://WEBSITE/Name if (!afterScheme.includes("/")) { return "qortal://WEBSITE/" + afterScheme; } const segments = afterScheme .split("/") .filter(function (segment) { return Boolean(segment); }) .map(safeDecodeURIComponent); let changed = true; while (changed) { changed = false; if ( segments.length >= 5 && String(segments[2] || "").toLowerCase() === "apps" && String(segments[3] || "").toLowerCase() === "qortal_integration" && String(segments[4] || "").toLowerCase() === "gateway" ) { segments.splice(2, 3); changed = true; continue; } if ( segments.length >= 5 && String(segments[2] || "").toLowerCase() === "render" && String(segments[3] || "") === String(segments[0] || "") && String(segments[4] || "") === String(segments[1] || "") ) { segments.splice(2, 3); changed = true; continue; } } return "qortal://" + segments.join("/") + suffix; } function detectCloudTheme() { 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 String(dataTheme).toLowerCase().includes("dark") ? "dark" : "light"; } 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 detectCloudLanguage() { return ( (document.documentElement && document.documentElement.lang) || navigator.language || "" ); } function applyEmbedThemeParams(address) { const normalized = normalizeBrowserAddress(address || ""); if (!normalized.toLowerCase().startsWith("qortal://")) { return normalized; } const hashParts = normalized.split("#"); const withoutHash = hashParts[0] || ""; const hash = hashParts.length > 1 ? "#" + hashParts.slice(1).join("#") : ""; const queryIndex = withoutHash.indexOf("?"); const base = queryIndex >= 0 ? withoutHash.slice(0, queryIndex) : withoutHash; const query = queryIndex >= 0 ? withoutHash.slice(queryIndex + 1) : ""; const params = new URLSearchParams(query); params.set("theme", detectCloudTheme()); const lang = detectCloudLanguage(); if (lang) { params.set("lang", lang); } const nextQuery = params.toString(); return base + (nextQuery ? "?" + nextQuery : "") + hash; } function extractQappTargetKey(address) { const normalized = normalizeBrowserAddress(address || ""); if (!normalized.toLowerCase().startsWith("qortal://")) { return ""; } const withoutScheme = normalized.slice("qortal://".length); const withoutQuery = withoutScheme.split("#")[0].split("?")[0]; const segments = withoutQuery.split("/").filter(function (segment) { return Boolean(segment); }); if (segments.length < 2) { return ""; } return ( String(segments[0]).toUpperCase() + "/" + String(segments[1]).toUpperCase() ); } function isEmbedLockedToDifferentQapp(address) { if (!embedLockQapp || !embedRequestedQapp) { return false; } const lockedKey = extractQappTargetKey(embedRequestedQapp); const candidateKey = extractQappTargetKey(address); if (!lockedKey || !candidateKey) { return false; } return lockedKey !== candidateKey; } function splitPathAndSuffix(value) { const raw = String(value || "").trim(); if (!raw) { return { path: "", suffix: "" }; } const queryIndex = raw.indexOf("?"); const hashIndex = raw.indexOf("#"); let splitIndex = -1; if (queryIndex >= 0 && hashIndex >= 0) { splitIndex = Math.min(queryIndex, hashIndex); } else if (queryIndex >= 0) { splitIndex = queryIndex; } else if (hashIndex >= 0) { splitIndex = hashIndex; } if (splitIndex < 0) { return { path: raw, suffix: "" }; } return { path: raw.slice(0, splitIndex), suffix: raw.slice(splitIndex), }; } function parseCurrentQappBaseAddress() { const source = normalizeBrowserAddress( currentViewerAddress || currentQappAddress || "" ); if (!source || !source.toLowerCase().startsWith("qortal://")) { return null; } const rawPath = stripQueryAndHash(source.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; } return { service: String(segments[0] || "") .trim() .toUpperCase(), name: String(segments[1] || "").trim(), }; } function resolveRelativeAddressForCurrentQapp(value) { const split = splitPathAndSuffix(value); let path = String(split.path || "").trim(); if ( !path || /^https?:\/\//i.test(path) || path.toLowerCase().startsWith("qortal://") ) { return ""; } path = path.replace(/^\/+/, ""); if (!path) { return ""; } const gatewayPrefix = "apps/qortal_integration/gateway/"; const lowerPath = path.toLowerCase(); if (lowerPath.startsWith(gatewayPrefix)) { path = path.slice(gatewayPrefix.length); } else if (lowerPath.startsWith("qortal_integration/gateway/")) { path = path.slice("qortal_integration/gateway/".length); } else { const embeddedGatewayIndex = lowerPath.indexOf("/" + gatewayPrefix); if (embeddedGatewayIndex >= 0) { path = path.slice(embeddedGatewayIndex + gatewayPrefix.length + 1); } } if (!path) { return ""; } if (path.toLowerCase().startsWith("render/")) { return normalizeBrowserAddress( "qortal://" + path.slice("render/".length) + split.suffix ); } const segments = path .split("/") .filter(function (segment) { return Boolean(segment); }) .map(safeDecodeURIComponent); if (segments.length < 1) { return ""; } if ( segments.length >= 2 && /^[A-Z0-9_]+$/.test(String(segments[0] || "")) ) { return normalizeBrowserAddress( "qortal://" + segments.join("/") + split.suffix ); } const base = parseCurrentQappBaseAddress(); if (!base || !base.service || !base.name) { return ""; } return normalizeBrowserAddress( "qortal://" + base.service + "/" + base.name + "/" + segments.join("/") + split.suffix ); } 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; } const suffix = splitPathAndSuffix(normalized).suffix || ""; if (service === "APP" || service === "WEBSITE") { return "qortal://" + service + "/" + name + suffix; } return "qortal://" + segments.join("/") + suffix; } function buildReloadSequence(address) { const normalized = normalizeBrowserAddress(address || ""); if (!normalized) { return null; } const split = splitPathAndSuffix(normalized); const fullAddress = normalizeBrowserAddress(split.path + (split.suffix || "")); const baseAddress = deriveSafeReloadAddress(split.path); const finalAddress = deriveSafeReloadAddress(fullAddress); const needsTwoStep = Boolean(split.suffix) && Boolean(baseAddress) && baseAddress !== finalAddress; return { fullAddress, baseAddress: baseAddress || finalAddress || fullAddress, finalAddress: finalAddress || fullAddress, needsTwoStep, }; } let pendingReloadFinalAddress = ""; let pendingReloadFallbackAddress = ""; let pendingReloadTimeout = null; let launchRetryAddress = ""; let launchRetryCount = 0; const launchRetryMax = 1; function clearPendingReloadSequence() { pendingReloadFinalAddress = ""; pendingReloadFallbackAddress = ""; if (pendingReloadTimeout) { window.clearTimeout(pendingReloadTimeout); pendingReloadTimeout = null; } } function startViewerReloadSequence(address) { const sequence = buildReloadSequence(address); if (!sequence) { return false; } clearPendingReloadSequence(); if (!sequence.needsTwoStep) { openAddress(sequence.finalAddress, modeCopy.browserName); return true; } pendingReloadFinalAddress = sequence.finalAddress; pendingReloadFallbackAddress = sequence.baseAddress; openAddress(sequence.baseAddress, modeCopy.browserName); pendingReloadTimeout = window.setTimeout(function () { if (!pendingReloadFinalAddress) { return; } const finalTarget = pendingReloadFinalAddress; clearPendingReloadSequence(); openAddress(finalTarget, modeCopy.browserName); }, 300); return true; } function isGatewayProxyViewerUrl(url) { const value = String(url || "").toLowerCase(); return ( value.indexOf("/apps/qortal_integration/gateway/") !== -1 || value.indexOf("/index.php/apps/qortal_integration/gateway/") !== -1 ); } function scheduleOneTimeLaunchRetry(reason) { const candidate = normalizeBrowserAddress( currentViewerAddress || currentQappAddress || "" ); if (!candidate) { return false; } if (launchRetryAddress !== candidate) { launchRetryAddress = candidate; launchRetryCount = 0; } if (launchRetryCount >= launchRetryMax) { return false; } launchRetryCount += 1; logDebug("warn", "Retrying initial Q-App load once", { reason: reason || "initial_load_failure", address: candidate, retryCount: launchRetryCount, }); window.setTimeout(function () { openAddress(candidate, modeCopy.browserName); }, 500); return true; } 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); } else { const resolvedRelative = resolveRelativeAddressForCurrentQapp( "/" + upstreamPath + parsed.search + parsed.hash ); if (resolvedRelative) { return resolvedRelative; } } 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("/")) { const parsedFromViewer = parseQortalAddressFromViewerUrl( window.location.origin + trimmed ); if (parsedFromViewer) { return parsedFromViewer; } const resolvedRelative = resolveRelativeAddressForCurrentQapp(trimmed); if (resolvedRelative) { return resolvedRelative; } return normalizeBrowserAddress( "qortal://" + trimmed.replace(/^\/+/, "") ); } if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) { const parsed = parseQortalAddressFromViewerUrl(trimmed); if (parsed) { return parsed; } } else { const resolvedRelative = resolveRelativeAddressForCurrentQapp(trimmed); if (resolvedRelative) { return resolvedRelative; } 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 routeQortalProtocolTarget(rawTarget, label) { const normalizedTarget = normalizeBrowserAddress( String(rawTarget || "").trim() ); if ( !normalizedTarget || !normalizedTarget.toLowerCase().startsWith("qortal://") ) { return false; } openAddress(normalizedTarget, label || modeCopy.browserName); return true; } function installViewerNavigationInterceptors() { if (!viewerFrame || !viewerFrame.contentWindow) { return; } const frameWindow = viewerFrame.contentWindow; let frameDocument = null; try { frameDocument = frameWindow.document; } catch (_error) { return; } if (!frameDocument) { return; } if (!frameWindow.__qortalNativeOpenInterceptorInstalled) { const originalOpen = typeof frameWindow.open === "function" ? frameWindow.open.bind(frameWindow) : null; frameWindow.open = function (url, target, features) { if (routeQortalProtocolTarget(url, modeCopy.browserName)) { logDebug("info", "Iframe window.open routed to integrated viewer", { url: String(url || ""), target: typeof target === "string" ? target : "", }); return frameWindow; } if (originalOpen) { return originalOpen(url, target, features); } return null; }; frameWindow.__qortalNativeOpenInterceptorInstalled = true; } if (frameDocument.__qortalNativeLinkInterceptorInstalled) { return; } frameDocument.addEventListener( "click", function (event) { const target = event.target instanceof Element ? event.target.closest("a[href]") : null; if (!target) { return; } const href = target.getAttribute("href") || ""; if ( !href || !String(href).trim().toLowerCase().startsWith("qortal://") ) { return; } event.preventDefault(); event.stopPropagation(); routeQortalProtocolTarget( href, target.textContent || modeCopy.browserName ); }, true ); frameDocument.__qortalNativeLinkInterceptorInstalled = true; } 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() { return buildDashboardHighlightUrl(["register-name"]); } function buildDashboardHighlightUrl(highlights) { const base = ( nextcloudPublicUrl || window.location.origin + (typeof OC !== "undefined" && OC.webroot ? OC.webroot : "") ).replace(/\/$/, ""); const validHighlights = Array.isArray(highlights) ? highlights .map(function (entry) { return String(entry || "") .trim() .toLowerCase(); }) .filter(function (entry) { return entry !== ""; }) : []; const query = validHighlights.length > 0 ? "?highlight=" + encodeURIComponent(validHighlights.join(",")) : ""; return base + "/apps/qortal_integration/account" + query; } function extractBalanceAmount(balance) { const pickAmount = function (value) { if (value === undefined || value === null || value === "") { return undefined; } if (typeof value === "number" || typeof value === "string") { return value; } if (!value || typeof value !== "object") { return undefined; } return ( value.balance ?? value.confirmedBalance ?? value.confirmed ?? value.availableBalance ?? value.amount ?? value.value ?? undefined ); }; if (Array.isArray(balance)) { for (let i = 0; i < balance.length; i += 1) { const amount = pickAmount(balance[i]); if (amount !== undefined) { return amount; } } return undefined; } return pickAmount(balance); } function parseBalanceQort(value) { const amount = extractBalanceAmount(value); if (amount === undefined || amount === null || amount === "") { return null; } const parsed = Number(amount); return Number.isFinite(parsed) ? parsed : null; } async function fetchBalanceQort(address) { const normalizedAddress = String(address || "").trim(); if (!normalizedAddress) { return null; } const now = Date.now(); if ( balanceLookupCache.address === normalizedAddress && typeof balanceLookupCache.balanceQort === "number" && now - balanceLookupCache.checkedAt <= nameLookupCacheTtlMs ) { return balanceLookupCache.balanceQort; } let balanceQort = null; try { balanceQort = parseBalanceQort( await postBrokerQortalRequest("GET_WALLET_BALANCE", { coin: "QORT", address: normalizedAddress, }) ); } catch (_error) { balanceQort = null; } if (balanceQort === null) { try { balanceQort = parseBalanceQort( await postBrokerQortalRequest("GET_BALANCE", { address: normalizedAddress, }) ); } catch (_error) { balanceQort = null; } } balanceLookupCache = { address: normalizedAddress, balanceQort: balanceQort, checkedAt: now, }; return balanceQort; } function buildIdentityPrerequisiteState( hasWallet, hasEnoughBalance, hasName ) { if (!hasWallet) { return { title: modeCopy.nameRequiredTitle, intro: "You have not yet created your " + modeCopy.accountLabel + ". To use distributed authentication, communications, and data inside apps, you must create your account first.", steps: [ modeCopy.createAccountStep, modeCopy.fundingStep, modeCopy.nameStep, ], outro: modeCopy.prereqOutro, highlights: ["create-account"], }; } if (!hasName && hasEnoughBalance === false) { return { title: modeCopy.nameRequiredTitle, intro: "Your account is created, but you do not yet have enough " + modeCopy.unitLabel + " to register a distributed identity name.", steps: [modeCopy.fundingStep, modeCopy.nameStep], outro: modeCopy.prereqOutro, highlights: ["balance", "purchases", "register-name"], }; } if (!hasName) { return { title: modeCopy.nameRequiredTitle, intro: "Your account is created, but you do not yet have a registered distributed identity name.", steps: [ "Open the dashboard and go to the name registration section.", modeCopy.nameStep, ], outro: modeCopy.prereqOutro, highlights: ["register-name"], }; } return null; } function isTransientFetchError(error) { const message = error && error.message ? String(error.message) : String(error || ""); const normalized = message.toLowerCase(); return ( normalized.includes("failed to fetch") || normalized.includes("networkerror") ); } function sleep(ms) { return new Promise(function (resolve) { window.setTimeout(resolve, ms); }); } async function fetchWithRetry(url, options, attempts, delayMs) { const maxAttempts = Number.isFinite(attempts) ? Math.max(1, Math.floor(attempts)) : 1; const retryDelay = Number.isFinite(delayMs) ? Math.max(0, Math.floor(delayMs)) : 0; let lastError = null; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { return await fetch(url, options); } catch (error) { lastError = error; if (!isTransientFetchError(error) || attempt >= maxAttempts) { throw error; } await sleep(retryDelay); } } throw lastError || new Error("fetch_failed"); } async function postBrokerQortalRequest(requestType, payload) { if (!qappsRequestUrl) { throw new Error(modeCopy.requestEndpointMissing); } const headers = { "Content-Type": "application/json" }; if (typeof OC !== "undefined" && OC.requestToken) { headers.requesttoken = OC.requestToken; } const outcome = await postQappsRequestWithHighRiskConfirmation( requestType, payload || {}, headers ); const response = outcome.response; const json = outcome.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(state) { return new Promise(function (resolve) { if ( !nameRequiredModal || !nameRequiredDashboardLink || !nameRequiredContinue || !nameRequiredCancel ) { resolve("continue"); return; } const promptState = state && typeof state === "object" ? state : null; const highlights = promptState && Array.isArray(promptState.highlights) ? promptState.highlights : ["register-name"]; const dashboardUrl = buildDashboardHighlightUrl(highlights); if (nameRequiredTitleEl) { nameRequiredTitleEl.textContent = promptState && promptState.title ? promptState.title : modeCopy.nameRequiredTitle; } if (nameRequiredNote1El) { nameRequiredNote1El.textContent = promptState && promptState.intro ? promptState.intro : ""; } if (nameRequiredStep1El) { nameRequiredStep1El.textContent = promptState && promptState.steps && promptState.steps[0] ? promptState.steps[0] : ""; nameRequiredStep1El.classList.toggle( "qortal-hidden", !nameRequiredStep1El.textContent ); } if (nameRequiredStep2El) { nameRequiredStep2El.textContent = promptState && promptState.steps && promptState.steps[1] ? promptState.steps[1] : ""; nameRequiredStep2El.classList.toggle( "qortal-hidden", !nameRequiredStep2El.textContent ); } if (nameRequiredStep3El) { nameRequiredStep3El.textContent = promptState && promptState.steps && promptState.steps[2] ? promptState.steps[2] : ""; nameRequiredStep3El.classList.toggle( "qortal-hidden", !nameRequiredStep3El.textContent ); } if (nameRequiredStepsEl) { nameRequiredStepsEl.classList.toggle( "qortal-hidden", !promptState || !Array.isArray(promptState.steps) || promptState.steps.length === 0 ); } if (nameRequiredNote2El) { nameRequiredNote2El.textContent = promptState && promptState.outro ? promptState.outro : ""; } 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) { const decisionWithoutAccount = await showRegisteredNameRequiredModal( buildIdentityPrerequisiteState(false, false, false) ); if (decisionWithoutAccount === "dashboard") { window.location.href = buildDashboardHighlightUrl(["create-account"]); return false; } return decisionWithoutAccount === "continue"; } const hasName = await hasRegisteredName(effectiveAddress); if (hasName) { return true; } const balanceQort = await fetchBalanceQort(effectiveAddress); const hasEnoughBalance = typeof balanceQort === "number" ? balanceQort >= 1.25 : null; const decision = await showRegisteredNameRequiredModal( buildIdentityPrerequisiteState(true, hasEnoughBalance, false) ); if (decision === "dashboard") { const highlights = hasEnoughBalance === false ? ["balance", "purchases", "register-name"] : ["register-name"]; window.location.href = buildDashboardHighlightUrl(highlights); 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" : ""; } if (isOpen) { startViewerNodeStatusPolling(); } else { stopViewerNodeStatusPolling(); clearPendingReloadSequence(); } } function extractAppNameFromQortalAddress(address) { const raw = String(address || "").trim(); if (!raw.toLowerCase().startsWith("qortal://")) { return ""; } const trimmed = raw.slice("qortal://".length).replace(/^\/+/, ""); if (!trimmed) { return ""; } const parts = trimmed.split("/").filter(Boolean); if (!parts.length) { return ""; } const first = String(parts[0] || "").toUpperCase(); if ((first === "APP" || first === "WEBSITE") && parts.length > 1) { return String(parts[1] || "").trim(); } return String(parts[0] || "").trim(); } function resolveQappIcon(entry) { if (!entry || typeof entry !== "object") { return ""; } const iconMode = normalizeQappIconMode(entry.iconMode); const iconUrl = String(entry.iconUrl || "").trim(); if (iconMode === "custom" && iconUrl) { if (iconUrl.toLowerCase().startsWith("qortal://")) { const iconPath = iconUrl.slice("qortal://".length).replace(/^\/+/, ""); const proxied = openGatewayPath(iconPath); return proxied || ""; } return iconUrl; } const appName = iconMode === "name" ? String(entry.iconFromName || "").trim() : extractAppNameFromQortalAddress(entry.address || ""); if (!appName) { return ""; } const thumbPath = "arbitrary/THUMBNAIL/" + appName + "/qortal_avatar"; return openGatewayPath(thumbPath); } function isViewerShowingApp() { return Boolean( viewerCard && !viewerCard.classList.contains("qortal-hidden") && currentQappAddress ); } function shouldPromptAppSwitch(targetAddress) { if (!isViewerShowingApp()) { return false; } const normalizedTarget = normalizeBrowserAddress(targetAddress || ""); const normalizedCurrent = normalizeBrowserAddress( currentViewerAddress || currentQappAddress || "" ); if (!normalizedTarget || !normalizedCurrent) { return false; } const toIdentity = function (value) { const raw = String(value || "") .trim() .replace(/^qortal:\/\//i, "") .replace(/^\/+/, ""); const segments = raw.split(/[/?#]/)[0].split("/").filter(Boolean); if (segments.length < 2) { return raw.toLowerCase(); } return ( String(segments[0] || "") + "/" + String(segments[1] || "") ).toLowerCase(); }; if (toIdentity(normalizedTarget) === toIdentity(normalizedCurrent)) { return false; } return normalizedTarget.toLowerCase() !== normalizedCurrent.toLowerCase(); } function closeSwitchModal(choice) { if (switchModal) { switchModal.classList.add("qortal-hidden"); } if (switchModalResolver) { const resolve = switchModalResolver; switchModalResolver = null; resolve(choice || "cancel"); } } function isMobileSwitchPromptMode() { const viewportWidth = (window.visualViewport && window.visualViewport.width) || window.innerWidth || document.documentElement.clientWidth || 0; return Boolean(viewportWidth && viewportWidth <= 780); } function openSwitchModal(targetAddress, targetName, mode) { if ( !switchModal || !switchModalOpenHereButton || !switchModalOpenNewTabButton || !switchModalCancelButton ) { return Promise.resolve("same"); } if (switchModalResolver) { const priorResolve = switchModalResolver; switchModalResolver = null; priorResolve("cancel"); } if (switchModalNoteEl) { const displayName = String( targetName || extractAppNameFromQortalAddress(targetAddress) || targetAddress || "selected destination" ); if (mode === 'leave') { switchModalNoteEl.textContent = 'Opening "' + displayName + '" here will close your current app in this tab.'; } else { switchModalNoteEl.textContent = 'Opening "' + displayName + '" here will replace your current app in this tab.'; } } if (isMobileSwitchPromptMode()) { const displayName = String( targetName || extractAppNameFromQortalAddress(targetAddress) || targetAddress || "selected destination" ); const template = mode === "leave" ? modeCopy.mobileLeaveNote : modeCopy.mobileSwitchNote; const message = String(template || "") .replace("{name}", displayName) .trim(); setMobileMenuOpen(false, false); return Promise.resolve(window.confirm(message) ? "same" : "cancel"); } switchModal.classList.remove("qortal-hidden"); return new Promise(function (resolve) { switchModalResolver = resolve; }); } function buildQappsPageUrl(address) { const normalizedAddress = normalizeBrowserAddress(address || ""); if (!normalizedAddress) { return ""; } const url = new URL(window.location.href); url.searchParams.set("qapp", normalizedAddress); return url.toString(); } async function openAddress(address, name) { if (!address || !viewerCard || !viewerFrame || !viewerAddress) { return; } const allowedToOpen = await ensureRegisteredNameBeforeOpening(); if (!allowedToOpen) { return; } const relativeResolvedAddress = resolveRelativeAddressForCurrentQapp(address); let normalizedAddress = normalizeBrowserAddress( relativeResolvedAddress || address ); if (embedMode) { normalizedAddress = applyEmbedThemeParams(normalizedAddress); } if (isEmbedLockedToDifferentQapp(normalizedAddress)) { const pageUrl = buildQappsPageUrl(normalizedAddress); if (pageUrl) { window.open(pageUrl, "_blank", "noopener"); } return; } const resolved = resolveGatewayAddress(normalizedAddress, true); if (!resolved) { window.alert(modeCopy.nodeMissingAlert); return; } currentQappAddress = normalizedAddress; currentQdnResourceTarget = parseQdnResourceTarget(normalizedAddress); logDebug("info", "Opening Q-App", { address: normalizedAddress, resolved }); if (launchRetryAddress !== normalizedAddress) { launchRetryAddress = normalizedAddress; launchRetryCount = 0; } 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"); } viewerCard.classList.remove("qortal-hidden"); // Keep the viewer visible before assigning src to avoid lazy/hidden first-load edge cases. try { viewerFrame.setAttribute("loading", "eager"); } catch (_error) { // Ignore unsupported attribute updates. } viewerFrame.src = resolved; setViewerOpen(true); setMobileMenuOpen(false, false); if (debugCard && effectiveDebugEnabled) { syncDebugPanelState(); } resizeViewerFrame(); 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; clearPendingReloadSequence(); 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 } }; } const handleViewerReload = function () { const preferredAddress = currentViewerAddress || currentQappAddress || ""; if ( preferredAddress && preferredAddress.toLowerCase().startsWith("qortal://") ) { logDebug("info", "Reload routed through safe sequence", { currentViewerAddress: currentViewerAddress || "", currentQappAddress: currentQappAddress || "", reloadAddress: preferredAddress, }); startViewerReloadSequence(preferredAddress); return; } try { if (viewerFrame.contentWindow) { viewerFrame.contentWindow.location.reload(); } } catch (_err) { // ignore cross-origin issues } }; if (viewerReload) { viewerReload.onclick = handleViewerReload; } if (viewerMobileRefresh) { viewerMobileRefresh.onclick = handleViewerReload; } } async function openAddressWithSwitchPrompt(address, name) { if (!address) { return; } const normalizedTarget = normalizeBrowserAddress(address); if (!shouldPromptAppSwitch(normalizedTarget)) { await openAddress(normalizedTarget, name); return; } const choice = await openSwitchModal(normalizedTarget, name); if (choice === "newtab") { const pageUrl = buildQappsPageUrl(normalizedTarget); if (pageUrl) { window.open(pageUrl, "_blank", "noopener"); } return; } if (choice === "same") { await openAddress(normalizedTarget, name); } } function resizeViewerFrame() { if (!viewerFrameWrap) { return; } const rect = viewerFrameWrap.getBoundingClientRect(); const rootRect = root.getBoundingClientRect(); const viewportHeight = getViewportHeight(); 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 safeAreaBottom = isMobile ? Math.max(0, window.innerHeight - viewportHeight) + 2 : 8; const padding = isMobile ? safeAreaBottom : 8; const debugHeight = debugCard && !debugCard.classList.contains("qortal-hidden") ? Math.ceil(debugCard.getBoundingClientRect().height) : 0; let nextHeight = Math.floor( viewportHeight - rect.top - padding - debugHeight ); if ( root.classList.contains(viewerOpenClass) && Number.isFinite(rootRect.bottom) ) { const containerPadding = isMobile ? 4 : 8; const containerBoundHeight = Math.floor( rootRect.bottom - rect.top - containerPadding - debugHeight ); if (Number.isFinite(containerBoundHeight) && containerBoundHeight > 0) { nextHeight = Math.min(nextHeight, containerBoundHeight); } } if (isMobile) { nextHeight = Math.min( nextHeight, Math.floor(viewportHeight - rect.top - safeAreaBottom - debugHeight) ); } nextHeight = Math.max(0, nextHeight); viewerFrameWrap.style.height = nextHeight + "px"; } function renderList() { if (galleryEl) { galleryEl.innerHTML = ""; } if (!listBody) { return; } listBody.innerHTML = ""; if (!enabled || qapps.length === 0) { listBody.innerHTML = '' + modeCopy.listEmpty + ""; return; } qapps.forEach(function (entry) { if (galleryEl) { const cardLink = document.createElement("a"); cardLink.className = "qortal-qapp-card"; cardLink.href = "#"; cardLink.title = entry.name || entry.address || ""; cardLink.setAttribute( "aria-label", (entry.name || entry.address || "Q-App") + " (open app)" ); cardLink.addEventListener("click", function (event) { event.preventDefault(); openAddressWithSwitchPrompt(entry.address || "", entry.name || ""); }); const iconUrl = resolveQappIcon(entry); if (iconUrl) { const image = document.createElement("img"); image.className = "qortal-qapp-card__image"; image.loading = "lazy"; image.alt = entry.name || "Q-App"; image.src = iconUrl; image.onerror = function () { image.remove(); if (!cardLink.querySelector(".qortal-qapp-card__fallback")) { const fallback = document.createElement("div"); fallback.className = "qortal-qapp-card__fallback"; fallback.textContent = entry.name || entry.address || "Q-App"; cardLink.appendChild(fallback); } }; cardLink.appendChild(image); } else { const fallback = document.createElement("div"); fallback.className = "qortal-qapp-card__fallback"; fallback.textContent = entry.name || entry.address || "Q-App"; cardLink.appendChild(fallback); } const title = document.createElement("div"); title.className = "qortal-qapp-card__title"; title.textContent = entry.name || entry.address || "Q-App"; cardLink.appendChild(title); const description = document.createElement("div"); description.className = "qortal-qapp-card__desc"; description.textContent = entry.description || "Open app"; cardLink.appendChild(description); galleryEl.appendChild(cardLink); } 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 = modeCopy.launchDisabledTitle; } launchButton.addEventListener("click", function () { openAddressWithSwitchPrompt(entry.address || "", entry.name || ""); }); actionCell.appendChild(launchButton); row.appendChild(nameCell); row.appendChild(addressCell); row.appendChild(descCell); row.appendChild(actionCell); listBody.appendChild(row); }); } applyModeCopy(); 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 () { openAddressWithSwitchPrompt(browserAddress, modeCopy.browserName); }); } } 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(); } if (switchModalOpenHereButton) { switchModalOpenHereButton.addEventListener("click", function () { closeSwitchModal("same"); }); } if (switchModalOpenNewTabButton) { switchModalOpenNewTabButton.addEventListener("click", function () { closeSwitchModal("newtab"); }); } if (switchModalCancelButton) { switchModalCancelButton.addEventListener("click", function () { closeSwitchModal("cancel"); }); } if (switchModal) { switchModal.addEventListener("click", function (event) { if ( event.target === switchModal || event.target.classList.contains("qortal-modal__backdrop") ) { closeSwitchModal("cancel"); } }); } document.addEventListener("keydown", function (event) { if ( event.key === "Escape" && switchModal && !switchModal.classList.contains("qortal-hidden") ) { closeSwitchModal("cancel"); } }); function extractQappAddressFromHref(href) { const rawHref = String(href || "").trim(); if (!rawHref) { return ""; } let parsed; try { parsed = new URL(rawHref, window.location.origin); } catch (_error) { return ""; } if (!/\/apps\/qortal_integration\/qapps/i.test(parsed.pathname)) { return ""; } const qapp = parsed.searchParams.get("qapp"); if (!qapp) { return ""; } return normalizeBrowserAddress(qapp); } function shouldPromptNavigationAway(target) { if (!target || !isViewerShowingApp()) { return false; } if (target.hasAttribute('download')) { return false; } const targetAttr = String(target.getAttribute('target') || '').trim().toLowerCase(); if (targetAttr === '_blank') { return false; } const href = String(target.getAttribute('href') || '').trim(); if (!href || href === '#' || href.toLowerCase().startsWith('javascript:')) { return false; } const qappAddress = extractQappAddressFromHref(href); if (qappAddress) { return shouldPromptAppSwitch(qappAddress); } let absoluteUrl = ''; try { absoluteUrl = new URL(href, window.location.origin).toString(); } catch (_error) { return false; } const currentUrl = new URL(window.location.href); if (absoluteUrl === currentUrl.toString()) { return false; } return true; } document.addEventListener("click", function (event) { if ( event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey ) { return; } const target = event.target instanceof Element ? event.target.closest("a[href]") : null; if (!target || !shouldPromptNavigationAway(target)) { return; } const href = String(target.getAttribute("href") || ""); const targetAddress = extractQappAddressFromHref(href); event.preventDefault(); const absoluteUrl = new URL(href, window.location.origin).toString(); const label = target.textContent || target.getAttribute('aria-label') || target.getAttribute('title') || targetAddress || absoluteUrl; openSwitchModal( targetAddress || absoluteUrl, label, targetAddress ? 'switch' : 'leave' ).then(function (choice) { if (choice === "newtab") { window.open(absoluteUrl, "_blank", "noopener"); return; } if (choice === "same") { window.location.href = absoluteUrl; } }); }); 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, }; balanceLookupCache = { address: "", balanceQort: 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 || ""); return; } const normalizedTarget = normalizeBrowserAddress(target); if (normalizedTarget.toLowerCase().startsWith("qortal://")) { openAddress(normalizedTarget, modeCopy.browserName); } } renderList(); Promise.resolve(refreshUserMapping()).finally(function () { openFromQuery(); }); if (debugCard) { debugCard.classList.toggle("qortal-hidden", !effectiveDebugEnabled); } if (debugCard && effectiveDebugEnabled) { syncDebugPanelState(); resizeViewerFrame(); } if (debugCard && effectiveDebugEnabled) { debugCard.addEventListener("pointerleave", releaseDebugDockHoverSuppression); debugCard.addEventListener("mouseleave", releaseDebugDockHoverSuppression); } if (debugCard && effectiveDebugEnabled && debugResizeHandle) { debugResizeHandle.addEventListener("pointerdown", function (event) { if (event.button !== 0 || debugCard.classList.contains("qortal-debug-docked-hidden")) { return; } if (debugCard.classList.contains("qortal-debug-minimized")) { debugCard.classList.remove("qortal-debug-minimized"); updateDebugPanelControls(); } event.preventDefault(); const startY = event.clientY; const startHeight = debugPanelExpandedHeight; const onPointerMove = function (moveEvent) { const delta = startY - moveEvent.clientY; setDebugPanelExpandedHeight(startHeight + delta, false); resizeViewerFrame(); }; const onPointerEnd = function () { document.removeEventListener("pointermove", onPointerMove); document.removeEventListener("pointerup", onPointerEnd); document.removeEventListener("pointercancel", onPointerEnd); setDebugPanelExpandedHeight(debugPanelExpandedHeight, true); resizeViewerFrame(); }; document.addEventListener("pointermove", onPointerMove); document.addEventListener("pointerup", onPointerEnd); document.addEventListener("pointercancel", onPointerEnd); }); } if (debugFloat) { debugFloat.addEventListener("click", function (event) { if (!debugCard || !effectiveDebugEnabled) { return; } if (event.detail > 0) { suppressDebugDockHover(); } const isDockHidden = debugCard.classList.contains( "qortal-debug-docked-hidden" ); setDebugDockHidden(!isDockHidden, true); if (isDockHidden) { debugCard.classList.remove("qortal-debug-minimized"); releaseDebugDockHoverSuppression(); updateDebugPanelControls(); } if (event.currentTarget instanceof HTMLElement) { event.currentTarget.blur(); } resizeViewerFrame(); }); } if (debugMinimize) { debugMinimize.addEventListener("click", function () { if (!debugCard || !effectiveDebugEnabled) { return; } if (debugCard.classList.contains("qortal-debug-docked-hidden")) { setDebugDockHidden(false, true); } const isMinimized = debugCard.classList.toggle("qortal-debug-minimized"); debugMinimize.setAttribute("aria-pressed", isMinimized ? "true" : "false"); updateDebugPanelControls(); resizeViewerFrame(); }); } if (debugCard && typeof ResizeObserver === "function" && effectiveDebugEnabled) { debugPanelResizeObserver = new ResizeObserver(function () { resizeViewerFrame(); }); debugPanelResizeObserver.observe(debugCard); } 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, modeCopy.browserName); }); viewerAddressInput.addEventListener("keydown", function (event) { if (event.key === "Enter") { event.preventDefault(); viewerAddressGo.click(); } }); } if (viewerFrame) { viewerFrame.addEventListener("load", async function () { installViewerNavigationInterceptors(); syncViewerAddressFromFrame(); if (pendingReloadFinalAddress) { const finalTarget = pendingReloadFinalAddress; clearPendingReloadSequence(); window.setTimeout(function () { openAddress(finalTarget, modeCopy.browserName); }, 0); return; } if (!viewerCard || viewerCard.classList.contains("qortal-hidden")) { clearQdnLoadingRetry("viewer_hidden"); return; } const frameSrc = String(viewerFrame.src || ""); const proxyFrame = isGatewayProxyViewerUrl(frameSrc); const detection = detectQdnLoadingInterstitial(); if (!detection.inspectable) { if (proxyFrame && scheduleOneTimeLaunchRetry("uninspectable_initial_load")) { return; } 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 && isTerminalQdnStatus(statusResult.status)) { launchRetryCount = 0; clearQdnLoadingRetry("content_loaded_terminal_status"); qdnLoadingRetryAttempts = 0; return; } 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") { launchRetryCount = 0; clearQdnLoadingRetry("content_loaded_ready"); qdnLoadingRetryAttempts = 0; return; } } launchRetryCount = 0; 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 isFileLikeValue(value) { if (!value) { return false; } if (typeof File !== "undefined" && value instanceof File) { return true; } if (typeof Blob !== "undefined" && value instanceof Blob) { return true; } return false; } function uint8ArrayToBase64(bytes) { const chunkSize = 0x8000; let binary = ""; for (let index = 0; index < bytes.length; index += chunkSize) { const chunk = bytes.subarray( index, Math.min(index + chunkSize, bytes.length) ); binary += String.fromCharCode.apply(null, chunk); } return window.btoa(binary); } async function fileLikeToBase64(value) { if (!isFileLikeValue(value) || typeof value.arrayBuffer !== "function") { throw new Error("Unsupported file payload for publish request"); } const arrayBuffer = await value.arrayBuffer(); return uint8ArrayToBase64(new Uint8Array(arrayBuffer)); } function pickPayloadBase64Value(value) { if (!value || typeof value !== "object") { return ""; } const candidates = [value.base64, value.data64, value.data]; for (let index = 0; index < candidates.length; index += 1) { const candidate = candidates[index]; if (typeof candidate === "string" && candidate.trim() !== "") { return candidate.trim(); } } return ""; } function unwrapSingleArrayValue(value) { if (!Array.isArray(value)) { return { value: value, multiple: false }; } if (value.length === 0) { return { value: null, multiple: false }; } if (value.length > 1) { return { value: value, multiple: true }; } return { value: value[0], multiple: false }; } async function normalizeFileUploadPayload(item, contextLabel) { if (!item || typeof item !== "object") { return item; } const normalized = Object.assign({}, item); const uploadType = String(normalized.uploadType || "") .trim() .toLowerCase(); const hasFileHints = Object.prototype.hasOwnProperty.call(normalized, "file") || Object.prototype.hasOwnProperty.call(normalized, "filePath"); if (uploadType !== "file" && !hasFileHints) { return normalized; } const fileUnwrapped = unwrapSingleArrayValue(normalized.file); const filePathUnwrapped = unwrapSingleArrayValue(normalized.filePath); if (fileUnwrapped.multiple || filePathUnwrapped.multiple) { throw new Error( contextLabel + " only supports one file per resource when uploadType=file" ); } const fileValue = fileUnwrapped.value; const filePathValue = filePathUnwrapped.value; const hasInlineBase64 = pickPayloadBase64Value(normalized) !== ""; const markedBase64 = normalized.isBase64 === true || String(normalized.encoding || "") .trim() .toLowerCase() === "base64"; if (hasInlineBase64) { const base64Data = pickPayloadBase64Value(normalized); normalized.data = base64Data; normalized.data64 = base64Data; normalized.base64 = base64Data; normalized.uploadType = "base64"; delete normalized.file; delete normalized.filePath; return normalized; } if ( markedBase64 && typeof fileValue === "string" && fileValue.trim() !== "" ) { const base64Data = fileValue.trim(); normalized.data = base64Data; normalized.data64 = base64Data; normalized.base64 = base64Data; normalized.uploadType = "base64"; delete normalized.file; delete normalized.filePath; return normalized; } const candidateFile = isFileLikeValue(fileValue) ? fileValue : isFileLikeValue(filePathValue) ? filePathValue : null; if (candidateFile) { const base64Data = await fileLikeToBase64(candidateFile); normalized.data = base64Data; normalized.data64 = base64Data; normalized.base64 = base64Data; normalized.uploadType = "base64"; delete normalized.file; delete normalized.filePath; return normalized; } if (typeof filePathValue === "string" && filePathValue.trim() !== "") { normalized.filePath = filePathValue.trim(); delete normalized.file; return normalized; } if (typeof fileValue === "string" && fileValue.trim() !== "") { if (markedBase64) { const base64Data = fileValue.trim(); normalized.data = base64Data; normalized.data64 = base64Data; normalized.base64 = base64Data; normalized.uploadType = "base64"; delete normalized.file; delete normalized.filePath; return normalized; } normalized.filePath = fileValue.trim(); delete normalized.file; return normalized; } throw new Error( contextLabel + " is missing supported file data for uploadType=file" ); } async function normalizeCryptoEncryptPayload(item, contextLabel) { if (!item || typeof item !== "object") { return item; } const normalized = Object.assign({}, item); const fileUnwrapped = unwrapSingleArrayValue(normalized.file); const blobUnwrapped = unwrapSingleArrayValue(normalized.blob); if (fileUnwrapped.multiple || blobUnwrapped.multiple) { throw new Error(contextLabel + " only supports one file payload"); } const fileValue = fileUnwrapped.value; const blobValue = blobUnwrapped.value; const candidateFile = isFileLikeValue(fileValue) ? fileValue : isFileLikeValue(blobValue) ? blobValue : null; if (candidateFile) { const base64Data = await fileLikeToBase64(candidateFile); if (!base64Data) { throw new Error(contextLabel + " requires non-empty file data"); } normalized.data = base64Data; normalized.data64 = base64Data; normalized.base64 = base64Data; delete normalized.file; delete normalized.blob; delete normalized.filePath; return normalized; } const markedBase64 = normalized.isBase64 === true || String(normalized.encoding || "") .trim() .toLowerCase() === "base64"; if ( markedBase64 && typeof fileValue === "string" && fileValue.trim() !== "" ) { const base64Data = fileValue.trim(); normalized.data = base64Data; normalized.data64 = base64Data; normalized.base64 = base64Data; delete normalized.file; delete normalized.blob; delete normalized.filePath; } return normalized; } function normalizeBridgePayload(requestType, payload) { if (!payload || typeof payload !== "object") { return {}; } const normalized = Object.assign({}, payload); const isEmptyValue = function (value) { return value === undefined || value === null || value === ""; }; const firstNonEmpty = function (values) { for (let i = 0; i < values.length; i += 1) { const value = values[i]; if (!isEmptyValue(value)) { return value; } } return ""; }; 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 normalizedName = firstNonEmpty([ normalized.name, normalized.registeredName, normalized.registered_name, ]); if (!isEmptyValue(normalizedName)) { normalized.name = normalizedName; if (isEmptyValue(normalized.registeredName)) { normalized.registeredName = normalizedName; } } const normalizedService = firstNonEmpty([ normalized.service, normalized.serviceName, normalized.service_name, normalized.type, ]); if (!isEmptyValue(normalizedService)) { normalized.service = normalizedService; } 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 resourceName = firstNonEmpty([ resourceCopy.name, resourceCopy.registeredName, resourceCopy.registered_name, ]); if (!isEmptyValue(resourceName)) { resourceCopy.name = resourceName; if (isEmptyValue(resourceCopy.registeredName)) { resourceCopy.registeredName = resourceName; } } const resourceService = firstNonEmpty([ resourceCopy.service, resourceCopy.serviceName, resourceCopy.service_name, resourceCopy.type, ]); if (!isEmptyValue(resourceService)) { resourceCopy.service = resourceService; } 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_DATA_WITH_SHARING_KEY", "DECRYPT_DATA_WITH_SHARING_KEY", "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; } async function normalizeBridgePayloadAsync(requestType, payload) { const normalized = normalizeBridgePayload(requestType, payload); if (!normalized || typeof normalized !== "object") { return normalized; } if (requestType === "PUBLISH_QDN_RESOURCE") { return await normalizeFileUploadPayload( normalized, "PUBLISH_QDN_RESOURCE" ); } if ( requestType === "PUBLISH_MULTIPLE_QDN_RESOURCES" && Array.isArray(normalized.resources) ) { const normalizedResources = []; for (let index = 0; index < normalized.resources.length; index += 1) { const resource = normalized.resources[index]; const normalizedResource = await normalizeFileUploadPayload( resource, "PUBLISH_MULTIPLE_QDN_RESOURCES resource " + index ); normalizedResources.push(normalizedResource); } normalized.resources = normalizedResources; } const cryptoEncryptActions = new Set([ "ENCRYPT_DATA", "ENCRYPT_DATA_WITH_SHARING_KEY", "ENCRYPT_QORTAL_GROUP_DATA", ]); if (cryptoEncryptActions.has(requestType)) { return await normalizeCryptoEncryptPayload(normalized, requestType); } 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 120; } const valueInt = Math.floor(parsed); const allowed = [60, 120, 180, 360, 720, 1440, 7200]; return allowed.includes(valueInt) ? valueInt : 120; } function sanitizeTemporaryApprovalRules(entries) { if (!Array.isArray(entries)) { return []; } return entries .filter(function (entry) { return ( entry && typeof entry === "object" && typeof entry.requestType === "string" && typeof entry.scope === "string" && typeof entry.app === "string" ); }) .map(function (entry) { return { requestType: String(entry.requestType || "").trim(), scope: String(entry.scope || "").trim(), app: String(entry.app || "").trim(), expiresAt: Number(entry.expiresAt || 0), }; }) .filter(function (entry) { return ( entry.requestType !== "" && entry.scope !== "" && Number.isFinite(entry.expiresAt) && entry.expiresAt > Date.now() ); }); } function loadTemporaryApprovalRules() { if (!window.sessionStorage) { return []; } try { return sanitizeTemporaryApprovalRules( JSON.parse( window.sessionStorage.getItem(temporaryApprovalStorageKey) || "[]" ) ); } catch (_error) { return []; } } function persistTemporaryApprovalRules() { if (!window.sessionStorage) { return; } try { window.sessionStorage.setItem( temporaryApprovalStorageKey, JSON.stringify(sanitizeTemporaryApprovalRules(temporaryApprovalRules)) ); } catch (_error) { // ignore storage failures } } 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; persistTemporaryApprovalRules(); return; } } temporaryApprovalRules.push({ requestType: normalizedRequestType, scope: normalizedScope, app: normalizedApp, expiresAt, }); persistTemporaryApprovalRules(); } function findTemporaryApprovalRule(requestType, scope, app) { const normalizedRequestType = String(requestType || "").trim(); const normalizedScope = String(scope || "").trim(); const normalizedApp = String(app || "").trim(); const now = Date.now(); let scopeAgnosticMatch = null; let mutated = false; for (let i = temporaryApprovalRules.length - 1; i >= 0; i -= 1) { const candidate = temporaryApprovalRules[i]; if (!candidate || candidate.expiresAt <= now) { temporaryApprovalRules.splice(i, 1); mutated = true; continue; } if (candidate.requestType !== normalizedRequestType) { continue; } if (candidate.app !== normalizedApp) { continue; } if (candidate.scope === normalizedScope) { return candidate; } if ( !scopeAgnosticMatch || candidate.expiresAt > scopeAgnosticMatch.expiresAt ) { scopeAgnosticMatch = candidate; } } if (mutated) { persistTemporaryApprovalRules(); } return scopeAgnosticMatch; } function approvalModeLabel(mode) { switch (normalizeApprovalMode(mode)) { case "type_minutes": return "Allow this request type for X minutes in this browser tab"; case "type_always": return "Always allow this request type for this app/scope"; 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 (approvalConfirm) { approvalConfirm.textContent = approvalConfirmLabel(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 blurActiveElement() { if (document.activeElement && typeof document.activeElement.blur === "function") { try { document.activeElement.blur(); } catch (_error) { // Ignore focus cleanup failures. } } } 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 = advancedUnlockSettingsEnabled ? normalizeApprovalMode(defaultApprovalMode) : "app_always"; 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(); if (advancedUnlockSettingsEnabled) { 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"; } } else if (approvalTempMinutesEl) { approvalTempMinutesEl.disabled = true; } showApprovalError(""); blurActiveElement(); 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 = advancedUnlockSettingsEnabled ? document.querySelector('input[name="qortal-approval-mode"]:checked') : null; const mode = advancedUnlockSettingsEnabled ? normalizeApprovalMode(choice ? choice.value : "once") : "app_always"; const unlockFieldsVisible = approvalUnlockFieldsEl && !approvalUnlockFieldsEl.classList.contains("qortal-hidden"); const passwordValue = approvalPassword ? approvalPassword.value.trim() : ""; const ttlSeconds = unlockFieldsVisible ? (advancedUnlockSettingsEnabled ? (approvalTtl && approvalTtl.checked ? 3600 : undefined) : 3600) : 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: advancedUnlockSettingsEnabled ? normalizeApprovalMinutes( approvalTempMinutesEl ? approvalTempMinutesEl.value : defaultApprovalTempMinutes ) : 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 = ""; showUnlockError(""); blurActiveElement(); unlockModal.classList.remove("qortal-hidden"); unlockConfirm.textContent = unlockConfirmLabel(); 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 = defaultApprovalTempMinutes * 60; 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 = !advancedUnlockSettingsEnabled; } const modeInputs = document.querySelectorAll( 'input[name="qortal-approval-mode"]' ); modeInputs.forEach(function (input) { input.onchange = null; }); if (approvalConfirm) { approvalConfirm.disabled = false; approvalConfirm.textContent = approvalConfirmLabel(false); } if (approvalCancel) { approvalCancel.disabled = false; } } function closeUnlockModal() { if (!unlockModal) { return; } unlockModal.classList.add("qortal-hidden"); showUnlockError(""); if (unlockConfirm) { unlockConfirm.disabled = false; unlockConfirm.textContent = unlockConfirmLabel(); } 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 fetchWithRetry( qappsApproveUrl, { method: "POST", headers, body: JSON.stringify({ walletId, scope, app: currentQappAddress, requestType, }), }, 2, 250 ); } 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 outcome = await postQappsRequestWithHighRiskConfirmation( requestType, retryPayload, headers ); return { retryResponse: outcome.response, retryJson: outcome.json }; }; 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 = normalizeQappsResultPayload( requestType, retryJson.data !== undefined ? retryJson.data : retryJson ); return { ok: true, result: retryResult }; } return { ok: false, error: "approval_failed" }; } async function autoApproveRequest(requestType, payload, approvalContext, headers, walletId) { const effectiveWalletId = walletId || (approvalContext && approvalContext.walletId) || currentWalletId; if (!effectiveWalletId) { throw new Error("walletId is not linked"); } const safeContext = Object.assign({}, approvalContext || {}, { walletId: effectiveWalletId, scope: normalizeApprovalScope( approvalContext && approvalContext.scope ? approvalContext.scope : "", requestType ), }); let token; try { token = await approveOnce(requestType, payload, safeContext); } catch (error) { const message = error && error.message ? error.message : String(error || "approval_failed"); if (!message.includes("wallet_locked")) { throw error; } markWalletLocked(); await ensureWalletUnlocked(effectiveWalletId, requestType); token = await approveOnce(requestType, payload, safeContext); } return await runApprovedRequestWithFallbacks( requestType, payload, token, safeContext, headers, { action: "once", password: "", ttlSeconds: undefined }, effectiveWalletId ); } 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 fetchWithRetry( qappsPermissionsUrl, { method: "POST", headers, body: JSON.stringify(permissionPayload), }, 2, 250 ); } 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 outcome = await postQappsRequestWithHighRiskConfirmation( requestType, payload, headers ); const retryResponse = outcome.response; const retryJson = outcome.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 = normalizeQappsResultPayload( requestType, 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 tryAutoApproveNonSpendingRequest( requestType, payload, approvalCtx, headers ) { if (isLikelySpendingRequestType(requestType)) { return { handled: false }; } const walletId = approvalCtx && approvalCtx.walletId ? approvalCtx.walletId : currentWalletId; if (!walletId) { return { handled: false }; } const normalizedScope = normalizeApprovalScope( approvalCtx && approvalCtx.scope ? approvalCtx.scope : "", requestType ); const normalizedContext = Object.assign({}, approvalCtx || {}, { walletId, scope: normalizedScope, }); try { await refreshWalletLockState(walletId); if (walletLockState.state === "locked") { await ensureWalletUnlocked(walletId, requestType); } await setPermission( "type_always", requestType, normalizedScope, walletId ); const permissionOutcome = await runPermissionRequestWithFallbacks( "type_always", requestType, payload, headers, { action: "type_always", password: "", ttlSeconds: undefined }, walletId, normalizedContext ); if (permissionOutcome && permissionOutcome.ok) { activeApprovalPolicyMode = "type_always"; logDebug("info", "Auto-approved non-spending request type", { requestType, scope: normalizedScope, walletId, }); return { handled: true, ok: true, result: permissionOutcome.result }; } logDebug( "warn", "Auto-approve non-spending request failed; falling back to manual", { requestType, error: permissionOutcome && permissionOutcome.error ? permissionOutcome.error : "approval_failed", } ); return { handled: false }; } catch (error) { const message = error && error.message ? error.message : String(error || "approval_failed"); if (message.includes("wallet_locked")) { return { handled: true, ok: false, error: "wallet_locked" }; } logDebug( "warn", "Auto-approve non-spending request threw; falling back to manual", { requestType, error: message, } ); return { handled: false }; } } 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 fetchWithRetry( qappsUnlockUrl, { method: "POST", headers, body: JSON.stringify({ walletId, password, ...(ttlSeconds ? { ttlSeconds } : {}), }), }, 2, 250 ); 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 = unlockConfirmLabel(); } 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; let payload = {}; try { payload = await normalizeBridgePayloadAsync( requestType, buildBridgePayload(message) ); } catch (normalizeError) { const normalizeMessage = normalizeError && normalizeError.message ? normalizeError.message : "Invalid publish payload"; if (hasPort && event.ports[0]) { postBridgeError(event.ports[0], normalizeMessage); } else { logDebug("error", "Payload normalization failed", { requestType, error: normalizeMessage, }); } return; } 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 = getNavigationAddressFromPayload(payload) || (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, modeCopy.browserName); 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, modeCopy.requestEndpointMissing); return; } const headers = { "Content-Type": "application/json" }; if (typeof OC !== "undefined" && OC.requestToken) { headers.requesttoken = OC.requestToken; } logDebug("info", "Broker qortalRequest start", { requestType, payload }); let requestOutcome = await postQappsRequestWithHighRiskConfirmation( requestType, payload, headers ); payload = requestOutcome.payload; const response = requestOutcome.response; const json = requestOutcome.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 ); requestOutcome = await postQappsRequestWithHighRiskConfirmation( requestType, payload, headers ); payload = requestOutcome.payload; const retryResponse = requestOutcome.response; const retryJson = requestOutcome.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, }); closeUnlockModal(); await refreshWalletLockState( retryApprovalContext.walletId || currentWalletId ); const autoOutcome = await autoApproveRequest( requestType, payload, retryApprovalContext, headers, retryApprovalContext.walletId || currentWalletId ); if (!autoOutcome.ok) { showApprovalError(autoOutcome.error || "approval_failed"); postBridgeError( port, autoOutcome.error || "qortal_request_failed" ); closeUnlockModal(); return; } logDebug("info", "Broker qortalRequest ok", { requestType, result: autoOutcome.result, }); port.postMessage({ result: autoOutcome.result, error: null, }); closeUnlockModal(); return; } 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 = unlockConfirmLabel(); } if (unlockCancel) { unlockCancel.disabled = false; } closeUnlockModal(); return; } } if ( typeof errorMessage === "string" && errorMessage.includes("approval_required") ) { logDebug("info", "Approval required", { requestType, context: approvalContext, }); await refreshWalletLockState( approvalContext.walletId || currentWalletId ); try { const autoOutcome = await autoApproveRequest( requestType, payload, approvalContext, headers, approvalContext.walletId || currentWalletId ); if (!autoOutcome.ok) { showApprovalError(autoOutcome.error || "approval_failed"); postBridgeError( port, autoOutcome.error || "qortal_request_failed" ); return; } logDebug("info", "Broker qortalRequest ok", { requestType, result: autoOutcome.result, }); port.postMessage({ result: autoOutcome.result, error: null }); return; } catch (approvalError) { showApprovalError(approvalError.message || "approval_failed"); logDebug("error", "Approval failed", { requestType, error: approvalError.message || "approval_failed", }); postBridgeError(port, approvalError.message || "approval_failed"); return; } } logDebug("error", "Broker qortalRequest failed", { requestType, error: json.error || "qortal_request_failed", }); postBridgeError(port, json.error || "qortal_request_failed"); return; } let resultPayload = normalizeQappsResultPayload( requestType, json.data !== undefined ? json.data : json ); 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; } function resolveSaveFileTarget(payload) { const raw = payload && typeof payload === "object" ? payload.saveTarget || payload.target || payload.destination || payload.targetType || payload.saveTo : ""; const normalized = String(raw || "") .trim() .toLowerCase(); if (!normalized) { return "browser-local"; } if ( normalized === "browser" || normalized === "browser-local" || normalized === "local" || normalized === "download" || normalized === "device" || normalized === "user-device" || normalized === "auto" ) { return "browser-local"; } if ( normalized === "daemon" || normalized === "daemon-local" || normalized === "server" || normalized === "server-local" ) { return "daemon-local"; } if ( normalized === "files" || normalized === "nextcloud" || normalized === "nextcloud-files" || normalized === "cloud-files" ) { return "nextcloud-files"; } return normalized; } function resolveSaveFileName(payload) { const defaultName = "download_" + Date.now() + ".bin"; if (!payload || typeof payload !== "object") { return defaultName; } const candidates = [ payload.filename, payload.fileName, payload.name, payload.downloadName, payload.filePath, payload.path, payload.savePath, payload.location, ]; for (let i = 0; i < candidates.length; i += 1) { const candidate = candidates[i]; if (typeof candidate !== "string") { continue; } const trimmed = candidate.trim(); if (!trimmed) { continue; } const basename = trimmed.replace(/\\/g, "/").split("/").pop(); const safe = String(basename || "") .trim() .replace(/[\u0000-\u001f<>:"|?*]+/g, "_"); if (safe) { return safe; } } return defaultName; } function decodeBase64ToUint8Array(base64Value) { const normalized = String(base64Value || "").trim(); if (!normalized) { return new Uint8Array(0); } try { const binary = window.atob(normalized); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) { bytes[i] = binary.charCodeAt(i) & 0xff; } return bytes; } catch (_error) { throw new Error("Invalid base64 payload for SAVE_FILE"); } } function encodeUtf8Bytes(text) { const normalized = String(text === undefined || text === null ? "" : text); if (typeof TextEncoder !== "undefined") { return new TextEncoder().encode(normalized); } // Fallback for very old browsers: preserve byte values via encodeURIComponent. const encoded = unescape(encodeURIComponent(normalized)); const bytes = new Uint8Array(encoded.length); for (let i = 0; i < encoded.length; i += 1) { bytes[i] = encoded.charCodeAt(i) & 0xff; } return bytes; } function decodeSaveFilePayload(payload) { const contentType = String( (payload && (payload.mimeType || payload.contentType || payload.type)) || "application/octet-stream" ).trim() || "application/octet-stream"; const encodingHint = String( (payload && (payload.encoding || payload.dataEncoding)) || "" ) .trim() .toLowerCase(); const explicitBase64 = payload && (payload.data64 || payload.base64 || payload.blob64 || payload.base64Data); if (explicitBase64 !== undefined && explicitBase64 !== null) { const bytes = decodeBase64ToUint8Array(explicitBase64); return { bytes: bytes, contentType: contentType, encoding: "base64" }; } const explicitText = payload && (payload.text || payload.stringData || payload.string || payload.plainText); if (explicitText !== undefined && explicitText !== null) { const bytes = encodeUtf8Bytes(explicitText); return { bytes: bytes, contentType: contentType, encoding: "utf8" }; } const genericData = payload && (payload.data !== undefined ? payload.data : payload.blob !== undefined ? payload.blob : payload.contents); if (genericData === undefined || genericData === null) { throw new Error("SAVE_FILE requires text, base64/data64, or data"); } if (genericData instanceof ArrayBuffer) { return { bytes: new Uint8Array(genericData), contentType: contentType, encoding: "arraybuffer", }; } if (ArrayBuffer.isView(genericData)) { return { bytes: new Uint8Array( genericData.buffer, genericData.byteOffset, genericData.byteLength ), contentType: contentType, encoding: "typedarray", }; } if (Array.isArray(genericData)) { return { bytes: new Uint8Array( genericData.map(function (value) { return Number(value) & 0xff; }) ), contentType: contentType, encoding: "bytes", }; } if ( encodingHint === "utf8" || encodingHint === "utf-8" || encodingHint === "text" || encodingHint === "string" ) { return { bytes: encodeUtf8Bytes(genericData), contentType: contentType, encoding: "utf8", }; } if (typeof genericData === "string") { return { bytes: decodeBase64ToUint8Array(genericData), contentType: contentType, encoding: "base64", }; } throw new Error("Unsupported SAVE_FILE payload data format"); } function triggerBrowserDownload(blob, fileName) { const blobUrl = window.URL.createObjectURL(blob); try { const link = document.createElement("a"); link.href = blobUrl; link.download = fileName; link.rel = "noopener"; link.style.display = "none"; document.body.appendChild(link); link.click(); document.body.removeChild(link); } finally { window.setTimeout(function () { window.URL.revokeObjectURL(blobUrl); }, 1000); } } async function saveFileToBrowser(payload) { const fileName = resolveSaveFileName(payload); const decoded = decodeSaveFilePayload( payload && typeof payload === "object" ? payload : {} ); const bytes = decoded.bytes instanceof Uint8Array ? decoded.bytes : new Uint8Array(decoded.bytes || []); const blob = new Blob([bytes], { type: decoded.contentType || "application/octet-stream", }); const preferPicker = !payload || payload.useBrowserDownload === true ? false : typeof window.showSaveFilePicker === "function"; if (preferPicker) { try { const handle = await window.showSaveFilePicker({ suggestedName: fileName, types: [ { description: "Downloaded file", accept: { [decoded.contentType || "application/octet-stream"]: [ "." + fileName.split(".").pop(), ], }, }, ], }); const writable = await handle.createWritable(); await writable.write(blob); await writable.close(); return { saved: true, target: "browser-local", method: "file-system-access", fileName: fileName, bytesWritten: bytes.byteLength, contentType: decoded.contentType, encoding: decoded.encoding, }; } catch (error) { if ( error && (error.name === "AbortError" || /abort/i.test(String(error.message || ""))) ) { throw new Error("SAVE_FILE canceled"); } // Fall through to browser download prompt. } } triggerBrowserDownload(blob, fileName); return { saved: true, target: "browser-local", method: "download", fileName: fileName, bytesWritten: bytes.byteLength, contentType: decoded.contentType, encoding: decoded.encoding, }; } 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 }; } case "SAVE_FILE": { const saveTarget = resolveSaveFileTarget(payload); if (saveTarget === "daemon-local") { return { handled: false }; } if (saveTarget === "nextcloud-files") { return { handled: true, error: "Nextcloud Files SAVE_FILE target is not available yet", }; } const result = await saveFileToBrowser(payload || {}); return { handled: true, data: result }; } 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 }; } })();