Files
status-app/ui/app/AppLayouts/Browser/adapters/WebViewAdapter.qml
T
Andrey Bocharnikov 50ef59a117 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
2026-08-13 09:42:17 +04:00

334 lines
14 KiB
QML

import QtQuick
import QtQml
import QtWebEngine
import StatusQ.Core.Theme
import StatusQ.Internal
import AppLayouts.Browser.views
AbstractWebView {
id: root
required property var profileManager
property var profile: root.profileParams
? root.profileManager.getOrCreateStorageProfile(root.profileParams)
: null
// Expose internal WebEngineView properties
property alias url: webView.url
readonly property alias title: webView.title
readonly property alias loading: webView.loading
readonly property alias canGoBack: webView.canGoBack
readonly property alias canGoForward: webView.canGoForward
readonly property alias loadProgress: webView.loadProgress
readonly property alias zoomFactor: webView.zoomFactor
readonly property alias history: webView.history
readonly property alias icon: webView.icon
readonly property alias htmlPageLoaded: webView.htmlPageLoaded
readonly property alias scrollPosition: webView.scrollPosition
// Capability flags for WebEngine
supportsZoom: true
supportsDevTools: true
supportsFindInPage: true
supportsIncognito: true
supportsHistory: true
hasNativeFindPanel: false
// Cookies (incl. HttpOnly) via BrowserProfileUtils; DOM storage via site_utils.js.
clearSiteDataSupported: true
// Override functions
function loadUrl(newUrl) { webView.url = newUrl }
// WebEngine reads local files without a per-directory grant; readAccessUrl is moot.
function loadFileUrl(fileUrl, readAccessUrl) { loadUrl(fileUrl) }
function goBack() { webView.goBack() }
function goForward() { webView.goForward() }
function goBackOrForward(offset) { webView.goBackOrForward(offset) }
function reload() { webView.reload() }
function stop() { webView.stop() }
function forceReload() { webView.triggerWebAction(WebEngineView.ReloadAndBypassCache) }
/// Host-side Retry: force Download via BrowserProfileUtils (QWebEnginePage::download).
/// Navigating renderable media (video/audio) would play in the tab instead.
function downloadUrl(url, suggestedFileName, token) {
if (!root.profile) {
console.warn("WebViewAdapter: downloadUrl requires a profile")
return
}
BrowserProfileUtils.downloadUrl(root.profile, webView, url,
suggestedFileName || "", token || "")
}
// Native per-site cookies, then site_utils.js for current-origin DOM + reload.
function clearSiteData() {
if (root.clearing || !root.profile)
return
const site = webView.url
if (!site.toString())
return
root.clearing = true
BrowserProfileUtils.clearSiteData(root.profile, site, function() {
root.clearing = false
// Skip wipe/reload if the user navigated away during clear.
if (String(webView.url) === String(site))
root._wipeDomAndReload(site)
})
}
// Profile-wide cookies + HTTP cache, then current-origin DOM via site_utils.js.
function clearBrowsingData() {
if (root.clearing || !root.profile)
return
const site = webView.url
root.clearing = true
BrowserProfileUtils.clearBrowsingData(root.profile, function() {
root.clearing = false
if (String(webView.url) === String(site))
root._wipeDomAndReload(site)
})
}
function runJavaScript(script, callback) {
if (callback === undefined)
webView.runJavaScript(script)
else
webView.runJavaScript(script, callback)
}
function _wipeDomAndReload(site) {
webView.runJavaScript(
"(function(){ if (window.StatusSiteUtils) { window.StatusSiteUtils.clearSiteDataAndReload(); return true; } return false; })()",
function(ok) {
if (String(webView.url) !== String(site))
return
if (!ok) {
// GET navigate — ReloadAndBypassCache can re-POST and re-Set-Cookie.
if (site && site.toString())
webView.url = site
}
}
)
}
function findText(text, flags) { webView.findText(text, flags) }
function changeZoomFactor(factor) { webView.zoomFactor = factor }
function acceptAsNewWindow(request) { request.openIn(webView) }
function detachView() {
// Detach internal views from scene graph before destroy.
webView.webChannel = null
devToolsView.inspectedView = null
webView.stop()
webView.visible = false
webView.parent = null
devToolsView.visible = false
devToolsView.parent = null
}
function triggerWebAction(action) {
// Map AbstractWebView.WebAction to WebEngineView.WebAction
switch (action) {
case AbstractWebView.WebAction.NoWebAction:
webView.triggerWebAction(WebEngineView.NoWebAction); break
case AbstractWebView.WebAction.Back:
webView.triggerWebAction(WebEngineView.Back); break
case AbstractWebView.WebAction.Forward:
webView.triggerWebAction(WebEngineView.Forward); break
case AbstractWebView.WebAction.Stop:
webView.triggerWebAction(WebEngineView.Stop); break
case AbstractWebView.WebAction.Reload:
webView.triggerWebAction(WebEngineView.Reload); break
case AbstractWebView.WebAction.Cut:
webView.triggerWebAction(WebEngineView.Cut); break
case AbstractWebView.WebAction.Copy:
webView.triggerWebAction(WebEngineView.Copy); break
case AbstractWebView.WebAction.Paste:
webView.triggerWebAction(WebEngineView.Paste); break
case AbstractWebView.WebAction.Undo:
webView.triggerWebAction(WebEngineView.Undo); break
case AbstractWebView.WebAction.RequestClose:
webView.triggerWebAction(WebEngineView.RequestClose); break
case AbstractWebView.WebAction.Redo:
webView.triggerWebAction(WebEngineView.Redo); break
case AbstractWebView.WebAction.SelectAll:
webView.triggerWebAction(WebEngineView.SelectAll); break
case AbstractWebView.WebAction.PasteAndMatchStyle:
webView.triggerWebAction(WebEngineView.PasteAndMatchStyle); break
default:
console.warn("WebViewAdapter: Unknown web action:", action)
}
}
WebEngineView {
id: webView
anchors.left: parent?.left
anchors.right: parent?.right
anchors.top: parent?.top
anchors.bottom: root.devToolsEnabled ? devToolsView.top : parent?.bottom
focus: true
property bool htmlPageLoaded: false
backgroundColor: Theme.palette.background
settings.autoLoadImages: root.localAccountSensitiveSettings.autoLoadImages
settings.javascriptEnabled: root.localAccountSensitiveSettings.javaScriptEnabled
settings.errorPageEnabled: root.localAccountSensitiveSettings.errorPageEnabled
settings.pluginsEnabled: root.localAccountSensitiveSettings.pluginsEnabled
settings.autoLoadIconsForPage: root.localAccountSensitiveSettings.autoLoadIconsForPage
settings.touchIconsEnabled: root.localAccountSensitiveSettings.touchIconsEnabled
settings.webRTCPublicInterfacesOnly: root.localAccountSensitiveSettings.webRTCPublicInterfacesOnly
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
// 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.
profile: root.profile ?? WebEngine.defaultProfile
onQuotaRequested: function(request) {
if (request.requestedSize <= 5 * 1024 * 1024)
request.accept()
else
request.reject()
}
onRegisterProtocolHandlerRequested: function(request) {
console.log("accepting registerProtocolHandler request for "
+ request.scheme + " from " + request.origin)
request.accept()
}
onRenderProcessTerminated: function(terminationStatus, exitCode) {
var status = ""
switch (terminationStatus) {
case WebEngineView.NormalTerminationStatus:
status = "(normal exit)"
break
case WebEngineView.AbnormalTerminationStatus:
status = "(abnormal exit)"
break
case WebEngineView.CrashedTerminationStatus:
status = "(crashed)"
break
case WebEngineView.KilledTerminationStatus:
status = "(killed)"
break
}
console.warn("Render process exited with code " + exitCode + " " + status)
}
onSelectClientCertificate: function(selection) {
selection.certificates[0].select()
}
onLoadingChanged: function(loadRequest) {
if (loadRequest.status === WebEngineView.LoadStartedStatus) {
webView.htmlPageLoaded = false
}
if (loadRequest.status === WebEngineView.LoadSucceededStatus) {
webView.htmlPageLoaded = true
}
}
onLoadProgressChanged: function(progress) {
if (progress >= 10)
webView.htmlPageLoaded = true
}
onJavaScriptConsoleMessage: function(level, message, lineNumber, sourceID) {
const isOurScript = ScriptUtils.isOurInjectedScript(sourceID, root.profile)
if (isOurScript || root.enableJsLogs)
console.log("[WebEngine]", sourceID + ":" + lineNumber, message)
}
onLinkHovered: (hoveredUrl) => root.linkHovered(hoveredUrl)
onWindowCloseRequested: root.windowCloseRequested()
onNewWindowRequested: (request) => {
if (!request.userInitiated) {
console.warn("Warning: Blocked a popup window.")
} else {
const makeCurrent = request.destination !== WebEngineNewWindowRequest.InNewBackgroundTab
root.newWindowRequested(makeCurrent, request.requestedUrl, (tab) => tab.acceptAsNewWindow(request))
}
}
onCertificateError: (error) => root.certificateError(error)
onJavaScriptDialogRequested: (request) => root.javaScriptDialogRequested(request)
onFindTextFinished: (result) => root.findTextFinished(result)
onPermissionRequested: function(permission) {
if (permission.permissionType === WebEnginePermission.PermissionType.ClipboardReadWrite) {
console.log("Clipboard access granted")
permission.grant()
}
}
}
Connections {
target: root.profile
function onDownloadRequested(download) {
// Profile emits for all tabs sharing it; forward only owner view.
if (download?.view && download.view !== webView)
return
// For viewless downloads, only visible adapter forwards to avoid fan-out.
if (!download?.view && !root.visible)
return
// Page-initiated: nothing on the host asked for it, so no token.
root.downloadRequested(download, "")
}
}
// Retry downloads start on a helper Core profile (not this Quick profile).
// Only the initiating view forwards; a viewless re-issue is delivered by
// ProfileManager instead (it needs no live Tab).
Connections {
target: BrowserProfileUtils
function onDownloadRequested(webEngineView, download, token) {
if (webEngineView !== webView)
return
root.downloadRequested(download, token)
}
}
WebEngineView {
id: devToolsView
anchors.left: parent?.left
anchors.right: parent?.right
anchors.bottom: parent?.bottom
height: root.devToolsEnabled ? root.devToolsHeight : 0
visible: root.devToolsEnabled
inspectedView: root.devToolsEnabled ? webView : null
settings.forceDarkMode: Application.styleHints.colorScheme === Qt.ColorScheme.Dark
onWindowCloseRequested: root.devToolsToggled(false)
}
Binding {
// Always apply a non-empty UA. Setting httpUserAgent to "" after a
// custom override does not restore the Chromium default — use the
// snapshot captured at profile creation instead.
when: !!(root.profile && root.profileParams && root.profileManager)
target: root.profile
property: "httpUserAgent"
value: root.profileParams.userAgent || root.profileManager.defaultHttpUserAgent
}
function applyProfileScripts() {
if (!root.profile || !root.profile.userScripts || !root.profileParams)
return
root.profile.userScripts.collection = root.profileManager.scriptListForParams(root.profileParams)
}
// Qt does not emit *Changed for the initial property value — only for later
// changes. Without onCompleted, userScripts (site_utils, dapp injectors) are
// never installed when profile is already set at construction time.
Component.onCompleted: applyProfileScripts()
onProfileChanged: applyProfileScripts()
Connections {
target: root.profileParams
function onScriptsChanged() {
root.applyProfileScripts()
}
}
}