6940 lines
211 KiB
JavaScript
6940 lines
211 KiB
JavaScript
(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 =
|
||
'<tr><td colspan="4">' + modeCopy.listEmpty + "</td></tr>";
|
||
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 };
|
||
}
|
||
})();
|