feat(browser): isolate the Tabs that show a downloaded file

A Tab opened to display a downloaded file is no longer a browsing Tab.
`profileParams` gains an orthogonal `localPreview` flag; it selects a
profile of its own — always off the record, never named — with no
injected scripts, no web channel and no connector.

The local-URL policy splits along the same line: browsing profiles reach
no file:// at all (a local path in the address bar dead-ends), and only
the preview profile reaches the downloads and player-page directories.
The default profile, which backs views whose storage profile could not be
created, carries the browsing policy either way.

WebEngine views drop localContentCanAccessRemoteUrls everywhere and grant
localContentCanAccessFileUrls to previews alone, so the player page can
load the media beside it.

Stack-target: PR 21853
This commit is contained in:
Andrey Bocharnikov
2026-08-13 09:42:17 +04:00
parent c9b6b22f0d
commit 50ef59a117
14 changed files with 533 additions and 98 deletions
+15 -2
View File
@@ -14,6 +14,9 @@ Accepted
codecs are a Capability too, so the allowlist is per Backend; needing the
player page is a Capability as well, and Backends without it load the media
file directly)
- **Amended**: 2026-08-11 — §8 (a Tab displaying a downloaded file is isolated
from browsing: its own ephemeral profile, no scripts, no channel, and the only
profile that may reach `file://`; browsing profiles reach none)
- **Amended**: 2026-08-11 — §7 (a re-issue carries a correlation token the
Backend echoes, so it reattaches to the armed Record by identity; the desktop
re-issue is viewless — the profile, not a Tab, owns the Backend — while mobile
@@ -204,8 +207,18 @@ library's Download object is a transient attachment to it.**
**The desktop file action copies a path, and says so.** Where mobile shares the
file itself through the system sheet, desktop has no file to hand anywhere — it
can only name the file's location. The action is therefore "Copy file path" and
yields the plain filesystem path, which the address bar already resolves to a
local file, so the copied text works both in our browser and in a terminal.
yields the plain filesystem path, which a terminal or a file manager resolves.
Our own address bar does not, by the rule below.
**A Tab showing a downloaded file is not a browsing Tab.** It gets local-preview
profile params: an ephemeral profile of its own, no injected scripts, no web
channel, no connector — and it is the only profile allowed to reach `file://`,
under the downloads and player-page directories alone. Browsing profiles reach
no local file at all, so a path typed in the address bar dead-ends. Splitting
the profile is what makes that affordable: with one profile for both, opening a
downloaded file meant leaving a filesystem door open to every site the user
visits, and closing it meant not opening the file. The isolation is one flag on
the params, so a preview Tab's params can never seed a browsing Tab.
The corollary: **state the Backend cannot report must not gate UI.** The seam
can open and close the native find panel but is never told when the user
@@ -0,0 +1,276 @@
import QtQuick
import QtTest
import AppLayouts.Browser.adapters
import AppLayouts.Browser.webview
/**
* Local preview tabs (ADR 0006 §8): a tab opened to display a downloaded file
* is isolated from browsing — its own ephemeral profile, no injected scripts,
* and its params never seed a browsing tab. The WebEngine settings and the
* local-URL policy that complete the isolation live in the Backend
* (WebViewAdapter / browserprofileutils.cpp) and are not reachable from QML.
*/
Item {
id: root
width: 400
height: 400
readonly property url profileManagerUrl: Qt.resolvedUrl(
"../../../ui/app/AppLayouts/Browser/adapters/ProfileManager.qml")
Component {
id: hostStackComponent
Item { width: 1; height: 1 }
}
Component {
id: browsingParamsComponent
ProfileParams {
userId: "user-1"
userAgent: ""
scripts: ["site_utils.js"]
offTheRecord: false
}
}
Component {
id: previewParamsComponent
ProfileParams {
userId: "user-1"
userAgent: ""
scripts: []
offTheRecord: true
localPreview: true
}
}
// Kind-agnostic params; userId / offTheRecord / localPreview per instance.
Component {
id: bareParamsComponent
ProfileParams {
userAgent: ""
scripts: []
}
}
Component {
id: fakeWebViewComponent
Item {
property ProfileParams profileParams: null
property url url: ""
property string uid: ""
property string title: ""
property url icon: ""
property bool offTheRecord: profileParams ? profileParams.offTheRecord
: false
}
}
Component {
id: tabsModelComponent
QtObject {
property int currentIndex: 0
property int count: 1
function createEmptyTab() {}
function removeTab(index) {}
}
}
Component {
id: browserWebViewContextComponent
BrowserWebViewContext {
required property Item hostStack
required property var tabsModelRef
thirdpartyServicesEnabled: true
isDebugEnabled: false
isMobile: true // no WebEngine ProfileManager under test
hasPopups: false
browserSettings: QtObject {}
connectorController: null
dappsEnabled: false
hostStackLayout: hostStack
tabsModel: tabsModelRef
defaultProfileParams: ProfileParams {
userId: "user-1"
userAgent: ""
scripts: ["site_utils.js"]
offTheRecord: false
}
otrProfileParams: ProfileParams {
userId: "user-1"
userAgent: ""
scripts: ["site_utils.js"]
offTheRecord: true
}
bookmarksStore: QtObject {}
downloadsStore: QtObject {}
determineRealURLFn: function(url) { return url }
downloadRequestHandler: function() {}
linkLongPressHandler: function() {}
sslErrorHandler: function() {}
jsDialogHandler: function() {}
findTextFinishedHandler: function() {}
savedSessionContext: QtObject {
function seedWebView() {}
}
}
}
TestCase {
name: "BrowserLocalPreview"
when: windowShown
function createProfileManager() {
const component = Qt.createComponent(root.profileManagerUrl)
verify(component.status === Component.Ready, component.errorString())
return createTemporaryObject(component, root)
}
/// Params for one profile kind: browsing, incognito, or local preview.
function params(userId, offTheRecord, localPreview) {
return createTemporaryObject(bareParamsComponent, root, {
userId: userId,
offTheRecord: offTheRecord,
localPreview: localPreview
})
}
// A preview keeps nothing on disk whatever tab it was opened from.
function test_previewParams_areNeverNamedStorage() {
const preview = createTemporaryObject(previewParamsComponent, root)
compare(preview.storageName, "")
// Even if built without the incognito flag: local preview implies it.
preview.offTheRecord = false
compare(preview.storageName, "")
const browsing = createTemporaryObject(browsingParamsComponent, root)
compare(browsing.storageName, "Profile_user-1")
compare(browsing.localPreview, false, "browsing params default to off")
}
// Which profile a view gets is the observable fact: a preview never
// lands on the profile that backs browsing, incognito or not.
function test_previewProfile_isSeparateFromBrowsing() {
const pm = createProfileManager()
const preview = pm.getProfile(params("user-1", true, true))
const browsing = pm.getProfile(params("user-1", false, false))
const incognito = pm.getProfile(params("user-1", true, false))
verify(!!preview && !!browsing && !!incognito, "each kind gets a profile")
// Equivalent params share one profile — params are a key, not an identity.
compare(pm.getProfile(params("user-1", false, false)), browsing)
verify(preview !== browsing)
verify(preview !== incognito)
verify(browsing !== incognito)
// Incognito does not split the preview profile in two.
compare(pm.getProfile(params("user-1", false, true)), preview)
// …but the user does, like every other profile.
verify(pm.getProfile(params("user-2", true, true)) !== preview)
}
// Nothing of ours runs in a preview: no site_utils, no dapp injectors.
function test_previewParams_getNoUserScripts() {
const pm = createProfileManager()
const browsing = createTemporaryObject(browsingParamsComponent, root)
compare(pm.scriptListForParams(browsing).length, 1)
const preview = createTemporaryObject(previewParamsComponent, root)
compare(pm.scriptListForParams(preview).length, 0)
// The flag decides, not the (empty) script list it was built with.
preview.scripts = ["site_utils.js", "ethereum_injector.js"]
compare(pm.scriptListForParams(preview).length, 0)
}
// A preview's isolated, script-free profile must not back a browsing tab
// opened from it (Ctrl+T, window.open, closing the last tab).
function test_browsingTabs_neverInheritPreviewParams() {
const hostStack = createTemporaryObject(hostStackComponent, root)
const tabsModel = createTemporaryObject(tabsModelComponent, root)
const ctx = createTemporaryObject(browserWebViewContextComponent, root, {
hostStack: hostStack,
tabsModelRef: tabsModel
})
const browsingParams = createTemporaryObject(browsingParamsComponent, root)
const view = createTemporaryObject(fakeWebViewComponent, hostStack,
{ profileParams: browsingParams })
compare(ctx.currentBrowsingProfileParams, browsingParams,
"an ordinary tab passes its own params on")
view.profileParams = createTemporaryObject(previewParamsComponent, root)
compare(ctx.currentBrowsingProfileParams, ctx.defaultProfileParams,
"a preview tab hands over the default profile instead")
}
// Leaving incognito swaps a tab's params; a preview has none to swap to.
function test_incognitoToggle_doesNotReachPreviewTabs() {
const hostStack = createTemporaryObject(hostStackComponent, root)
const tabsModel = createTemporaryObject(tabsModelComponent, root)
const ctx = createTemporaryObject(browserWebViewContextComponent, root, {
hostStack: hostStack,
tabsModelRef: tabsModel
})
const previewParams = createTemporaryObject(previewParamsComponent, root)
const view = createTemporaryObject(fakeWebViewComponent, hostStack,
{ profileParams: previewParams })
ctx.setIncognitoCurrent(false)
compare(view.profileParams, previewParams)
ctx.setIncognitoCurrent(true)
compare(view.profileParams, previewParams)
// An ordinary tab still switches.
view.profileParams = createTemporaryObject(browsingParamsComponent, root)
ctx.setIncognitoCurrent(true)
compare(view.profileParams, ctx.otrProfileParams)
}
// A restored session reopens URLs in browsing tabs, so a preview tab
// must never enter it — the file it showed is no page to come back to,
// and restoring it would put a local path in a browsing profile.
function test_previewTabs_areNeverSaved() {
const determineRealURL = function(url) { return url }
const browsing = createTemporaryObject(fakeWebViewComponent, root, {
profileParams: createTemporaryObject(browsingParamsComponent, root),
url: "https://example.com/page",
uid: "tab-1",
title: "Example"
})
const dto = BrowserSessionUtils.buildTabDto(browsing, [], determineRealURL)
verify(!!dto, "an ordinary tab is saved")
compare(dto.url, "https://example.com/page")
const previewParams = createTemporaryObject(previewParamsComponent, root)
const preview = createTemporaryObject(fakeWebViewComponent, root, {
profileParams: previewParams,
url: "file:///tmp/downloads/report.pdf",
uid: "tab-2",
title: "report.pdf"
})
compare(BrowserSessionUtils.buildTabDto(preview, [], determineRealURL), null)
// …and the flag is what decides, not the incognito mode it implies:
// a filter change there must not silently start persisting previews.
previewParams.offTheRecord = false
compare(preview.offTheRecord, false)
compare(BrowserSessionUtils.buildTabDto(preview, [], determineRealURL), null)
}
}
}
@@ -32,12 +32,19 @@ public:
// are not indexed until they are (re)set in the current session.
Q_INVOKABLE void trackProfile(QObject *profile);
// Install the local-browsing policy on `profile`: file:// navigation is
// blocked except under the downloads location and the generated player-page
// directory (ADR 0006 §8). Call once per profile at creation, before the
// first navigation. Also (re)installs on the default profile, which backs
// views whose storage profile could not be created.
Q_INVOKABLE void installLocalUrlPolicy(QObject *profile);
// Install the browsing URL policy on `profile`: file:// navigation
// (main frame and sub-frames) is blocked outright (ADR 0006 §8). Call once
// per profile at creation, before the first navigation. Also (re)installs on
// the default profile, which backs views whose storage profile could not be
// created.
Q_INVOKABLE void installBrowsingUrlPolicy(QObject *profile);
// Install the local-preview policy on `profile`: file:// navigation is
// allowed under the downloads location and the generated player-page
// directory, and blocked everywhere else. Only for the profile backing tabs
// that display a downloaded file — never for a browsing profile. The default
// profile keeps the browsing policy (see installBrowsingUrlPolicy).
Q_INVOKABLE void installLocalPreviewUrlPolicy(QObject *profile);
// Clears profile-wide browsing data: HTTP cache and all cookies.
// `profile` must be a QML WebEngineProfile (QQuickWebEngineProfile);
@@ -79,8 +86,14 @@ private:
struct TrackedStore;
struct DownloadHelper;
// Stateless policy shared by every profile; owned by this singleton.
QWebEngineUrlRequestInterceptor *m_localUrlPolicy = nullptr;
// Stateless policies, one per profile role; shared and owned by this
// singleton (created on first use).
QWebEngineUrlRequestInterceptor *m_browsingUrlPolicy = nullptr;
QWebEngineUrlRequestInterceptor *m_localPreviewUrlPolicy = nullptr;
QWebEngineUrlRequestInterceptor *browsingUrlPolicy();
QWebEngineUrlRequestInterceptor *localPreviewUrlPolicy();
void installDefaultProfileFallback(QObject *installedOn);
TrackedStore *trackedStoreFor(QObject *profile) const;
DownloadHelper *helperFor(QObject *profile);
+61 -18
View File
@@ -199,24 +199,33 @@ QString canonicalSubDir(const QString &parent, const QString &leaf)
return root.isEmpty() ? QString() : root + QLatin1Char('/') + leaf;
}
// The Backend's local-browsing policy: file:// navigation is blocked except
// inside the two directories the browser itself writes — the platform downloads
// location (Download Targets) and the temp directory holding generated player
// pages (ADR 0006 §8). Owned here rather than injected from QML so both Backends
// keep the policy library-side; mobilewebview has the equivalent guard.
// The Backend's local-browsing policy, split by what the profile is for
// (ADR 0006 §8). A browsing profile reaches no file:// at all — typing a local
// path in the address bar dead-ends. The local-preview profile, which backs
// only the tabs that display a downloaded file, reaches the two directories the
// browser itself writes: the platform downloads location (Download Targets) and
// the temp directory holding generated player pages. Owned here rather than
// injected from QML so both Backends keep the policy library-side; mobilewebview
// has the equivalent guard.
//
// Scope: navigations only (main frame + subframes), matching what the QML
// navigationRequested handler used to cover. Subresources are deliberately left
// alone so a player page can load the media it points at; Chromium already
// forbids web origins from reaching file:// subresources.
//
// Stateless after construction (two const roots computed on the UI thread), so
// it is safe whichever thread WebEngine calls interceptRequest on.
// Stateless after construction (a mode and two const roots computed on the UI
// thread), so it is safe whichever thread WebEngine calls interceptRequest on.
class LocalUrlPolicyInterceptor final : public QWebEngineUrlRequestInterceptor
{
public:
explicit LocalUrlPolicyInterceptor(QObject *parent = nullptr)
enum class Mode {
DenyLocalFiles, // browsing profiles
AllowDownloadedFiles // the local-preview profile
};
explicit LocalUrlPolicyInterceptor(Mode mode, QObject *parent = nullptr)
: QWebEngineUrlRequestInterceptor(parent)
, m_mode(mode)
, m_downloadsDir(canonicalPath(
QStandardPaths::writableLocation(QStandardPaths::DownloadLocation)))
, m_playerDir(canonicalSubDir(
@@ -246,6 +255,8 @@ public:
private:
bool isAllowed(const QString &localFile) const
{
if (m_mode == Mode::DenyLocalFiles)
return false;
const QString path = canonicalPath(localFile);
if (path.isEmpty())
return false;
@@ -258,6 +269,7 @@ private:
return !dir.isEmpty() && path.startsWith(dir + QLatin1Char('/'));
}
const Mode m_mode;
const QString m_downloadsDir;
const QString m_playerDir;
};
@@ -334,24 +346,55 @@ BrowserProfileUtils::~BrowserProfileUtils()
m_downloadHelpers.clear();
}
void BrowserProfileUtils::installLocalUrlPolicy(QObject *profile)
QWebEngineUrlRequestInterceptor *BrowserProfileUtils::browsingUrlPolicy()
{
if (!m_browsingUrlPolicy)
m_browsingUrlPolicy = new LocalUrlPolicyInterceptor(
LocalUrlPolicyInterceptor::Mode::DenyLocalFiles, this);
return m_browsingUrlPolicy;
}
QWebEngineUrlRequestInterceptor *BrowserProfileUtils::localPreviewUrlPolicy()
{
if (!m_localPreviewUrlPolicy)
m_localPreviewUrlPolicy = new LocalUrlPolicyInterceptor(
LocalUrlPolicyInterceptor::Mode::AllowDownloadedFiles, this);
return m_localPreviewUrlPolicy;
}
// A storage profile can fail to instantiate (one live profile per data path),
// leaving the view on the default profile — so it carries a policy too. It backs
// browsing views, hence the browsing one: a preview that lands there shows
// nothing, rather than handing file access to a browsing context.
void BrowserProfileUtils::installDefaultProfileFallback(QObject *installedOn)
{
auto *fallback = QQuickWebEngineProfile::defaultProfile();
if (fallback && fallback != installedOn)
fallback->setUrlRequestInterceptor(browsingUrlPolicy());
}
void BrowserProfileUtils::installBrowsingUrlPolicy(QObject *profile)
{
auto *webProfile = qobject_cast<QQuickWebEngineProfile *>(profile);
if (!webProfile) {
qWarning("BrowserProfileUtils::installLocalUrlPolicy: expected a WebEngineProfile");
qWarning("BrowserProfileUtils::installBrowsingUrlPolicy: expected a WebEngineProfile");
return;
}
if (!m_localUrlPolicy)
m_localUrlPolicy = new LocalUrlPolicyInterceptor(this);
webProfile->setUrlRequestInterceptor(browsingUrlPolicy());
installDefaultProfileFallback(webProfile);
}
webProfile->setUrlRequestInterceptor(m_localUrlPolicy);
void BrowserProfileUtils::installLocalPreviewUrlPolicy(QObject *profile)
{
auto *webProfile = qobject_cast<QQuickWebEngineProfile *>(profile);
if (!webProfile) {
qWarning("BrowserProfileUtils::installLocalPreviewUrlPolicy: expected a WebEngineProfile");
return;
}
// A storage profile can fail to instantiate (one live profile per data path),
// leaving the view on the default profile — it must carry the policy too.
if (auto *fallback = QQuickWebEngineProfile::defaultProfile();
fallback && fallback != webProfile)
fallback->setUrlRequestInterceptor(m_localUrlPolicy);
webProfile->setUrlRequestInterceptor(localPreviewUrlPolicy());
installDefaultProfileFallback(webProfile);
}
BrowserProfileUtils::TrackedStore *BrowserProfileUtils::trackedStoreFor(QObject *profile) const
+22 -7
View File
@@ -63,10 +63,18 @@ StatusSectionLayout {
Qt.callLater(() => _internal.addNewTab(root.browserRootStore.determineRealURL(url), initialTitle, activate))
}
/// Local file in a new tab, read-granted to a directory (ADR 0006 §8).
function openFileUrlInNewTab(fileUrl, readAccessUrl) {
/// A downloaded local file in a Tab of its own (ADR 0006 §8): local-preview
/// params keep it out of the browsing context — its own ephemeral profile,
/// no injected scripts, no web channel, and the only profile that may reach
/// file:// at all. `url` is a file URL, or the generated media player page.
function openLocalPreviewInNewTab(url) {
Qt.callLater(() => _internal.addLocalPreviewTab(url))
}
/// Local preview loaded through loadFileUrl, read-granted to a directory.
function openLocalPreviewFileInNewTab(fileUrl, readAccessUrl) {
Qt.callLater(() => {
const tab = _internal.addNewTab("", "", false)
const tab = _internal.addLocalPreviewTab("")
if (tab)
tab.loadFileUrl(fileUrl, readAccessUrl || "")
})
@@ -181,7 +189,7 @@ StatusSectionLayout {
id: _internal
readonly property Item currentWebView: webViewContext.currentWebView
readonly property bool currentTabIncognito: currentWebView?.offTheRecord ?? false
readonly property bool currentTabIncognito: currentWebView?.incognito ?? false
readonly property bool currentTabLoading: currentWebView?.loading ?? false
property real lastScrollPos: 0
property bool scrolledUp: true
@@ -271,12 +279,17 @@ StatusSectionLayout {
}
function addNewTab(url, initialTitle, activate) {
var tab = webViewContext.createEmptyTab(tabs.count !== 0 ? currentWebView.profileParams : browserConfig.defaultProfileParams, false, true, url, initialTitle);
var tab = webViewContext.createEmptyTab(webViewContext.currentBrowsingProfileParams, false, true, url, initialTitle);
if (activate)
browserToolbarLoader.activateAddressBar()
return tab;
}
function addLocalPreviewTab(url) {
return webViewContext.createEmptyTab(
browserConfig.localPreviewProfileParams, false, true, url, "")
}
function addNewEmptyTab() {
addNewTab("", "", true)
}
@@ -378,8 +391,10 @@ StatusSectionLayout {
_internal.reissueDownload(wantOtr, url, fileName, token)
onDownloadAttributed: (view) => _internal.closeDownloadOnlyTab(view)
hideFindUiFn: () => _internal.hideFindBar()
openUrlFn: (url) => root.openUrlInNewTab(url)
openFileUrlFn: (fileUrl, readAccessUrl) => root.openFileUrlInNewTab(fileUrl, readAccessUrl)
// Both only ever carry local files (a downloaded file, a player page).
openUrlFn: (url) => root.openLocalPreviewInNewTab(url)
openFileUrlFn: (fileUrl, readAccessUrl) =>
root.openLocalPreviewFileInNewTab(fileUrl, readAccessUrl)
supportsPdfFn: () => BrowserBackendCapabilities.pdfViewerSupported
}
@@ -15,6 +15,11 @@ Item {
readonly property bool offTheRecord: profileParams.offTheRecord
// The user-facing privacy mode. A local preview rides an OTR profile for
// containment, not privacy — it must not wear the incognito look.
readonly property bool incognito: profileParams.offTheRecord
&& !profileParams.localPreview
// === State Properties ===
property url url: ""
property string uid: ""
@@ -35,8 +35,11 @@ AbstractWebView {
anchors.fill: parent
visible: root.visible
freeze: root.freeze
userScripts: root.profileParams.scripts
webChannel: root.webChannel
// Nothing of ours runs in a local preview, and nothing of ours is
// reachable from it: a downloaded page could bring its own
// qwebchannel.js (ADR 0006 §8).
userScripts: root.profileParams.localPreview ? [] : root.profileParams.scripts
webChannel: root.profileParams.localPreview ? null : root.webChannel
offTheRecord: root.profileParams.offTheRecord
storageName: root.profileParams.storageName
@@ -5,68 +5,83 @@ import StatusQ.Internal
QtObject {
id: root
property var profiles: ({})
/// A Download re-issued through BrowserProfileUtils that no live Web View
/// initiated (host-side Retry needs no Tab — ADR 0006 §7). View-attributed
/// re-issues are delivered by the owning WebViewAdapter instead.
signal viewlessDownloadRequested(var download, string token)
readonly property Connections _profileUtilsDownloads: Connections {
target: BrowserProfileUtils
function onDownloadRequested(webEngineView, download, token) {
if (webEngineView)
return
root.viewlessDownloadRequested(download, token)
}
}
// Chromium default UA (same for all profiles in a Qt build). Snapshot before
// Binding override — httpUserAgent="" does not restore navigator.userAgent.
property string defaultHttpUserAgent: ""
function _key(userUID, offTheRecord) {
return userUID + "::" + (offTheRecord ? "otr" : "default")
}
readonly property QtObject d: QtObject {
function createScriptFromPath(scriptEntry) {
const path = scriptEntry.path ?? scriptEntry
const runOnSubFrames = scriptEntry.runOnSubFrames ?? true
const pathStr = path.toString()
const name = pathStr.split("/").pop()
return {
name: name,
sourceUrl: path,
injectionPoint: WebEngineScript.DocumentCreation,
worldId: WebEngineScript.MainWorld,
runsOnSubFrames: runOnSubFrames
// Cached WebEngine profiles, keyed by key().
readonly property var profiles: ({})
readonly property Connections profileUtilsDownloads: Connections {
target: BrowserProfileUtils
function onDownloadRequested(webEngineView, download, token) {
if (webEngineView)
return
root.viewlessDownloadRequested(download, token)
}
}
// Local previews get a profile of their own, never shared with browsing:
// separate cache and cookie jar, and the only profile allowed to reach
// file:// (see the two local-URL policies below).
function key(userUID, offTheRecord, localPreview) {
if (localPreview)
return userUID + "::preview"
return userUID + "::" + (offTheRecord ? "otr" : "default")
}
function createScriptFromPath(scriptEntry) {
const path = scriptEntry.path ?? scriptEntry
const runOnSubFrames = scriptEntry.runOnSubFrames ?? true
const pathStr = path.toString()
const name = pathStr.split("/").pop()
return {
name: name,
sourceUrl: path,
injectionPoint: WebEngineScript.DocumentCreation,
worldId: WebEngineScript.MainWorld,
runsOnSubFrames: runOnSubFrames
}
}
function getProfilePrototype(storageName, offTheRecord, key) {
const storageNameProp = storageName
? `storageName: "${storageName.replace(/"/g, '\\"')}"`
: ""
const persistentCookiesPolicy = offTheRecord
? "persistentCookiesPolicy: WebEngineProfile.NoPersistentCookies"
: ""
return Qt.createQmlObject(`
import QtWebEngine
WebEngineProfilePrototype {
${storageNameProp}
${persistentCookiesPolicy}
}
`, root, "ProfilePrototype_" + key)
}
}
function _getProfilePrototype(storageName, offTheRecord, key) {
const storageNameProp = storageName
? `storageName: "${storageName.replace(/"/g, '\\"')}"`
: ""
const persistentCookiesPolicy = offTheRecord
? "persistentCookiesPolicy: WebEngineProfile.NoPersistentCookies"
: ""
return Qt.createQmlObject(`
import QtWebEngine
WebEngineProfilePrototype {
${storageNameProp}
${persistentCookiesPolicy}
}
`, root, "ProfilePrototype_" + key)
}
function getOrCreateStorageProfile(profileParams) {
const key = root._key(profileParams.userId, profileParams.offTheRecord)
let p = root.profiles[key]
const localPreview = !!profileParams.localPreview
const key = d.key(profileParams.userId, profileParams.offTheRecord,
localPreview)
let p = d.profiles[key]
if (!p) {
const prototype = root._getProfilePrototype(
profileParams.storageName,
profileParams.offTheRecord,
// A local preview is always off the record and never named, so it
// keeps no storage of its own whatever tab it was opened from.
const prototype = d.getProfilePrototype(
localPreview ? "" : profileParams.storageName,
localPreview || profileParams.offTheRecord,
key)
p = prototype.instance()
// Qt allows one live profile per data path and returns null on collision,
@@ -81,12 +96,16 @@ QtObject {
// Live cookie index for per-site clear (Qt 6 loadAllCookies is a no-op
// for re-emitting existing cookies — see BrowserProfileUtils).
BrowserProfileUtils.trackProfile(p)
// Backend-owned local-browsing policy (ADR 0006 §8): only the
// downloads and player-page directories are reachable via file://.
BrowserProfileUtils.installLocalUrlPolicy(p)
// Backend-owned local-browsing policy (ADR 0006 §8): browsing
// profiles reach no file:// at all; the local-preview profile
// reaches only the downloads and player-page directories.
if (localPreview)
BrowserProfileUtils.installLocalPreviewUrlPolicy(p)
else
BrowserProfileUtils.installBrowsingUrlPolicy(p)
if (!root.defaultHttpUserAgent)
root.defaultHttpUserAgent = p.httpUserAgent
root.profiles[key] = p
d.profiles[key] = p
}
return p
@@ -97,8 +116,13 @@ QtObject {
}
function scriptListForParams(profileParams) {
// Nothing of ours runs in a local preview — no site_utils, no dapp
// injectors — whatever the params were built with.
if (profileParams.localPreview)
return []
if (!profileParams.scripts || profileParams.scripts.length === 0)
return []
return profileParams.scripts.map(path => createScriptFromPath(path))
return profileParams.scripts.map(path => d.createScriptFromPath(path))
}
}
@@ -8,5 +8,13 @@ QtObject {
required property var scripts
required property bool offTheRecord
readonly property string storageName: offTheRecord ? "" : "Profile_" + userId
/// Tabs that only display a downloaded local file (ADR 0006 §8). Orthogonal
/// to incognito: such a tab is isolated from browsing — a profile of its own
/// that never reaches disk, no injected scripts, no web channel, and file://
/// reachable only under the directories the browser itself wrote.
property bool localPreview: false
// A local preview is ephemeral whatever tab it was opened from.
readonly property string storageName:
(offTheRecord || localPreview) ? "" : "Profile_" + userId
}
@@ -179,8 +179,15 @@ AbstractWebView {
settings.pdfViewerEnabled: true
settings.focusOnNavigationEnabled: true
settings.forceDarkMode: Application.styleHints.colorScheme === Qt.ColorScheme.Dark
// A local page never talks to the network, so nothing it reads off the
// disk can leave the machine (ADR 0006 §8).
settings.localContentCanAccessRemoteUrls: false
// Only a preview reads local files: the generated player page loads the
// media next to it. Browsing tabs get no filesystem reach at all.
settings.localContentCanAccessFileUrls: !!root.profileParams?.localPreview
webChannel: root.webChannel
// A preview shows a downloaded file — the dapp bridge has no business there.
webChannel: root.profileParams?.localPreview ? null : root.webChannel
// Never null: a view with no profile aborts the render path. ProfileManager
// yields null only while a previous Browser still holds the data path, and
// the default profile keeps this view renderable until it is torn down.
@@ -163,7 +163,7 @@ FocusScope {
property bool isStartPage: false
readonly property var webView: root.fnGetWebView(tabButton.TabBar.index)
readonly property bool incognito: webView?.offTheRecord ?? false
readonly property bool incognito: webView?.incognito ?? false
readonly property string tabTitle: SQUtils.StringUtils.escapeHtml(
root.savedSessionContext.displayTitle(webView, isStartPage)
@@ -47,4 +47,14 @@ QtObject {
scripts: root.scriptPaths
offTheRecord: true
}
// Tabs opened to display a downloaded local file. No injected scripts: a
// local page must not meet site_utils or the dapp injectors (ADR 0006 §8).
readonly property ProfileParams localPreviewProfileParams: ProfileParams {
userId: root.userUID
userAgent: root.httpUserAgent
scripts: []
offTheRecord: true
localPreview: true
}
}
@@ -55,6 +55,11 @@ function displayTitle(webView, persistedRecord, labels) {
function buildTabDto(webView, savedTabs, determineRealURL) {
if (!webView || webView.offTheRecord)
return null
// A local preview is not a browsing Tab (ADR 0006 §8): the file it shows is
// no page to restore to. Its params are off the record as well, so this is
// belt and braces — and deliberately so, rather than leaning on that.
if (webView.profileParams && webView.profileParams.localPreview)
return null
const rawUrl = webView.url.toString()
const normalizedUrl = determineRealURL(rawUrl)
@@ -76,6 +76,13 @@ QtObject {
return BrowserWebViewContext.ContentMode.WebContent
}
/// The params a new browsing Tab inherits: the current Tab's, unless it is a
/// local preview — its isolated, script-free profile must never back browsing.
readonly property ProfileParams currentBrowsingProfileParams: {
const params = currentWebView?.profileParams ?? null
return params && !params.localPreview ? params : defaultProfileParams
}
readonly property string currentClientId: currentWebView?.bridge?.clientId
?? ConnectorConstants.clientIdFor(currentWebView ? currentWebView.offTheRecord : false)
@@ -224,6 +231,10 @@ QtObject {
function setIncognitoCurrent(checked) {
if (!currentWebView)
return
// The incognito toggle does not reach a local preview: swapping its
// params would hand the file it shows to a browsing profile.
if (currentWebView.profileParams.localPreview)
return
const target = checked ? otrProfileParams : defaultProfileParams
if (currentWebView.profileParams !== target)
currentWebView.profileParams = target
@@ -260,10 +271,8 @@ QtObject {
return
var view = getWebView(index)
if (tabsModel.count <= 1) {
var fallbackProfileParams = root.currentWebView ? currentWebView.profileParams : root.defaultProfileParams
createEmptyTab(fallbackProfileParams, true)
}
if (tabsModel.count <= 1)
createEmptyTab(root.currentBrowsingProfileParams, true)
tabsModel.removeTab(index)
if (!view)
return
@@ -384,8 +393,12 @@ QtObject {
freeze: root.isMobile && (root.hasPopups || retained)
readonly property ConnectorBridge bridge: ConnectorBridge {
connectorController: root.dappsEnabled ? root.connectorController : null
tabUrl: lazyView.url
// A local preview is outside the dapp world (ADR 0006 §8): no
// connector, no channel reaching the page (see WebViewAdapter),
// and the path of the file it shows is no dapp URL.
connectorController: root.dappsEnabled && !lazyView.profileParams.localPreview
? root.connectorController : null
tabUrl: lazyView.profileParams.localPreview ? "" : lazyView.url
tabIncognito: lazyView.offTheRecord
tabTitle: lazyView.title
tabIconUrl: lazyView.icon
@@ -403,8 +416,8 @@ QtObject {
onWindowCloseRequested: root.removeView(StackLayout.index)
onNewWindowRequested: (makeCurrent, requestedUrl, callback) => {
var profileParams = root.currentWebView ? root.currentWebView.profileParams : root.defaultProfileParams
var tab = root.createEmptyTab(profileParams, false, makeCurrent, requestedUrl)
var tab = root.createEmptyTab(root.currentBrowsingProfileParams,
false, makeCurrent, requestedUrl)
// Born from a page, nothing loaded in it yet: the Tab is download-only
// until it commits a page of its own (ADR 0006 §6).
if (tab)