mirror of
https://github.com/status-im/status-app.git
synced 2026-08-27 07:01:14 +00:00
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
446 lines
16 KiB
QML
446 lines
16 KiB
QML
import QtQuick
|
|
import QtQuick.Controls
|
|
import QtQuick.Layouts
|
|
|
|
import StatusQ.Core.Utils as SQUtils
|
|
|
|
import utils
|
|
|
|
import AppLayouts.Browser.adapters
|
|
import AppLayouts.Browser.provider.qml
|
|
|
|
import "../provider/qml/Utils.js" as BrowserProviderUtils
|
|
|
|
QtObject {
|
|
id: root
|
|
|
|
required property bool thirdpartyServicesEnabled
|
|
required property bool isDebugEnabled
|
|
required property bool isMobile
|
|
required property bool hasPopups
|
|
|
|
required property var browserSettings
|
|
required property var connectorController
|
|
required property bool dappsEnabled
|
|
|
|
required property Item hostStackLayout
|
|
required property var tabsModel
|
|
required property ProfileParams defaultProfileParams
|
|
required property ProfileParams otrProfileParams
|
|
|
|
required property var bookmarksStore
|
|
required property var downloadsStore
|
|
|
|
readonly property var profileManager: _profileManagerLoader.item
|
|
|
|
readonly property Loader _profileManagerLoader: Loader {
|
|
active: !root.isMobile
|
|
source: "../adapters/ProfileManager.qml"
|
|
}
|
|
|
|
readonly property Connections _viewlessDownloads: Connections {
|
|
target: root.profileManager
|
|
ignoreUnknownSignals: true
|
|
function onViewlessDownloadRequested(download, token) {
|
|
root.downloadRequestHandler(download, null, token)
|
|
}
|
|
}
|
|
|
|
required property var determineRealURLFn
|
|
/// (download, hostView, token) — hostView is the LazyWebViewAdapter that raised
|
|
/// the request, null for a host-side re-issue that no Tab initiated. token is
|
|
/// the correlation token echoed by the Backend (empty unless the host re-issued).
|
|
required property var downloadRequestHandler
|
|
/// (linkUrl, imageUrl, position, hostView) — long-press menu (mobile Backends).
|
|
required property var linkLongPressHandler
|
|
required property var sslErrorHandler
|
|
required property var jsDialogHandler
|
|
required property var findTextFinishedHandler
|
|
required property var savedSessionContext
|
|
|
|
enum ContentMode {
|
|
WebContent = 0,
|
|
EmptyContent
|
|
}
|
|
|
|
// Retained Views: host Web Views kept alive (hidden + frozen) after Tab close
|
|
// while they still own non-terminal Downloads. Not part of the Tab set.
|
|
property var _retainedViews: []
|
|
|
|
readonly property Item currentWebView: tabsModel.currentIndex < tabsModel.count ? (getCurrentWebView() ?? null) : null
|
|
readonly property int currentContentMode: {
|
|
if (!currentWebView)
|
|
return BrowserWebViewContext.ContentMode.EmptyContent
|
|
if (!currentWebView.url?.toString())
|
|
return BrowserWebViewContext.ContentMode.EmptyContent
|
|
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)
|
|
|
|
readonly property Connections _currentIndexConnections: Connections {
|
|
target: tabsModel
|
|
function onCurrentIndexChanged() {
|
|
root.ensureCurrentWebViewLoaded()
|
|
}
|
|
}
|
|
|
|
function createEmptyTab(profileParams, createAsStartPage = false, focusOnNewTab = true, url = undefined, initialTitle = undefined, initialIcon = undefined, initialUid = undefined) {
|
|
focusOnNewTab = focusOnNewTab && !createAsStartPage
|
|
|
|
var webview = webViewAdapterComponent.createObject(hostStackLayout, {
|
|
profileParams: profileParams
|
|
})
|
|
if (!webview) {
|
|
console.error("[Browser] Failed to create webview")
|
|
return null
|
|
}
|
|
|
|
webview.uid = (initialUid || "").trim() || SQUtils.Utils.uuid()
|
|
|
|
savedSessionContext.seedWebView(webview, { title: initialTitle, icon: initialIcon })
|
|
|
|
tabsModel.createEmptyTab(createAsStartPage, focusOnNewTab)
|
|
|
|
if (createAsStartPage && thirdpartyServicesEnabled)
|
|
webview.url = Constants.browserDefaultHomepage
|
|
else if (url !== undefined)
|
|
webview.url = url
|
|
else if (!!browserSettings.browserHomepage)
|
|
webview.url = determineRealURLFn(browserSettings.browserHomepage)
|
|
|
|
if ((focusOnNewTab || createAsStartPage) && webview.url.toString() && typeof webview.ensureLoaded === "function")
|
|
webview.ensureLoaded()
|
|
|
|
return webview
|
|
}
|
|
|
|
function getCurrentWebView() { // -> WebEngineView/WebView
|
|
return getWebView(tabsModel.currentIndex)
|
|
}
|
|
|
|
function getWebView(index) { // -> WebEngineView/WebView
|
|
return hostStackLayout.children[index]
|
|
}
|
|
|
|
function ensureCurrentWebViewLoaded() {
|
|
const w = getCurrentWebView()
|
|
if (w && w.url && w.url.toString() && typeof w.ensureLoaded === "function")
|
|
w.ensureLoaded()
|
|
}
|
|
|
|
function setCurrentWebUrl(url) {
|
|
var target = currentWebView
|
|
if (!target) {
|
|
console.error("[Browser] currentWebView is null, cannot set URL")
|
|
return
|
|
}
|
|
|
|
const newUrl = determineRealURLFn(url)
|
|
Qt.callLater(function() {
|
|
target.url = newUrl
|
|
if (newUrl && newUrl.toString() && typeof target.ensureLoaded === "function")
|
|
target.ensureLoaded()
|
|
})
|
|
}
|
|
|
|
function disconnectDapp(dappUrl) {
|
|
const origin = BrowserProviderUtils.normalizeOrigin(dappUrl)
|
|
if (!origin || !connectorController)
|
|
return false
|
|
|
|
return connectorController.disconnect(origin, currentClientId)
|
|
}
|
|
|
|
function changeAccountForCurrentDapp(address) {
|
|
currentWebView?.bridge?.connectorManager.changeAccount(address)
|
|
}
|
|
|
|
function goBackCurrent() {
|
|
if (!currentWebView)
|
|
return
|
|
currentWebView.goBack()
|
|
}
|
|
|
|
function goForwardCurrent() {
|
|
if (!currentWebView)
|
|
return
|
|
currentWebView.goForward()
|
|
}
|
|
|
|
function goBackOrForwardCurrent(offset) {
|
|
if (!currentWebView)
|
|
return
|
|
currentWebView.goBackOrForward(offset)
|
|
}
|
|
|
|
function reloadCurrent() {
|
|
if (!currentWebView)
|
|
return
|
|
if (typeof currentWebView.ensureLoaded === "function")
|
|
currentWebView.ensureLoaded()
|
|
currentWebView.reload()
|
|
}
|
|
|
|
function stopCurrent() {
|
|
if (!currentWebView)
|
|
return
|
|
currentWebView.stop()
|
|
}
|
|
|
|
function forceReloadCurrent() {
|
|
if (!currentWebView)
|
|
return
|
|
if (typeof currentWebView.ensureLoaded === "function")
|
|
currentWebView.ensureLoaded()
|
|
currentWebView.forceReload()
|
|
}
|
|
|
|
function clearSiteDataCurrent() {
|
|
if (!currentWebView)
|
|
return
|
|
currentWebView.clearSiteData()
|
|
}
|
|
|
|
function clearBrowsingDataCurrent() {
|
|
if (!currentWebView)
|
|
return
|
|
currentWebView.clearBrowsingData()
|
|
}
|
|
|
|
function findTextCurrent(text, backward = false) {
|
|
if (!currentWebView)
|
|
return
|
|
|
|
if (backward) {
|
|
currentWebView.findText(text, currentWebView.findBackward)
|
|
return
|
|
}
|
|
|
|
currentWebView.findText(text)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Stop → update setting → deferred reload so profile.httpUserAgent Binding
|
|
// settles before navigation.
|
|
function setCompatibilityMode(checked) {
|
|
for (let i = 0; i < tabsModel.count; ++i)
|
|
getWebView(i)?.stop()
|
|
|
|
browserSettings.compatibilityMode = checked
|
|
|
|
Qt.callLater(() => {
|
|
for (let i = 0; i < tabsModel.count; ++i)
|
|
getWebView(i)?.reload()
|
|
})
|
|
}
|
|
|
|
function changeZoomCurrent(delta) {
|
|
if (!currentWebView)
|
|
return
|
|
currentWebView.changeZoomFactor(currentWebView.zoomFactor + delta)
|
|
}
|
|
|
|
function resetZoomCurrent() {
|
|
if (!currentWebView)
|
|
return
|
|
currentWebView.changeZoomFactor(1.0)
|
|
}
|
|
|
|
function removeView(index) {
|
|
if (index < 0 || index >= tabsModel.count)
|
|
return
|
|
|
|
var view = getWebView(index)
|
|
if (tabsModel.count <= 1)
|
|
createEmptyTab(root.currentBrowsingProfileParams, true)
|
|
tabsModel.removeTab(index)
|
|
if (!view)
|
|
return
|
|
|
|
// Mobile Backend aborts Downloads when the Web View is destroyed.
|
|
// Retain (hide + freeze) until DownloadsStore reports the view is clear.
|
|
// Desktop WebEngine keeps transfers alive after view destruction — no retain.
|
|
// Closing never cancels a Download (ADR 0006 §6).
|
|
const shouldRetain = root.isMobile
|
|
&& !!downloadsStore
|
|
&& typeof downloadsStore.viewHasNonTerminalDownloads === "function"
|
|
&& downloadsStore.viewHasNonTerminalDownloads(view)
|
|
|
|
if (shouldRetain) {
|
|
view.retained = true
|
|
view.focus = false
|
|
// Reparent out of the StackLayout so children stay 1:1 with tabs.
|
|
// visible/freeze follow `retained` bindings on the adapter.
|
|
// Do not detachView()/stop() — that would abort the transfer.
|
|
view.parent = null
|
|
_retainedViews = _retainedViews.concat([view])
|
|
return
|
|
}
|
|
|
|
root._destroyWebView(view)
|
|
}
|
|
|
|
/// Close the Tab backing `view`, whatever index it sits at. The host names
|
|
/// the Web View it got from a signal; the Tab set is ours, so the lookup is
|
|
/// too — no index arithmetic escapes this object.
|
|
function removeViewFor(view) {
|
|
if (!view)
|
|
return false
|
|
for (let i = 0; i < tabsModel.count; ++i) {
|
|
if (getWebView(i) === view) {
|
|
removeView(i)
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// A live Tab whose Backend can run a host-side Download re-issue on the
|
|
/// requested profile (ADR 0006 §7), or null when there is none.
|
|
/// Retained Views finish only what they already own (§6), and a local
|
|
/// preview is not a browsing profile at all — re-issuing on either would
|
|
/// break the mandatory profile match.
|
|
function firstLiveDownloadBackend(wantOtr) {
|
|
for (let i = 0; i < tabsModel.count; ++i) {
|
|
const view = getWebView(i)
|
|
if (!view || typeof view.downloadUrl !== "function")
|
|
continue
|
|
if (view.retained || view.profileParams?.localPreview)
|
|
continue
|
|
if (!!view.offTheRecord !== !!wantOtr)
|
|
continue
|
|
return view
|
|
}
|
|
return null
|
|
}
|
|
|
|
function _destroyWebView(view) {
|
|
if (!view)
|
|
return
|
|
view.visible = false
|
|
view.enabled = false
|
|
view.focus = false
|
|
if (typeof view.detachView === "function")
|
|
view.detachView()
|
|
view.parent = null
|
|
view.destroy()
|
|
}
|
|
|
|
function _destroyRetainedView(view) {
|
|
if (!view)
|
|
return
|
|
const next = []
|
|
let found = false
|
|
for (let i = 0; i < _retainedViews.length; ++i) {
|
|
if (_retainedViews[i] === view)
|
|
found = true
|
|
else
|
|
next.push(_retainedViews[i])
|
|
}
|
|
if (!found)
|
|
return
|
|
_retainedViews = next
|
|
root._destroyWebView(view)
|
|
}
|
|
|
|
readonly property Connections _retainedDownloadsConnections: Connections {
|
|
target: root.downloadsStore
|
|
ignoreUnknownSignals: true
|
|
function onViewDownloadsCleared(view) {
|
|
root._destroyRetainedView(view)
|
|
}
|
|
}
|
|
|
|
readonly property var webViewAdapterComponent: Component {
|
|
LazyWebViewAdapter {
|
|
id: lazyView
|
|
|
|
// StackLayout never sizes a child that was added while the layout was
|
|
// hidden, and neither forceLayout() nor re-setting currentIndex repairs
|
|
// it — the view stays 0x0 and renders nothing (issue 21282). Size to the
|
|
// host instead of relying on the layout to do it.
|
|
width: root.hostStackLayout.width
|
|
height: root.hostStackLayout.height
|
|
|
|
// On mobile, only the active tab must be visible; native WKWebView
|
|
// subviews share the same UIKit window and ignore QML z-order,
|
|
// so StackLayout alone cannot hide inactive tabs reliably.
|
|
// Retained Views stay hidden regardless of StackLayout current item.
|
|
visible: retained ? false
|
|
: (root.isMobile ? StackLayout.isCurrentItem : true)
|
|
enabled: visible
|
|
// Freeze while a QML popup is shown, or while Retained for Downloads.
|
|
freeze: root.isMobile && (root.hasPopups || retained)
|
|
|
|
readonly property ConnectorBridge bridge: ConnectorBridge {
|
|
// 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
|
|
}
|
|
|
|
webChannel: bridge.channel
|
|
|
|
bookmarksStore: root.bookmarksStore
|
|
profileManager: root.profileManager
|
|
enableJsLogs: root.isDebugEnabled
|
|
localAccountSensitiveSettings: root.browserSettings
|
|
|
|
devToolsEnabled: root.browserSettings.devToolsEnabled
|
|
onDevToolsToggled: enabled => root.browserSettings.devToolsEnabled = enabled
|
|
|
|
onWindowCloseRequested: root.removeView(StackLayout.index)
|
|
onNewWindowRequested: (makeCurrent, requestedUrl, callback) => {
|
|
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)
|
|
tab.pristinePopup = true
|
|
callback(tab)
|
|
}
|
|
onDownloadRequested: (download, token) => {
|
|
// Retained Views finish only the Downloads they already own (ADR 0006 §6).
|
|
if (lazyView.retained) {
|
|
if (download && download.cancel)
|
|
download.cancel()
|
|
return
|
|
}
|
|
root.downloadRequestHandler(download, lazyView, token)
|
|
}
|
|
onLinkLongPressed: (linkUrl, imageUrl, position) => {
|
|
if (!lazyView.retained)
|
|
root.linkLongPressHandler(linkUrl, imageUrl, position, lazyView)
|
|
}
|
|
onCertificateError: (error) => root.sslErrorHandler(error)
|
|
onJavaScriptDialogRequested: (request) => root.jsDialogHandler(request)
|
|
onFindTextFinished: (result) => root.findTextFinishedHandler(result)
|
|
}
|
|
}
|
|
}
|