< Summary

Information
Class: /home/runner/work/TauriQt/TauriQt/tauri-runtime-qt/src/qt_scheme.cpp
Assembly: Default
File(s): /home/runner/work/TauriQt/TauriQt/tauri-runtime-qt/src/qt_scheme.cpp
Line coverage
71%
Covered lines: 198
Uncovered lines: 79
Coverable lines: 277
Total lines: 449
Line coverage: 71.4%
Branch coverage
42%
Covered branches: 53
Total branches: 124
Branch coverage: 42.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

File(s)

/home/runner/work/TauriQt/TauriQt/tauri-runtime-qt/src/qt_scheme.cpp

#LineLine coverage
 1// SPDX-FileCopyrightText: 2026 Alper Çelik
 2// SPDX-License-Identifier: MIT OR Apache-2.0
 3//
 4// Custom URL schemes (`ipc://`, `tauri://`, `asset://`) for the Qt runtime.
 5//
 6// The runtime implements no protocol logic of its own: every request is
 7// serialized into the `\n`-separated `k: v` form Tauri expects and handed to the
 8// Rust callback `qt_scheme_request` (declared by the cxx bridge), whose
 9// `QtSchemeResponse` is then written straight back onto the Qt job.
 10
 11#include "qt_scheme.hpp"
 12
 13#include <cstdio>
 14
 15
 16// The cxx bridge declares the Rust callback `qt_scheme_request` and the
 17// `::QtSchemeResponse` struct (with the correct linkage). Never hand-declare
 18// them here; include the generated header instead.
 19#include "tauri-runtime-qt/src/qt.rs.h"
 20
 21#include <cstdio>
 22
 23#include <QtCore/QBuffer>
 24#include <QtCore/QByteArray>
 25#include <QtCore/QDebug>
 26#include <QtCore/QHash>
 27#include <QtCore/QIODevice>
 28#include <QtCore/QList>
 29#include <QtCore/QMultiMap>
 30#include <QtCore/QPair>
 31#include <QtCore/QPointer>
 32#include <QtCore/QUrl>
 33#include <QtWebEngineCore/QWebEnginePage>
 34#include <QtWebEngineCore/QWebEngineProfile>
 35#include <QtWebEngineCore/QWebEngineUrlRequestJob>
 36#include <QtWebEngineCore/QWebEngineUrlScheme>
 37#include <QtWebEngineCore/QWebEngineUrlSchemeHandler>
 38#include <QtWebEngineWidgets/QWebEngineView>
 39
 40namespace {
 41
 42// Scheme kinds shared with the Rust side: 0 = ipc, 1 = tauri, 2 = asset.
 43enum SchemeKind : std::int32_t {
 44    SchemeIpc = 0,
 45    SchemeTauri = 1,
 46    SchemeAsset = 2,
 47};
 48
 49// webview id -> backing view (weak).
 10550QHash<std::uint64_t, QPointer<QWebEngineView>>& webviewRegistry() {
 10551    static QHash<std::uint64_t, QPointer<QWebEngineView>> registry;
 10552    return registry;
 10553}
 54
 55// Parses a `\n`-separated `k: v` header list. Splits on the first `:` and
 56// strips surrounding whitespace and any trailing CR from each value.
 132357QList<QPair<QByteArray, QByteArray>> parseHeaderList(const QByteArray& blob) {
 132358    QList<QPair<QByteArray, QByteArray>> headers;
 132359    const QList<QByteArray> lines = blob.split('\n');
 789660    for (const QByteArray& raw : lines) {
 789661        QByteArray line = raw;
 789662        if (line.endsWith('\r')) {
 063            line.chop(1);
 064        }
 789665        if (line.trimmed().isEmpty()) {
 132366            continue;
 132367        }
 657368        const int colon = line.indexOf(':');
 657369        if (colon < 0) {
 070            continue;
 071        }
 657372        const QByteArray key = line.left(colon).trimmed();
 657373        if (key.isEmpty()) {
 074            continue;
 075        }
 657376        headers.append({key, line.mid(colon + 1).trimmed()});
 657377    }
 132378    return headers;
 132379}
 80
 81// Serializes the job's request headers into the `\n`-separated `k: v` form Tauri
 82// expects. Tauri's ipc parser hard-requires an `Origin` header, which
 83// `requestHeaders()` may omit, so it is synthesized from the job's initiator
 84// (the serialized origin of the content that started the request).
 132385QByteArray serializeRequestHeaders(QWebEngineUrlRequestJob* job) {
 132386    QByteArray out;
 132387    bool hasOrigin = false;
 132388    const QMap<QByteArray, QByteArray> headers = job->requestHeaders();
 1052189    for (auto it = headers.constBegin(); it != headers.constEnd(); ++it) {
 919890        if (it.key().compare("Origin", Qt::CaseInsensitive) == 0) {
 091            hasOrigin = true;
 092        }
 919893        out += it.key();
 919894        out += ": ";
 919895        out += it.value();
 919896        out += '\n';
 919897    }
 132398    if (!hasOrigin) {
 132399        const QUrl initiator = job->initiator();
 1323100        if (initiator.isValid() && !initiator.isEmpty()) {
 1302101            out += "Origin: ";
 1302102            out += initiator.toString(QUrl::FullyEncoded).toUtf8();
 1302103            out += '\n';
 1302104        }
 1323105    }
 1323106    return out;
 1323107}
 108
 109// A request whose response Tauri will deliver asynchronously (a later call to
 110// `qt_scheme_respond`). Keyed by the request id passed to `qt_scheme_request`.
 111struct PendingRequest {
 112    QPointer<QWebEngineUrlRequestJob> job;
 113    std::uint64_t webview_id = 0;
 114};
 115
 105116QHash<std::uint64_t, PendingRequest>& pendingRequests() {
 105117    static QHash<std::uint64_t, PendingRequest> requests;
 105118    return requests;
 105119}
 120
 1323121std::uint64_t nextRequestId() {
 1323122    static std::uint64_t next = 0;
 1323123    return ++next;
 1323124}
 125
 126// Process-wide QObject on the GUI thread used as the receiver context for
 127// marshalling async responses. Created on the GUI thread (first call is from
 128// `requestStarted`).
 1365129QObject* dispatchContext() {
 1365130    static QObject* context = nullptr;
 1365131    if (context == nullptr) {
 21132        context = new QObject();
 21133    }
 1365134    return context;
 1365135}
 136
 137// Applies a complete response to a job: every header through
 138// `setAdditionalResponseHeaders`, `Content-Type` (or `application/json`) as the
 139// reply mime, and the body through a `QBuffer` parented to the job so the web
 140// engine's cross-thread reads stay valid and nothing leaks.
 141void applyResponse(QWebEngineUrlRequestJob* job, std::int32_t status,
 1323142                   const QByteArray& rawHeaders, const QByteArray& body) {
 1323143    if (job == nullptr) {
 0144        return;
 0145    }
 1323146    const QList<QPair<QByteArray, QByteArray>> headers = parseHeaderList(rawHeaders);
 147
 1323148    QByteArray contentType;
 1323149    QByteArray location;
 1323150    bool hasExposeHeaders = false;
 1323151    QMultiMap<QByteArray, QByteArray> additional;
 152
 6573153    for (const QPair<QByteArray, QByteArray>& header : headers) {
 6573154        if (header.first.compare("Content-Type", Qt::CaseInsensitive) == 0) {
 1323155            if (contentType.isEmpty()) {
 1323156                contentType = header.second;
 1323157            }
 1323158            continue; // passed to reply() instead
 1323159        }
 5250160        if (header.first.compare("Access-Control-Expose-Headers", Qt::CaseInsensitive) == 0) {
 2625161            hasExposeHeaders = true;
 2625162        } else if (header.first.compare("Location", Qt::CaseInsensitive) == 0) {
 0163            location = header.second;
 0164        }
 5250165        additional.insert(header.first, header.second);
 5250166    }
 167
 168    // Qt's job API has no HTTP status setter; a redirect is the only
 169    // status-driven behaviour we can honour. Everything else (including Tauri's
 170    // 500 error responses) is delivered as a body + headers.
 1323171    if (status >= 300 && status < 400 && !location.isEmpty()) {
 0172        job->redirect(QUrl(QString::fromUtf8(location)));
 0173        return;
 0174    }
 175
 176    // Tauri's JS reads `Tauri-Response` (and keys off `Content-Type`); both must
 177    // be exposed across the CORS boundary.
 1323178    if (!hasExposeHeaders) {
 0179        additional.insert(QByteArrayLiteral("Access-Control-Expose-Headers"),
 0180                          QByteArrayLiteral("Tauri-Response, Content-Type"));
 0181    }
 182
 1323183    auto* device = new QBuffer(job); // parented to the job: freed with it
 1323184    device->setData(body);
 1323185    device->open(QIODevice::ReadOnly);
 186
 1323187    job->setAdditionalResponseHeaders(additional);
 1323188    job->reply(contentType.isEmpty() ? QByteArrayLiteral("application/json") : contentType, device);
 1323189}
 190
 191// One `QWebEngineUrlSchemeHandler` per scheme; forwards every request to Rust.
 192class TauriSchemeHandler final : public QWebEngineUrlSchemeHandler {
 193public:
 194    TauriSchemeHandler(std::int32_t kind, std::uint64_t webview_id, QObject* parent = nullptr)
 63195        : QWebEngineUrlSchemeHandler(parent), kind_(kind), webview_id_(webview_id) {}
 196
 197    void requestStarted(QWebEngineUrlRequestJob* job) override;
 198
 199private:
 200    std::int32_t kind_;
 201    std::uint64_t webview_id_;
 202};
 203
 204// Owns the per-request state and drives the reply.
 205//
 206// Created on the GUI thread from `requestStarted` and parented to the job, so
 207// `job->destroyed` tears it down and any still-queued work becomes a no-op. The
 208// reply `QIODevice` is likewise parented to the job (see `applyResponse`): the
 209// web engine may read the device on another thread until the job is deleted, and
 210// a job child is exactly as long-lived as the job.
 211class JobContext final : public QObject {
 212public:
 213    JobContext(QWebEngineUrlRequestJob* job, std::int32_t kind, std::uint64_t webview_id)
 1323214        : QObject(job), job_(job), kind_(kind), webview_id_(webview_id) {
 1323215        method_ = job->requestMethod();
 1323216        url_ = job->requestUrl();
 1323217        headers_ = serializeRequestHeaders(job);
 1323218    }
 219
 220    // Collects the full request body before dispatching. `job->requestBody()`
 221    // is a sequential device whose data may arrive after `requestStarted`, so
 222    // the body is accumulated on `readyRead` and the dispatch is driven by
 223    // `readChannelFinished` (the reliable "body complete" signal), never with a
 224    // partial body.
 1323225    void start() {
 1323226        QIODevice* body = job_ ? job_->requestBody() : nullptr;
 227        // Only POST carries a body Tauri reads; every other method (the
 228        // `tauri://`/`asset://` GET loads, OPTIONS preflights) can be dispatched
 229        // right away.
 1323230        if (body == nullptr || method_ != QByteArrayLiteral("POST")) {
 21231            if (body != nullptr) {
 21232                bodyData_ += body->readAll();
 21233            }
 21234            dispatch();
 21235            return;
 21236        }
 237
 1302238        body_ = body;
 1302239        bodyData_ += body_->readAll();
 240
 241        // Dump whatever arrived into the accumulator; never dispatch a partial
 242        // body here (only the end-of-stream cases below may).
 1302243        QObject::connect(body_, &QIODevice::readyRead, this, [this]() {
 0244            if (dispatched_ || body_ == nullptr) {
 0245                return;
 0246            }
 0247            bodyData_ += body_->readAll();
 0248            if (body_->atEnd() || (body_->bytesAvailable() == 0 && !body_->isSequential())) {
 0249                dispatch();
 0250            }
 0251        });
 252        // Qt's "input stream closed" signal: for the custom-scheme POST body
 253        // device this fires once the whole body is available.
 1302254        QObject::connect(body_, &QIODevice::readChannelFinished, this, [this]() {
 0255            if (dispatched_ || body_ == nullptr) {
 0256                return;
 0257            }
 0258            bodyData_ += body_->readAll();
 0259            dispatch();
 0260        });
 261
 262        // Already fully buffered when we looked (Chromium usually delivers
 263        // custom-scheme POST bodies this way): dispatch right away.
 1302264        if (!body_->isSequential() && (body_->atEnd() || body_->bytesAvailable() == 0)) {
 0265            dispatch();
 0266            return;
 0267        }
 268
 269        // QtWebEngine 6.11 hands over an already-ended, empty device for
 270        // custom-scheme POSTs (the invoke payload travels in the
 271        // `X-Tauriqt-Payload` header instead), so an empty device dispatches at
 272        // once; non-empty devices are accumulated to completion.
 1302273        if (body_->atEnd() || (body_->bytesAvailable() == 0 && !body_->isSequential())) {
 1302274            dispatch();
 1302275            return;
 1302276        }
 277
 0278        QObject::connect(body_, &QIODevice::readyRead, this, [this]() {
 0279            if (dispatched_ || body_ == nullptr) {
 0280                return;
 0281            }
 0282            bodyData_ += body_->readAll();
 0283            if (body_->atEnd() || (body_->bytesAvailable() == 0 && !body_->isSequential())) {
 0284                dispatch();
 0285            }
 0286        });
 0287        QObject::connect(body_, &QIODevice::readChannelFinished, this, [this]() {
 0288            if (dispatched_ || body_ == nullptr) {
 0289                return;
 0290            }
 0291            bodyData_ += body_->readAll();
 0292            dispatch();
 0293        });
 294        // Safety net: never leave a request unanswered.
 0295        QMetaObject::invokeMethod(this, [this]() {
 0296            if (!dispatched_ && bodyData_.isEmpty()) {
 0297                dispatch();
 0298            }
 0299        }, Qt::QueuedConnection);
 0300    }
 301
 302private:
 1323303    void dispatch() {
 1323304        if (dispatched_) {
 0305            return;
 0306        }
 1323307        dispatched_ = true;
 1323308        if (!job_) {
 0309            return;
 0310        }
 311
 1323312        const std::uint64_t request_id = nextRequestId();
 1323313        const QByteArray urlBytes = url_.toString(QUrl::FullyEncoded).toUtf8();
 1323314        const ::QtSchemeResponse response = qt_scheme_request(
 1323315            webview_id_,
 1323316            request_id,
 1323317            kind_,
 1323318            rust::Str(method_.constData(), static_cast<std::size_t>(method_.size())),
 1323319            rust::Str(urlBytes.constData(), static_cast<std::size_t>(urlBytes.size())),
 1323320            rust::Str(headers_.constData(), static_cast<std::size_t>(headers_.size())),
 1323321            rust::Str(bodyData_.constData(), static_cast<std::size_t>(bodyData_.size())));
 322
 1323323        if (response.handled == 2) {
 324            // Tauri answered asynchronously: keep the job alive and wait for a
 325            // later `qt_scheme_respond(request_id, ...)`. Drop the entry if the
 326            // job is destroyed first (aborted request).
 21327            PendingRequest pending;
 21328            pending.job = job_;
 21329            pending.webview_id = webview_id_;
 21330            pendingRequests().insert(request_id, pending);
 21331            QObject::connect(job_, &QObject::destroyed, dispatchContext(),
 21332                             [request_id]() { pendingRequests().remove(request_id); });
 21333            return;
 21334        }
 1302335        if (response.handled != 1) {
 0336            job_->fail(QWebEngineUrlRequestJob::UrlNotFound);
 0337            return;
 0338        }
 1302339        applyResponse(job_, response.status,
 1302340                      QByteArray(response.headers.data(),
 1302341                                 static_cast<int>(response.headers.size())),
 1302342                      QByteArray(response.body.data(), static_cast<int>(response.body.size())));
 1302343    }
 344
 345    QPointer<QWebEngineUrlRequestJob> job_;
 346    std::int32_t kind_;
 347    std::uint64_t webview_id_;
 348    QByteArray method_;
 349    QUrl url_;
 350    QByteArray headers_;
 351    QByteArray bodyData_;
 352    QIODevice* body_ = nullptr; // owned by the job
 353    bool dispatched_ = false;
 354};
 355
 1323356void TauriSchemeHandler::requestStarted(QWebEngineUrlRequestJob* job) {
 1323357    if (job == nullptr) {
 0358        return;
 0359    }
 1323360    (void)dispatchContext(); // created here, on the GUI thread
 1323361    (new JobContext(job, kind_, webview_id_))->start();
 1323362}
 363
 42364void installOnProfile(QWebEngineProfile* profile, std::uint64_t webview_id) {
 42365    if (profile == nullptr) {
 0366        return;
 0367    }
 42368    if (profile->urlSchemeHandler(QByteArrayLiteral("ipc")) != nullptr) {
 21369        return; // already installed for this profile
 21370    }
 21371    profile->installUrlSchemeHandler(QByteArrayLiteral("ipc"),
 21372                                     new TauriSchemeHandler(SchemeIpc, webview_id, profile));
 21373    profile->installUrlSchemeHandler(QByteArrayLiteral("tauri"),
 21374                                     new TauriSchemeHandler(SchemeTauri, webview_id, profile));
 21375    profile->installUrlSchemeHandler(QByteArrayLiteral("asset"),
 21376                                     new TauriSchemeHandler(SchemeAsset, webview_id, profile));
 21377}
 378
 379} // namespace
 380
 21381void qt_scheme_register_schemes() {
 21382    static bool registered = false;
 21383    if (registered) {
 0384        return;
 0385    }
 21386    registered = true;
 387
 388    // Tauri's URLs are `{scheme}://localhost/{path}`: host present, no port, so
 389    // Syntax::Host with PortUnspecified is the exact fit. SecureScheme makes the
 390    // origin trustworthy, CorsEnabled enables cross-origin loads from the page,
 391    // and FetchApiAllowed is required for `fetch()`/XHR with a body.
 21392    const QByteArray schemes[] = {
 21393        QByteArrayLiteral("ipc"),
 21394        QByteArrayLiteral("tauri"),
 21395        QByteArrayLiteral("asset"),
 21396    };
 63397    for (const QByteArray& name : schemes) {
 63398        QWebEngineUrlScheme scheme(name);
 63399        scheme.setSyntax(QWebEngineUrlScheme::Syntax::Host);
 63400        scheme.setDefaultPort(QWebEngineUrlScheme::PortUnspecified);
 63401        scheme.setFlags(QWebEngineUrlScheme::SecureScheme
 63402                        | QWebEngineUrlScheme::CorsEnabled
 63403                        | QWebEngineUrlScheme::FetchApiAllowed);
 63404        QWebEngineUrlScheme::registerScheme(scheme);
 63405    }
 21406}
 407
 21408void qt_scheme_bind_webview(std::uint64_t webview_id, QWebEngineView* view) {
 21409    webviewRegistry().insert(webview_id, QPointer<QWebEngineView>(view));
 21410}
 411
 42412void qt_scheme_install_handler(std::uint64_t webview_id) {
 42413    const auto it = webviewRegistry().constFind(webview_id);
 42414    QWebEngineView* view = (it != webviewRegistry().constEnd()) ? it.value().data() : nullptr;
 42415    if (view == nullptr) {
 0416        qWarning("tauri-runtime-qt: qt_scheme_install_handler(%llu): webview not bound; "
 0417                 "call qt_scheme_bind_webview() after creating the view",
 0418                 static_cast<unsigned long long>(webview_id));
 0419        return;
 0420    }
 42421    QWebEnginePage* page = view->page();
 42422    installOnProfile(page != nullptr ? page->profile() : nullptr, webview_id);
 42423}
 424
 425void qt_scheme_respond(std::uint64_t request_id, std::int32_t status,
 21426                       rust::Str headers, rust::Str body) {
 427    // Copy out of the Rust-borrowed strings before marshalling (the borrowed
 428    // memory is not valid once this call returns).
 21429    const QByteArray rawHeaders(headers.data(), static_cast<int>(headers.size()));
 21430    const QByteArray rawBody(body.data(), static_cast<int>(body.size()));
 431
 432    // The responder may fire on any thread; always deliver on the GUI thread,
 433    // where the job and its reply device live.
 21434    QMetaObject::invokeMethod(
 21435        dispatchContext(),
 21436        [request_id, status, rawHeaders, rawBody]() {
 21437            const auto it = pendingRequests().find(request_id);
 21438            if (it == pendingRequests().end()) {
 0439                return; // already answered, or the job was destroyed
 0440            }
 21441            QWebEngineUrlRequestJob* job = it.value().job.data();
 21442            pendingRequests().erase(it);
 21443            if (job == nullptr) {
 0444                return;
 0445            }
 21446            applyResponse(job, status, rawHeaders, rawBody);
 21447        },
 21448        Qt::QueuedConnection);
 21449}

