diff --git a/docs/adr/0006-browser-download-records.md b/docs/adr/0006-browser-download-records.md index 6ff3c5c4e3..adde9bddba 100644 --- a/docs/adr/0006-browser-download-records.md +++ b/docs/adr/0006-browser-download-records.md @@ -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 diff --git a/storybook/qmlTests/tests/tst_BrowserLocalPreview.qml b/storybook/qmlTests/tests/tst_BrowserLocalPreview.qml new file mode 100644 index 0000000000..ff08d1d2a8 --- /dev/null +++ b/storybook/qmlTests/tests/tst_BrowserLocalPreview.qml @@ -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) + } + } +} diff --git a/ui/StatusQ/include/StatusQ/browserprofileutils.h b/ui/StatusQ/include/StatusQ/browserprofileutils.h index 460149bb1d..efc2075e0a 100644 --- a/ui/StatusQ/include/StatusQ/browserprofileutils.h +++ b/ui/StatusQ/include/StatusQ/browserprofileutils.h @@ -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); diff --git a/ui/StatusQ/src/browserprofileutils.cpp b/ui/StatusQ/src/browserprofileutils.cpp index fe493ad7ab..d47d95b457 100644 --- a/ui/StatusQ/src/browserprofileutils.cpp +++ b/ui/StatusQ/src/browserprofileutils.cpp @@ -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(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(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 diff --git a/ui/app/AppLayouts/Browser/BrowserLayout.qml b/ui/app/AppLayouts/Browser/BrowserLayout.qml index 33ff3eae22..34662f14d9 100644 --- a/ui/app/AppLayouts/Browser/BrowserLayout.qml +++ b/ui/app/AppLayouts/Browser/BrowserLayout.qml @@ -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 } diff --git a/ui/app/AppLayouts/Browser/adapters/AbstractWebView.qml b/ui/app/AppLayouts/Browser/adapters/AbstractWebView.qml index 618ebbfdb6..3121700fa5 100644 --- a/ui/app/AppLayouts/Browser/adapters/AbstractWebView.qml +++ b/ui/app/AppLayouts/Browser/adapters/AbstractWebView.qml @@ -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: "" diff --git a/ui/app/AppLayouts/Browser/adapters/MobileWebViewAdapter.qml b/ui/app/AppLayouts/Browser/adapters/MobileWebViewAdapter.qml index f0a80317c8..fbfc2a0d81 100644 --- a/ui/app/AppLayouts/Browser/adapters/MobileWebViewAdapter.qml +++ b/ui/app/AppLayouts/Browser/adapters/MobileWebViewAdapter.qml @@ -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 diff --git a/ui/app/AppLayouts/Browser/adapters/ProfileManager.qml b/ui/app/AppLayouts/Browser/adapters/ProfileManager.qml index 8cdacd953d..07ff647c6e 100644 --- a/ui/app/AppLayouts/Browser/adapters/ProfileManager.qml +++ b/ui/app/AppLayouts/Browser/adapters/ProfileManager.qml @@ -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)) } + } diff --git a/ui/app/AppLayouts/Browser/adapters/ProfileParams.qml b/ui/app/AppLayouts/Browser/adapters/ProfileParams.qml index 71b0194532..77be24a8ae 100644 --- a/ui/app/AppLayouts/Browser/adapters/ProfileParams.qml +++ b/ui/app/AppLayouts/Browser/adapters/ProfileParams.qml @@ -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 } diff --git a/ui/app/AppLayouts/Browser/adapters/WebViewAdapter.qml b/ui/app/AppLayouts/Browser/adapters/WebViewAdapter.qml index 339abbfa0a..90aa446f33 100644 --- a/ui/app/AppLayouts/Browser/adapters/WebViewAdapter.qml +++ b/ui/app/AppLayouts/Browser/adapters/WebViewAdapter.qml @@ -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. diff --git a/ui/app/AppLayouts/Browser/panels/BrowserTabView.qml b/ui/app/AppLayouts/Browser/panels/BrowserTabView.qml index dc20fd71d2..a07f38501e 100644 --- a/ui/app/AppLayouts/Browser/panels/BrowserTabView.qml +++ b/ui/app/AppLayouts/Browser/panels/BrowserTabView.qml @@ -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) diff --git a/ui/app/AppLayouts/Browser/provider/qml/BrowserConfig.qml b/ui/app/AppLayouts/Browser/provider/qml/BrowserConfig.qml index c4b4ef521f..e3aed1daab 100644 --- a/ui/app/AppLayouts/Browser/provider/qml/BrowserConfig.qml +++ b/ui/app/AppLayouts/Browser/provider/qml/BrowserConfig.qml @@ -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 + } } diff --git a/ui/app/AppLayouts/Browser/webview/BrowserSessionUtils.js b/ui/app/AppLayouts/Browser/webview/BrowserSessionUtils.js index f2fe1c3d26..d2e8b0f947 100644 --- a/ui/app/AppLayouts/Browser/webview/BrowserSessionUtils.js +++ b/ui/app/AppLayouts/Browser/webview/BrowserSessionUtils.js @@ -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) diff --git a/ui/app/AppLayouts/Browser/webview/BrowserWebViewContext.qml b/ui/app/AppLayouts/Browser/webview/BrowserWebViewContext.qml index 3f77c9dd72..838dbe30fd 100644 --- a/ui/app/AppLayouts/Browser/webview/BrowserWebViewContext.qml +++ b/ui/app/AppLayouts/Browser/webview/BrowserWebViewContext.qml @@ -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)