mirror of
https://github.com/status-im/status-app.git
synced 2026-08-27 07:01:14 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user