Methods/Properties

qt_scheme.cpp:_ZN12_GLOBAL__N_115webviewRegistryEv
qt_scheme.cpp:_ZN12_GLOBAL__N_115parseHeaderListERK10QByteArray
qt_scheme.cpp:_ZN12_GLOBAL__N_123serializeRequestHeadersEP23QWebEngineUrlRequestJob
qt_scheme.cpp:_ZN12_GLOBAL__N_115pendingRequestsEv
qt_scheme.cpp:_ZN12_GLOBAL__N_113nextRequestIdEv
qt_scheme.cpp:_ZN12_GLOBAL__N_115dispatchContextEv
qt_scheme.cpp:_ZN12_GLOBAL__N_113applyResponseEP23QWebEngineUrlRequestJobiRK10QByteArrayS4_
qt_scheme.cpp:_ZN12_GLOBAL__N_118TauriSchemeHandlerC2EimP7QObject
qt_scheme.cpp:_ZN12_GLOBAL__N_110JobContextC2EP23QWebEngineUrlRequestJobim
qt_scheme.cpp:_ZN12_GLOBAL__N_110JobContext5startEv
qt_scheme.cpp:_ZZN12_GLOBAL__N_110JobContext5startEvENKUlvE_clEv
qt_scheme.cpp:_ZZN12_GLOBAL__N_110JobContext5startEvENKUlvE0_clEv
qt_scheme.cpp:_ZZN12_GLOBAL__N_110JobContext5startEvENKUlvE1_clEv
qt_scheme.cpp:_ZZN12_GLOBAL__N_110JobContext5startEvENKUlvE2_clEv
qt_scheme.cpp:_ZZN12_GLOBAL__N_110JobContext5startEvENKUlvE3_clEv
qt_scheme.cpp:_ZN12_GLOBAL__N_110JobContext8dispatchEv
qt_scheme.cpp:_ZZN12_GLOBAL__N_110JobContext8dispatchEvENKUlvE_clEv
qt_scheme.cpp:_ZN12_GLOBAL__N_118TauriSchemeHandler14requestStartedEP23QWebEngineUrlRequestJob
qt_scheme.cpp:_ZN12_GLOBAL__N_116installOnProfileEP17QWebEngineProfilem
_Z26qt_scheme_register_schemesv
_Z22qt_scheme_bind_webviewmP14QWebEngineView
_Z25qt_scheme_install_handlerm
_Z17qt_scheme_respondmiN4rust10cxxbridge13StrES1_
qt_scheme.cpp:_ZZ17qt_scheme_respondmiN4rust10cxxbridge13StrES1_ENK3$_0clEv