quick/QuickHtmlReader.cpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #include "QuickHtmlReader.h" | ||
| 2 | #include "util/HostMatch.h" | ||
| 3 | #include "presentation/MailReaderState.h" | ||
| 4 | #include "presentation/DesignTokens.h" | ||
| 5 | #include "presentation/WorkspaceState.h" | ||
| 6 | #include "app/core/MailSessionCore.h" | ||
| 7 | #include "app/core/AccountSession.h" | ||
| 8 | #include "data/MailCache.h" | ||
| 9 | #include "service/HtmlInlineImageService.h" | ||
| 10 | #include "ui/HtmlSanitizer.h" | ||
| 11 | #include "performance/MailOpenPerformanceTracker.h" | ||
| 12 | #include "ui/HtmlDocumentDelivery.h" | ||
| 13 | #include "ui/ExternalContentInterceptor.h" | ||
| 14 | #include "ui/ExternalContentPolicy.h" | ||
| 15 | #include <QColor> | ||
| 16 | #include <QDataStream> | ||
| 17 | #include <QQmlComponent> | ||
| 18 | #include <QQmlEngine> | ||
| 19 | #include <QQuickWebEngineProfile> | ||
| 20 | #include <QLoggingCategory> | ||
| 21 | #include <QQuickWebEngineDownloadRequest> | ||
| 22 | #include <QGuiApplication> | ||
| 23 | #include <QClipboard> | ||
| 24 | #include <QFutureWatcher> | ||
| 25 | #include <QtConcurrent> | ||
| 26 | |||
| 27 | ✗ | Q_LOGGING_CATEGORY(lcQuickHtml, "mailjd.quick.html") | |
| 28 | |||
| 29 | 795 | QuickHtmlReader::QuickHtmlReader(MailReaderState &reader, MailSessionCore &session, DesignTokens &tokens, WorkspaceState &workspace, QObject *parent) | |
| 30 | 795 | : QObject(parent), m_reader(reader), m_session(session), m_tokens(tokens), m_workspace(workspace), | |
| 31 |
10/16✓ Branch 6 → 7 taken 795 times.
✓ Branch 7 → 8 taken 795 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 795 times.
✓ Branch 10 → 11 taken 795 times.
✓ Branch 11 → 12 taken 795 times.
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 795 times.
✓ Branch 15 → 16 taken 795 times.
✓ Branch 16 → 17 taken 795 times.
✓ Branch 17 → 18 taken 795 times.
✓ Branch 20 → 21 taken 795 times.
✗ Branch 61 → 62 not taken.
✗ Branch 61 → 63 not taken.
✗ Branch 64 → 65 not taken.
✗ Branch 64 → 66 not taken.
|
795 | m_delivery(new HtmlDocumentDelivery(this)), m_interceptor(new ExternalContentInterceptor(this)) { |
| 32 |
1/1✓ Branch 24 → 25 taken 795 times.
|
795 | reader.setHtmlRenderer(this); |
| 33 |
2/2✓ Branch 26 → 27 taken 795 times.
✓ Branch 27 → 28 taken 795 times.
|
795 | m_interceptor->setDocumentPolicy({}, true, {}); |
| 34 |
1/1✓ Branch 30 → 31 taken 795 times.
|
795 | m_retention.setSingleShot(true); |
| 35 |
1/1✓ Branch 31 → 32 taken 795 times.
|
795 | m_retention.setInterval(5000); |
| 36 |
1/1✓ Branch 32 → 33 taken 795 times.
|
795 | m_timeout.setSingleShot(true); |
| 37 |
1/1✓ Branch 33 → 34 taken 795 times.
|
795 | m_timeout.setInterval(30000); |
| 38 |
1/1✓ Branch 34 → 35 taken 795 times.
|
795 | m_envelopePool.setMaxThreadCount(1); |
| 39 |
1/1✓ Branch 35 → 36 taken 795 times.
|
795 | m_envelopePool.setExpiryTimeout(-1); |
| 40 |
1/1✓ Branch 36 → 37 taken 795 times.
|
795 | connect(&m_retention, &QTimer::timeout, this, &QuickHtmlReader::retire); |
| 41 |
1/3✗ Branch 2 → 3 not taken.
✗ Branch 3 → 4 not taken.
✓ Branch 38 → 39 taken 795 times.
|
795 | connect(&m_timeout, &QTimer::timeout, this, [this] { fail(tr("The HTML reader did not finish loading. Retry.")); }); |
| 42 |
1/1✓ Branch 40 → 41 taken 795 times.
|
795 | connect(&reader, &MailReaderState::changed, this, &QuickHtmlReader::synchronize); |
| 43 |
1/1✓ Branch 42 → 43 taken 795 times.
|
795 | connect(&workspace, &WorkspaceState::activeTabChanged, this, [this] { |
| 44 |
1/2✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 512 times.
|
512 | if (m_tabId == m_workspace.activeTabId()) return; |
| 45 | // A logical owner can change without another body delivery or mode change. | ||
| 46 | // Revoke the old owner's rights even when both tabs display the same mail. | ||
| 47 |
5/12✓ Branch 8 → 9 taken 512 times.
✓ Branch 9 → 10 taken 512 times.
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 18 taken 185 times.
✓ Branch 23 → 24 taken 512 times.
✗ Branch 23 → 25 not taken.
✗ Branch 25 → 26 not taken.
✓ Branch 25 → 27 taken 512 times.
✗ Branch 34 → 35 not taken.
✗ Branch 34 → 36 not taken.
✗ Branch 38 → 39 not taken.
✗ Branch 38 → 40 not taken.
|
1209 | if (WorkspaceState::decode(m_reader.identity()) == m_workspace.mailId() && |
| 48 |
5/10✓ Branch 11 → 12 taken 185 times.
✓ Branch 11 → 18 taken 327 times.
✗ Branch 16 → 17 not taken.
✗ Branch 16 → 18 not taken.
✓ Branch 19 → 20 taken 185 times.
✓ Branch 19 → 21 taken 327 times.
✓ Branch 21 → 22 taken 512 times.
✗ Branch 21 → 23 not taken.
✗ Branch 29 → 30 not taken.
✗ Branch 29 → 31 not taken.
|
1209 | m_workspace.readingMode() == "html" && m_reader.htmlRequested()) start(); |
| 49 | 512 | else invalidate(); | |
| 50 | }); | ||
| 51 |
2/3✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 1072 times.
✓ Branch 44 → 45 taken 795 times.
|
1867 | connect(&tokens, &DesignTokens::changed, this, [this] { if (m_reader.htmlRequested()) start(); }); |
| 52 |
1/1✓ Branch 47 → 48 taken 795 times.
|
795 | connect(session.accounts(), &AccountSession::accountsLoaded, this, [this] { |
| 53 |
1/1✓ Branch 2 → 3 taken 793 times.
|
793 | const QPointer<QuickHtmlReader> guard(this); |
| 54 |
1/1✓ Branch 3 → 4 taken 793 times.
|
793 | m_workspace.clearHtmlConsent(); |
| 55 | 793 | m_allowExternal = false; | |
| 56 |
1/1✓ Branch 4 → 5 taken 793 times.
|
793 | invalidate(); |
| 57 |
2/3✓ Branch 6 → 7 taken 793 times.
✗ Branch 6 → 8 not taken.
✓ Branch 7 → 8 taken 793 times.
|
793 | if (guard) retire(); |
| 58 | 793 | }); | |
| 59 |
1/1✓ Branch 49 → 50 taken 795 times.
|
795 | connect(m_interceptor, &ExternalContentInterceptor::documentExternalContentBlocked, this, [this](const QUrl &url) { |
| 60 | ✗ | if (url != m_url || !m_reader.htmlRequested()) return; | |
| 61 | ✗ | m_blocked = true; | |
| 62 | ✗ | emit changed(); | |
| 63 | }); | ||
| 64 | // Policy writers (settings surfaces, whitelist editors) publish changes | ||
| 65 | // through the shared policy/cache signals; the running document refreshes | ||
| 66 | // within its existing consent and document-token boundaries. | ||
| 67 |
1/1✓ Branch 52 → 53 taken 795 times.
|
795 | connect(m_session.cache(), &MailCache::whitelistChanged, this, &QuickHtmlReader::refreshPolicy); |
| 68 |
1/1✓ Branch 55 → 56 taken 795 times.
|
795 | connect(m_session.cache(), &MailCache::folderIdentityInvalidated, this, |
| 69 | 795 | [this](qint64 folderId) { | |
| 70 |
4/4✓ Branch 2 → 3 taken 16 times.
✓ Branch 3 → 4 taken 16 times.
✓ Branch 6 → 7 taken 10 times.
✓ Branch 6 → 8 taken 6 times.
|
16 | if (WorkspaceState::decode(m_reader.identity()).folderId == folderId) |
| 71 | 10 | invalidate(); | |
| 72 | 16 | }); | |
| 73 |
2/2✓ Branch 57 → 58 taken 795 times.
✓ Branch 58 → 59 taken 795 times.
|
795 | connect(ExternalContentPolicy::instance(), &ExternalContentPolicy::changed, this, &QuickHtmlReader::refreshPolicy); |
| 74 | 795 | } | |
| 75 | 795 | QuickHtmlReader::~QuickHtmlReader() { | |
| 76 | 795 | invalidate(); | |
| 77 | 1590 | m_envelopePool.waitForDone(); | |
| 78 | 795 | m_reader.setHtmlRenderer(nullptr); | |
| 79 | 795 | releaseProfile(); | |
| 80 | 795 | } | |
| 81 | ✗ | QObject *QuickHtmlReader::profile() const { return m_profile.data(); } | |
| 82 | 8691 | void QuickHtmlReader::invalidate() { | |
| 83 |
1/1✓ Branch 2 → 3 taken 8691 times.
|
8691 | const QPointer<QuickHtmlReader> guard(this); |
| 84 | 8691 | ++m_lease; | |
| 85 |
1/2✗ Branch 4 → 5 not taken.
✓ Branch 4 → 7 taken 8691 times.
|
8691 | if (m_activeEnvelopeCancelled) m_activeEnvelopeCancelled->store(true); |
| 86 | 8691 | m_pendingEnvelope.reset(); | |
| 87 |
2/2✓ Branch 8 → 9 taken 8691 times.
✓ Branch 9 → 10 taken 8691 times.
|
8691 | HtmlInlineImageService::shared()->cancel(this); |
| 88 |
1/3✗ Branch 11 → 12 not taken.
✓ Branch 11 → 14 taken 8691 times.
✗ Branch 13 → 14 not taken.
|
8691 | if (m_sanitizer) m_sanitizer->cancel(this); |
| 89 |
1/1✓ Branch 14 → 15 taken 8691 times.
|
8691 | m_timeout.stop(); |
| 90 |
2/2✓ Branch 16 → 17 taken 8691 times.
✓ Branch 17 → 18 taken 8691 times.
|
8691 | m_interceptor->setDocumentPolicy({}, true, {}); |
| 91 |
1/1✓ Branch 20 → 21 taken 8691 times.
|
8691 | m_delivery->clear(); |
| 92 | 8691 | m_ready = m_blocked = false; | |
| 93 | 8691 | m_restoringScroll = false; | |
| 94 | 8691 | m_leaseUidValidity = 0; | |
| 95 |
1/1✓ Branch 21 → 22 taken 8691 times.
|
8691 | const bool hadUrl = !m_url.isEmpty(); |
| 96 |
1/1✓ Branch 22 → 23 taken 8691 times.
|
8691 | m_url = QUrl(); |
| 97 |
1/1✓ Branch 25 → 26 taken 8691 times.
|
8691 | emit changed(); |
| 98 |
3/7✓ Branch 27 → 28 taken 8691 times.
✗ Branch 27 → 30 not taken.
✗ Branch 28 → 29 not taken.
✓ Branch 28 → 30 taken 8691 times.
✗ Branch 31 → 32 not taken.
✓ Branch 31 → 33 taken 8691 times.
✗ Branch 32 → 33 not taken.
|
8691 | if (guard && hadUrl) emit documentUrlChanged(); |
| 99 | 8691 | } | |
| 100 | 6839 | void QuickHtmlReader::synchronize() { | |
| 101 |
2/2✓ Branch 3 → 4 taken 258 times.
✓ Branch 3 → 5 taken 6581 times.
|
6839 | if (m_reader.renderRevision() == m_revision) return; |
| 102 | 6581 | m_revision = m_reader.renderRevision(); | |
| 103 | 6581 | m_allowExternal = false; | |
| 104 |
1/2✓ Branch 7 → 8 taken 6581 times.
✗ Branch 7 → 19 not taken.
|
6581 | if (!m_reader.htmlRequested()) { |
| 105 |
1/1✓ Branch 8 → 9 taken 6581 times.
|
6581 | const QPointer<QuickHtmlReader> guard(this); |
| 106 |
1/1✓ Branch 9 → 10 taken 6581 times.
|
6581 | invalidate(); |
| 107 |
3/7✓ Branch 11 → 12 taken 6581 times.
✗ Branch 11 → 14 not taken.
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 6581 times.
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 6581 times.
✗ Branch 16 → 17 not taken.
|
6581 | if (guard && m_resident) m_retention.start(); |
| 108 | 6581 | return; | |
| 109 | 6581 | } | |
| 110 | ✗ | start(); | |
| 111 | } | ||
| 112 | ✗ | bool QuickHtmlReader::current(quint64 lease) const { | |
| 113 | ✗ | const auto id = WorkspaceState::decode(m_reader.identity()); | |
| 114 | ✗ | return lease == m_lease && m_revision == m_reader.renderRevision() && m_reader.htmlRequested() && | |
| 115 | ✗ | m_tabId == m_workspace.activeTabId() && id == m_workspace.mailId() && | |
| 116 | ✗ | m_accountGeneration == m_session.accounts()->generation() && id.isValid() && | |
| 117 | ✗ | m_leaseUidValidity == m_reader.body().cacheUidValidity; | |
| 118 | ✗ | } | |
| 119 | ✗ | void QuickHtmlReader::start() { | |
| 120 | ✗ | const QPointer<QuickHtmlReader> guard(this); | |
| 121 | ✗ | invalidate(); | |
| 122 | ✗ | if (!guard) return; | |
| 123 | ✗ | qCDebug(lcQuickHtml) << "Starting display lease" << m_lease << "revision" << m_revision; | |
| 124 | ✗ | m_retention.stop(); | |
| 125 | ✗ | if (!m_reader.htmlRequested()) return; | |
| 126 | // Resident full-HTML reading keeps the DOMPurify page warm until retire(). | ||
| 127 | ✗ | m_sanitizer = HtmlSanitizer::shared(); | |
| 128 | ✗ | m_sanitizer->holdWarm(this, true); | |
| 129 | // The first lease check still reads the authoritative cache epoch. Later | ||
| 130 | // callbacks use this snapshot; setUidValidity revokes it synchronously | ||
| 131 | // before any purge or epoch write instead of re-querying SQLite per check. | ||
| 132 | ✗ | const auto id = WorkspaceState::decode(m_reader.identity()); | |
| 133 | ✗ | m_leaseUidValidity = m_session.cache()->uidValidity(id.folderId); | |
| 134 | ✗ | const auto lease = m_lease; | |
| 135 | ✗ | m_tabId = m_workspace.activeTabId(); | |
| 136 | ✗ | m_accountGeneration = m_session.accounts()->generation(); | |
| 137 | ✗ | m_reader.prepareHtml(m_revision); | |
| 138 | ✗ | if (!guard || !current(lease)) return; | |
| 139 | ✗ | const HtmlInlineImageRequest input{m_session.cache()->databasePath(), WorkspaceState::decode(m_reader.identity()), | |
| 140 | ✗ | m_reader.body().cacheUidValidity, m_reader.body().textHtml}; | |
| 141 | ✗ | HtmlInlineImageService::shared()->load(this, input, [this, lease, input](HtmlInlineImageResult result) { | |
| 142 | ✗ | if (!current(lease)) return; | |
| 143 | ✗ | if (!result.error.isEmpty()) { fail(tr("Could not prepare HTML images: %1").arg(result.error)); return; } | |
| 144 | ✗ | const QPointer<QuickHtmlReader> guard(this); | |
| 145 | ✗ | m_digest = result.contentDigest; | |
| 146 | ✗ | const auto state = m_workspace.bindHtmlDocument(m_tabId, input.mailId, input.uidValidity, m_digest); | |
| 147 | ✗ | if (!guard || !current(lease)) return; | |
| 148 | ✗ | m_allowExternal = state.allowExternal; | |
| 149 | ✗ | m_scrollTarget = state.scroll; | |
| 150 | ✗ | const bool profileReady = ensureProfile(); | |
| 151 | ✗ | if (!guard || !current(lease)) return; | |
| 152 | ✗ | if (!profileReady) { fail(tr("The HTML rendering profile could not be created. Retry.")); return; } | |
| 153 | // P90.7 trace: the inline-image preparation finished right here. | ||
| 154 | { | ||
| 155 | ✗ | const auto traceId = WorkspaceState::decode(m_reader.identity()); | |
| 156 | ✗ | MailJD::Performance::MailOpenPerformanceTracker::instance() | |
| 157 | ✗ | .inlinePreparationFinished(traceId.folderId, traceId.uid); | |
| 158 | ✗ | MailJD::Performance::MailOpenPerformanceTracker::instance() | |
| 159 | ✗ | .sanitizerStarted(traceId.folderId, traceId.uid); | |
| 160 | ✗ | } | |
| 161 | ✗ | m_sanitizer = HtmlSanitizer::shared(); | |
| 162 | ✗ | QByteArray identity; | |
| 163 | ✗ | QDataStream stream(&identity, QIODevice::WriteOnly); | |
| 164 | ✗ | stream << input.databasePath << input.mailId.accountId << input.mailId.folderId << input.mailId.uid << input.uidValidity; | |
| 165 | ✗ | m_sanitizer->sanitize(input.expectedHtml, result.images, [this, lease](const QString &clean) { | |
| 166 | ✗ | if (!current(lease)) return; | |
| 167 | { | ||
| 168 | ✗ | const auto traceId = WorkspaceState::decode(m_reader.identity()); | |
| 169 | ✗ | MailJD::Performance::MailOpenPerformanceTracker::instance() | |
| 170 | ✗ | .sanitizerFinished(traceId.folderId, traceId.uid, true); | |
| 171 | ✗ | } | |
| 172 | ✗ | if (clean.isEmpty()) { fail(tr("The HTML message could not be sanitized. Retry.")); return; } | |
| 173 | ✗ | queueEnvelope(clean, lease); | |
| 174 | }, this, identity); | ||
| 175 | ✗ | }); | |
| 176 | ✗ | } | |
| 177 | ✗ | void QuickHtmlReader::queueEnvelope(QString clean, quint64 lease) { | |
| 178 | ✗ | const auto colors = m_tokens.palette(); | |
| 179 | ✗ | const auto color = [&](const char *key) { | |
| 180 | ✗ | return QColor(colors.value(QString::fromLatin1(key)).toString()).name(); | |
| 181 | ✗ | }; | |
| 182 | ✗ | m_pendingEnvelope = EnvelopeJob{std::move(clean), color("surface"), color("text"), | |
| 183 | color("accent"), lease, | ||
| 184 | ✗ | std::make_shared<std::atomic_bool>(false)}; | |
| 185 | ✗ | dispatchEnvelope(); | |
| 186 | ✗ | } | |
| 187 | ✗ | void QuickHtmlReader::dispatchEnvelope() { | |
| 188 | ✗ | if (m_envelopeBusy || !m_pendingEnvelope) return; | |
| 189 | ✗ | auto job = std::move(*m_pendingEnvelope); | |
| 190 | ✗ | m_pendingEnvelope.reset(); | |
| 191 | ✗ | if (!current(job.lease)) return; | |
| 192 | ✗ | m_envelopeBusy = true; | |
| 193 | ✗ | m_activeEnvelopeCancelled = job.cancelled; | |
| 194 | ✗ | const quint64 lease = job.lease; | |
| 195 | ✗ | auto *watcher = new QFutureWatcher<QByteArray>(this); | |
| 196 | ✗ | connect(watcher, &QFutureWatcher<QByteArray>::finished, this, [this, watcher, lease] { | |
| 197 | ✗ | const QPointer<QuickHtmlReader> guard(this); | |
| 198 | ✗ | const QByteArray html = watcher->result(); | |
| 199 | ✗ | watcher->deleteLater(); | |
| 200 | ✗ | m_envelopeBusy = false; | |
| 201 | ✗ | m_activeEnvelopeCancelled.reset(); | |
| 202 | ✗ | if (current(lease)) { | |
| 203 | ✗ | if (html.isEmpty()) { | |
| 204 | ✗ | fail(tr("The HTML message exceeds the display limit.")); | |
| 205 | } else { | ||
| 206 | ✗ | const auto url = m_delivery->publish(html); | |
| 207 | ✗ | if (url.isEmpty()) { | |
| 208 | ✗ | fail(tr("The HTML message exceeds the display limit.")); | |
| 209 | } else { | ||
| 210 | ✗ | const bool block = !m_allowExternal && ExternalContentPolicy::instance()->shouldBlock(); | |
| 211 | ✗ | m_interceptor->setDocumentPolicy(url, block, m_session.cache()->allowlist().whitelistedDomains()); | |
| 212 | ✗ | if (!guard) return; | |
| 213 | ✗ | if (current(lease)) { | |
| 214 | ✗ | m_url = url; | |
| 215 | ✗ | m_timeout.start(); | |
| 216 | ✗ | emit documentUrlChanged(); | |
| 217 | } | ||
| 218 | } | ||
| 219 | ✗ | } | |
| 220 | } | ||
| 221 | ✗ | if (guard) dispatchEnvelope(); | |
| 222 | ✗ | }); | |
| 223 | ✗ | watcher->setFuture(QtConcurrent::run(&m_envelopePool, [job = std::move(job)]() -> QByteArray { | |
| 224 | ✗ | if (job.cancelled->load()) return {}; | |
| 225 | ✗ | const QString envelope = QStringLiteral("<!DOCTYPE html><html><head><meta charset='utf-8'>" | |
| 226 | "<meta name='referrer' content='no-referrer'>" | ||
| 227 | "<meta http-equiv='Content-Security-Policy' content=\"default-src 'none'; base-uri 'none'; " | ||
| 228 | "form-action 'none'; style-src 'unsafe-inline'; img-src data: https: http:;\">" | ||
| 229 | "<style>html,body{background:%1;color:%2}body{font-family:sans-serif;margin:8px}" | ||
| 230 | "a[href]{cursor:pointer;color:%3}</style></head><body>%4</body></html>") | ||
| 231 | ✗ | .arg(job.surface, job.text, job.accent, job.clean); | |
| 232 | ✗ | if (job.cancelled->load()) return {}; | |
| 233 | ✗ | QByteArray encoded = envelope.toUtf8(); | |
| 234 | ✗ | if (job.cancelled->load() || encoded.size() > HtmlDocumentDelivery::MaxDocumentBytes) | |
| 235 | ✗ | return {}; | |
| 236 | ✗ | return encoded; | |
| 237 | ✗ | })); | |
| 238 | ✗ | } | |
| 239 | 9 | void QuickHtmlReader::refreshPolicy() { | |
| 240 | // Only the running document reacts; stale leases, foreign tabs and | ||
| 241 | // account-generation changes are handled by invalidate()/synchronize(). | ||
| 242 |
3/10✓ Branch 2 → 3 taken 9 times.
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 8 taken 9 times.
✗ Branch 5 → 6 not taken.
✗ Branch 5 → 8 not taken.
✗ Branch 6 → 7 not taken.
✗ Branch 7 → 8 not taken.
✗ Branch 7 → 9 not taken.
✓ Branch 10 → 11 taken 9 times.
✗ Branch 10 → 12 not taken.
|
9 | if (m_url.isEmpty() || !m_reader.htmlRequested() || !current(m_lease)) return; |
| 243 | ✗ | const auto blockedHosts = m_interceptor->blockedDomains(); | |
| 244 | ✗ | const auto domains = m_session.cache()->allowlist().whitelistedDomains(); | |
| 245 | ✗ | const bool block = !m_allowExternal && ExternalContentPolicy::instance()->shouldBlock(); | |
| 246 | ✗ | m_interceptor->setDocumentPolicy(m_url, block, domains); | |
| 247 | ✗ | if (!m_blocked) return; | |
| 248 | // Denied requests stay denied inside the running document; only a fresh | ||
| 249 | // lease re-requests them. It re-binds the same snapshot digest, so per-tab | ||
| 250 | // consent and scroll position survive. A global permission unblocks every | ||
| 251 | // denied host; a whitelist entry must cover one of the blocked hosts. | ||
| 252 | // An entry covers its subdomains like the interceptor does (67.A4): | ||
| 253 | // allowing "example.com" loads a blocked "img.example.com". | ||
| 254 | ✗ | const auto covered = [&domains](const QString &host) { | |
| 255 | ✗ | for (const QString &entry : domains) | |
| 256 | ✗ | if (HostMatch::isHostOrSubdomain(host, entry)) return true; | |
| 257 | ✗ | return false; | |
| 258 | ✗ | }; | |
| 259 | ✗ | bool loadable = !block; | |
| 260 | ✗ | if (block) { | |
| 261 | ✗ | for (const QString &host : blockedHosts) { | |
| 262 | ✗ | if (covered(host)) { loadable = true; break; } | |
| 263 | } | ||
| 264 | } | ||
| 265 | ✗ | if (loadable) start(); | |
| 266 | ✗ | } | |
| 267 | |||
| 268 | ✗ | QStringList QuickHtmlReader::blockedDomains() const { | |
| 269 | ✗ | if (!m_blocked || !m_interceptor) return {}; | |
| 270 | ✗ | QStringList hosts(m_interceptor->blockedDomains().cbegin(), m_interceptor->blockedDomains().cend()); | |
| 271 | ✗ | hosts.sort(); | |
| 272 | ✗ | return hosts; | |
| 273 | ✗ | } | |
| 274 | ✗ | bool QuickHtmlReader::ensureProfile() { | |
| 275 | ✗ | const QPointer<QuickHtmlReader> guard(this); | |
| 276 | ✗ | if (!m_profile) { | |
| 277 | ✗ | if (!m_engine) return false; | |
| 278 | ✗ | QQmlComponent component(m_engine); | |
| 279 | ✗ | component.setData("import QtWebEngine\nWebEngineProfilePrototype {}", QUrl{}); | |
| 280 | ✗ | m_prototype.reset(component.create()); | |
| 281 | ✗ | QQuickWebEngineProfile *profile = nullptr; | |
| 282 | ✗ | if (!m_prototype || !QMetaObject::invokeMethod(m_prototype.get(), "instance", Q_RETURN_ARG(QQuickWebEngineProfile *, profile)) || !profile) | |
| 283 | ✗ | return false; | |
| 284 | ✗ | m_profile = profile; | |
| 285 | ✗ | QQmlEngine::setObjectOwnership(profile, QQmlEngine::CppOwnership); | |
| 286 | ✗ | profile->setOffTheRecord(true); | |
| 287 | ✗ | profile->setHttpCacheType(QQuickWebEngineProfile::NoCache); | |
| 288 | ✗ | profile->setPersistentCookiesPolicy(QQuickWebEngineProfile::NoPersistentCookies); | |
| 289 | ✗ | profile->setUrlRequestInterceptor(m_interceptor); | |
| 290 | ✗ | profile->installUrlSchemeHandler(HtmlDocumentDelivery::Scheme, m_delivery); | |
| 291 | ✗ | connect(profile, &QQuickWebEngineProfile::downloadRequested, this, [](QQuickWebEngineDownloadRequest *request) { request->cancel(); }); | |
| 292 | ✗ | emit profileChanged(); | |
| 293 | ✗ | if (!guard) return false; | |
| 294 | ✗ | } | |
| 295 | ✗ | m_resident = true; | |
| 296 | ✗ | emit changed(); | |
| 297 | ✗ | return true; | |
| 298 | ✗ | } | |
| 299 | ✗ | void QuickHtmlReader::loaded(const QUrl &url, bool ok) { | |
| 300 | ✗ | qCDebug(lcQuickHtml) << "Display load completed" << "success" << ok << "currentUrl" << (url == m_url) | |
| 301 | ✗ | << "currentLease" << current(m_lease) << "ready" << m_ready; | |
| 302 | ✗ | if (!url.isEmpty() && url == m_url) { | |
| 303 | // P90.7 trace: the document load finished for this lease (the probe | ||
| 304 | // classifies the generation itself; a stale load cancels, not fails). | ||
| 305 | ✗ | const auto traceId = WorkspaceState::decode(m_reader.identity()); | |
| 306 | ✗ | MailJD::Performance::MailOpenPerformanceTracker::instance() | |
| 307 | ✗ | .webEngineLoadFinished(traceId.folderId, traceId.uid, ok, current(m_lease)); | |
| 308 | ✗ | } | |
| 309 | ✗ | if (url.isEmpty() || url != m_url || !current(m_lease)) return; | |
| 310 | ✗ | if (ok && m_ready) return; | |
| 311 | ✗ | if (!ok) { fail(tr("The HTML message could not be displayed. Retry.")); return; } | |
| 312 | ✗ | m_timeout.stop(); | |
| 313 | ✗ | m_ready = true; | |
| 314 | ✗ | m_restoringScroll = true; | |
| 315 | ✗ | const QPointer<QuickHtmlReader> guard(this); | |
| 316 | ✗ | m_reader.finishHtml(m_revision, {}); | |
| 317 | ✗ | if (!guard || !isCurrentDocument(url)) return; | |
| 318 | ✗ | emit changed(); | |
| 319 | ✗ | if (guard && isCurrentDocument(url)) emit restoreScrollRequested(url, m_scrollTarget); | |
| 320 | ✗ | } | |
| 321 | ✗ | void QuickHtmlReader::fail(const QString &error) { | |
| 322 | ✗ | const QPointer<QuickHtmlReader> guard(this); | |
| 323 | ✗ | const auto revision = m_revision; | |
| 324 | ✗ | qCWarning(lcQuickHtml) << "Display lease failed:" << error; | |
| 325 | ✗ | invalidate(); | |
| 326 | ✗ | if (guard) m_reader.finishHtml(revision, error); | |
| 327 | ✗ | } | |
| 328 | ✗ | void QuickHtmlReader::crashed() { | |
| 329 | ✗ | if (!m_reader.htmlRequested()) return; | |
| 330 | ✗ | const QPointer<QuickHtmlReader> guard(this); | |
| 331 | ✗ | fail(tr("The HTML renderer stopped. Retry.")); | |
| 332 | ✗ | if (!guard) return; | |
| 333 | ✗ | const auto failedLease = m_lease; | |
| 334 | // Chromium still owns the emitting view's stack while reporting its crash. | ||
| 335 | // Destroy the view/profile only after that callback has completely unwound. | ||
| 336 | ✗ | QTimer::singleShot(0, this, [this, failedLease] { if (m_lease == failedLease) retire(); }); | |
| 337 | ✗ | } | |
| 338 | ✗ | void QuickHtmlReader::allowExternal() { | |
| 339 | ✗ | if (!m_reader.htmlRequested() || !m_blocked) return; | |
| 340 | ✗ | if (!m_workspace.allowHtmlExternal(m_tabId, WorkspaceState::decode(m_reader.identity()), | |
| 341 | ✗ | m_reader.body().cacheUidValidity, m_digest)) return; | |
| 342 | ✗ | m_allowExternal = true; | |
| 343 | ✗ | start(); | |
| 344 | } | ||
| 345 | ✗ | void QuickHtmlReader::openLink(const QUrl &url, bool userInitiated) { | |
| 346 | ✗ | if (!userInitiated || !isCurrentDocument(m_url) || !canOpenLink(url)) return; | |
| 347 | ✗ | emit externalLinkRequested(url); | |
| 348 | } | ||
| 349 | ✗ | bool QuickHtmlReader::isCurrentDocument(const QUrl &document) const { | |
| 350 | ✗ | return m_ready && !document.isEmpty() && document == m_url && current(m_lease); | |
| 351 | } | ||
| 352 | ✗ | bool QuickHtmlReader::canOpenLink(const QUrl &url) const { | |
| 353 | ✗ | return ExternalContentPolicy::canOpenExternalLink(url); | |
| 354 | } | ||
| 355 | ✗ | bool QuickHtmlReader::copyLink(const QUrl &document, const QUrl &url) { | |
| 356 | ✗ | if (!isCurrentDocument(document) || !canOpenLink(url)) return false; | |
| 357 | ✗ | QGuiApplication::clipboard()->setText(url.toString(QUrl::FullyEncoded)); | |
| 358 | ✗ | return true; | |
| 359 | } | ||
| 360 | ✗ | void QuickHtmlReader::rememberScroll(const QUrl &document, const QPointF &position) { | |
| 361 | ✗ | if (m_restoringScroll || !isCurrentDocument(document)) return; | |
| 362 | ✗ | m_workspace.rememberHtmlScroll(m_tabId, WorkspaceState::decode(m_reader.identity()), | |
| 363 | ✗ | m_reader.body().cacheUidValidity, m_digest, position); | |
| 364 | } | ||
| 365 | ✗ | void QuickHtmlReader::scrollRestored(const QUrl &document) { | |
| 366 | ✗ | if (isCurrentDocument(document)) m_restoringScroll = false; | |
| 367 | ✗ | } | |
| 368 | 793 | void QuickHtmlReader::retire() { | |
| 369 |
1/1✓ Branch 2 → 3 taken 793 times.
|
793 | const QPointer<QuickHtmlReader> guard(this); |
| 370 |
1/1✓ Branch 3 → 4 taken 793 times.
|
793 | m_retention.stop(); |
| 371 | 793 | m_resident = false; | |
| 372 |
1/3✗ Branch 5 → 6 not taken.
✓ Branch 5 → 8 taken 793 times.
✗ Branch 7 → 8 not taken.
|
793 | if (m_sanitizer) m_sanitizer->holdWarm(this, false); |
| 373 |
1/1✓ Branch 8 → 9 taken 793 times.
|
793 | emit changed(); |
| 374 |
4/7✓ Branch 2 → 3 taken 737 times.
✗ Branch 2 → 5 not taken.
✓ Branch 3 → 4 taken 737 times.
✗ Branch 3 → 5 not taken.
✓ Branch 10 → 11 taken 793 times.
✗ Branch 10 → 13 not taken.
✓ Branch 11 → 12 taken 793 times.
|
1530 | if (guard) QTimer::singleShot(0, this, [this] { if (!m_resident && !m_liveViews) releaseProfile(); }); |
| 375 | 793 | } | |
| 376 | ✗ | void QuickHtmlReader::viewReleased() { | |
| 377 | ✗ | m_liveViews = qMax(0, m_liveViews - 1); | |
| 378 | // Chromium and QML finish destroying the view before retiring its profile. | ||
| 379 | ✗ | QTimer::singleShot(0, this, [this] { if (!m_resident && !m_liveViews) releaseProfile(); }); | |
| 380 | ✗ | } | |
| 381 | 1532 | void QuickHtmlReader::releaseProfile() { | |
| 382 |
1/2✗ Branch 3 → 4 not taken.
✓ Branch 3 → 8 taken 1532 times.
|
1532 | if (m_profile) { |
| 383 | ✗ | m_profile->setUrlRequestInterceptor(nullptr); | |
| 384 | ✗ | m_profile->removeUrlSchemeHandler(m_delivery); | |
| 385 | } | ||
| 386 | 1532 | m_prototype.reset(); | |
| 387 | 1532 | m_profile = nullptr; | |
| 388 | 1532 | emit profileChanged(); | |
| 389 | 1532 | } | |
| 390 |