v1
This commit is contained in:
227
blocklists.py
Normal file
227
blocklists.py
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# v2
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pickle
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def load_domain_list(
|
||||||
|
path: Path,
|
||||||
|
) -> set[str]:
|
||||||
|
|
||||||
|
domains: set[str] = set()
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
return domains
|
||||||
|
|
||||||
|
for raw in path.read_text(
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="ignore",
|
||||||
|
).splitlines():
|
||||||
|
|
||||||
|
line = raw.strip()
|
||||||
|
|
||||||
|
if (
|
||||||
|
not line
|
||||||
|
or line.startswith("#")
|
||||||
|
or line.startswith("!")
|
||||||
|
or line.startswith("//")
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line.startswith("||"):
|
||||||
|
line = line[2:]
|
||||||
|
|
||||||
|
if line.startswith(
|
||||||
|
"0.0.0.0 "
|
||||||
|
):
|
||||||
|
line = line.split(
|
||||||
|
None,
|
||||||
|
1,
|
||||||
|
)[1]
|
||||||
|
|
||||||
|
elif line.startswith(
|
||||||
|
"127.0.0.1 "
|
||||||
|
):
|
||||||
|
line = line.split(
|
||||||
|
None,
|
||||||
|
1,
|
||||||
|
)[1]
|
||||||
|
|
||||||
|
line = (
|
||||||
|
line.replace("^", "")
|
||||||
|
.replace("/", "")
|
||||||
|
.strip()
|
||||||
|
.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
"." not in line
|
||||||
|
or " " in line
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
domains.add(
|
||||||
|
line
|
||||||
|
)
|
||||||
|
|
||||||
|
return domains
|
||||||
|
|
||||||
|
|
||||||
|
def save_cache(
|
||||||
|
cache_file: Path,
|
||||||
|
domains: set[str],
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
cache_file.parent.mkdir(
|
||||||
|
parents=True,
|
||||||
|
exist_ok=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with cache_file.open(
|
||||||
|
"wb"
|
||||||
|
) as fh:
|
||||||
|
pickle.dump(
|
||||||
|
domains,
|
||||||
|
fh,
|
||||||
|
protocol=pickle.HIGHEST_PROTOCOL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_cache(
|
||||||
|
cache_file: Path,
|
||||||
|
) -> set[str]:
|
||||||
|
|
||||||
|
if not cache_file.exists():
|
||||||
|
return set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with cache_file.open(
|
||||||
|
"rb"
|
||||||
|
) as fh:
|
||||||
|
data = pickle.load(
|
||||||
|
fh
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(
|
||||||
|
data,
|
||||||
|
set,
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
str(domain)
|
||||||
|
.lower()
|
||||||
|
.strip()
|
||||||
|
for domain in data
|
||||||
|
if domain
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
class BlocklistEngine:
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
blocklists: list[Path],
|
||||||
|
cache_file: Path,
|
||||||
|
):
|
||||||
|
self.blocklists = (
|
||||||
|
blocklists
|
||||||
|
)
|
||||||
|
|
||||||
|
self.cache_file = (
|
||||||
|
cache_file
|
||||||
|
)
|
||||||
|
|
||||||
|
self.domains: set[str] = (
|
||||||
|
set()
|
||||||
|
)
|
||||||
|
|
||||||
|
def load(
|
||||||
|
self,
|
||||||
|
rebuild: bool = False,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
if (
|
||||||
|
not rebuild
|
||||||
|
and self.cache_file.exists()
|
||||||
|
):
|
||||||
|
cached = load_cache(
|
||||||
|
self.cache_file
|
||||||
|
)
|
||||||
|
|
||||||
|
if cached:
|
||||||
|
self.domains = cached
|
||||||
|
return
|
||||||
|
|
||||||
|
domains: set[str] = set()
|
||||||
|
|
||||||
|
for path in (
|
||||||
|
self.blocklists
|
||||||
|
):
|
||||||
|
domains.update(
|
||||||
|
load_domain_list(
|
||||||
|
path
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.domains = domains
|
||||||
|
|
||||||
|
save_cache(
|
||||||
|
self.cache_file,
|
||||||
|
domains,
|
||||||
|
)
|
||||||
|
|
||||||
|
def contains(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
|
host = (
|
||||||
|
host.lower()
|
||||||
|
.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not host:
|
||||||
|
return False
|
||||||
|
|
||||||
|
parts = host.split(
|
||||||
|
"."
|
||||||
|
)
|
||||||
|
|
||||||
|
for index in range(
|
||||||
|
len(parts)
|
||||||
|
):
|
||||||
|
candidate = ".".join(
|
||||||
|
parts[index:]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
candidate
|
||||||
|
in self.domains
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def count(
|
||||||
|
self,
|
||||||
|
) -> int:
|
||||||
|
return len(
|
||||||
|
self.domains
|
||||||
|
)
|
||||||
|
|
||||||
|
def reload(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
self.load(
|
||||||
|
rebuild=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def empty(
|
||||||
|
self,
|
||||||
|
) -> bool:
|
||||||
|
return not self.domains
|
||||||
983
browser.py
Normal file
983
browser.py
Normal file
@@ -0,0 +1,983 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject, QUrl, Signal, Slot
|
||||||
|
from PySide6.QtGui import QDesktopServices
|
||||||
|
from PySide6.QtWebChannel import QWebChannel
|
||||||
|
from PySide6.QtWebEngineCore import (
|
||||||
|
QWebEngineDownloadRequest,
|
||||||
|
QWebEnginePage,
|
||||||
|
QWebEngineProfile,
|
||||||
|
QWebEngineSettings,
|
||||||
|
QWebEngineUrlRequestInterceptor,
|
||||||
|
)
|
||||||
|
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||||
|
from PySide6.QtWidgets import QMessageBox
|
||||||
|
|
||||||
|
from addons import AddonManager
|
||||||
|
from cdp import CDPClient
|
||||||
|
from config import (
|
||||||
|
DEFAULT_SETTINGS,
|
||||||
|
FEATURE_PERMISSION_MAP,
|
||||||
|
START_URL,
|
||||||
|
|
||||||
|
)
|
||||||
|
from models import ProfileContext
|
||||||
|
from network import NetworkBackend
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CACHE_SIZE_BYTES = 512 * 1024 * 1024
|
||||||
|
|
||||||
|
BLOCKED_PROTOCOLS = {
|
||||||
|
"file",
|
||||||
|
"shell",
|
||||||
|
"cmd",
|
||||||
|
"powershell",
|
||||||
|
"ms-settings",
|
||||||
|
}
|
||||||
|
|
||||||
|
PROMPT_PROTOCOLS = {
|
||||||
|
"mailto",
|
||||||
|
"discord",
|
||||||
|
"spotify",
|
||||||
|
"steam",
|
||||||
|
}
|
||||||
|
|
||||||
|
BLOCKED_EXTENSIONS = {
|
||||||
|
".bat",
|
||||||
|
".cmd",
|
||||||
|
".ps1",
|
||||||
|
".vbs",
|
||||||
|
".js",
|
||||||
|
".jse",
|
||||||
|
".wsf",
|
||||||
|
".scr",
|
||||||
|
".hta",
|
||||||
|
".lnk",
|
||||||
|
".reg",
|
||||||
|
".iso",
|
||||||
|
".img",
|
||||||
|
".cpl",
|
||||||
|
".msc",
|
||||||
|
".jar",
|
||||||
|
".msix",
|
||||||
|
".msixbundle",
|
||||||
|
".appinstaller",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
WARN_EXTENSIONS = {
|
||||||
|
".exe",
|
||||||
|
".dll",
|
||||||
|
".msi",
|
||||||
|
".com",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Bridge(QObject):
|
||||||
|
messageReceived = Signal(str)
|
||||||
|
pluginLogReceived = Signal(str)
|
||||||
|
pluginErrorReceived = Signal(str)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
db,
|
||||||
|
context: ProfileContext,
|
||||||
|
):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.db = db
|
||||||
|
self.context = context
|
||||||
|
|
||||||
|
@Slot(str)
|
||||||
|
def log(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
self.messageReceived.emit(
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
@Slot(str)
|
||||||
|
def plugin_log(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
self.pluginLogReceived.emit(
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
@Slot(str)
|
||||||
|
def plugin_error(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
self.pluginErrorReceived.emit(
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
@Slot(result=str)
|
||||||
|
def version(
|
||||||
|
self,
|
||||||
|
) -> str:
|
||||||
|
return "1.0.0"
|
||||||
|
|
||||||
|
@Slot(result=str)
|
||||||
|
def platform(
|
||||||
|
self,
|
||||||
|
) -> str:
|
||||||
|
return "PySide6"
|
||||||
|
|
||||||
|
@Slot(result=str)
|
||||||
|
def plugin_runtime(
|
||||||
|
self,
|
||||||
|
) -> str:
|
||||||
|
return "bdapi-lite"
|
||||||
|
|
||||||
|
@Slot(str, str, result=str)
|
||||||
|
def pluginLoad(
|
||||||
|
self,
|
||||||
|
plugin: str,
|
||||||
|
key: str,
|
||||||
|
) -> str:
|
||||||
|
value = (
|
||||||
|
self.db.plugin_setting_get(
|
||||||
|
self.context.name,
|
||||||
|
plugin,
|
||||||
|
key,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Slot(str, str, str)
|
||||||
|
def pluginSave(
|
||||||
|
self,
|
||||||
|
plugin: str,
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
value = json.loads(
|
||||||
|
value
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.db.plugin_setting_set(
|
||||||
|
self.context.name,
|
||||||
|
plugin,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Slot(str, str, str)
|
||||||
|
def pluginLog(
|
||||||
|
self,
|
||||||
|
plugin: str,
|
||||||
|
level: str,
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
self.db.plugin_log(
|
||||||
|
self.context.name,
|
||||||
|
plugin,
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Slot(str, str)
|
||||||
|
def pluginCrash(
|
||||||
|
self,
|
||||||
|
plugin: str,
|
||||||
|
error: str,
|
||||||
|
) -> None:
|
||||||
|
self.db.plugin_crash(
|
||||||
|
self.context.name,
|
||||||
|
plugin,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
|
# v3
|
||||||
|
class RequestInterceptor(QWebEngineUrlRequestInterceptor):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
network: NetworkBackend,
|
||||||
|
):
|
||||||
|
super().__init__()
|
||||||
|
self.network = network
|
||||||
|
|
||||||
|
def interceptRequest(
|
||||||
|
self,
|
||||||
|
info,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
host = (
|
||||||
|
info.requestUrl()
|
||||||
|
.host()
|
||||||
|
.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
request = {
|
||||||
|
"url": info.requestUrl().toString(),
|
||||||
|
"host": host,
|
||||||
|
"headers": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, blocked = (
|
||||||
|
self.network.process_request(
|
||||||
|
request
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if blocked:
|
||||||
|
info.block(True)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
log.exception(
|
||||||
|
"request interception failed"
|
||||||
|
)
|
||||||
|
class BrowserPage(QWebEnginePage):
|
||||||
|
consoleMessage = Signal(str)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
profile: QWebEngineProfile,
|
||||||
|
permissions: dict[str, Any],
|
||||||
|
parent=None,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
profile,
|
||||||
|
parent,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.permissions = permissions or {}
|
||||||
|
|
||||||
|
self.featurePermissionRequested.connect(
|
||||||
|
self._handle_permission_request
|
||||||
|
)
|
||||||
|
|
||||||
|
def javaScriptConsoleMessage(
|
||||||
|
self,
|
||||||
|
level,
|
||||||
|
message: str,
|
||||||
|
line: int,
|
||||||
|
source: str,
|
||||||
|
) -> None:
|
||||||
|
self.consoleMessage.emit(
|
||||||
|
f"{source}:{line} {message}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def acceptNavigationRequest(
|
||||||
|
self,
|
||||||
|
url,
|
||||||
|
nav_type,
|
||||||
|
is_main_frame,
|
||||||
|
) -> bool:
|
||||||
|
scheme = (
|
||||||
|
url.scheme()
|
||||||
|
.lower()
|
||||||
|
.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
if scheme in {
|
||||||
|
"javascript",
|
||||||
|
"data",
|
||||||
|
}:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if scheme in BLOCKED_PROTOCOLS:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if scheme in PROMPT_PROTOCOLS:
|
||||||
|
result = QMessageBox.question(
|
||||||
|
self.view(),
|
||||||
|
"External Link",
|
||||||
|
(
|
||||||
|
"Open external application?\n\n"
|
||||||
|
f"{url.toString()}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
result
|
||||||
|
== QMessageBox.StandardButton.Yes
|
||||||
|
):
|
||||||
|
QDesktopServices.openUrl(
|
||||||
|
url
|
||||||
|
)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
return super().acceptNavigationRequest(
|
||||||
|
url,
|
||||||
|
nav_type,
|
||||||
|
is_main_frame,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handle_permission_request(
|
||||||
|
self,
|
||||||
|
origin,
|
||||||
|
feature,
|
||||||
|
) -> None:
|
||||||
|
feature_name = str(
|
||||||
|
feature
|
||||||
|
)
|
||||||
|
|
||||||
|
allowed = False
|
||||||
|
|
||||||
|
for (
|
||||||
|
key,
|
||||||
|
permission,
|
||||||
|
) in FEATURE_PERMISSION_MAP.items():
|
||||||
|
if key in feature_name:
|
||||||
|
allowed = bool(
|
||||||
|
self.permissions.get(
|
||||||
|
permission,
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
self.setFeaturePermission(
|
||||||
|
origin,
|
||||||
|
feature,
|
||||||
|
(
|
||||||
|
QWebEnginePage.PermissionPolicy.PermissionGrantedByUser
|
||||||
|
if allowed
|
||||||
|
else QWebEnginePage.PermissionPolicy.PermissionDeniedByUser
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserView(QWebEngineView):
|
||||||
|
downloadProgress = Signal(
|
||||||
|
str,
|
||||||
|
int,
|
||||||
|
int,
|
||||||
|
)
|
||||||
|
|
||||||
|
pluginDiagnostics = Signal(
|
||||||
|
dict
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
db,
|
||||||
|
context: ProfileContext,
|
||||||
|
settings: dict[str, Any] | None,
|
||||||
|
parent=None,
|
||||||
|
):
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
self.db = db
|
||||||
|
self.context = context
|
||||||
|
|
||||||
|
self._settings_data = (
|
||||||
|
settings
|
||||||
|
or dict(
|
||||||
|
DEFAULT_SETTINGS
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.ensure_profile(
|
||||||
|
self.context.name
|
||||||
|
)
|
||||||
|
|
||||||
|
self.network = (
|
||||||
|
NetworkBackend(
|
||||||
|
self.db,
|
||||||
|
self._settings_data,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.profile = (
|
||||||
|
self._create_profile()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.interceptor = (
|
||||||
|
RequestInterceptor(
|
||||||
|
self.network
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.profile.setUrlRequestInterceptor(
|
||||||
|
self.interceptor
|
||||||
|
)
|
||||||
|
|
||||||
|
self.page_ = BrowserPage(
|
||||||
|
self.profile,
|
||||||
|
self._settings_data.get(
|
||||||
|
"permissions",
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
self,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.setPage(
|
||||||
|
self.page_
|
||||||
|
)
|
||||||
|
|
||||||
|
self.bridge = None
|
||||||
|
self.channel = None
|
||||||
|
self.addons = None
|
||||||
|
self.cdp = None
|
||||||
|
self._devtools_page = None
|
||||||
|
self._devtools_view = None
|
||||||
|
|
||||||
|
self._configure_web_settings()
|
||||||
|
self._initialize_bridge()
|
||||||
|
self._initialize_downloads()
|
||||||
|
self._load_addons()
|
||||||
|
|
||||||
|
def _create_profile(
|
||||||
|
self,
|
||||||
|
) -> QWebEngineProfile:
|
||||||
|
storage_path = (
|
||||||
|
self.context.storage_path
|
||||||
|
)
|
||||||
|
|
||||||
|
cache_path = (
|
||||||
|
self.context.cache_path
|
||||||
|
)
|
||||||
|
|
||||||
|
profile = (
|
||||||
|
QWebEngineProfile(
|
||||||
|
self.context.name,
|
||||||
|
self,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
profile.setPersistentStoragePath(
|
||||||
|
str(storage_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
profile.setCachePath(
|
||||||
|
str(cache_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
profile.setHttpCacheMaximumSize(
|
||||||
|
CACHE_SIZE_BYTES
|
||||||
|
)
|
||||||
|
|
||||||
|
profile.setPersistentCookiesPolicy(
|
||||||
|
QWebEngineProfile.PersistentCookiesPolicy.ForcePersistentCookies
|
||||||
|
)
|
||||||
|
|
||||||
|
return profile
|
||||||
|
|
||||||
|
def _configure_web_settings(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
settings = self.settings()
|
||||||
|
|
||||||
|
for attribute in (
|
||||||
|
QWebEngineSettings.WebAttribute.JavascriptEnabled,
|
||||||
|
QWebEngineSettings.WebAttribute.LocalStorageEnabled,
|
||||||
|
QWebEngineSettings.WebAttribute.FullScreenSupportEnabled,
|
||||||
|
QWebEngineSettings.WebAttribute.ScrollAnimatorEnabled,
|
||||||
|
QWebEngineSettings.WebAttribute.WebGLEnabled,
|
||||||
|
QWebEngineSettings.WebAttribute.Accelerated2dCanvasEnabled,
|
||||||
|
):
|
||||||
|
settings.setAttribute(
|
||||||
|
attribute,
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
settings.setAttribute(
|
||||||
|
QWebEngineSettings.WebAttribute.AllowRunningInsecureContent,
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _initialize_bridge(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
self.bridge = Bridge(
|
||||||
|
self.db,
|
||||||
|
self.context,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.channel = (
|
||||||
|
QWebChannel()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.channel.registerObject(
|
||||||
|
"bridge",
|
||||||
|
self.bridge,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.page_.setWebChannel(
|
||||||
|
self.channel
|
||||||
|
)
|
||||||
|
|
||||||
|
def _initialize_downloads(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
self.profile.downloadRequested.connect(
|
||||||
|
self._handle_download
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_addons(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
self.addons = AddonManager(
|
||||||
|
self.context.name,
|
||||||
|
self.db,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.addons.install(
|
||||||
|
self.profile
|
||||||
|
)
|
||||||
|
|
||||||
|
def plugin_inventory(
|
||||||
|
self,
|
||||||
|
) -> list[dict]:
|
||||||
|
if not self.addons:
|
||||||
|
return []
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
for plugin in (
|
||||||
|
self.addons.plugins.discover()
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
rows.append(
|
||||||
|
self.addons.plugins.metadata(
|
||||||
|
plugin
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
log.exception(
|
||||||
|
"plugin metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def install_plugin_file(
|
||||||
|
self,
|
||||||
|
source: str | Path,
|
||||||
|
) -> Path:
|
||||||
|
installed = (
|
||||||
|
self.addons.plugins.install_file(
|
||||||
|
Path(source)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.reload_addons()
|
||||||
|
|
||||||
|
return installed
|
||||||
|
|
||||||
|
def plugin_diagnostics(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
self.page().runJavaScript(
|
||||||
|
"""
|
||||||
|
(() => {
|
||||||
|
if (!window.PluginRuntime)
|
||||||
|
return {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
loaded:Object.keys(
|
||||||
|
PluginRuntime.plugins || {}
|
||||||
|
),
|
||||||
|
failed:Object.keys(
|
||||||
|
PluginRuntime.failed || {}
|
||||||
|
),
|
||||||
|
failures:
|
||||||
|
PluginRuntime.failed || {}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
""",
|
||||||
|
self.pluginDiagnostics.emit,
|
||||||
|
)
|
||||||
|
|
||||||
|
def initialize_cdp(
|
||||||
|
self,
|
||||||
|
websocket_url: str,
|
||||||
|
) -> None:
|
||||||
|
if not self._settings_data.get(
|
||||||
|
"enable_cdp",
|
||||||
|
False,
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.cdp:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.cdp = CDPClient(
|
||||||
|
db=self.db,
|
||||||
|
websocket_url=websocket_url,
|
||||||
|
capture_bodies=False,
|
||||||
|
backend=self.network,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.cdp.start()
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
log.exception(
|
||||||
|
"cdp startup failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
# v2
|
||||||
|
def _download_directory(
|
||||||
|
self,
|
||||||
|
) -> Path:
|
||||||
|
|
||||||
|
directory = (
|
||||||
|
self.context
|
||||||
|
.downloads_path
|
||||||
|
)
|
||||||
|
|
||||||
|
directory.mkdir(
|
||||||
|
parents=True,
|
||||||
|
exist_ok=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return directory
|
||||||
|
|
||||||
|
def _handle_download(
|
||||||
|
self,
|
||||||
|
download: QWebEngineDownloadRequest,
|
||||||
|
) -> None:
|
||||||
|
filename = (
|
||||||
|
download.downloadFileName()
|
||||||
|
)
|
||||||
|
|
||||||
|
extension = (
|
||||||
|
Path(filename)
|
||||||
|
.suffix
|
||||||
|
.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
if extension in BLOCKED_EXTENSIONS:
|
||||||
|
download.cancel()
|
||||||
|
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"Download Blocked",
|
||||||
|
filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
if extension in WARN_EXTENSIONS:
|
||||||
|
result = QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"Executable Download",
|
||||||
|
filename,
|
||||||
|
QMessageBox.StandardButton.Yes
|
||||||
|
| QMessageBox.StandardButton.No,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
result
|
||||||
|
!= QMessageBox.StandardButton.Yes
|
||||||
|
):
|
||||||
|
download.cancel()
|
||||||
|
return
|
||||||
|
|
||||||
|
download.setDownloadDirectory(
|
||||||
|
str(
|
||||||
|
self._download_directory()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
download.accept()
|
||||||
|
|
||||||
|
download.receivedBytesChanged.connect(
|
||||||
|
lambda d=download:
|
||||||
|
self._emit_download_progress(
|
||||||
|
d
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _emit_download_progress(
|
||||||
|
self,
|
||||||
|
download: QWebEngineDownloadRequest,
|
||||||
|
) -> None:
|
||||||
|
self.downloadProgress.emit(
|
||||||
|
download.downloadFileName(),
|
||||||
|
download.receivedBytes(),
|
||||||
|
download.totalBytes(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def reload_addons(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
self.profile.scripts().clear()
|
||||||
|
self._load_addons()
|
||||||
|
self.reload()
|
||||||
|
|
||||||
|
def open(
|
||||||
|
self,
|
||||||
|
url: str = START_URL,
|
||||||
|
) -> None:
|
||||||
|
self.load(
|
||||||
|
QUrl(url)
|
||||||
|
)
|
||||||
|
|
||||||
|
def open_devtools(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
if self._devtools_page is None:
|
||||||
|
self._devtools_page = (
|
||||||
|
QWebEnginePage(
|
||||||
|
self.profile,
|
||||||
|
self,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.page_.setDevToolsPage(
|
||||||
|
self._devtools_page
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._devtools_view is None:
|
||||||
|
self._devtools_view = (
|
||||||
|
QWebEngineView()
|
||||||
|
)
|
||||||
|
|
||||||
|
self._devtools_view.setPage(
|
||||||
|
self._devtools_page
|
||||||
|
)
|
||||||
|
|
||||||
|
self._devtools_view.resize(
|
||||||
|
1400,
|
||||||
|
900,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._devtools_view.show()
|
||||||
|
|
||||||
|
def switch_profile(
|
||||||
|
self,
|
||||||
|
profile_name: str,
|
||||||
|
) -> None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"profile switching requires browser recreation"
|
||||||
|
)
|
||||||
|
|
||||||
|
# v3
|
||||||
|
def install_plugin_url(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
) -> Path:
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from urllib.parse import (
|
||||||
|
urlparse,
|
||||||
|
)
|
||||||
|
from urllib.request import (
|
||||||
|
urlopen,
|
||||||
|
)
|
||||||
|
|
||||||
|
parsed = urlparse(
|
||||||
|
url
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
parsed.scheme
|
||||||
|
not in {
|
||||||
|
"https"
|
||||||
|
}
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"https required"
|
||||||
|
)
|
||||||
|
|
||||||
|
with urlopen(
|
||||||
|
url,
|
||||||
|
timeout=30,
|
||||||
|
) as response:
|
||||||
|
|
||||||
|
content_length = (
|
||||||
|
response.headers.get(
|
||||||
|
"Content-Length"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if content_length:
|
||||||
|
if (
|
||||||
|
int(content_length)
|
||||||
|
> PLUGIN_MAX_SIZE
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"plugin exceeds size limit"
|
||||||
|
)
|
||||||
|
|
||||||
|
content_type = (
|
||||||
|
response.headers.get(
|
||||||
|
"Content-Type",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.split(";")[0]
|
||||||
|
.strip()
|
||||||
|
.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
allowed_types = {
|
||||||
|
"application/javascript",
|
||||||
|
"text/javascript",
|
||||||
|
"application/x-javascript",
|
||||||
|
"text/plain",
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
content_type
|
||||||
|
and content_type
|
||||||
|
not in allowed_types
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"unsupported content type: "
|
||||||
|
f"{content_type}"
|
||||||
|
)
|
||||||
|
|
||||||
|
data = bytearray()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
chunk = response.read(
|
||||||
|
65536
|
||||||
|
)
|
||||||
|
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
|
||||||
|
data.extend(
|
||||||
|
chunk
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
len(data)
|
||||||
|
> PLUGIN_MAX_SIZE
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"plugin exceeds size limit"
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
suffix=".plugin.js",
|
||||||
|
delete=False,
|
||||||
|
) as handle:
|
||||||
|
handle.write(data)
|
||||||
|
|
||||||
|
path = Path(
|
||||||
|
handle.name
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
installed = (
|
||||||
|
self.install_plugin_file(
|
||||||
|
path
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return installed
|
||||||
|
|
||||||
|
finally:
|
||||||
|
path.unlink(
|
||||||
|
missing_ok=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def remove_plugin(
|
||||||
|
self,
|
||||||
|
plugin_name: str,
|
||||||
|
) -> bool:
|
||||||
|
removed = (
|
||||||
|
self.addons.uninstall_plugin(
|
||||||
|
plugin_name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if removed:
|
||||||
|
self.reload_addons()
|
||||||
|
|
||||||
|
return removed
|
||||||
|
|
||||||
|
def set_plugin_enabled(
|
||||||
|
self,
|
||||||
|
plugin_name: str,
|
||||||
|
enabled: bool,
|
||||||
|
) -> None:
|
||||||
|
if enabled:
|
||||||
|
self.addons.enable_plugin(
|
||||||
|
plugin_name
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.addons.disable_plugin(
|
||||||
|
plugin_name
|
||||||
|
)
|
||||||
|
|
||||||
|
self.reload_addons()
|
||||||
|
|
||||||
|
# v2
|
||||||
|
def shutdown(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
if self.cdp:
|
||||||
|
try:
|
||||||
|
self.cdp.stop()
|
||||||
|
except Exception:
|
||||||
|
log.exception(
|
||||||
|
"cdp stop failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.cdp = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.setPage(None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if self.page_:
|
||||||
|
self.page_.deleteLater()
|
||||||
|
self.page_ = None
|
||||||
|
|
||||||
|
if self.channel:
|
||||||
|
self.channel.deleteLater()
|
||||||
|
self.channel = None
|
||||||
|
|
||||||
|
if self.bridge:
|
||||||
|
self.bridge.deleteLater()
|
||||||
|
self.bridge = None
|
||||||
|
|
||||||
|
if self.profile:
|
||||||
|
self.profile.deleteLater()
|
||||||
|
self.profile = None
|
||||||
|
if (
|
||||||
|
self.profile
|
||||||
|
and self.interceptor
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
self.profile.setUrlRequestInterceptor(
|
||||||
|
None
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if self.interceptor:
|
||||||
|
self.interceptor.deleteLater()
|
||||||
|
self.interceptor = None
|
||||||
|
if self.page_:
|
||||||
|
try:
|
||||||
|
self.page_.triggerAction(
|
||||||
|
QWebEnginePage.WebAction.Stop
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if self._devtools_page:
|
||||||
|
self._devtools_page.deleteLater()
|
||||||
|
self._devtools_page = None
|
||||||
|
|
||||||
|
if self._devtools_view:
|
||||||
|
self._devtools_view.close()
|
||||||
|
self._devtools_view.deleteLater()
|
||||||
|
self._devtools_view = None
|
||||||
|
|
||||||
714
cdp.py
Normal file
714
cdp.py
Normal file
@@ -0,0 +1,714 @@
|
|||||||
|
# v7
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject, Signal
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import websockets
|
||||||
|
except Exception:
|
||||||
|
websockets = None
|
||||||
|
|
||||||
|
|
||||||
|
class CDPEvents(QObject):
|
||||||
|
connected = Signal()
|
||||||
|
disconnected = Signal()
|
||||||
|
requestCaptured = Signal(dict)
|
||||||
|
responseCaptured = Signal(dict)
|
||||||
|
errorOccurred = Signal(str)
|
||||||
|
|
||||||
|
|
||||||
|
class CDPClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
db,
|
||||||
|
websocket_url: str,
|
||||||
|
capture_bodies: bool = False,
|
||||||
|
backend=None,
|
||||||
|
) -> None:
|
||||||
|
self.db = db
|
||||||
|
self.websocket_url = websocket_url
|
||||||
|
self.capture_bodies = capture_bodies
|
||||||
|
self.backend = backend
|
||||||
|
|
||||||
|
self.events = CDPEvents()
|
||||||
|
|
||||||
|
self._loop = None
|
||||||
|
self._thread = None
|
||||||
|
self._socket = None
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
self._message_id = 0
|
||||||
|
|
||||||
|
self._requests: dict[
|
||||||
|
str,
|
||||||
|
dict[str, Any]
|
||||||
|
] = {}
|
||||||
|
|
||||||
|
self._pending: dict[
|
||||||
|
int,
|
||||||
|
asyncio.Future,
|
||||||
|
] = {}
|
||||||
|
|
||||||
|
self._reconnect_delay = 5
|
||||||
|
|
||||||
|
def start(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
if websockets is None:
|
||||||
|
log.warning(
|
||||||
|
"websockets package not installed"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._thread_main,
|
||||||
|
daemon=True,
|
||||||
|
name="CDPThread",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
if (
|
||||||
|
self._loop
|
||||||
|
and self._socket
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
self._socket.close(),
|
||||||
|
self._loop,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if (
|
||||||
|
self._thread
|
||||||
|
and self._thread.is_alive()
|
||||||
|
):
|
||||||
|
self._thread.join(
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
|
||||||
|
self._socket = None
|
||||||
|
self._thread = None
|
||||||
|
self._pending.clear()
|
||||||
|
self._requests.clear()
|
||||||
|
|
||||||
|
def send(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
params: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
if (
|
||||||
|
not self._loop
|
||||||
|
or not self._running
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
self._send(
|
||||||
|
method,
|
||||||
|
params or {},
|
||||||
|
),
|
||||||
|
self._loop,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _thread_main(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
self._loop = asyncio.new_event_loop()
|
||||||
|
|
||||||
|
asyncio.set_event_loop(
|
||||||
|
self._loop
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._loop.run_until_complete(
|
||||||
|
self._run()
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
log.exception(
|
||||||
|
"cdp loop failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
self._socket = None
|
||||||
|
self._loop = None
|
||||||
|
|
||||||
|
async def _run(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
while self._running:
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._connect()
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception(
|
||||||
|
"cdp connect failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.events.errorOccurred.emit(
|
||||||
|
str(exc)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
|
||||||
|
await asyncio.sleep(
|
||||||
|
self._reconnect_delay
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _connect(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
async with websockets.connect(
|
||||||
|
self.websocket_url,
|
||||||
|
max_size=32 * 1024 * 1024,
|
||||||
|
ping_interval=30,
|
||||||
|
ping_timeout=30,
|
||||||
|
close_timeout=10,
|
||||||
|
) as socket:
|
||||||
|
|
||||||
|
self._socket = socket
|
||||||
|
|
||||||
|
self._requests.clear()
|
||||||
|
|
||||||
|
self.events.connected.emit()
|
||||||
|
|
||||||
|
await self._send(
|
||||||
|
"Network.enable",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._send(
|
||||||
|
"Page.enable",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._send(
|
||||||
|
"Runtime.enable",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._send(
|
||||||
|
"Fetch.enable",
|
||||||
|
{
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"urlPattern": "*",
|
||||||
|
"requestStage": "Request",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
|
||||||
|
raw = await socket.recv()
|
||||||
|
|
||||||
|
try:
|
||||||
|
message = json.loads(
|
||||||
|
raw
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "id" in message:
|
||||||
|
|
||||||
|
future = self._pending.pop(
|
||||||
|
message["id"],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
future
|
||||||
|
and not future.done()
|
||||||
|
):
|
||||||
|
future.set_result(
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
await self._handle_message(
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
self._socket = None
|
||||||
|
|
||||||
|
for future in (
|
||||||
|
self._pending.values()
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
if not future.done():
|
||||||
|
future.cancel()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._pending.clear()
|
||||||
|
|
||||||
|
self.events.disconnected.emit()
|
||||||
|
|
||||||
|
async def _send(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
params: dict,
|
||||||
|
) -> None:
|
||||||
|
if not self._socket:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._message_id += 1
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"id": self._message_id,
|
||||||
|
"method": method,
|
||||||
|
"params": params,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._socket.send(
|
||||||
|
json.dumps(payload)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
log.exception(
|
||||||
|
"cdp send failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _send_wait(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
params: dict | None = None,
|
||||||
|
timeout: float = 5.0,
|
||||||
|
) -> dict | None:
|
||||||
|
|
||||||
|
if (
|
||||||
|
not self._socket
|
||||||
|
or not self._loop
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._message_id += 1
|
||||||
|
|
||||||
|
message_id = (
|
||||||
|
self._message_id
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"id": message_id,
|
||||||
|
"method": method,
|
||||||
|
"params": params or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
future = (
|
||||||
|
self._loop.create_future()
|
||||||
|
)
|
||||||
|
|
||||||
|
self._pending[
|
||||||
|
message_id
|
||||||
|
] = future
|
||||||
|
|
||||||
|
await self._socket.send(
|
||||||
|
json.dumps(payload)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(
|
||||||
|
future,
|
||||||
|
timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
finally:
|
||||||
|
self._pending.pop(
|
||||||
|
message_id,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _handle_message(
|
||||||
|
self,
|
||||||
|
message: dict,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
method = message.get(
|
||||||
|
"method"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not method:
|
||||||
|
return
|
||||||
|
|
||||||
|
params = message.get(
|
||||||
|
"params",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
method
|
||||||
|
== "Network.requestWillBeSent"
|
||||||
|
):
|
||||||
|
self._handle_request(
|
||||||
|
params
|
||||||
|
)
|
||||||
|
|
||||||
|
elif (
|
||||||
|
method
|
||||||
|
== "Network.responseReceived"
|
||||||
|
):
|
||||||
|
await self._handle_response(
|
||||||
|
params
|
||||||
|
)
|
||||||
|
|
||||||
|
elif (
|
||||||
|
method
|
||||||
|
== "Network.loadingFailed"
|
||||||
|
):
|
||||||
|
request_id = params.get(
|
||||||
|
"requestId"
|
||||||
|
)
|
||||||
|
|
||||||
|
if request_id:
|
||||||
|
self._requests.pop(
|
||||||
|
request_id,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif (
|
||||||
|
method
|
||||||
|
== "Fetch.requestPaused"
|
||||||
|
):
|
||||||
|
await self._continue_fetch(
|
||||||
|
params
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _continue_fetch(
|
||||||
|
self,
|
||||||
|
params: dict,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
request_id = params.get(
|
||||||
|
"requestId"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not request_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.backend:
|
||||||
|
|
||||||
|
try:
|
||||||
|
request_data = params.get(
|
||||||
|
"request",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
url = request_data.get(
|
||||||
|
"url",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
host = (
|
||||||
|
urlparse(url)
|
||||||
|
.hostname
|
||||||
|
or ""
|
||||||
|
).lower()
|
||||||
|
except Exception:
|
||||||
|
host = ""
|
||||||
|
|
||||||
|
request = {
|
||||||
|
"url": url,
|
||||||
|
"host": host,
|
||||||
|
"headers": request_data.get(
|
||||||
|
"headers",
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, blocked = (
|
||||||
|
self.backend.process_request(
|
||||||
|
request
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if blocked:
|
||||||
|
|
||||||
|
await self._send(
|
||||||
|
"Fetch.failRequest",
|
||||||
|
{
|
||||||
|
"requestId":
|
||||||
|
request_id,
|
||||||
|
"errorReason":
|
||||||
|
"BlockedByClient",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
log.exception(
|
||||||
|
"fetch policy evaluation failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._send(
|
||||||
|
"Fetch.continueRequest",
|
||||||
|
{
|
||||||
|
"requestId":
|
||||||
|
request_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handle_request(
|
||||||
|
self,
|
||||||
|
params: dict,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
request = params.get(
|
||||||
|
"request",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
request_id = (
|
||||||
|
params.get(
|
||||||
|
"requestId"
|
||||||
|
)
|
||||||
|
or str(
|
||||||
|
uuid.uuid4()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"request_id":
|
||||||
|
request_id,
|
||||||
|
"timestamp":
|
||||||
|
datetime.now(
|
||||||
|
UTC
|
||||||
|
).isoformat(),
|
||||||
|
"method":
|
||||||
|
request.get(
|
||||||
|
"method"
|
||||||
|
),
|
||||||
|
"url":
|
||||||
|
request.get(
|
||||||
|
"url"
|
||||||
|
),
|
||||||
|
"headers":
|
||||||
|
request.get(
|
||||||
|
"headers",
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
"start":
|
||||||
|
time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.backend:
|
||||||
|
|
||||||
|
try:
|
||||||
|
data, blocked = (
|
||||||
|
self.backend.process_request(
|
||||||
|
data
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if blocked:
|
||||||
|
return
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
log.exception(
|
||||||
|
"network pipeline failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._requests[
|
||||||
|
request_id
|
||||||
|
] = data
|
||||||
|
|
||||||
|
self.events.requestCaptured.emit(
|
||||||
|
data
|
||||||
|
)
|
||||||
|
|
||||||
|
# v7
|
||||||
|
async def _fetch_body(
|
||||||
|
self,
|
||||||
|
request_id: str,
|
||||||
|
) -> str | None:
|
||||||
|
|
||||||
|
result = await self._send_wait(
|
||||||
|
"Network.getResponseBody",
|
||||||
|
{
|
||||||
|
"requestId":
|
||||||
|
request_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return (
|
||||||
|
result.get(
|
||||||
|
"result",
|
||||||
|
{},
|
||||||
|
).get(
|
||||||
|
"body"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _handle_response(
|
||||||
|
self,
|
||||||
|
params: dict,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
request_id = params.get(
|
||||||
|
"requestId"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = params.get(
|
||||||
|
"response",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
existing = (
|
||||||
|
self._requests.pop(
|
||||||
|
request_id,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
duration = None
|
||||||
|
|
||||||
|
if "start" in existing:
|
||||||
|
duration = (
|
||||||
|
time.time()
|
||||||
|
- existing["start"]
|
||||||
|
) * 1000
|
||||||
|
|
||||||
|
url = existing.get(
|
||||||
|
"url"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
host = (
|
||||||
|
urlparse(
|
||||||
|
url
|
||||||
|
).hostname
|
||||||
|
if url
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
host = None
|
||||||
|
|
||||||
|
response_body = None
|
||||||
|
|
||||||
|
if (
|
||||||
|
self.capture_bodies
|
||||||
|
and request_id
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
response_body = (
|
||||||
|
await self._fetch_body(
|
||||||
|
request_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
record = {
|
||||||
|
"request_id":
|
||||||
|
request_id,
|
||||||
|
"timestamp":
|
||||||
|
existing.get(
|
||||||
|
"timestamp"
|
||||||
|
),
|
||||||
|
"method":
|
||||||
|
existing.get(
|
||||||
|
"method"
|
||||||
|
),
|
||||||
|
"url":
|
||||||
|
url,
|
||||||
|
"host":
|
||||||
|
host,
|
||||||
|
"status":
|
||||||
|
response.get(
|
||||||
|
"status"
|
||||||
|
),
|
||||||
|
"request_headers":
|
||||||
|
existing.get(
|
||||||
|
"headers",
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
"response_headers":
|
||||||
|
response.get(
|
||||||
|
"headers",
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
"duration_ms":
|
||||||
|
duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.db.queue_request(
|
||||||
|
request_id=record[
|
||||||
|
"request_id"
|
||||||
|
],
|
||||||
|
timestamp=record[
|
||||||
|
"timestamp"
|
||||||
|
],
|
||||||
|
method=record[
|
||||||
|
"method"
|
||||||
|
],
|
||||||
|
url=record[
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
host=record[
|
||||||
|
"host"
|
||||||
|
],
|
||||||
|
status_code=record[
|
||||||
|
"status"
|
||||||
|
],
|
||||||
|
resource_type=response.get(
|
||||||
|
"mimeType"
|
||||||
|
),
|
||||||
|
request_headers=record[
|
||||||
|
"request_headers"
|
||||||
|
],
|
||||||
|
response_headers=record[
|
||||||
|
"response_headers"
|
||||||
|
],
|
||||||
|
request_body=None,
|
||||||
|
response_body=response_body,
|
||||||
|
duration_ms=record[
|
||||||
|
"duration_ms"
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
log.exception(
|
||||||
|
"failed to persist request"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.events.responseCaptured.emit(
|
||||||
|
record
|
||||||
|
)
|
||||||
536
config.py
Normal file
536
config.py
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
# v6
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
APP_NAME = "Discord Client"
|
||||||
|
APP_VERSION = "1.0.0"
|
||||||
|
|
||||||
|
START_URL = "https://discord.com/app"
|
||||||
|
|
||||||
|
WINDOW_WIDTH = 1400
|
||||||
|
WINDOW_HEIGHT = 900
|
||||||
|
|
||||||
|
LOG_LEVEL = logging.INFO
|
||||||
|
|
||||||
|
BASE_DIR = Path.home() / ".discord-client"
|
||||||
|
|
||||||
|
DATABASE_PATH = BASE_DIR / "client.db"
|
||||||
|
SETTINGS_FILE = BASE_DIR / "settings.json"
|
||||||
|
|
||||||
|
PROFILES_DIR = BASE_DIR / "profiles"
|
||||||
|
DOWNLOADS_DIR = BASE_DIR / "downloads"
|
||||||
|
LOGS_DIR = BASE_DIR / "logs"
|
||||||
|
|
||||||
|
THEMES_DIR = BASE_DIR / "themes"
|
||||||
|
EXTENSIONS_DIR = BASE_DIR / "extensions"
|
||||||
|
|
||||||
|
EXPORTS_DIR = BASE_DIR / "exports"
|
||||||
|
HAR_DIR = EXPORTS_DIR / "har"
|
||||||
|
|
||||||
|
DEFAULT_PROFILE = "default"
|
||||||
|
|
||||||
|
NETWORK_RETENTION = 100_000
|
||||||
|
NETWORK_BATCH_SIZE = 100
|
||||||
|
NETWORK_FLUSH_INTERVAL = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
DISCORD_TELEMETRY_BLOCKS = [
|
||||||
|
"sentry.io",
|
||||||
|
"www.sentry.io",
|
||||||
|
"crash.discord.com",
|
||||||
|
"telemetry.discord.com",
|
||||||
|
"api.statsig.com",
|
||||||
|
"segment.io",
|
||||||
|
"cdn.segment.com",
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_PROXY = {
|
||||||
|
"enabled": False,
|
||||||
|
"proxy_type": "http",
|
||||||
|
"host": "",
|
||||||
|
"port": 0,
|
||||||
|
"username": "",
|
||||||
|
"password": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_TRAY = {
|
||||||
|
"enabled": True,
|
||||||
|
"minimize_to_tray": True,
|
||||||
|
"close_to_tray": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_DEVTOOLS = {
|
||||||
|
"enabled": True,
|
||||||
|
"capture_requests": True,
|
||||||
|
"capture_responses": True,
|
||||||
|
"capture_bodies": False,
|
||||||
|
"auto_open": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_PERMISSIONS = {
|
||||||
|
"microphone": False,
|
||||||
|
"camera": False,
|
||||||
|
"notifications": False,
|
||||||
|
"clipboard": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_SETTINGS: dict[str, Any] = {
|
||||||
|
"active_profile": DEFAULT_PROFILE,
|
||||||
|
"window_width": WINDOW_WIDTH,
|
||||||
|
"window_height": WINDOW_HEIGHT,
|
||||||
|
"download_directory": str(DOWNLOADS_DIR),
|
||||||
|
"blocked_hosts": list(
|
||||||
|
DISCORD_TELEMETRY_BLOCKS
|
||||||
|
),
|
||||||
|
"network_logging": True,
|
||||||
|
"enable_cdp": False,
|
||||||
|
"enable_vencord": False,
|
||||||
|
"permissions": DEFAULT_PERMISSIONS,
|
||||||
|
"proxy": DEFAULT_PROXY,
|
||||||
|
"tray": DEFAULT_TRAY,
|
||||||
|
"devtools": DEFAULT_DEVTOOLS,
|
||||||
|
"blocklist_mode": "pro",
|
||||||
|
"disabled_blocklists": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
FEATURE_PERMISSION_MAP = {
|
||||||
|
"MediaAudioCapture": "microphone",
|
||||||
|
"MediaVideoCapture": "camera",
|
||||||
|
"MediaAudioVideoCapture": "camera",
|
||||||
|
"Notifications": "notifications",
|
||||||
|
"ClipboardReadWrite": "clipboard",
|
||||||
|
"ClipboardSanitizedWrite": "clipboard",
|
||||||
|
}
|
||||||
|
|
||||||
|
PLUGIN_FILE_PATTERN = "*.plugin.js"
|
||||||
|
|
||||||
|
PLUGIN_MAX_SIZE = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
PLUGIN_CRASH_LIMIT = 5
|
||||||
|
|
||||||
|
PLUGIN_AUTOLOAD = True
|
||||||
|
|
||||||
|
PLUGIN_ALLOWED_EXTENSIONS = {
|
||||||
|
".plugin.js",
|
||||||
|
}
|
||||||
|
|
||||||
|
PLUGIN_ALLOWED_SCHEMES = {
|
||||||
|
"https",
|
||||||
|
}
|
||||||
|
|
||||||
|
PLUGIN_BLOCKED_PATTERNS = {
|
||||||
|
"child_process.exec(",
|
||||||
|
"child_process.spawn(",
|
||||||
|
"powershell.exe",
|
||||||
|
"cmd.exe /c",
|
||||||
|
"process.exec(",
|
||||||
|
}
|
||||||
|
PLUGIN_PERMISSIONS = {
|
||||||
|
"storage",
|
||||||
|
"dom",
|
||||||
|
"network",
|
||||||
|
"websocket",
|
||||||
|
"notifications",
|
||||||
|
"clipboard",
|
||||||
|
}
|
||||||
|
PROFILE_EXPORT_VERSION = 1
|
||||||
|
|
||||||
|
PROFILE_EXPORT_NAME = (
|
||||||
|
"profilebundle"
|
||||||
|
)
|
||||||
|
|
||||||
|
# v1
|
||||||
|
BLOCKLISTS_DIR = (
|
||||||
|
BASE_DIR
|
||||||
|
/ "blocklists"
|
||||||
|
)
|
||||||
|
|
||||||
|
BLOCKLIST_CACHE_DIR = (
|
||||||
|
BLOCKLISTS_DIR
|
||||||
|
/ "cache"
|
||||||
|
)
|
||||||
|
def blocklists_dir(
|
||||||
|
) -> Path:
|
||||||
|
BLOCKLISTS_DIR.mkdir(
|
||||||
|
parents=True,
|
||||||
|
exist_ok=True,
|
||||||
|
)
|
||||||
|
return BLOCKLISTS_DIR
|
||||||
|
|
||||||
|
|
||||||
|
# v1
|
||||||
|
def blocklist_cache_dir(
|
||||||
|
) -> Path:
|
||||||
|
BLOCKLIST_CACHE_DIR.mkdir(
|
||||||
|
parents=True,
|
||||||
|
exist_ok=True,
|
||||||
|
)
|
||||||
|
return BLOCKLIST_CACHE_DIR
|
||||||
|
|
||||||
|
def ensure_directories() -> None:
|
||||||
|
for directory in (
|
||||||
|
BASE_DIR,
|
||||||
|
PROFILES_DIR,
|
||||||
|
DOWNLOADS_DIR,
|
||||||
|
LOGS_DIR,
|
||||||
|
THEMES_DIR,
|
||||||
|
EXTENSIONS_DIR,
|
||||||
|
EXPORTS_DIR,
|
||||||
|
HAR_DIR,
|
||||||
|
BLOCKLISTS_DIR,
|
||||||
|
BLOCKLIST_CACHE_DIR,
|
||||||
|
):
|
||||||
|
directory.mkdir(
|
||||||
|
parents=True,
|
||||||
|
exist_ok=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def merge_dict(
|
||||||
|
defaults: dict,
|
||||||
|
current: dict,
|
||||||
|
) -> dict:
|
||||||
|
merged = dict(defaults)
|
||||||
|
|
||||||
|
for key, value in current.items():
|
||||||
|
if (
|
||||||
|
key in merged
|
||||||
|
and isinstance(
|
||||||
|
merged[key],
|
||||||
|
dict,
|
||||||
|
)
|
||||||
|
and isinstance(
|
||||||
|
value,
|
||||||
|
dict,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
merged[key] = merge_dict(
|
||||||
|
merged[key],
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
merged[key] = value
|
||||||
|
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_proxy(
|
||||||
|
settings: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
proxy = settings.get(
|
||||||
|
"proxy",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(
|
||||||
|
proxy,
|
||||||
|
dict,
|
||||||
|
):
|
||||||
|
settings["proxy"] = dict(
|
||||||
|
DEFAULT_PROXY
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (
|
||||||
|
"type" in proxy
|
||||||
|
and "proxy_type"
|
||||||
|
not in proxy
|
||||||
|
):
|
||||||
|
proxy["proxy_type"] = proxy.pop(
|
||||||
|
"type"
|
||||||
|
)
|
||||||
|
|
||||||
|
settings["proxy"] = merge_dict(
|
||||||
|
DEFAULT_PROXY,
|
||||||
|
proxy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_permissions(
|
||||||
|
settings: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
permissions = settings.get(
|
||||||
|
"permissions",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(
|
||||||
|
permissions,
|
||||||
|
dict,
|
||||||
|
):
|
||||||
|
permissions = {}
|
||||||
|
|
||||||
|
settings["permissions"] = merge_dict(
|
||||||
|
DEFAULT_PERMISSIONS,
|
||||||
|
permissions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_blocked_hosts(
|
||||||
|
settings: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
hosts = {
|
||||||
|
str(host)
|
||||||
|
.strip()
|
||||||
|
.lower()
|
||||||
|
for host in settings.get(
|
||||||
|
"blocked_hosts",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
if str(host).strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
hosts.update(
|
||||||
|
DISCORD_TELEMETRY_BLOCKS
|
||||||
|
)
|
||||||
|
|
||||||
|
settings["blocked_hosts"] = sorted(
|
||||||
|
hosts
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_settings() -> dict[str, Any]:
|
||||||
|
ensure_directories()
|
||||||
|
|
||||||
|
if not SETTINGS_FILE.exists():
|
||||||
|
settings = merge_dict(
|
||||||
|
{},
|
||||||
|
DEFAULT_SETTINGS,
|
||||||
|
)
|
||||||
|
|
||||||
|
save_settings(
|
||||||
|
settings
|
||||||
|
)
|
||||||
|
|
||||||
|
return settings
|
||||||
|
|
||||||
|
try:
|
||||||
|
loaded = json.loads(
|
||||||
|
SETTINGS_FILE.read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(
|
||||||
|
loaded,
|
||||||
|
dict,
|
||||||
|
):
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
if (
|
||||||
|
"active_profile"
|
||||||
|
not in loaded
|
||||||
|
and "active_account"
|
||||||
|
in loaded
|
||||||
|
):
|
||||||
|
loaded[
|
||||||
|
"active_profile"
|
||||||
|
] = loaded[
|
||||||
|
"active_account"
|
||||||
|
]
|
||||||
|
|
||||||
|
settings = merge_dict(
|
||||||
|
DEFAULT_SETTINGS,
|
||||||
|
loaded,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not settings.get(
|
||||||
|
"active_profile"
|
||||||
|
):
|
||||||
|
settings[
|
||||||
|
"active_profile"
|
||||||
|
] = DEFAULT_PROFILE
|
||||||
|
|
||||||
|
settings[
|
||||||
|
"download_directory"
|
||||||
|
] = str(
|
||||||
|
settings.get(
|
||||||
|
"download_directory"
|
||||||
|
)
|
||||||
|
or DOWNLOADS_DIR
|
||||||
|
)
|
||||||
|
|
||||||
|
_normalize_proxy(
|
||||||
|
settings
|
||||||
|
)
|
||||||
|
|
||||||
|
_normalize_permissions(
|
||||||
|
settings
|
||||||
|
)
|
||||||
|
|
||||||
|
_normalize_blocked_hosts(
|
||||||
|
settings
|
||||||
|
)
|
||||||
|
|
||||||
|
return settings
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
settings = merge_dict(
|
||||||
|
{},
|
||||||
|
DEFAULT_SETTINGS,
|
||||||
|
)
|
||||||
|
|
||||||
|
save_settings(
|
||||||
|
settings
|
||||||
|
)
|
||||||
|
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def save_settings(
|
||||||
|
settings: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
ensure_directories()
|
||||||
|
|
||||||
|
_normalize_proxy(
|
||||||
|
settings
|
||||||
|
)
|
||||||
|
|
||||||
|
_normalize_permissions(
|
||||||
|
settings
|
||||||
|
)
|
||||||
|
|
||||||
|
_normalize_blocked_hosts(
|
||||||
|
settings
|
||||||
|
)
|
||||||
|
|
||||||
|
SETTINGS_FILE.write_text(
|
||||||
|
json.dumps(
|
||||||
|
settings,
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def profile_root(
|
||||||
|
profile_name: str,
|
||||||
|
) -> Path:
|
||||||
|
profile_name = (
|
||||||
|
str(profile_name).strip()
|
||||||
|
or DEFAULT_PROFILE
|
||||||
|
)
|
||||||
|
|
||||||
|
root = (
|
||||||
|
PROFILES_DIR
|
||||||
|
/ profile_name
|
||||||
|
)
|
||||||
|
|
||||||
|
root.mkdir(
|
||||||
|
parents=True,
|
||||||
|
exist_ok=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def profile_paths(
|
||||||
|
profile_name: str,
|
||||||
|
) -> tuple[Path, Path]:
|
||||||
|
root = profile_root(
|
||||||
|
profile_name
|
||||||
|
)
|
||||||
|
|
||||||
|
storage = root / "storage"
|
||||||
|
cache = root / "cache"
|
||||||
|
|
||||||
|
storage.mkdir(
|
||||||
|
parents=True,
|
||||||
|
exist_ok=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
cache.mkdir(
|
||||||
|
parents=True,
|
||||||
|
exist_ok=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
storage,
|
||||||
|
cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def profile_theme_dir(
|
||||||
|
profile_name: str,
|
||||||
|
) -> Path:
|
||||||
|
path = (
|
||||||
|
THEMES_DIR
|
||||||
|
/ profile_name
|
||||||
|
)
|
||||||
|
|
||||||
|
path.mkdir(
|
||||||
|
parents=True,
|
||||||
|
exist_ok=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def profile_extension_dir(
|
||||||
|
profile_name: str,
|
||||||
|
) -> Path:
|
||||||
|
path = (
|
||||||
|
EXTENSIONS_DIR
|
||||||
|
/ profile_name
|
||||||
|
)
|
||||||
|
|
||||||
|
path.mkdir(
|
||||||
|
parents=True,
|
||||||
|
exist_ok=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def profile_vencord_dir(
|
||||||
|
profile_name: str,
|
||||||
|
) -> Path:
|
||||||
|
path = (
|
||||||
|
profile_root(
|
||||||
|
profile_name
|
||||||
|
)
|
||||||
|
/ "vencord"
|
||||||
|
)
|
||||||
|
|
||||||
|
path.mkdir(
|
||||||
|
parents=True,
|
||||||
|
exist_ok=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
for child in (
|
||||||
|
"plugins",
|
||||||
|
"themes",
|
||||||
|
"logs",
|
||||||
|
):
|
||||||
|
(
|
||||||
|
path / child
|
||||||
|
).mkdir(
|
||||||
|
parents=True,
|
||||||
|
exist_ok=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def profile_plugin_dir(
|
||||||
|
profile_name: str,
|
||||||
|
) -> Path:
|
||||||
|
root = (
|
||||||
|
profile_vencord_dir(
|
||||||
|
profile_name
|
||||||
|
)
|
||||||
|
/ "plugins"
|
||||||
|
)
|
||||||
|
|
||||||
|
root.mkdir(
|
||||||
|
parents=True,
|
||||||
|
exist_ok=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return root
|
||||||
Reference in New Issue
Block a user