Commit 047b754f authored by nextime's avatar nextime

Handler for qextension: url reqeiring works.

parent 525064a2
/**************************************************************************** // Copyright (C) 2016 The Qt Company Ltd.
** // Copyright (C) 2016 Klarälvdalens Datakonsult AB, a KDAB Group company, info@kdab.com, author Milian Wolff <milian.wolff@kdab.com>
** Copyright (C) 2016 The Qt Company Ltd. // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
** Copyright (C) 2016 Klarälvdalens Datakonsult AB, a KDAB Group company, info@kdab.com, author Milian Wolff <milian.wolff@kdab.com>
** Contact: https://www.qt.io/licensing/
**
** This file is part of the QtWebChannel module of the Qt Toolkit.
**
** $QT_BEGIN_LICENSE:LGPL$
** Commercial License Usage
** Licensees holding valid commercial Qt licenses may use this file in
** accordance with the commercial license agreement provided with the
** Software or, alternatively, in accordance with the terms contained in
** a written agreement between you and The Qt Company. For licensing terms
** and conditions see https://www.qt.io/terms-conditions. For further
** information use the contact form at https://www.qt.io/contact-us.
**
** GNU Lesser General Public License Usage
** Alternatively, this file may be used under the terms of the GNU Lesser
** General Public License version 3 as published by the Free Software
** Foundation and appearing in the file LICENSE.LGPL3 included in the
** packaging of this file. Please review the following information to
** ensure the GNU Lesser General Public License version 3 requirements
** will be met: https://www.gnu.org/licenses/lgpl-3.0.html.
**
** GNU General Public License Usage
** Alternatively, this file may be used under the terms of the GNU
** General Public License version 2.0 or (at your option) the GNU General
** Public license version 3 or any later version approved by the KDE Free
** Qt Foundation. The licenses are as published by the Free Software
** Foundation and appearing in the file LICENSE.GPL2 and LICENSE.GPL3
** included in the packaging of this file. Please review the following
** information to ensure the GNU General Public License requirements will
** be met: https://www.gnu.org/licenses/gpl-2.0.html and
** https://www.gnu.org/licenses/gpl-3.0.html.
**
** $QT_END_LICENSE$
**
****************************************************************************/
"use strict"; "use strict";
...@@ -53,7 +17,7 @@ var QWebChannelMessageTypes = { ...@@ -53,7 +17,7 @@ var QWebChannelMessageTypes = {
response: 10, response: 10,
}; };
var QWebChannel = function(transport, initCallback) var QWebChannel = function(transport, initCallback, converters)
{ {
if (typeof transport !== "object" || typeof transport.send !== "function") { if (typeof transport !== "object" || typeof transport.send !== "function") {
console.error("The QWebChannel expects a transport object with a send function and onmessage callback property." + console.error("The QWebChannel expects a transport object with a send function and onmessage callback property." +
...@@ -64,6 +28,43 @@ var QWebChannel = function(transport, initCallback) ...@@ -64,6 +28,43 @@ var QWebChannel = function(transport, initCallback)
var channel = this; var channel = this;
this.transport = transport; this.transport = transport;
var converterRegistry =
{
Date : function(response) {
if (typeof response === "string"
&& response.match(
/^-?\d+-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d*)?([-+\u2212](\d{2}):(\d{2})|Z)?$/)) {
var date = new Date(response);
if (!isNaN(date))
return date;
}
return undefined; // Return undefined if current converter is not applicable
}
};
this.usedConverters = [];
this.addConverter = function(converter)
{
if (typeof converter === "string") {
if (converterRegistry.hasOwnProperty(converter))
this.usedConverters.push(converterRegistry[converter]);
else
console.error("Converter '" + converter + "' not found");
} else if (typeof converter === "function") {
this.usedConverters.push(converter);
} else {
console.error("Invalid converter object type " + typeof converter);
}
}
if (Array.isArray(converters)) {
for (const converter of converters)
this.addConverter(converter);
} else if (converters !== undefined) {
this.addConverter(converters);
}
this.send = function(data) this.send = function(data)
{ {
if (typeof(data) !== "string") { if (typeof(data) !== "string") {
...@@ -190,6 +191,12 @@ function QObject(name, data, webChannel) ...@@ -190,6 +191,12 @@ function QObject(name, data, webChannel)
this.unwrapQObject = function(response) this.unwrapQObject = function(response)
{ {
for (const converter of webChannel.usedConverters) {
var result = converter(response);
if (result !== undefined)
return result;
}
if (response instanceof Array) { if (response instanceof Array) {
// support list of objects // support list of objects
return response.map(qobj => object.unwrapQObject(qobj)) return response.map(qobj => object.unwrapQObject(qobj))
......
...@@ -1549,19 +1549,35 @@ class ChromeUrlInterceptor(QWebEngineUrlRequestInterceptor): ...@@ -1549,19 +1549,35 @@ class ChromeUrlInterceptor(QWebEngineUrlRequestInterceptor):
# XXX QUI # XXX QUI
class ExtensionSchemeHandler(QWebEngineUrlSchemeHandler): class ExtensionSchemeHandler(QWebEngineUrlSchemeHandler):
"""A custom URL scheme handler for loading extension files.""" """A custom URL scheme handler for loading extension files."""
def __init__(self, extensions_dir, parent=None): def __init__(self, extensions_dir, loaded_extensions, parent=None):
super().__init__(parent) super().__init__(parent)
self.extensions_dir = extensions_dir self.extensions_dir = extensions_dir
self.loaded_extensions = loaded_extensions
self.jobs = {} # Keep track of active jobs and their buffers self.jobs = {} # Keep track of active jobs and their buffers
self.assets_extensions_dir = "assets/browser/extensions" # Path to the source extensions self.assets_extensions_dir = "assets/browser/extensions" # Path to the source extensions
def rewrite_src_urls(self, html_text, extension_id):
def rewrite_url(match):
# Group 1 is the attribute name (src), group 2 is the URL (quoted or unquoted)
url = match.group(2)
if (url.startswith('/') and
not url.startswith(f'/{extension_id}/') and
not url.startswith('data:') and
'://' not in url):
# Preserve the original quote style or lack thereof
quote = match.group(3) or ''
return f'src={quote}/{extension_id}{url}{quote}'
return match.group(0)
# Match src attributes: quoted (src="/url" or src='/url') or unquoted (src=/url)
pattern = r'src=([\'"])?([^\'"\s>]+)([\'"])?'
return re.sub(pattern, rewrite_url, html_text)
def requestStarted(self, job: QWebEngineUrlRequestJob): def requestStarted(self, job: QWebEngineUrlRequestJob):
print("GGGGGGGGGGGGGGGGGGGGGGGGGGGG >>>>> ")
print(job)
url = job.requestUrl() url = job.requestUrl()
ext_id = url.host() ext_id = url.host()
resource_path = url.path().lstrip('/') resource_path = url.path().lstrip('/')
print(url, self.extensions_dir)
# First try to load from the profile's extensions directory # First try to load from the profile's extensions directory
file_path = os.path.abspath(os.path.join(self.extensions_dir, ext_id, resource_path)) file_path = os.path.abspath(os.path.join(self.extensions_dir, ext_id, resource_path))
...@@ -1572,8 +1588,7 @@ class ExtensionSchemeHandler(QWebEngineUrlSchemeHandler): ...@@ -1572,8 +1588,7 @@ class ExtensionSchemeHandler(QWebEngineUrlSchemeHandler):
if os.path.exists(assets_file_path): if os.path.exists(assets_file_path):
file_path = assets_file_path file_path = assets_file_path
#try: try:
if True:
with open(file_path, 'rb') as f: with open(file_path, 'rb') as f:
content = f.read() content = f.read()
...@@ -1590,8 +1605,7 @@ class ExtensionSchemeHandler(QWebEngineUrlSchemeHandler): ...@@ -1590,8 +1605,7 @@ class ExtensionSchemeHandler(QWebEngineUrlSchemeHandler):
# Special handling for HTML files - inject WebChannel initialization code # Special handling for HTML files - inject WebChannel initialization code
if file_path.endswith(('.html', '.htm')) and mime_type in (b'text/html', b'application/xhtml+xml'): if file_path.endswith(('.html', '.htm')) and mime_type in (b'text/html', b'application/xhtml+xml'):
print(f"Injecting WebChannel initialization into HTML file: {file_path}") print(f"Injecting WebChannel initialization into HTML file: {file_path}")
#try: try:
if True:
# Decode the HTML content # Decode the HTML content
html_content = content.decode('utf-8') html_content = content.decode('utf-8')
...@@ -1715,6 +1729,104 @@ class ExtensionSchemeHandler(QWebEngineUrlSchemeHandler): ...@@ -1715,6 +1729,104 @@ class ExtensionSchemeHandler(QWebEngineUrlSchemeHandler):
</script> </script>
""" """
if ext_id:
# Inject URL modifier
init_script = init_script + f"""
<script>
(function() {{
console.log("Starting --------------------------------------------------------------");
const extensionId = '{ext_id}';
function rewriteUrl(url) {{
// Rewrite relative URLs starting with '/' but not already containing the extension_id
if (!url.startsWith('/') || url.startsWith(`/${{extensionId}}/`) || url.startsWith('data:') || url.includes('://')) {{
return url;
}}
return `/${{extensionId}}${{url}}`;
}}
function rewriteHtmlAttributes() {{
console.log("Rewriting attributes");
const selectors = ['script[src]', 'link[href]', 'img[src]', 'source[src]', 'iframe[src]'];
selectors.forEach(selector => {{
document.querySelectorAll(selector).forEach(element => {{
const attr = element.src ? 'src' : 'href';
const originalUrl = element.getAttribute(attr);
const newUrl = rewriteUrl(originalUrl);
if (newUrl !== originalUrl) {{
element.setAttribute(attr, newUrl);
}}
}});
}});
}}
// Intercept fetch
const originalFetch = window.fetch;
window.fetch = function(url, options) {{
if (typeof url === 'string') {{
url = rewriteUrl(url);
}} else if (url instanceof Request) {{
url = new Request(rewriteUrl(url.url), url);
}}
return originalFetch.call(this, url, options);
}};
// Intercept XMLHttpRequest
const originalXhrOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url, ...args) {{
url = rewriteUrl(url);
return originalXhrOpen.call(this, method, url, ...args);
}};
// Run immediately and on DOM changes
rewriteHtmlAttributes();
// Set up MutationObserver safely
function setupObserver() {{
rewriteHtmlAttributes();
const target = document.body || document.documentElement;
if (!target) {{
// Retry if neither body nor documentElement is available
setTimeout(setupObserver, 10);
return;
}}
const observer = new MutationObserver(rewriteHtmlAttributes);
observer.observe(target, {{ childList: true, subtree: true }});
console.log("Observer done");
}}
// Run observer setup after DOM is ready or immediately if possible
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', setupObserver);
}} else {{
setupObserver();
}}
"""+"""
const scriptobserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
// Check for added nodes
mutation.addedNodes.forEach((node) => {
if (node.tagName === 'SCRIPT' && node.src) {
newsrc = rewriteUrl(node.src);
if (newsrc != node.src) {
node.src = newsrc;
console.log(node, "update src");
}
}
});
});
});
// Start observing the document with the configured parameters
scriptobserver.observe(document, {
childList: true,
subtree: true
});
})();
</script>
"""
# Fix URL handling in extensions
if ext_id:
html_content = self.rewrite_src_urls(html_content, ext_id)
# Inject the script right before the closing </head> tag # Inject the script right before the closing </head> tag
if '</head>' in html_content: if '</head>' in html_content:
html_content = html_content.replace('</head>', init_script + '</head>') html_content = html_content.replace('</head>', init_script + '</head>')
...@@ -1725,18 +1837,16 @@ class ExtensionSchemeHandler(QWebEngineUrlSchemeHandler): ...@@ -1725,18 +1837,16 @@ class ExtensionSchemeHandler(QWebEngineUrlSchemeHandler):
# Convert back to bytes # Convert back to bytes
content = html_content.encode('utf-8') content = html_content.encode('utf-8')
#except Exception as e:
# print(f"Error injecting WebChannel initialization: {e}") except Exception as e:
# # Continue with the original content if there's an error print(f"Error injecting WebChannel initialization: {e}")
# Continue with the original content if there's an error
# Special handling for service worker scripts # Special handling for service worker scripts
if resource_path.endswith('background.js'): if resource_path.endswith('background.js'):
print(f"Loading background script: {file_path}") print(f"Loading background script: {file_path}")
# You might want to add special headers or processing for service workers # You might want to add special headers or processing for service workers
print("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaa")
print(file_path)
print("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaa")
buf = QBuffer(parent=self) buf = QBuffer(parent=self)
buf.setData(content) buf.setData(content)
...@@ -1748,7 +1858,6 @@ class ExtensionSchemeHandler(QWebEngineUrlSchemeHandler): ...@@ -1748,7 +1858,6 @@ class ExtensionSchemeHandler(QWebEngineUrlSchemeHandler):
# Reply with the content # Reply with the content
job.reply(mime_type, buf) job.reply(mime_type, buf)
print(f"Replied with content for: {file_path}") print(f"Replied with content for: {file_path}")
"""
except FileNotFoundError as e: except FileNotFoundError as e:
print(f"Extension resource not found: {file_path} {e}") print(f"Extension resource not found: {file_path} {e}")
job.fail(QWebEngineUrlRequestJob.Error.UrlNotFound) job.fail(QWebEngineUrlRequestJob.Error.UrlNotFound)
...@@ -1760,7 +1869,6 @@ class ExtensionSchemeHandler(QWebEngineUrlSchemeHandler): ...@@ -1760,7 +1869,6 @@ class ExtensionSchemeHandler(QWebEngineUrlSchemeHandler):
if job in self.jobs: if job in self.jobs:
del self.jobs[job] del self.jobs[job]
"""
class ExtensionDialog(QDialog): class ExtensionDialog(QDialog):
""" """
Dialog for managing browser extensions. Dialog for managing browser extensions.
...@@ -2740,7 +2848,7 @@ class Browser(QMainWindow): ...@@ -2740,7 +2848,7 @@ class Browser(QMainWindow):
self.update_extensions_list() self.update_extensions_list()
# Install URL scheme handlers # Install URL scheme handlers
self.extension_scheme_handler = ExtensionSchemeHandler(self.extensions_dir) self.extension_scheme_handler = ExtensionSchemeHandler(self.extensions_dir, self.loaded_extensions)
# Reinstall URL scheme handlers # Reinstall URL scheme handlers
self.profile.installUrlSchemeHandler(b"qextension", self.extension_scheme_handler) self.profile.installUrlSchemeHandler(b"qextension", self.extension_scheme_handler)
...@@ -2949,7 +3057,7 @@ class Browser(QMainWindow): ...@@ -2949,7 +3057,7 @@ class Browser(QMainWindow):
url = self.url_input.text().strip() url = self.url_input.text().strip()
# Add http:// if no protocol is specified # Add http:// if no protocol is specified
if url and not url.startswith(("http://", "https://", "file://", "about:", "chrome://")): if url and not url.startswith(("http://", "https://", "file://", "about:", "chrome://", "qextension://", "qrc://")):
url = "http://" + url url = "http://" + url
# Navigate the current tab # Navigate the current tab
...@@ -3610,6 +3718,7 @@ class Browser(QMainWindow): ...@@ -3610,6 +3718,7 @@ class Browser(QMainWindow):
for ext_name in self.loaded_extensions.keys(): for ext_name in self.loaded_extensions.keys():
manifest = self.loaded_extensions[ext_name]['manifest'] manifest = self.loaded_extensions[ext_name]['manifest']
ext_path = self.loaded_extensions[ext_name]['path']
# Check for a popup action # Check for a popup action
popup_path = None popup_path = None
...@@ -3909,7 +4018,8 @@ class Browser(QMainWindow): ...@@ -3909,7 +4018,8 @@ class Browser(QMainWindow):
</html>""") </html>""")
# Open the temporary HTML file in a new tab # Open the temporary HTML file in a new tab
tab = self.new_page(f"file:///{temp_html_path}")
tab = self.new_page(f"file://{temp_html_path}")
self.tabs.setTabText(self.tabs.indexOf(tab), f"{extension_id} Background") self.tabs.setTabText(self.tabs.indexOf(tab), f"{extension_id} Background")
# Ensure WebChannel is set up for this tab # Ensure WebChannel is set up for this tab
......
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
qbrowser.py - A Qt6-based browser that wraps and extends the Playwright browser class
This module provides a Qt6-based browser interface that wraps the Playwright browser
API and launches Chromium browser instances without decorations using the --app flag.
Each new page is opened as a new tab in the Qt6 window, and the interface includes
URL input, home button, and browser extension management.
"""
import os
import sys
import subprocess
import tempfile
import json
import shutil
import asyncio
import platform
import signal
from pathlib import Path
from typing import Dict, List, Optional, Union, Callable, Any
import configparser
from functools import partial
from PyQt6.QtCore import Qt, QUrl, QProcess, pyqtSignal, QSize, QRect, QTimer, QMimeData, QPoint, QByteArray, QBuffer
from PyQt6.QtGui import QIcon, QAction, QKeySequence, QFont, QColor, QPainter, QDrag, QPixmap
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QTabWidget, QToolBar, QLineEdit,
QPushButton, QWidget, QVBoxLayout, QHBoxLayout, QDialog,
QLabel, QListWidget, QListWidgetItem, QCheckBox, QMenu,
QSizePolicy, QStyle, QFrame, QSplitter, QMessageBox, QStatusBar, QTabBar, QStyleOptionTab, QFileDialog,
QInputDialog, QTextEdit
)
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWebEngineCore import (
QWebEngineProfile, QWebEngineSettings, QWebEnginePage, QWebEngineUrlSchemeHandler,
QWebEngineUrlRequestJob, QWebEngineUrlRequestInterceptor, QWebEngineUrlScheme
)
import mimetypes
# Import Playwright for API compatibility
from playwright.async_api import async_playwright, Browser as PlaywrightBrowser
from playwright.async_api import Page as PlaywrightPage
from playwright.async_api import BrowserContext as PlaywrightBrowserContext
class ChromeWebEnginePage(QWebEnginePage):
"""
Custom QWebEnginePage that allows navigation to chrome:// URLs
"""
def certificateError(self, error):
# Accept all certificates to allow chrome:// URLs
return True
def javaScriptConsoleMessage(self, level, message, line, source):
# Log JavaScript console messages
print(f"JS Console ({source}:{line}): {message}")
def acceptNavigationRequest(self, url, type, isMainFrame):
# Always accept chrome:// URLs
if url.scheme() == "chrome":
print(f"Accepting chrome:// URL navigation: {url.toString()}")
return True
return super().acceptNavigationRequest(url, type, isMainFrame)
def createWindow(self, windowType):
"""Create a new window when requested, especially for chrome:// URLs"""
# Get the main browser window
browser = self.parent().window()
if isinstance(browser, Browser):
# Create a new tab with our custom page
tab = browser.new_page("about:blank")
if hasattr(tab, 'web_view'):
# Ensure it's using our custom page class
if not isinstance(tab.web_view.page(), ChromeWebEnginePage):
page = ChromeWebEnginePage(browser.profile, tab.web_view)
tab.web_view.setPage(page)
return tab.web_view.page()
# Fallback: create a default page
return ChromeWebEnginePage(self.profile())
def javaScriptAlert(self, securityOrigin, msg):
"""Handle JavaScript alerts, especially from chrome:// URLs"""
print(f"JS Alert from {securityOrigin.toString()}: {msg}")
# Allow alerts from chrome:// URLs to pass through
return super().javaScriptAlert(securityOrigin, msg)
def javaScriptConfirm(self, securityOrigin, msg):
"""Handle JavaScript confirms, especially from chrome:// URLs"""
print(f"JS Confirm from {securityOrigin.toString()}: {msg}")
# Allow confirms from chrome:// URLs to pass through
return super().javaScriptConfirm(securityOrigin, msg)
def urlChanged(self, url):
"""Handle URL changes, especially to chrome:// URLs"""
if url.scheme() == "chrome":
print(f"URL changed to chrome:// URL: {url.toString()}")
super().urlChanged(url)
class BrowserTab(QWidget):
"""
A tab widget that contains a web view and manages a Chromium process.
"""
urlChanged = pyqtSignal(str)
titleChanged = pyqtSignal(str)
loadStatusChanged = pyqtSignal()
def __init__(self, parent=None, url="about:blank", profile=None):
super().__init__(parent)
self.parent = parent
self.url = url
self.profile = profile
self.process = None
self.page = None # Playwright page object
self.is_loading = False
# Create a container for the web view
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setSpacing(0)
# Create a placeholder widget until the Chromium process is ready
self.placeholder = QLabel("Loading browser...")
self.placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.layout.addWidget(self.placeholder)
# Start the Chromium process
self.start_browser()
def start_browser(self):
"""Initialize the QWebEngineView for browsing."""
# Create a QWebEngineView for the Qt UI
# This will be the actual browser, no separate Chromium process
self.web_view = QWebEngineView()
self.web_view.setEnabled(True) # Enable interaction with this view
# Set up a custom profile for this tab with our ChromeWebEnginePage that allows chrome:// URLs
page = ChromeWebEnginePage(self.profile, self.web_view)
self.web_view.setPage(page)
# Connect signals
self.web_view.loadStarted.connect(self.on_load_started)
self.web_view.loadFinished.connect(self.on_load_finished)
self.web_view.urlChanged.connect(lambda url: self.urlChanged.emit(url.toString()))
self.web_view.titleChanged.connect(lambda title: self.titleChanged.emit(title))
# Load the URL
self.web_view.load(QUrl(self.url))
# Replace the placeholder with the web view
self.layout.removeWidget(self.placeholder)
self.placeholder.deleteLater()
self.layout.addWidget(self.web_view)
print(f"Loading URL in QWebEngineView: {self.url}")
def navigate(self, url):
"""Navigate to the specified URL."""
self.url = url
if hasattr(self, 'web_view'):
print(f"Navigating to: {url}")
# Special handling for chrome:// URLs
if url.startswith("chrome://"):
self.navigate_to_chrome_url(url)
else:
self.web_view.load(QUrl(url))
# If we have a Playwright page, navigate it as well for API compatibility
if self.page:
asyncio.create_task(self.page.goto(url))
def navigate_to_chrome_url(self, url):
"""Special handling for chrome:// URLs at the tab level."""
print(f"Tab-level chrome:// URL navigation: {url}")
# Ensure we're using the ChromeWebEnginePage
if not isinstance(self.web_view.page(), ChromeWebEnginePage):
# Get the browser window to access the profile
browser = self.parent.window()
if isinstance(browser, Browser):
page = ChromeWebEnginePage(browser.profile, self.web_view)
self.web_view.setPage(page)
# Now navigate to the chrome:// URL
self.web_view.load(QUrl(url))
def on_load_started(self):
self.is_loading = True
self.loadStatusChanged.emit()
def on_load_finished(self, success):
"""Handle the page load finishing."""
self.is_loading = False
self.loadStatusChanged.emit()
if success:
print(f"Page loaded successfully: {self.web_view.url().toString()}")
# Update the title if available
title = self.web_view.title()
if title:
self.titleChanged.emit(title)
else:
print(f"Failed to load page: {self.url}")
# We no longer need process handling methods since we're using QWebEngineView directly
def close(self):
"""Close the tab."""
# Close the web view
if hasattr(self, 'web_view'):
self.web_view.close()
super().close()
class ChromeUrlInterceptor(QWebEngineUrlRequestInterceptor):
"""
Intercepts URL requests to handle chrome:// URLs specially
"""
def __init__(self, browser):
super().__init__()
self.browser = browser
def interceptRequest(self, info):
url = info.requestUrl()
if url.scheme() == "chrome":
print(f"Intercepted chrome:// URL: {url.toString()}")
# We don't block the request, just log it for debugging
# The actual handling is done by ChromeWebEnginePage
class ExtensionSchemeHandler(QWebEngineUrlSchemeHandler):
"""A custom URL scheme handler for loading extension files."""
def __init__(self, extensions_dir, parent=None):
super().__init__(parent)
self.extensions_dir = extensions_dir
self.jobs = {} # Keep track of active jobs and their buffers
def requestStarted(self, job: QWebEngineUrlRequestJob):
url = job.requestUrl()
ext_id = url.host()
resource_path = url.path().lstrip('/')
file_path = os.path.abspath(os.path.join(self.extensions_dir, ext_id, resource_path))
try:
with open(file_path, 'rb') as f:
content = f.read()
mime_type, _ = mimetypes.guess_type(file_path)
if mime_type:
mime_type = mime_type.encode()
else:
mime_type = b'application/octet-stream'
buf = QBuffer(parent=self)
buf.setData(content)
buf.open(QBuffer.OpenModeFlag.ReadOnly)
# Store buffer to prevent garbage collection
self.jobs[job] = buf
# Reply with the content
job.reply(mime_type, buf)
print(f"Replied with content for: {file_path}")
except FileNotFoundError:
print(f"Extension resource not found: {file_path}")
job.fail(QWebEngineUrlRequestJob.Error.UrlNotFound)
if job in self.jobs:
del self.jobs[job]
except Exception as e:
print(f"Error loading extension resource {file_path}: {e}")
job.fail(QWebEngineUrlRequestJob.Error.RequestFailed)
if job in self.jobs:
del self.jobs[job]
class ExtensionDialog(QDialog):
"""
Dialog for managing browser extensions.
"""
def __init__(self, parent=None, extensions_dir=None):
super().__init__(parent)
self.setWindowTitle("Browser Extensions")
self.setMinimumSize(500, 400)
self.extensions_dir = extensions_dir
self.open_popups = []
# Create layout
layout = QVBoxLayout(self)
# Create extensions list
self.extensions_list = QListWidget()
layout.addWidget(self.extensions_list)
# Create buttons
button_layout = QHBoxLayout()
self.install_button = QPushButton("Install New")
self.remove_button = QPushButton("Remove")
self.enable_button = QPushButton("Enable/Disable")
self.popup_button = QPushButton("Open Popup")
self.close_button = QPushButton("Close")
button_layout.addWidget(self.install_button)
button_layout.addWidget(self.remove_button)
button_layout.addWidget(self.enable_button)
button_layout.addWidget(self.popup_button)
button_layout.addStretch()
button_layout.addWidget(self.close_button)
layout.addLayout(button_layout)
# Connect signals
self.close_button.clicked.connect(self.accept)
self.install_button.clicked.connect(self.install_extension)
self.remove_button.clicked.connect(self.remove_extension)
self.enable_button.clicked.connect(self.toggle_extension)
self.popup_button.clicked.connect(self.open_extension_popup)
# Load extensions
self.load_extensions()
def load_extensions(self):
"""Load and display installed extensions."""
self.extensions_list.clear()
if not self.extensions_dir or not os.path.exists(self.extensions_dir):
self.extensions_list.addItem("No extensions directory found")
return
# Find all extension directories
for ext_name in os.listdir(self.extensions_dir):
ext_path = os.path.join(self.extensions_dir, ext_name)
if os.path.isdir(ext_path):
enabled = not ext_name.endswith(".disabled")
base_name = ext_name[:-9] if not enabled else ext_name
manifest_path = os.path.join(ext_path, "manifest.json")
if os.path.exists(manifest_path):
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
name = manifest.get("name", base_name)
version = manifest.get("version", "unknown")
status = "ACTIVE" if enabled else "INACTIVE"
item = QListWidgetItem(f"{name} (v{version}) - {status}")
item.setData(Qt.ItemDataRole.UserRole, ext_path)
item.setCheckState(Qt.CheckState.Checked if enabled else Qt.CheckState.Unchecked)
self.extensions_list.addItem(item)
except Exception as e:
print(f"Error loading extension manifest for {ext_name}: {e}")
item = QListWidgetItem(f"{ext_name} (Error loading manifest)")
item.setData(Qt.ItemDataRole.UserRole, ext_path)
self.extensions_list.addItem(item)
def install_extension(self):
"""Install a new extension from a directory."""
source_dir = QFileDialog.getExistingDirectory(self, "Select Extension Directory")
if not source_dir:
return
ext_name = os.path.basename(source_dir)
dest_dir = os.path.join(self.extensions_dir, ext_name)
if os.path.exists(dest_dir):
QMessageBox.warning(self, "Extension Exists", f"An extension named '{ext_name}' already exists.")
return
try:
shutil.copytree(source_dir, dest_dir)
self.load_extensions()
QMessageBox.information(self, "Restart Required",
"The extension has been installed. Please restart the browser for it to be loaded.")
except Exception as e:
QMessageBox.critical(self, "Installation Error", f"Could not install the extension: {e}")
def remove_extension(self):
"""Remove the selected extension."""
selected_items = self.extensions_list.selectedItems()
if not selected_items:
return
for item in selected_items:
ext_path = item.data(Qt.ItemDataRole.UserRole)
if ext_path and os.path.exists(ext_path):
try:
shutil.rmtree(ext_path)
print(f"Removed extension: {ext_path}")
except Exception as e:
print(f"Error removing extension: {e}")
self.load_extensions()
def toggle_extension(self):
"""Toggle the enabled state of the selected extension and reload the profile."""
selected_items = self.extensions_list.selectedItems()
if not selected_items:
return
item = selected_items[0]
ext_path = item.data(Qt.ItemDataRole.UserRole)
ext_name = os.path.basename(ext_path)
is_enabled = not ext_name.endswith(".disabled")
if is_enabled:
new_path = ext_path + ".disabled"
else:
new_path = ext_path[:-9]
try:
# Rename the folder to enable/disable the extension
os.rename(ext_path, new_path)
# Update the list in the dialog
self.load_extensions()
# Get the main browser window and trigger a profile reload
browser = self.parent()
if browser and hasattr(browser, 'reload_profile_and_tabs'):
browser.reload_profile_and_tabs()
else:
# This is a fallback in case the parent isn't the browser window
QMessageBox.information(self, "Restart Required",
"Change applied. Please restart the browser for it to take effect.")
except OSError as e:
QMessageBox.critical(self, "Error", f"Could not change extension state: {e}")
def open_extension_popup(self):
"""Open the popup of the selected extension, if it has one."""
selected_items = self.extensions_list.selectedItems()
if not selected_items:
return
item = selected_items[0]
ext_path = item.data(Qt.ItemDataRole.UserRole)
ext_name = os.path.basename(ext_path)
if ext_name.endswith(".disabled"):
QMessageBox.warning(self, "Extension Disabled", "Cannot open popup for a disabled extension.")
return
manifest_path = os.path.join(ext_path, "manifest.json")
if not os.path.exists(manifest_path):
QMessageBox.warning(self, "Error", "manifest.json not found for this extension.")
return
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
except Exception as e:
QMessageBox.critical(self, "Error", f"Could not read manifest.json: {e}")
return
popup_path = None
action = manifest.get('action') or manifest.get('browser_action') or manifest.get('page_action')
if action and action.get('default_popup'):
popup_path = action.get('default_popup')
if not popup_path:
QMessageBox.information(self, "No Popup", "This extension does not have a popup.")
return
popup_file = os.path.join(ext_path, popup_path)
if not os.path.exists(popup_file):
QMessageBox.warning(self, "Error", f"Popup file not found: {popup_path}")
return
# Open the popup in a new dialog
popup_dialog = QDialog(self)
popup_dialog.setWindowTitle(manifest.get("name", "Extension Popup"))
layout = QVBoxLayout(popup_dialog)
web_view = QWebEngineView()
browser_window = self.parent()
if not browser_window or not isinstance(browser_window, Browser):
QMessageBox.critical(self, "Error", "Could not get browser window reference.")
return
# Use the main browser's profile so the scheme handler is available
page = QWebEnginePage(browser_window.profile, web_view)
web_view.setPage(page)
popup_url = QUrl(f"qextension://{ext_name}/{popup_path}")
web_view.load(popup_url)
layout.addWidget(web_view)
popup_dialog.setLayout(layout)
popup_dialog.resize(400, 600)
popup_dialog.show()
self.open_popups.append(popup_dialog)
popup_dialog.finished.connect(lambda: self.open_popups.remove(popup_dialog))
class DetachableTabBar(QTabBar):
tabDetached = pyqtSignal(int, QPoint)
def __init__(self, parent=None):
super().__init__(parent)
self.setAcceptDrops(True)
self.drag_start_pos = QPoint()
self.drag_tab_index = -1
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self.drag_start_pos = event.pos()
self.drag_tab_index = self.tabAt(self.drag_start_pos)
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
if not (event.buttons() & Qt.MouseButton.LeftButton):
return super().mouseMoveEvent(event)
if self.drag_tab_index == -1:
return super().mouseMoveEvent(event)
tab_widget = self.parent().widget(self.drag_tab_index)
if tab_widget and hasattr(tab_widget, 'is_loading') and tab_widget.is_loading:
return # Don't allow dragging while the tab is loading
if (event.pos() - self.drag_start_pos).manhattanLength() < QApplication.startDragDistance():
return super().mouseMoveEvent(event)
drag = QDrag(self)
mime_data = QMimeData()
mime_data.setData("application/x-qbrowser-tab-index", QByteArray(str(self.drag_tab_index).encode()))
drag.setMimeData(mime_data)
# Use a simple dummy pixmap to avoid all painter errors
pixmap = QPixmap(100, 30)
pixmap.fill(QColor(53, 53, 53))
painter = QPainter(pixmap)
painter.setPen(Qt.GlobalColor.white)
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, self.tabText(self.drag_tab_index))
painter.end()
drag.setPixmap(pixmap)
tab_rect = self.tabRect(self.drag_tab_index)
if tab_rect.isValid():
drag.setHotSpot(event.pos() - tab_rect.topLeft())
else:
drag.setHotSpot(event.pos()) # Fallback hotspot
drop_action = drag.exec(Qt.DropAction.MoveAction)
if drop_action == Qt.DropAction.IgnoreAction:
self.tabDetached.emit(self.drag_tab_index, event.globalPosition().toPoint())
self.drag_tab_index = -1
super().mouseMoveEvent(event)
def dragEnterEvent(self, event):
if event.mimeData().hasFormat("application/x-qbrowser-tab-index"):
event.acceptProposedAction()
else:
super().dragEnterEvent(event)
def dropEvent(self, event):
if not event.mimeData().hasFormat("application/x-qbrowser-tab-index"):
return super().dropEvent(event)
source_tab_bar = event.source()
if not isinstance(source_tab_bar, DetachableTabBar):
return super().dropEvent(event)
source_widget = source_tab_bar.parent()
dest_widget = self.parent()
from_index = int(event.mimeData().data("application/x-qbrowser-tab-index"))
to_index = self.tabAt(event.position().toPoint())
if source_widget == dest_widget:
if to_index == -1:
to_index = self.count()
self.moveTab(from_index, to_index)
self.parent().setCurrentIndex(to_index)
event.acceptProposedAction()
return
# Cross-widget drop
tab_content = source_widget.widget(from_index)
tab_title = source_widget.tabText(from_index)
# Disconnect signals from the old browser
old_browser = tab_content.browser
if old_browser:
try:
tab_content.urlChanged.disconnect(old_browser.update_url)
tab_content.titleChanged.disconnect()
tab_content.loadStatusChanged.disconnect(old_browser.update_status_bar)
except (TypeError, RuntimeError):
pass
# Remove from source widget. The page widget is not deleted.
source_widget.removeTab(from_index)
if to_index == -1:
to_index = self.count()
# Add to destination widget
new_index = dest_widget.insertTab(to_index, tab_content, tab_title)
dest_widget.setCurrentIndex(new_index)
# Connect signals to the new browser
new_browser = dest_widget.window()
tab_content.browser = new_browser
tab_content.urlChanged.connect(new_browser.update_url)
tab_content.titleChanged.connect(lambda title, tab=tab_content: new_browser.update_title(title, tab))
tab_content.loadStatusChanged.connect(new_browser.update_status_bar)
# Close the source window if it has no more tabs
if source_widget.count() == 0:
source_widget.window().close()
event.setDropAction(Qt.DropAction.MoveAction)
event.accept()
class ProfileDialog(QDialog):
"""
Dialog for selecting or creating a browser profile.
"""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Select Profile")
self.setMinimumWidth(400)
self.selected_profile_path = None
self.profiles_dir = "data"
if not os.path.exists(self.profiles_dir):
os.makedirs(self.profiles_dir)
layout = QVBoxLayout(self)
layout.addWidget(QLabel("Select a profile to launch:"))
self.profile_list = QListWidget()
self.profile_list.itemDoubleClicked.connect(self.accept_selection)
layout.addWidget(self.profile_list)
button_layout = QHBoxLayout()
self.new_button = QPushButton("Create New")
self.new_button.clicked.connect(self.create_new_profile)
button_layout.addWidget(self.new_button)
self.delete_button = QPushButton("Delete")
self.delete_button.clicked.connect(self.delete_profile)
button_layout.addWidget(self.delete_button)
button_layout.addStretch()
self.select_button = QPushButton("Launch")
self.select_button.setDefault(True)
self.select_button.clicked.connect(self.accept_selection)
button_layout.addWidget(self.select_button)
layout.addLayout(button_layout)
self.load_profiles()
self.update_button_states()
def update_button_states(self):
has_selection = len(self.profile_list.selectedItems()) > 0
self.select_button.setEnabled(has_selection)
self.delete_button.setEnabled(has_selection)
def load_profiles(self):
self.profile_list.clear()
profiles = []
for item in os.listdir(self.profiles_dir):
if os.path.isdir(os.path.join(self.profiles_dir, item)) and item.startswith("browser_profile_"):
profiles.append(item)
for profile_dir_name in sorted(profiles):
profile_name = profile_dir_name.replace("browser_profile_", "")
list_item = QListWidgetItem(profile_name)
list_item.setData(Qt.ItemDataRole.UserRole, os.path.join(self.profiles_dir, profile_dir_name))
self.profile_list.addItem(list_item)
if self.profile_list.count() > 0:
self.profile_list.setCurrentRow(0)
self.profile_list.itemSelectionChanged.connect(self.update_button_states)
self.update_button_states()
def create_new_profile(self):
while True:
profile_name, ok = QInputDialog.getText(self, "Create New Profile", "Enter a name for the new profile:")
if not ok:
return # User cancelled
if not profile_name.strip():
QMessageBox.warning(self, "Invalid Name", "Profile name cannot be empty.")
continue
profile_path = os.path.join(self.profiles_dir, f"browser_profile_{profile_name.strip()}")
if not os.path.exists(profile_path):
try:
os.makedirs(profile_path)
self._install_default_extensions(profile_path)
self.load_profiles()
# Select the new profile
for i in range(self.profile_list.count()):
if self.profile_list.item(i).text() == profile_name.strip():
self.profile_list.setCurrentRow(i)
break
return
except OSError as e:
QMessageBox.critical(self, "Error", f"Could not create profile directory: {e}")
return
else:
QMessageBox.warning(self, "Profile Exists", "A profile with that name already exists.")
def delete_profile(self):
selected_items = self.profile_list.selectedItems()
if not selected_items:
return
item = selected_items[0]
profile_name = item.text()
profile_path = item.data(Qt.ItemDataRole.UserRole)
reply = QMessageBox.question(self, "Confirm Deletion", f"Are you sure you want to delete the profile '{profile_name}'?\nThis will permanently delete all its data.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No)
if reply == QMessageBox.StandardButton.Yes:
try:
shutil.rmtree(profile_path)
self.load_profiles()
self.update_button_states()
except Exception as e:
QMessageBox.critical(self, "Error", f"Could not delete profile: {e}")
def accept_selection(self):
selected_items = self.profile_list.selectedItems()
if not selected_items:
return
item = selected_items[0]
self.selected_profile_path = item.data(Qt.ItemDataRole.UserRole)
self.accept()
@staticmethod
def _install_default_extensions(profile_path):
"""Copies default extensions from assets to a new profile directory."""
source_extensions_dir = "assets/browser/extensions"
if not os.path.isdir(source_extensions_dir):
print(f"Default extensions directory not found: {source_extensions_dir}")
return
dest_extensions_dir = os.path.join(profile_path, "Default", "Extensions")
try:
os.makedirs(dest_extensions_dir, exist_ok=True)
for item_name in os.listdir(source_extensions_dir):
source_item_path = os.path.join(source_extensions_dir, item_name)
dest_item_path = os.path.join(dest_extensions_dir, item_name)
if os.path.isdir(source_item_path):
if not os.path.exists(dest_item_path):
shutil.copytree(source_item_path, dest_item_path)
print(f"Installed default extension '{item_name}' to profile.")
except (OSError, IOError) as e:
QMessageBox.warning(None, "Extension Installation Error",
f"Could not install default extensions: {e}")
@staticmethod
def get_profile_path(parent=None):
profiles_dir = "data"
if not os.path.exists(profiles_dir):
os.makedirs(profiles_dir)
profiles = [p for p in os.listdir(profiles_dir) if p.startswith("browser_profile_")]
if not profiles:
# First run experience: must create a profile to continue.
while True:
profile_name, ok = QInputDialog.getText(parent, "Create First Profile", "Welcome! Please create a profile to begin:")
if not ok:
return None # User cancelled initial creation, so exit.
if not profile_name.strip():
QMessageBox.warning(parent, "Invalid Name", "Profile name cannot be empty.")
continue
profile_path = os.path.join(profiles_dir, f"browser_profile_{profile_name.strip()}")
if os.path.exists(profile_path):
QMessageBox.warning(parent, "Profile Exists", "A profile with that name already exists. Please choose another name.")
continue
try:
os.makedirs(profile_path)
ProfileDialog._install_default_extensions(profile_path)
return profile_path # Success!
except OSError as e:
QMessageBox.critical(parent, "Error", f"Could not create profile directory: {e}")
return None
else:
# Profiles exist, show the manager dialog.
dialog = ProfileDialog(parent)
if dialog.exec() == QDialog.DialogCode.Accepted:
return dialog.selected_profile_path
else:
return None # User closed the manager.
class Browser(QMainWindow):
"""
Main browser window that wraps the Playwright browser class.
"""
instances = []
def __init__(self, initial_url=None, debug=False, detached_tab=None, profile_path=None):
super().__init__()
Browser.instances.append(self)
self.setWindowTitle("SHMCamStudio Browser")
self.setWindowIcon(QIcon('assets/logo.jpg'))
self.resize(1024, 768)
# Create a central widget and layout
self.central_widget = QWidget()
self.setCentralWidget(self.central_widget)
self.layout = QVBoxLayout(self.central_widget)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setSpacing(0)
# Create toolbar
self.toolbar = QToolBar()
self.toolbar.setMovable(False)
self.toolbar.setIconSize(QSize(24, 24))
# Add back button
self.back_button = QPushButton()
self.back_button.setIcon(self._get_themed_icon(QStyle.StandardPixmap.SP_ArrowBack))
self.back_button.setToolTip("Back")
self.back_button.clicked.connect(self.navigate_back)
self.toolbar.addWidget(self.back_button)
# Add forward button
self.forward_button = QPushButton()
self.forward_button.setIcon(self._get_themed_icon(QStyle.StandardPixmap.SP_ArrowForward))
self.forward_button.setToolTip("Forward")
self.forward_button.clicked.connect(self.navigate_forward)
self.toolbar.addWidget(self.forward_button)
# Add reload button
self.reload_button = QPushButton()
self.reload_button.setIcon(self._get_themed_icon(QStyle.StandardPixmap.SP_BrowserReload))
self.reload_button.setToolTip("Reload (F5)")
self.reload_button.clicked.connect(self.reload_page)
self.toolbar.addWidget(self.reload_button)
# Add home button
self.home_button = QPushButton()
self.home_button.setIcon(self._get_themed_icon(QStyle.StandardPixmap.SP_DirHomeIcon))
self.home_button.setToolTip("Home")
self.home_button.clicked.connect(self.navigate_home)
self.toolbar.addWidget(self.home_button)
# Add URL input
self.url_input = QLineEdit()
self.url_input.setPlaceholderText("Enter URL...")
self.url_input.returnPressed.connect(self.navigate_to_url)
self.toolbar.addWidget(self.url_input)
# The extension buttons will be inserted before this one, so we need its action.
self.extensions_button = QPushButton()
self.extensions_button.setIcon(self._get_themed_icon(QStyle.StandardPixmap.SP_FileDialogDetailedView))
self.extensions_button.setToolTip("Extensions")
self.extensions_button.clicked.connect(self.show_extensions)
self.extensions_button_action = self.toolbar.addWidget(self.extensions_button)
# Add Chrome URLs button with dropdown menu
self.chrome_button = QPushButton()
self.chrome_button.setIcon(self._get_themed_icon(QStyle.StandardPixmap.SP_TitleBarMenuButton))
self.chrome_button.setToolTip("Chrome URLs")
self.chrome_menu = QMenu(self)
# Add common chrome:// URLs to the menu
chrome_urls = {
"Extensions": "chrome://extensions/",
"Settings": "chrome://settings/",
"History": "chrome://history/",
"Bookmarks": "chrome://bookmarks/",
"Downloads": "chrome://downloads/",
"Flags": "chrome://flags/",
"Version": "chrome://version/",
"GPU": "chrome://gpu/",
"Network": "chrome://net-internals/",
"Inspect": "chrome://inspect/",
"Media": "chrome://media-internals/",
"Components": "chrome://components/",
"System": "chrome://system/",
"Chrome URLs": "chrome://chrome-urls/" # This shows all available chrome:// URLs
}
# Add a separator and debug options
self.chrome_menu.addSeparator()
debug_action = self.chrome_menu.addAction("Debug Chrome URLs")
debug_action.triggered.connect(self.debug_chrome_urls)
for name, url in chrome_urls.items():
action = self.chrome_menu.addAction(name)
action.triggered.connect(lambda checked, u=url: self.navigate_to_chrome_url(u))
self.chrome_button.setMenu(self.chrome_menu)
self.toolbar.addWidget(self.chrome_button)
# Add new window button
self.new_window_button = QPushButton()
self.new_window_button.setIcon(self._get_themed_icon(QStyle.StandardPixmap.SP_FileIcon))
self.new_window_button.setToolTip("New Window (Ctrl+W)")
self.new_window_button.clicked.connect(self.open_new_window)
self.toolbar.addWidget(self.new_window_button)
if debug:
self.debug_button = QPushButton()
self.debug_button.setIcon(self._get_themed_icon(QStyle.StandardPixmap.SP_MessageBoxWarning))
self.debug_button.setToolTip("Toggle Developer Tools")
self.debug_button.clicked.connect(self.toggle_devtools)
self.toolbar.addWidget(self.debug_button)
self.layout.addWidget(self.toolbar)
# Create tab widget
self.tabs = QTabWidget()
self.tab_bar = DetachableTabBar()
self.tabs.setTabBar(self.tab_bar)
self.tab_bar.tabDetached.connect(self.handle_tab_detached)
self.tabs.setTabsClosable(True)
# self.tabs.setMovable(True) # This is handled by our DetachableTabBar
self.tabs.setDocumentMode(True)
self.tabs.tabCloseRequested.connect(self.close_tab)
self.tabs.currentChanged.connect(self.tab_changed)
self.layout.addWidget(self.tabs)
# Add status bar
self.status_bar = QStatusBar()
self.setStatusBar(self.status_bar)
# Add a new tab button
self.tabs.setCornerWidget(self._create_new_tab_button(), Qt.Corner.TopRightCorner)
# Set the home URL from config
config = configparser.ConfigParser()
config.read('shmcamstudio.conf')
self.home_url = config.get('Browser', 'home_url', fallback='https://www.google.com')
# Centralized browser profile
if not profile_path:
# This should not happen if main is used, but as a fallback
profile_path = "data/browser_profile_default"
self.profile_dir = profile_path
if not os.path.exists(self.profile_dir):
os.makedirs(self.profile_dir)
# Extensions must be in a specific subdirectory of the profile
self.extensions_dir = os.path.join(self.profile_dir, "Default", "Extensions")
if not os.path.exists(self.extensions_dir):
os.makedirs(self.extensions_dir)
self.profile = QWebEngineProfile(self.profile_dir, self)
# Enable extension developer mode to allow loading unpacked extensions
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.AllowWindowActivationFromJavaScript, True)
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanAccessClipboard, True)
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessFileUrls, True)
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.AllowRunningInsecureContent, True)
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanPaste, True)
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows, True)
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.LocalStorageEnabled, True)
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.WebGLEnabled, True)
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.PluginsEnabled, True)
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.FullScreenSupportEnabled, True)
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, True)
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.ErrorPageEnabled, False)
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.FocusOnNavigationEnabled, True)
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, True)
# Enable access to chrome:// URLs and other special URLs
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, True)
# Additional settings to ensure chrome:// URLs work
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.ErrorPageEnabled, False)
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.FocusOnNavigationEnabled, True)
# Set developer mode flag in preferences
self.profile.setHttpUserAgent(self.profile.httpUserAgent() + " ChromiumExtensionDevMode")
# Enable Autofill feature for QWebEngine (using preferences instead of WebAttribute)
# Note: AutoFillEnabled attribute is not available in this PyQt6 version
# Create a directory for extension preferences if it doesn't exist
prefs_dir = os.path.join(self.profile_dir, "Default")
if not os.path.exists(prefs_dir):
os.makedirs(prefs_dir)
# Create or update the Preferences file to enable extension developer mode
prefs_file = os.path.join(prefs_dir, "Preferences")
prefs_data = {}
if os.path.exists(prefs_file):
try:
with open(prefs_file, 'r') as f:
prefs_data = json.load(f)
except json.JSONDecodeError:
# If the file exists but is invalid JSON, start with an empty dict
pass
# Set extension developer mode preferences
if 'extensions' not in prefs_data:
prefs_data['extensions'] = {}
prefs_data['extensions']['developer_mode'] = True
# Enable unpacked extensions and user scripts
if 'extensions' in prefs_data:
# Allow loading unpacked extensions
prefs_data['extensions']['allow_file_access'] = True
# Allow user scripts (required by some extensions like Lovense)
prefs_data['extensions']['allow_user_scripts'] = True
# Enable Autofill in preferences
if 'autofill' not in prefs_data:
prefs_data['autofill'] = {}
prefs_data['autofill']['enabled'] = True
# Enable additional features for extensions
if 'browser' not in prefs_data:
prefs_data['browser'] = {}
if 'enabled_labs_experiments' not in prefs_data['browser']:
prefs_data['browser']['enabled_labs_experiments'] = []
# Add Autofill experiments if not already present
autofill_experiments = [
"enable-autofill-credit-card-upload",
"enable-autofill-credit-card-authentication",
"enable-autofill-address-save-prompts",
"enable-experimental-web-platform-features"
]
for experiment in autofill_experiments:
if experiment not in prefs_data['browser']['enabled_labs_experiments']:
prefs_data['browser']['enabled_labs_experiments'].append(experiment)
# Write the updated preferences back to the file
with open(prefs_file, 'w') as f:
json.dump(prefs_data, f, indent=2)
# Check for Lovense extension and configure it specifically
lovense_dir = os.path.join(self.extensions_dir, "lovense")
if os.path.exists(lovense_dir):
print("Lovense extension found, applying special configuration...")
# Create secure preferences file if it doesn't exist
secure_prefs_file = os.path.join(self.profile_dir, "Default", "Secure Preferences")
secure_prefs_data = {}
if os.path.exists(secure_prefs_file):
try:
with open(secure_prefs_file, 'r') as f:
secure_prefs_data = json.load(f)
except:
secure_prefs_data = {}
# Ensure extensions section exists
if 'extensions' not in secure_prefs_data:
secure_prefs_data['extensions'] = {}
# Add settings for Lovense
if 'settings' not in secure_prefs_data['extensions']:
secure_prefs_data['extensions']['settings'] = {}
# Set Lovense as trusted
secure_prefs_data['extensions']['settings']["lovense"] = {
"ack_external_install_prompt": True,
"installed_by_default": False,
"installed_by_oem": False,
"installed_by_policy": False,
"mark_acknowledged_external_install": True,
"was_installed_by_enterprise_policy": False
}
# Write secure preferences
with open(secure_prefs_file, 'w') as f:
json.dump(secure_prefs_data, f, indent=2)
# Create Local State file with extension settings
local_state_file = os.path.join(self.profile_dir, "Local State")
local_state_data = {}
if os.path.exists(local_state_file):
try:
with open(local_state_file, 'r') as f:
local_state_data = json.load(f)
except:
local_state_data = {}
# Ensure extensions section exists
if 'extensions' not in local_state_data:
local_state_data['extensions'] = {}
# Enable developer mode in local state
local_state_data['extensions']['ui'] = {
"developer_mode": True
}
# Write local state
with open(local_state_file, 'w') as f:
json.dump(local_state_data, f, indent=2)
print("Lovense extension configuration complete")
# Install URL scheme handlers
self.extension_scheme_handler = ExtensionSchemeHandler(self.extensions_dir)
# Reinstall URL scheme handlers
self.profile.installUrlSchemeHandler(b"qextension", self.extension_scheme_handler)
# Re-register chrome:// protocol with Qt
QWebEngineProfile.defaultProfile().setUrlRequestInterceptor(
ChromeUrlInterceptor(self)
)
# Register chrome:// protocol with Qt
QWebEngineProfile.defaultProfile().setUrlRequestInterceptor(
ChromeUrlInterceptor(self)
)
if detached_tab:
widget, title = detached_tab
index = self.tabs.addTab(widget, title)
self.tabs.setCurrentIndex(index)
widget.browser = self
widget.urlChanged.connect(self.update_url)
widget.titleChanged.connect(lambda t, tab=widget: self.update_title(t, tab))
widget.loadStatusChanged.connect(self.update_status_bar)
if hasattr(widget, 'web_view'):
self.update_title(widget.web_view.title(), widget)
self.update_url(widget.web_view.url().toString())
else:
# Create a new tab on startup
url_to_open = initial_url if initial_url is not None else self.home_url
self.new_page(url_to_open)
# Set up keyboard shortcuts
self._setup_shortcuts()
# Store Playwright objects
self.playwright = None
self.playwright_browser = None
self.browser_contexts = {}
self.dev_tools_windows = []
self.open_extension_popups = []
self.extension_actions = []
self.update_extension_buttons()
def closeEvent(self, event):
if self in Browser.instances:
Browser.instances.remove(self)
super().closeEvent(event)
def _get_themed_icon(self, standard_pixmap, color="white"):
"""Create a themed icon from a standard pixmap."""
icon = self.style().standardIcon(standard_pixmap)
pixmap = icon.pixmap(self.toolbar.iconSize())
painter = QPainter(pixmap)
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn)
painter.fillRect(pixmap.rect(), QColor(color))
painter.end()
return QIcon(pixmap)
def _create_new_tab_button(self):
"""Create a button for adding new tabs."""
button = QPushButton("+")
button.setToolTip("New Tab")
button.clicked.connect(lambda: self.new_page(self.home_url))
return button
def _setup_shortcuts(self):
"""Set up keyboard shortcuts."""
# Ctrl+T: New Tab
new_tab_shortcut = QAction("New Tab", self)
new_tab_shortcut.setShortcut(QKeySequence("Ctrl+T"))
new_tab_shortcut.triggered.connect(lambda: self.new_page(self.home_url))
self.addAction(new_tab_shortcut)
# Ctrl+Tab: Cycle Tabs
cycle_tabs_shortcut = QAction("Cycle Tabs", self)
cycle_tabs_shortcut.setShortcut(QKeySequence("Ctrl+Tab"))
cycle_tabs_shortcut.triggered.connect(self.cycle_tabs)
self.addAction(cycle_tabs_shortcut)
# Ctrl+W: New Window
new_window_shortcut = QAction("New Window", self)
new_window_shortcut.setShortcut(QKeySequence("Ctrl+W"))
new_window_shortcut.triggered.connect(self.open_new_window)
self.addAction(new_window_shortcut)
# Ctrl+L: Focus URL bar
focus_url_shortcut = QAction("Focus URL", self)
focus_url_shortcut.setShortcut(QKeySequence("Ctrl+L"))
focus_url_shortcut.triggered.connect(self.url_input.setFocus)
self.addAction(focus_url_shortcut)
# F5: Reload page
reload_shortcut = QAction("Reload", self)
reload_shortcut.setShortcut(QKeySequence("F5"))
reload_shortcut.triggered.connect(self.reload_page)
self.addAction(reload_shortcut)
def cycle_tabs(self):
"""Cycle through the open tabs."""
if self.tabs.count() > 1:
next_index = (self.tabs.currentIndex() + 1) % self.tabs.count()
self.tabs.setCurrentIndex(next_index)
def on_tab_removed(self):
"""Close the window if the last tab is removed."""
if self.tabs.count() == 0:
self.close()
def handle_tab_detached(self, index, pos):
"""Handle a tab being detached to a new window."""
if index < 0 or index >= self.tabs.count():
return
widget = self.tabs.widget(index)
title = self.tabs.tabText(index)
try:
widget.urlChanged.disconnect(self.update_url)
widget.titleChanged.disconnect()
widget.loadStatusChanged.disconnect(self.update_status_bar)
except (TypeError, RuntimeError):
pass
# Create a new browser instance, which will take ownership of the widget.
new_browser = Browser(
debug=("--debug" in sys.argv),
detached_tab=(widget, title),
profile_path=self.profile_dir
)
new_browser.setGeometry(self.geometry())
new_browser.move(pos - QPoint(new_browser.width() // 4, 10))
new_browser.show()
# Now that the widget is safely reparented, remove the tab from the old browser.
# The on_tab_removed method will handle closing the window if it's empty.
self.tabs.removeTab(index)
self.on_tab_removed()
def new_page(self, url="about:blank"):
"""Create a new browser tab with the specified URL."""
# Create a new tab
tab = BrowserTab(parent=self.tabs, url=url, profile=self.profile)
# Store a reference to the Browser instance
tab.browser = self
tab.urlChanged.connect(self.update_url)
tab.titleChanged.connect(lambda title, tab=tab: self.update_title(title, tab))
tab.loadStatusChanged.connect(self.update_status_bar)
# Add the tab to the tab widget
index = self.tabs.addTab(tab, "New Tab")
self.tabs.setCurrentIndex(index)
return tab
def close_tab(self, index):
"""Close the tab at the specified index."""
if index < 0 or index >= self.tabs.count():
return
# Get the tab widget and schedule it for deletion.
tab = self.tabs.widget(index)
if tab:
tab.deleteLater()
# Remove the tab from the tab bar.
self.tabs.removeTab(index)
self.on_tab_removed()
def tab_changed(self, index):
"""Handle tab change events."""
if index < 0:
return
# Update the URL input
tab = self.tabs.widget(index)
if hasattr(tab, 'url'):
self.url_input.setText(tab.url)
self.update_status_bar()
def update_url(self, url):
"""Update the URL input when the page URL changes."""
self.url_input.setText(url)
# Update the tab's stored URL
current_tab = self.tabs.currentWidget()
if current_tab:
current_tab.url = url
def update_title(self, title, tab):
"""Update the tab title when the page title changes."""
index = self.tabs.indexOf(tab)
if index >= 0:
self.tabs.setTabText(index, title or "New Tab")
def navigate_to_url(self):
"""Navigate to the URL in the URL input."""
url = self.url_input.text().strip()
# Add http:// if no protocol is specified
if url and not url.startswith(("http://", "https://", "file://", "about:", "chrome://")):
url = "http://" + url
# Navigate the current tab
current_tab = self.tabs.currentWidget()
if current_tab:
current_tab.navigate(url)
def navigate_home(self):
"""Navigate to the home URL."""
current_tab = self.tabs.currentWidget()
if current_tab:
current_tab.navigate(self.home_url)
def navigate_to_chrome_url(self, url):
"""Navigate to a chrome:// URL."""
print(f"Navigating to chrome URL: {url}")
# Create a new tab specifically for chrome:// URLs
tab = self.new_page("about:blank")
# Ensure the tab is using our custom ChromeWebEnginePage
if hasattr(tab, 'web_view'):
# If the page isn't already a ChromeWebEnginePage, replace it
if not isinstance(tab.web_view.page(), ChromeWebEnginePage):
page = ChromeWebEnginePage(self.profile, tab.web_view)
tab.web_view.setPage(page)
# Now navigate to the chrome:// URL
tab.web_view.load(QUrl(url))
print(f"Created new tab for chrome URL: {url}")
def navigate_back(self):
"""Navigate back in the current tab."""
current_tab = self.tabs.currentWidget()
if current_tab and hasattr(current_tab, 'web_view'):
current_tab.web_view.back()
def navigate_forward(self):
"""Navigate forward in the current tab."""
current_tab = self.tabs.currentWidget()
if current_tab and hasattr(current_tab, 'web_view'):
current_tab.web_view.forward()
def reload_page(self):
"""Reload the current tab."""
current_tab = self.tabs.currentWidget()
if current_tab and hasattr(current_tab, 'web_view'):
current_tab.web_view.reload()
def open_new_window(self):
"""Open a new browser window."""
new_browser = Browser(debug="--debug" in sys.argv, profile_path=self.profile_dir)
new_browser.show()
def toggle_devtools(self):
"""Opens the remote developer tools URL in a new window or shows extension debug info."""
# Create a dialog to show debug options
debug_dialog = QDialog(self)
debug_dialog.setWindowTitle("Developer Tools")
debug_dialog.resize(800, 600)
layout = QVBoxLayout()
# Add tabs for different debug options
tabs = QTabWidget()
# Tab 1: Remote DevTools
remote_tab = QWidget()
remote_layout = QVBoxLayout(remote_tab)
web_view = QWebEngineView()
web_view.load(QUrl("http://localhost:9222"))
remote_layout.addWidget(web_view)
# Tab 2: Extension Debug Info
ext_tab = QWidget()
ext_layout = QVBoxLayout(ext_tab)
# Add a text area to show preferences file content
prefs_label = QLabel("Preferences File Content:")
ext_layout.addWidget(prefs_label)
prefs_text = QTextEdit()
prefs_text.setReadOnly(True)
ext_layout.addWidget(prefs_text)
# Add a button to refresh the preferences content
refresh_button = QPushButton("Refresh Preferences")
ext_layout.addWidget(refresh_button)
# Add a button to force enable developer mode
force_button = QPushButton("Force Enable Developer Mode")
ext_layout.addWidget(force_button)
# Add a button specifically for fixing the Lovense extension
lovense_button = QPushButton("Fix Lovense Extension")
ext_layout.addWidget(lovense_button)
# Function to load and display preferences
def load_preferences():
prefs_file = os.path.join(self.profile_dir, "Default", "Preferences")
if os.path.exists(prefs_file):
try:
with open(prefs_file, 'r') as f:
prefs_data = json.load(f)
# Format the JSON for better readability
formatted_json = json.dumps(prefs_data, indent=2)
prefs_text.setText(formatted_json)
# Highlight extension developer mode status
ext_dev_mode = prefs_data.get('extensions', {}).get('developer_mode', False)
allow_file_access = prefs_data.get('extensions', {}).get('allow_file_access', False)
allow_user_scripts = prefs_data.get('extensions', {}).get('allow_user_scripts', False)
status_text = f"Extension Developer Mode: {ext_dev_mode}\n"
status_text += f"Allow File Access: {allow_file_access}\n"
status_text += f"Allow User Scripts: {allow_user_scripts}\n"
QMessageBox.information(debug_dialog, "Extension Settings Status", status_text)
except Exception as e:
prefs_text.setText(f"Error loading preferences: {e}")
else:
prefs_text.setText("Preferences file not found")
# Function to force enable developer mode
def force_developer_mode():
prefs_file = os.path.join(self.profile_dir, "Default", "Preferences")
if os.path.exists(prefs_file):
try:
with open(prefs_file, 'r') as f:
prefs_data = json.load(f)
# Ensure extensions section exists
if 'extensions' not in prefs_data:
prefs_data['extensions'] = {}
# Force enable all developer mode settings
prefs_data['extensions']['developer_mode'] = True
prefs_data['extensions']['allow_file_access'] = True
prefs_data['extensions']['allow_user_scripts'] = True
# Write back to file
with open(prefs_file, 'w') as f:
json.dump(prefs_data, f, indent=2)
# Reload the profile
self.reload_profile_and_tabs()
QMessageBox.information(debug_dialog, "Success",
"Developer mode forcefully enabled. Profile reloaded.")
# Refresh the displayed preferences
load_preferences()
except Exception as e:
QMessageBox.critical(debug_dialog, "Error", f"Could not force developer mode: {e}")
else:
QMessageBox.warning(debug_dialog, "Error", "Preferences file not found")
# Function to specifically fix the Lovense extension
def fix_lovense_extension():
# Check if Lovense extension exists
lovense_dir = os.path.join(self.extensions_dir, "lovense")
if not os.path.exists(lovense_dir):
QMessageBox.warning(debug_dialog, "Error", "Lovense extension not found in extensions directory")
return
try:
# 1. Update preferences file with specific settings for Lovense
prefs_file = os.path.join(self.profile_dir, "Default", "Preferences")
if os.path.exists(prefs_file):
with open(prefs_file, 'r') as f:
prefs_data = json.load(f)
# Ensure extensions section exists
if 'extensions' not in prefs_data:
prefs_data['extensions'] = {}
# Force enable all developer mode settings
prefs_data['extensions']['developer_mode'] = True
prefs_data['extensions']['allow_file_access'] = True
prefs_data['extensions']['allow_user_scripts'] = True
# Add specific settings for Lovense extension
if 'settings' not in prefs_data['extensions']:
prefs_data['extensions']['settings'] = {}
# Get Lovense extension ID (directory name)
lovense_id = "lovense"
# Set specific permissions for Lovense
prefs_data['extensions']['settings'][lovense_id] = {
"active_permissions": {
"api": ["storage", "unlimitedStorage", "userScripts", "desktopCapture"],
"explicit_host": ["<all_urls>"],
"manifest_permissions": [],
"scriptable_host": ["<all_urls>"]
},
"granted_permissions": {
"api": ["storage", "unlimitedStorage", "userScripts", "desktopCapture"],
"explicit_host": ["<all_urls>"],
"manifest_permissions": [],
"scriptable_host": ["<all_urls>"]
},
"location": 1, # 1 = unpacked extension
"runtime_allowed_hosts": ["*://*.lovense.com/*", "*://localhost/*"],
"runtime_blocked_hosts": []
}
# Write back to file
with open(prefs_file, 'w') as f:
json.dump(prefs_data, f, indent=2)
# 2. Create a secure_preferences file if it doesn't exist
secure_prefs_file = os.path.join(self.profile_dir, "Default", "Secure Preferences")
secure_prefs_data = {}
if os.path.exists(secure_prefs_file):
try:
with open(secure_prefs_file, 'r') as f:
secure_prefs_data = json.load(f)
except:
secure_prefs_data = {}
# Ensure extensions section exists
if 'extensions' not in secure_prefs_data:
secure_prefs_data['extensions'] = {}
# Add settings for Lovense
if 'settings' not in secure_prefs_data['extensions']:
secure_prefs_data['extensions']['settings'] = {}
# Set Lovense as trusted
secure_prefs_data['extensions']['settings']["lovense"] = {
"ack_external_install_prompt": True,
"installed_by_default": False,
"installed_by_oem": False,
"installed_by_policy": False,
"mark_acknowledged_external_install": True,
"was_installed_by_enterprise_policy": False
}
# Write secure preferences
with open(secure_prefs_file, 'w') as f:
json.dump(secure_prefs_data, f, indent=2)
# 3. Create Local State file with extension settings
local_state_file = os.path.join(self.profile_dir, "Local State")
local_state_data = {}
if os.path.exists(local_state_file):
try:
with open(local_state_file, 'r') as f:
local_state_data = json.load(f)
except:
local_state_data = {}
# Ensure extensions section exists
if 'extensions' not in local_state_data:
local_state_data['extensions'] = {}
# Enable developer mode in local state
local_state_data['extensions']['ui'] = {
"developer_mode": True
}
# Write local state
with open(local_state_file, 'w') as f:
json.dump(local_state_data, f, indent=2)
# Reload the profile
self.reload_profile_and_tabs()
QMessageBox.information(debug_dialog, "Success",
"Lovense extension has been fixed and profile reloaded.")
# Refresh the displayed preferences
load_preferences()
except Exception as e:
QMessageBox.critical(debug_dialog, "Error", f"Could not fix Lovense extension: {e}")
# Connect buttons to functions
refresh_button.clicked.connect(load_preferences)
force_button.clicked.connect(force_developer_mode)
lovense_button.clicked.connect(fix_lovense_extension)
# Load preferences initially
load_preferences()
# Add tabs to the tab widget
tabs.addTab(remote_tab, "Remote DevTools")
tabs.addTab(ext_tab, "Extension Debug")
layout.addWidget(tabs)
debug_dialog.setLayout(layout)
debug_dialog.show()
self.dev_tools_windows.append(debug_dialog)
debug_dialog.finished.connect(lambda: self.dev_tools_windows.remove(debug_dialog))
def update_status_bar(self):
"""Update the status bar based on the current tab's state."""
current_tab = self.tabs.currentWidget()
if not current_tab:
self.status_bar.showMessage("Ready")
return
if current_tab.is_loading:
self.status_bar.showMessage("Loading...")
else:
self.status_bar.showMessage("Ready")
def reload_profile_and_tabs(self):
"""Reloads the browser profile and recreates pages in all tabs."""
print("Reloading profile to apply extension changes...")
# 1. Save the URLs and current index of all open tabs
urls = []
current_index = self.tabs.currentIndex()
for i in range(self.tabs.count()):
tab = self.tabs.widget(i)
if hasattr(tab, 'web_view') and tab.web_view.url().isValid():
urls.append(tab.web_view.url().toString())
else:
urls.append("about:blank")
# 2. Store the old profile to delete it later
old_profile = self.profile
old_profile.clearHttpCache()
# 3. Clear all existing tabs. This removes references to pages using the old profile.
self.tabs.clear()
# 4. Create a new profile object. This forces a re-read from disk because
# the old one will be properly deleted.
self.profile = QWebEngineProfile(self.profile_dir, self)
# Re-enable extension developer mode and Autofill settings
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.AllowWindowActivationFromJavaScript, True)
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanAccessClipboard, True)
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessFileUrls, True)
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.AllowRunningInsecureContent, True)
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanPaste, True)
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows, True)
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.LocalStorageEnabled, True)
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.WebGLEnabled, True)
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.PluginsEnabled, True)
self.profile.settings().setAttribute(QWebEngineSettings.WebAttribute.FullScreenSupportEnabled, True)
# Set developer mode flag in preferences
self.profile.setHttpUserAgent(self.profile.httpUserAgent() + " ChromiumExtensionDevMode")
# Re-apply extension developer mode settings to preferences file
prefs_dir = os.path.join(self.profile_dir, "Default")
prefs_file = os.path.join(prefs_dir, "Preferences")
if os.path.exists(prefs_file):
try:
with open(prefs_file, 'r') as f:
prefs_data = json.load(f)
# Ensure extension developer mode is enabled
if 'extensions' not in prefs_data:
prefs_data['extensions'] = {}
prefs_data['extensions']['developer_mode'] = True
prefs_data['extensions']['allow_file_access'] = True
prefs_data['extensions']['allow_user_scripts'] = True
# Write the updated preferences back to the file
with open(prefs_file, 'w') as f:
json.dump(prefs_data, f, indent=2)
# Check for Lovense extension and reconfigure it
lovense_dir = os.path.join(self.extensions_dir, "lovense")
if os.path.exists(lovense_dir):
print("Lovense extension found during reload, reapplying special configuration...")
# Update specific settings for Lovense extension in preferences
if 'settings' not in prefs_data['extensions']:
prefs_data['extensions']['settings'] = {}
# Set specific permissions for Lovense
prefs_data['extensions']['settings']["lovense"] = {
"active_permissions": {
"api": ["storage", "unlimitedStorage", "userScripts", "desktopCapture"],
"explicit_host": ["<all_urls>"],
"manifest_permissions": [],
"scriptable_host": ["<all_urls>"]
},
"granted_permissions": {
"api": ["storage", "unlimitedStorage", "userScripts", "desktopCapture"],
"explicit_host": ["<all_urls>"],
"manifest_permissions": [],
"scriptable_host": ["<all_urls>"]
},
"location": 1, # 1 = unpacked extension
"runtime_allowed_hosts": ["*://*.lovense.com/*", "*://localhost/*"],
"runtime_blocked_hosts": []
}
# Write updated preferences with Lovense settings
with open(prefs_file, 'w') as f:
json.dump(prefs_data, f, indent=2)
# Update secure preferences
secure_prefs_file = os.path.join(self.profile_dir, "Default", "Secure Preferences")
secure_prefs_data = {}
if os.path.exists(secure_prefs_file):
try:
with open(secure_prefs_file, 'r') as f:
secure_prefs_data = json.load(f)
except:
secure_prefs_data = {}
# Ensure extensions section exists
if 'extensions' not in secure_prefs_data:
secure_prefs_data['extensions'] = {}
# Add settings for Lovense
if 'settings' not in secure_prefs_data['extensions']:
secure_prefs_data['extensions']['settings'] = {}
# Set Lovense as trusted
secure_prefs_data['extensions']['settings']["lovense"] = {
"ack_external_install_prompt": True,
"installed_by_default": False,
"installed_by_oem": False,
"installed_by_policy": False,
"mark_acknowledged_external_install": True,
"was_installed_by_enterprise_policy": False
}
# Write secure preferences
with open(secure_prefs_file, 'w') as f:
json.dump(secure_prefs_data, f, indent=2)
# Update Local State file
local_state_file = os.path.join(self.profile_dir, "Local State")
local_state_data = {}
if os.path.exists(local_state_file):
try:
with open(local_state_file, 'r') as f:
local_state_data = json.load(f)
except:
local_state_data = {}
# Ensure extensions section exists
if 'extensions' not in local_state_data:
local_state_data['extensions'] = {}
# Enable developer mode in local state
local_state_data['extensions']['ui'] = {
"developer_mode": True
}
# Write local state
with open(local_state_file, 'w') as f:
json.dump(local_state_data, f, indent=2)
print("Lovense extension reconfiguration complete during reload")
except Exception as e:
print(f"Error updating preferences file during reload: {e}")
self.profile.installUrlSchemeHandler(b"qextension", self.extension_scheme_handler)
# 5. Re-create all tabs with the new profile
if not urls: # Ensure there's at least one tab
urls.append(self.home_url)
for url in urls:
self.new_page(url)
# 6. Restore the previously active tab
if current_index != -1 and current_index < len(urls):
self.tabs.setCurrentIndex(current_index)
# 7. Schedule the old profile for deletion. This is crucial.
old_profile.setParent(None)
old_profile.deleteLater()
print("Profile and tabs reloaded.")
self.update_extension_buttons()
def open_extension_popup_from_toolbar(self, ext_path):
"""Opens an extension's popup from a toolbar button click."""
ext_name = os.path.basename(ext_path)
manifest_path = os.path.join(ext_path, "manifest.json")
if not os.path.exists(manifest_path):
QMessageBox.warning(self, "Error", "manifest.json not found for this extension.")
return
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
except Exception as e:
QMessageBox.critical(self, "Error", f"Could not read manifest.json: {e}")
return
popup_path = None
action = manifest.get('action') or manifest.get('browser_action') or manifest.get('page_action')
if action and action.get('default_popup'):
popup_path = action.get('default_popup')
if not popup_path:
# This should not happen if the button was created, but as a safeguard:
QMessageBox.information(self, "No Popup", "This extension does not have a popup.")
return
popup_file = os.path.join(ext_path, popup_path)
if not os.path.exists(popup_file):
QMessageBox.warning(self, "Error", f"Popup file not found: {popup_path}")
return
# Open the popup in a new non-modal dialog
popup_dialog = QDialog(self)
popup_dialog.setWindowTitle(manifest.get("name", "Extension Popup"))
layout = QVBoxLayout(popup_dialog)
web_view = QWebEngineView()
# The 'self' here is the Browser instance, so self.profile is correct
page = QWebEnginePage(self.profile, web_view)
web_view.setPage(page)
popup_url = QUrl(f"qextension://{ext_name}/{popup_path}")
web_view.load(popup_url)
layout.addWidget(web_view)
popup_dialog.setLayout(layout)
popup_dialog.resize(400, 600)
popup_dialog.show()
# Keep a reference to prevent garbage collection
self.open_extension_popups.append(popup_dialog)
popup_dialog.finished.connect(lambda: self.open_extension_popups.remove(popup_dialog))
def update_extension_buttons(self):
"""Scans for extensions and adds a button for each one with a popup to the main toolbar."""
# Remove previous extension buttons
for action in self.extension_actions:
self.toolbar.removeAction(action)
self.extension_actions.clear()
if not self.extensions_dir or not os.path.exists(self.extensions_dir):
return
# Find all enabled extension directories
for ext_name in sorted(os.listdir(self.extensions_dir)):
if ext_name.endswith(".disabled"):
continue
ext_path = os.path.join(self.extensions_dir, ext_name)
if not os.path.isdir(ext_path):
continue
manifest_path = os.path.join(ext_path, "manifest.json")
if not os.path.exists(manifest_path):
continue
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
except Exception as e:
print(f"Error loading manifest for {ext_name}: {e}")
continue
# Check for a popup action
popup_path = None
action = manifest.get('action') or manifest.get('browser_action') or manifest.get('page_action')
if action and action.get('default_popup'):
popup_path = action.get('default_popup')
if popup_path:
# Create a button for this extension
button = QPushButton()
button.setFlat(True)
button.setIconSize(QSize(22, 22))
# Set the icon
icon_path = None
if manifest.get("icons"):
# Prefer a 24px or 16px icon for the toolbar
icon_path = manifest["icons"].get("24") or manifest["icons"].get("16") or next(iter(manifest["icons"].values()), None)
if icon_path:
full_icon_path = os.path.join(ext_path, icon_path)
if os.path.exists(full_icon_path):
button.setIcon(QIcon(full_icon_path))
button.setToolTip(manifest.get("name", ext_name))
button.clicked.connect(partial(self.open_extension_popup_from_toolbar, ext_path))
# Insert the button before the main "Extensions" button
action = self.toolbar.insertWidget(self.extensions_button_action, button)
self.extension_actions.append(action)
def debug_chrome_urls(self):
"""Debug chrome:// URLs by showing information and testing navigation."""
debug_dialog = QDialog(self)
debug_dialog.setWindowTitle("Chrome URL Debug")
debug_dialog.resize(600, 500)
layout = QVBoxLayout(debug_dialog)
# Add information about the current configuration
info_text = QTextEdit()
info_text.setReadOnly(True)
# Collect debug information
debug_info = [
"Chrome URL Debug Information:",
"----------------------------",
f"Profile Path: {self.profile_dir}",
f"User Agent: {self.profile.httpUserAgent()}",
"WebEngine Settings:",
f" LocalContentCanAccessRemoteUrls: {self.profile.settings().testAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls)}",
f" ErrorPageEnabled: {self.profile.settings().testAttribute(QWebEngineSettings.WebAttribute.ErrorPageEnabled)}",
f" FocusOnNavigationEnabled: {self.profile.settings().testAttribute(QWebEngineSettings.WebAttribute.FocusOnNavigationEnabled)}",
f" AllowRunningInsecureContent: {self.profile.settings().testAttribute(QWebEngineSettings.WebAttribute.AllowRunningInsecureContent)}",
"Environment Variables:",
f" QTWEBENGINE_CHROMIUM_FLAGS: {os.environ.get('QTWEBENGINE_CHROMIUM_FLAGS', 'Not set')}",
"----------------------------",
"Instructions:",
"1. Click 'Test Chrome URL' to open chrome://version/ in a new tab",
"2. If it doesn't work, try restarting the browser with --debug flag",
"3. Check if the custom ChromeWebEnginePage is being used correctly"
]
info_text.setText("\n".join(debug_info))
layout.addWidget(info_text)
# Add buttons for testing
button_layout = QHBoxLayout()
test_button = QPushButton("Test Chrome URL")
test_button.clicked.connect(lambda: self.navigate_to_chrome_url("chrome://version/"))
button_layout.addWidget(test_button)
close_button = QPushButton("Close")
close_button.clicked.connect(debug_dialog.accept)
button_layout.addWidget(close_button)
layout.addLayout(button_layout)
debug_dialog.exec()
def show_extensions(self):
"""Show the extensions dialog."""
dialog = ExtensionDialog(self, self.extensions_dir)
dialog.exec()
class QPlaywrightBrowser:
"""
A class that wraps the Playwright browser API and uses Qt6Browser internally.
This class exposes the Playwright API while using the Qt6 browser for display.
"""
def __init__(self):
self.app = QApplication.instance() or QApplication(sys.argv)
self.browser_ui = None
self.playwright = None
self.browser = None
self.contexts = {}
self.event_loop = None
async def launch(self, **kwargs):
"""Launch a new browser instance."""
# Initialize Playwright if not already done
if not self.playwright:
self.playwright = await async_playwright().start()
# Create the Qt browser UI
if not self.browser_ui:
self.browser_ui = Browser()
self.browser_ui.show()
# Launch the actual Playwright browser (hidden)
# We'll use this for API compatibility but display in Qt
self.browser = await self.playwright.chromium.launch(
headless=True, # Run headless since we're displaying in Qt
args = [
'--headless=new',
'--enable-features=Autofill',
]+os.environ.get('QTWEBENGINE_CHROMIUM_FLAGS').split(),
**kwargs
)
#
# Create a default context
default_context = await self.browser.new_context()
self.contexts["default"] = default_context
return self
async def new_context(self, **kwargs):
"""Create a new browser context."""
if not self.browser:
await self.launch()
# Create a new context in the Playwright browser
context_id = f"context_{len(self.contexts)}"
context = await self.browser.new_context(**kwargs)
self.contexts[context_id] = context
# Return a wrapper that provides both Playwright API and Qt UI
return QPlaywrightBrowserContext(self, context, context_id)
async def new_page(self, url="about:blank"):
"""Create a new page in the default context."""
if "default" not in self.contexts:
await self.launch()
# Create a new page in the Playwright browser
pw_page = await self.contexts["default"].new_page()
await pw_page.goto(url)
# Create a new tab in the Qt browser
qt_tab = self.browser_ui.new_page(url)
qt_tab.page = pw_page
# Return a wrapper that provides both Playwright API and Qt UI
return QPlaywrightPage(pw_page, qt_tab)
async def close(self):
"""Close the browser."""
# Close all Playwright contexts and browser
if self.browser:
for context_id, context in self.contexts.items():
await context.close()
await self.browser.close()
self.contexts = {}
self.browser = None
# Close the Qt browser UI
if self.browser_ui:
self.browser_ui.close()
self.browser_ui = None
# Close Playwright
if self.playwright:
await self.playwright.stop()
self.playwright = None
def run(self):
"""Run the application event loop."""
return self.app.exec()
def run_async(self, coro):
"""Run an async coroutine in the Qt event loop."""
if not self.event_loop:
self.event_loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.event_loop)
# Create a QTimer to process asyncio events
timer = QTimer()
timer.timeout.connect(lambda: self.event_loop.run_until_complete(asyncio.sleep(0)))
timer.start(10) # 10ms interval
# Run the coroutine
future = asyncio.run_coroutine_threadsafe(coro, self.event_loop)
return future
class QPlaywrightBrowserContext:
"""
A wrapper around a Playwright BrowserContext that provides both
Playwright API and Qt UI integration.
"""
def __init__(self, qbrowser, context, context_id):
self.qbrowser = qbrowser
self.context = context
self.context_id = context_id
self.pages = []
async def new_page(self):
"""Create a new page in this context."""
# Create a new page in the Playwright context
pw_page = await self.context.new_page()
# Create a new tab in the Qt browser
qt_tab = self.qbrowser.browser_ui.new_page("about:blank")
qt_tab.page = pw_page
# Create a wrapper page
page = QPlaywrightPage(pw_page, qt_tab)
self.pages.append(page)
return page
async def close(self):
"""Close this context."""
# Close all pages
for page in self.pages:
await page.close()
# Close the Playwright context
await self.context.close()
# Remove from the qbrowser contexts
if self.context_id in self.qbrowser.contexts:
del self.qbrowser.contexts[self.context_id]
class QPlaywrightPage:
"""
A wrapper around a Playwright Page that provides both
Playwright API and Qt UI integration.
"""
def __init__(self, pw_page, qt_tab):
self.pw_page = pw_page
self.qt_tab = qt_tab
# Forward Playwright page methods and properties
self.goto = pw_page.goto
self.click = pw_page.click
self.fill = pw_page.fill
self.type = pw_page.type
self.press = pw_page.press
self.wait_for_selector = pw_page.wait_for_selector
self.wait_for_navigation = pw_page.wait_for_navigation
self.wait_for_load_state = pw_page.wait_for_load_state
self.evaluate = pw_page.evaluate
self.screenshot = pw_page.screenshot
self.content = pw_page.content
self.title = pw_page.title
self.url = pw_page.url
async def close(self):
"""Close this page."""
# Close the Playwright page
await self.pw_page.close()
# Close the Qt tab
# We need to use the Qt event loop for this
index = self.qt_tab.parent.indexOf(self.qt_tab)
if index >= 0:
self.qt_tab.parent.close_tab(index)
async def main_async():
"""Async main function to run the browser."""
browser = QPlaywrightBrowser()
await browser.launch()
# Open a page
url = "https://www.google.com"
if len(sys.argv) > 1:
url = sys.argv[1]
await browser.new_page(url)
# Keep the browser running
while True:
await asyncio.sleep(0.1)
def main():
"""Main function to run the browser as a standalone application."""
# Check for extension developer mode flag
ext_dev_mode = "--ext-dev-mode" in sys.argv
if ext_dev_mode:
print("Extension developer mode explicitly enabled via command line")
# Enable Autofill features and extension developer mode via environment variables
chromium_flags = "--enable-features=AutofillEnableAccountWalletStorage,AutofillAddressProfileSavePrompt,AutofillCreditCardUpload,AutofillEnableToolbarStatusChip,AutofillKeyboardAccessory,AutofillShowAllSuggestionsOnPrefsCheckout,AutofillShowTypePredictions,AutofillUpstream,PasswordGeneration,PasswordGenerationBottomSheetUI,PasswordGenerationExperiment,ExtensionsToolbarMenu,ChromeUIDebugTools --extensions-on-chrome-urls --allow-file-access-from-files --allow-running-insecure-content --enable-user-scripts --allow-universal-access-from-files --disable-web-security --disable-site-isolation-trials --allow-insecure-localhost --ignore-certificate-errors --ignore-urlfetcher-cert-requests --disable-features=BlockInsecurePrivateNetworkRequests"
# Add additional flags for extension developer mode if explicitly requested
if ext_dev_mode:
chromium_flags += " --load-extension=assets/browser/extensions/lovense --disable-extensions-except=assets/browser/extensions/lovense --force-dev-mode-highlighting"
# Explicitly enable chrome:// URLs
chromium_flags += " --enable-chrome-urls --enable-features=ChromeUIDebugTools"
# Register chrome:// as a known scheme
chrome_scheme = QWebEngineUrlScheme(b"chrome")
chrome_scheme.setFlags(QWebEngineUrlScheme.Flag.SecureScheme |
QWebEngineUrlScheme.Flag.LocalScheme |
QWebEngineUrlScheme.Flag.LocalAccessAllowed |
QWebEngineUrlScheme.Flag.ServiceWorkersAllowed)
QWebEngineUrlScheme.registerScheme(chrome_scheme)
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = chromium_flags
debug_mode = "--debug" in sys.argv
if debug_mode:
os.environ["QTWEBENGINE_REMOTE_DEBUGGING"] = "9222"
# Print environment variables for debugging
print(f"QTWEBENGINE_CHROMIUM_FLAGS: {os.environ.get('QTWEBENGINE_CHROMIUM_FLAGS')}")
if debug_mode:
print(f"QTWEBENGINE_REMOTE_DEBUGGING: {os.environ.get('QTWEBENGINE_REMOTE_DEBUGGING')}")
app = QApplication.instance() or QApplication(sys.argv)
# Allow Ctrl+C to kill the application gracefully
signal.signal(signal.SIGINT, signal.SIG_DFL)
app.setWindowIcon(QIcon('assets/logo.jpg'))
# Set a modern, dark theme
app.setStyle("Fusion")
dark_palette = app.palette()
dark_palette.setColor(dark_palette.ColorRole.Window, QColor(45, 45, 45))
dark_palette.setColor(dark_palette.ColorRole.WindowText, Qt.GlobalColor.white)
dark_palette.setColor(dark_palette.ColorRole.Base, QColor(25, 25, 25))
dark_palette.setColor(dark_palette.ColorRole.AlternateBase, QColor(53, 53, 53))
dark_palette.setColor(dark_palette.ColorRole.ToolTipBase, QColor(25, 25, 25))
dark_palette.setColor(dark_palette.ColorRole.ToolTipText, Qt.GlobalColor.white)
dark_palette.setColor(dark_palette.ColorRole.Text, Qt.GlobalColor.white)
dark_palette.setColor(dark_palette.ColorRole.Button, QColor(53, 53, 53))
dark_palette.setColor(dark_palette.ColorRole.ButtonText, Qt.GlobalColor.white)
dark_palette.setColor(dark_palette.ColorRole.BrightText, Qt.GlobalColor.red)
dark_palette.setColor(dark_palette.ColorRole.Link, QColor(42, 130, 218))
dark_palette.setColor(dark_palette.ColorRole.Highlight, QColor(42, 130, 218))
dark_palette.setColor(dark_palette.ColorRole.HighlightedText, Qt.GlobalColor.black)
app.setPalette(dark_palette)
# Set a better font
font = QFont("Cantarell", 10)
if platform.system() == "Windows":
font = QFont("Segoe UI", 10)
elif platform.system() == "Darwin":
font = QFont("San Francisco", 10)
app.setFont(font)
# Create a dummy parent for dialogs to ensure they are properly modal
dummy_parent = QWidget()
# Get profile path
profile_path = ProfileDialog.get_profile_path(dummy_parent)
if not profile_path:
return 0 # Exit gracefully
# Create a simple browser directly without Playwright for now
# This ensures we at least get a window showing
initial_url = None
if len(sys.argv) > 1 and not sys.argv[1].startswith('--'):
initial_url = sys.argv[1]
browser = Browser(initial_url=initial_url, debug=debug_mode, profile_path=profile_path)
browser.show()
print("Browser window should be visible now")
# Run the Qt event loop
return app.exec()
if __name__ == "__main__":
sys.exit(main())
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment