< 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).
 10050QHash<std::uint64_t, QPointer<QWebEngineView>>& webviewRegistry() {
 10051    static QHash<std::uint64_t, QPointer<QWebEngineView>> registry;
 10052    return registry;
 10053}
 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.
 126057QList<QPair<QByteArray, QByteArray>> parseHeaderList(const QByteArray& blob) {
 126058    QList<QPair<QByteArray, QByteArray>> headers;
 126059    const QList<QByteArray> lines = blob.split('\n');
 752060    for (const QByteArray& raw : lines) {
 752061        QByteArray line = raw;
 752062        if (line.endsWith('\r')) {
 063            line.chop(1);
 064        }
 752065        if (line.trimmed().isEmpty()) {
 126066            continue;
 126067        }
 626068        const int colon = line.indexOf(':');
 626069        if (colon < 0) {
 070            continue;
 071        }
 626072        const QByteArray key = line.left(colon).trimmed();
 626073        if (key.isEmpty()) {
 074            continue;
 075        }
 626076        headers.append({key, line.mid(colon + 1).trimmed()});
 626077    }
 126078    return headers;
 126079}
 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).
 126085QByteArray serializeRequestHeaders(QWebEngineUrlRequestJob* job) {
 126086    QByteArray out;
 126087    bool hasOrigin = false;
 126088    const QMap<QByteArray, QByteArray> headers = job->requestHeaders();
 1002089    for (auto it = headers.constBegin(); it != headers.constEnd(); ++it) {
 876090        if (it.key().compare("Origin", Qt::CaseInsensitive) == 0) {
 091            hasOrigin = true;
 092        }
 876093        out += it.key();
 876094        out += ": ";
 876095        out += it.value();
 876096        out += '\n';
 876097    }
 126098    if (!hasOrigin) {
 126099        const QUrl initiator = job->initiator();
 1260100        if (initiator.isValid() && !initiator.isEmpty()) {
 1240101            out += "Origin: ";
 1240102            out += initiator.toString(QUrl::FullyEncoded).toUtf8();
 1240103            out += '\n';
 1240104        }
 1260105    }
 1260106    return out;
 1260107}
 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
 100116QHash<std::uint64_t, PendingRequest>& pendingRequests() {
 100117    static QHash<std::uint64_t, PendingRequest> requests;
 100118    return requests;
 100119}
 120
 1260121std::uint64_t nextRequestId() {
 1260122    static std::uint64_t next = 0;
 1260123    return ++next;
 1260124}
 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`).
 1300129QObject* dispatchContext() {
 1300130    static QObject* context = nullptr;
 1300131    if (context == nullptr) {
 20132        context = new QObject();
 20133    }
 1300134    return context;
 1300135}
 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,
 1260142                   const QByteArray& rawHeaders, const QByteArray& body) {
 1260143    if (job == nullptr) {
 0144        return;
 0145    }
 1260146    const QList<QPair<QByteArray, QByteArray>> headers = parseHeaderList(rawHeaders);
 147
 1260148    QByteArray contentType;
 1260149    QByteArray location;
 1260150    bool hasExposeHeaders = false;
 1260151    QMultiMap<QByteArray, QByteArray> additional;
 152
 6260153    for (const QPair<QByteArray, QByteArray>& header : headers) {
 6260154        if (header.first.compare("Content-Type", Qt::CaseInsensitive) == 0) {
 1260155            if (contentType.isEmpty()) {
 1260156                contentType = header.second;
 1260157            }
 1260158            continue; // passed to reply() instead
 1260159        }
 5000160        if (header.first.compare("Access-Control-Expose-Headers", Qt::CaseInsensitive) == 0) {
 2500161            hasExposeHeaders = true;
 2500162        } else if (header.first.compare("Location", Qt::CaseInsensitive) == 0) {
 0163            location = header.second;
 0164        }
 5000165        additional.insert(header.first, header.second);
 5000166    }
 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.
 1260171    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.
 1260178    if (!hasExposeHeaders) {
 0179        additional.insert(QByteArrayLiteral("Access-Control-Expose-Headers"),
 0180                          QByteArrayLiteral("Tauri-Response, Content-Type"));
 0181    }
 182
 1260183    auto* device = new QBuffer(job); // parented to the job: freed with it
 1260184    device->setData(body);
 1260185    device->open(QIODevice::ReadOnly);
 186
 1260187    job->setAdditionalResponseHeaders(additional);
 1260188    job->reply(contentType.isEmpty() ? QByteArrayLiteral("application/json") : contentType, device);
 1260189}
 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)
 60195        : 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)
 1260214        : QObject(job), job_(job), kind_(kind), webview_id_(webview_id) {
 1260215        method_ = job->requestMethod();
 1260216        url_ = job->requestUrl();
 1260217        headers_ = serializeRequestHeaders(job);
 1260218    }
 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.
 1260225    void start() {
 1260226        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.
 1260230        if (body == nullptr || method_ != QByteArrayLiteral("POST")) {
 20231            if (body != nullptr) {
 20232                bodyData_ += body->readAll();
 20233            }
 20234            dispatch();
 20235            return;
 20236        }
 237
 1240238        body_ = body;
 1240239        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).
 1240243        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.
 1240254        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.
 1240264        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.
 1240273        if (body_->atEnd() || (body_->bytesAvailable() == 0 && !body_->isSequential())) {
 1240274            dispatch();
 1240275            return;
 1240276        }
 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:
 1260303    void dispatch() {
 1260304        if (dispatched_) {
 0305            return;
 0306        }
 1260307        dispatched_ = true;
 1260308        if (!job_) {
 0309            return;
 0310        }
 311
 1260312        const std::uint64_t request_id = nextRequestId();
 1260313        const QByteArray urlBytes = url_.toString(QUrl::FullyEncoded).toUtf8();
 1260314        const ::QtSchemeResponse response = qt_scheme_request(
 1260315            webview_id_,
 1260316            request_id,
 1260317            kind_,
 1260318            rust::Str(method_.constData(), static_cast<std::size_t>(method_.size())),
 1260319            rust::Str(urlBytes.constData(), static_cast<std::size_t>(urlBytes.size())),
 1260320            rust::Str(headers_.constData(), static_cast<std::size_t>(headers_.size())),
 1260321            rust::Str(bodyData_.constData(), static_cast<std::size_t>(bodyData_.size())));
 322
 1260323        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).
 20327            PendingRequest pending;
 20328            pending.job = job_;
 20329            pending.webview_id = webview_id_;
 20330            pendingRequests().insert(request_id, pending);
 20331            QObject::connect(job_, &QObject::destroyed, dispatchContext(),
 20332                             [request_id]() { pendingRequests().remove(request_id); });
 20333            return;
 20334        }
 1240335        if (response.handled != 1) {
 0336            job_->fail(QWebEngineUrlRequestJob::UrlNotFound);
 0337            return;
 0338        }
 1240339        applyResponse(job_, response.status,
 1240340                      QByteArray(response.headers.data(),
 1240341                                 static_cast<int>(response.headers.size())),
 1240342                      QByteArray(response.body.data(), static_cast<int>(response.body.size())));
 1240343    }
 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
 1260356void TauriSchemeHandler::requestStarted(QWebEngineUrlRequestJob* job) {
 1260357    if (job == nullptr) {
 0358        return;
 0359    }
 1260360    (void)dispatchContext(); // created here, on the GUI thread
 1260361    (new JobContext(job, kind_, webview_id_))->start();
 1260362}
 363
 40364void installOnProfile(QWebEngineProfile* profile, std::uint64_t webview_id) {
 40365    if (profile == nullptr) {
 0366        return;
 0367    }
 40368    if (profile->urlSchemeHandler(QByteArrayLiteral("ipc")) != nullptr) {
 20369        return; // already installed for this profile
 20370    }
 20371    profile->installUrlSchemeHandler(QByteArrayLiteral("ipc"),
 20372                                     new TauriSchemeHandler(SchemeIpc, webview_id, profile));
 20373    profile->installUrlSchemeHandler(QByteArrayLiteral("tauri"),
 20374                                     new TauriSchemeHandler(SchemeTauri, webview_id, profile));
 20375    profile->installUrlSchemeHandler(QByteArrayLiteral("asset"),
 20376                                     new TauriSchemeHandler(SchemeAsset, webview_id, profile));
 20377}
 378
 379} // namespace
 380
 20381void qt_scheme_register_schemes() {
 20382    static bool registered = false;
 20383    if (registered) {
 0384        return;
 0385    }
 20386    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.
 20392    const QByteArray schemes[] = {
 20393        QByteArrayLiteral("ipc"),
 20394        QByteArrayLiteral("tauri"),
 20395        QByteArrayLiteral("asset"),
 20396    };
 60397    for (const QByteArray& name : schemes) {
 60398        QWebEngineUrlScheme scheme(name);
 60399        scheme.setSyntax(QWebEngineUrlScheme::Syntax::Host);
 60400        scheme.setDefaultPort(QWebEngineUrlScheme::PortUnspecified);
 60401        scheme.setFlags(QWebEngineUrlScheme::SecureScheme
 60402                        | QWebEngineUrlScheme::CorsEnabled
 60403                        | QWebEngineUrlScheme::FetchApiAllowed);
 60404        QWebEngineUrlScheme::registerScheme(scheme);
 60405    }
 20406}
 407
 20408void qt_scheme_bind_webview(std::uint64_t webview_id, QWebEngineView* view) {
 20409    webviewRegistry().insert(webview_id, QPointer<QWebEngineView>(view));
 20410}
 411
 40412void qt_scheme_install_handler(std::uint64_t webview_id) {
 40413    const auto it = webviewRegistry().constFind(webview_id);
 40414    QWebEngineView* view = (it != webviewRegistry().constEnd()) ? it.value().data() : nullptr;
 40415    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    }
 40421    QWebEnginePage* page = view->page();
 40422    installOnProfile(page != nullptr ? page->profile() : nullptr, webview_id);
 40423}
 424
 425void qt_scheme_respond(std::uint64_t request_id, std::int32_t status,
 20426                       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).
 20429    const QByteArray rawHeaders(headers.data(), static_cast<int>(headers.size()));
 20430    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.
 20434    QMetaObject::invokeMethod(
 20435        dispatchContext(),
 20436        [request_id, status, rawHeaders, rawBody]() {
 20437            const auto it = pendingRequests().find(request_id);
 20438            if (it == pendingRequests().end()) {
 0439                return; // already answered, or the job was destroyed
 0440            }
 20441            QWebEngineUrlRequestJob* job = it.value().job.data();
 20442            pendingRequests().erase(it);
 20443            if (job == nullptr) {
 0444                return;
 0445            }
 20446            applyResponse(job, status, rawHeaders, rawBody);
 20447        },
 20448        Qt::QueuedConnection);
 20449}

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