stupidpythonbrowser 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pybrowser/__init__.py +14 -0
- pybrowser/browser.py +3175 -0
- pybrowser/doh_server.py +423 -0
- pybrowser/incognito.py +36 -0
- pybrowser/main.py +66 -0
- pybrowser/settings.py +656 -0
- pybrowser/task_manager.py +366 -0
- pybrowser/terminal.py +451 -0
- stupidpythonbrowser-1.0.1.dist-info/METADATA +25 -0
- stupidpythonbrowser-1.0.1.dist-info/RECORD +14 -0
- stupidpythonbrowser-1.0.1.dist-info/WHEEL +5 -0
- stupidpythonbrowser-1.0.1.dist-info/entry_points.txt +2 -0
- stupidpythonbrowser-1.0.1.dist-info/licenses/LICENSE +674 -0
- stupidpythonbrowser-1.0.1.dist-info/top_level.txt +1 -0
pybrowser/doh_server.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyBrowser DNS-over-HTTPS (DoH) Proxy Engine & StevenBlack Adblock Service.
|
|
3
|
+
Provides encrypted local DNS proxying and WebKit content rule compilation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
import threading
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
import socket
|
|
13
|
+
import select
|
|
14
|
+
import socketserver
|
|
15
|
+
import http.server
|
|
16
|
+
import ssl
|
|
17
|
+
import ctypes
|
|
18
|
+
import re
|
|
19
|
+
import ipaddress
|
|
20
|
+
|
|
21
|
+
import Cocoa
|
|
22
|
+
import WebKit
|
|
23
|
+
import objc
|
|
24
|
+
|
|
25
|
+
from pybrowser.settings import (
|
|
26
|
+
SETTINGS, ADBLOCK_CACHE_FILE, DNS_CACHE_FILE, FILTER_LIST_URL,
|
|
27
|
+
LOCAL_PROXY_PORT, BASE_ADBLOCK_DOMAINS
|
|
28
|
+
)
|
|
29
|
+
from pybrowser.terminal import dispatch_to_main
|
|
30
|
+
|
|
31
|
+
STEVENBLACK_DOMAINS = set()
|
|
32
|
+
ACTIVE_BLOCK_DOMAINS = set()
|
|
33
|
+
ADBLOCK_LOCK = threading.Lock()
|
|
34
|
+
WK_RULE_LIST = None
|
|
35
|
+
DNS_CACHE = {}
|
|
36
|
+
DNS_LOCK = threading.Lock()
|
|
37
|
+
DNS_CACHE_DIRTY = False
|
|
38
|
+
|
|
39
|
+
def rebuild_active_domains():
|
|
40
|
+
global ACTIVE_BLOCK_DOMAINS
|
|
41
|
+
with ADBLOCK_LOCK:
|
|
42
|
+
s = set()
|
|
43
|
+
if SETTINGS.settings.get("adblock_base_enabled", True):
|
|
44
|
+
s.update(BASE_ADBLOCK_DOMAINS)
|
|
45
|
+
if SETTINGS.settings.get("adblock_stevenblack_enabled", True):
|
|
46
|
+
s.update(STEVENBLACK_DOMAINS)
|
|
47
|
+
s.update(SETTINGS.get_custom_adblock_domains())
|
|
48
|
+
ACTIVE_BLOCK_DOMAINS = s
|
|
49
|
+
|
|
50
|
+
def is_domain_blocked(host):
|
|
51
|
+
if not host:
|
|
52
|
+
return False
|
|
53
|
+
host = host.strip().lower()
|
|
54
|
+
if host in ACTIVE_BLOCK_DOMAINS:
|
|
55
|
+
return True
|
|
56
|
+
parts = host.split(".")
|
|
57
|
+
for i in range(1, len(parts) - 1):
|
|
58
|
+
if ".".join(parts[i:]) in ACTIVE_BLOCK_DOMAINS:
|
|
59
|
+
return True
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
def search_blocked_domains(query, limit=100):
|
|
63
|
+
query = (query or "").strip().lower()
|
|
64
|
+
results = []
|
|
65
|
+
custom = SETTINGS.get_custom_adblock_domains()
|
|
66
|
+
with ADBLOCK_LOCK:
|
|
67
|
+
sb = list(STEVENBLACK_DOMAINS)
|
|
68
|
+
base = list(BASE_ADBLOCK_DOMAINS)
|
|
69
|
+
|
|
70
|
+
for d in sorted(custom):
|
|
71
|
+
if not query or query in d:
|
|
72
|
+
results.append({"domain": d, "source": "Custom List"})
|
|
73
|
+
if len(results) >= limit:
|
|
74
|
+
return results
|
|
75
|
+
|
|
76
|
+
for d in sorted(base):
|
|
77
|
+
if not query or query in d:
|
|
78
|
+
results.append({"domain": d, "source": "Built-in High Impact"})
|
|
79
|
+
if len(results) >= limit:
|
|
80
|
+
return results
|
|
81
|
+
|
|
82
|
+
for d in sb:
|
|
83
|
+
if not query or query in d:
|
|
84
|
+
results.append({"domain": d, "source": "StevenBlack Unified"})
|
|
85
|
+
if len(results) >= limit:
|
|
86
|
+
return results
|
|
87
|
+
|
|
88
|
+
return results
|
|
89
|
+
|
|
90
|
+
def load_dns_cache():
|
|
91
|
+
global DNS_CACHE
|
|
92
|
+
if os.path.exists(DNS_CACHE_FILE):
|
|
93
|
+
try:
|
|
94
|
+
with open(DNS_CACHE_FILE, 'r', encoding='utf-8') as f:
|
|
95
|
+
DNS_CACHE = json.load(f)
|
|
96
|
+
except Exception:
|
|
97
|
+
DNS_CACHE = {}
|
|
98
|
+
|
|
99
|
+
def flush_dns_cache_to_disk():
|
|
100
|
+
global DNS_CACHE_DIRTY
|
|
101
|
+
with DNS_LOCK:
|
|
102
|
+
if DNS_CACHE_DIRTY:
|
|
103
|
+
try:
|
|
104
|
+
with open(DNS_CACHE_FILE, 'w', encoding='utf-8') as f:
|
|
105
|
+
json.dump(DNS_CACHE, f, indent=2)
|
|
106
|
+
DNS_CACHE_DIRTY = False
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
def doh_resolve(hostname):
|
|
111
|
+
global DNS_CACHE_DIRTY
|
|
112
|
+
now = time.time()
|
|
113
|
+
clean_host = hostname.strip("[]").strip()
|
|
114
|
+
|
|
115
|
+
# If already an IPv4 or IPv6 address literal, bypass DNS lookup
|
|
116
|
+
try:
|
|
117
|
+
ipaddress.ip_address(clean_host)
|
|
118
|
+
return clean_host
|
|
119
|
+
except ValueError:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
with DNS_LOCK:
|
|
123
|
+
if clean_host in DNS_CACHE:
|
|
124
|
+
record = DNS_CACHE[clean_host]
|
|
125
|
+
if isinstance(record, dict) and record.get("expires", 0) > now:
|
|
126
|
+
return record["ip"]
|
|
127
|
+
|
|
128
|
+
provider = SETTINGS.settings.get("doh_provider", "cloudflare")
|
|
129
|
+
doh_configs = {
|
|
130
|
+
"cloudflare": ("1.1.1.1", "cloudflare-dns.com", "/dns-query"),
|
|
131
|
+
"cloudflare_security": ("1.1.1.2", "security.cloudflare-dns.com", "/dns-query"),
|
|
132
|
+
"google": ("8.8.8.8", "dns.google", "/resolve"),
|
|
133
|
+
"quad9": ("9.9.9.9", "dns.quad9.net", "/dns-query"),
|
|
134
|
+
"adguard": ("94.140.14.14", "dns.adguard-dns.com", "/dns-query"),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if provider == "custom":
|
|
138
|
+
custom_url = SETTINGS.settings.get("doh_custom_url", "")
|
|
139
|
+
if custom_url.startswith("https://"):
|
|
140
|
+
try:
|
|
141
|
+
parsed = urllib.parse.urlparse(custom_url)
|
|
142
|
+
host_hdr = parsed.netloc
|
|
143
|
+
path_base = parsed.path or "/dns-query"
|
|
144
|
+
ip_target = host_hdr.split(":")[0]
|
|
145
|
+
except Exception:
|
|
146
|
+
ip_target, host_hdr, path_base = doh_configs["cloudflare"]
|
|
147
|
+
else:
|
|
148
|
+
ip_target, host_hdr, path_base = doh_configs["cloudflare"]
|
|
149
|
+
else:
|
|
150
|
+
ip_target, host_hdr, path_base = doh_configs.get(provider, doh_configs["cloudflare"])
|
|
151
|
+
|
|
152
|
+
for _ in range(2):
|
|
153
|
+
try:
|
|
154
|
+
ctx = ssl.create_default_context()
|
|
155
|
+
sock = socket.create_connection((ip_target, 443), timeout=3.5)
|
|
156
|
+
ssock = ctx.wrap_socket(sock, server_hostname=host_hdr)
|
|
157
|
+
sep = "&" if "?" in path_base else "?"
|
|
158
|
+
query_path = f"{path_base}{sep}name={urllib.parse.quote(clean_host)}&type=A"
|
|
159
|
+
req = (
|
|
160
|
+
f"GET {query_path} HTTP/1.1\r\n"
|
|
161
|
+
f"Host: {host_hdr}\r\n"
|
|
162
|
+
f"Accept: application/dns-json\r\n"
|
|
163
|
+
f"User-Agent: PyBrowser-DoH/4.7\r\n"
|
|
164
|
+
f"Connection: close\r\n\r\n"
|
|
165
|
+
)
|
|
166
|
+
ssock.sendall(req.encode('utf-8'))
|
|
167
|
+
raw_resp = b""
|
|
168
|
+
while True:
|
|
169
|
+
chunk = ssock.recv(4096)
|
|
170
|
+
if not chunk:
|
|
171
|
+
break
|
|
172
|
+
raw_resp += chunk
|
|
173
|
+
ssock.close()
|
|
174
|
+
|
|
175
|
+
start = raw_resp.find(b'{')
|
|
176
|
+
end = raw_resp.rfind(b'}')
|
|
177
|
+
if start != -1 and end != -1 and end > start:
|
|
178
|
+
data = json.loads(raw_resp[start:end+1].decode("utf-8", errors="ignore"))
|
|
179
|
+
for answer in data.get("Answer", []):
|
|
180
|
+
if answer.get("type") == 1:
|
|
181
|
+
ip = answer.get("data")
|
|
182
|
+
with DNS_LOCK:
|
|
183
|
+
DNS_CACHE[clean_host] = {"ip": ip, "expires": now + 3600}
|
|
184
|
+
DNS_CACHE_DIRTY = True
|
|
185
|
+
return ip
|
|
186
|
+
except Exception:
|
|
187
|
+
time.sleep(0.05)
|
|
188
|
+
|
|
189
|
+
return clean_host
|
|
190
|
+
|
|
191
|
+
class DoHProxyHandler(http.server.BaseHTTPRequestHandler):
|
|
192
|
+
def log_message(self, format, *args):
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
def do_CONNECT(self):
|
|
196
|
+
try:
|
|
197
|
+
host, port = (self.path.rsplit(":", 1) if ":" in self.path else (self.path, 443))
|
|
198
|
+
port = int(port)
|
|
199
|
+
host = host.strip("[]")
|
|
200
|
+
except Exception:
|
|
201
|
+
self.send_error(400, "Bad Request")
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
if SETTINGS.settings.get("adblock_enabled", True) and is_domain_blocked(host):
|
|
205
|
+
self.send_error(403, "Blocked by PyBrowser uBlock Engine")
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
ip = doh_resolve(host)
|
|
209
|
+
try:
|
|
210
|
+
upstream = socket.create_connection((ip, port), timeout=6)
|
|
211
|
+
self.send_response(200, "Connection Established")
|
|
212
|
+
self.end_headers()
|
|
213
|
+
self._tunnel(self.connection, upstream)
|
|
214
|
+
except Exception:
|
|
215
|
+
self.send_error(502, "Bad Gateway")
|
|
216
|
+
|
|
217
|
+
def do_GET(self): self._proxy_plain_http()
|
|
218
|
+
def do_POST(self): self._proxy_plain_http()
|
|
219
|
+
def do_HEAD(self): self._proxy_plain_http()
|
|
220
|
+
def do_PUT(self): self._proxy_plain_http()
|
|
221
|
+
def do_DELETE(self): self._proxy_plain_http()
|
|
222
|
+
def do_PATCH(self): self._proxy_plain_http()
|
|
223
|
+
def do_OPTIONS(self): self._proxy_plain_http()
|
|
224
|
+
|
|
225
|
+
def _proxy_plain_http(self):
|
|
226
|
+
try:
|
|
227
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
228
|
+
host = parsed.hostname or self.headers.get("Host", "").split(":")[0]
|
|
229
|
+
port = parsed.port or 80
|
|
230
|
+
if SETTINGS.settings.get("adblock_enabled", True) and is_domain_blocked(host):
|
|
231
|
+
self.send_error(403, "Blocked by PyBrowser")
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
ip = doh_resolve(host)
|
|
235
|
+
upstream = socket.create_connection((ip, port), timeout=6)
|
|
236
|
+
req_path = parsed.path or "/"
|
|
237
|
+
if parsed.query:
|
|
238
|
+
req_path += f"?{parsed.query}"
|
|
239
|
+
|
|
240
|
+
raw_req = f"{self.command} {req_path} HTTP/1.1\r\n"
|
|
241
|
+
for k, v in self.headers.items():
|
|
242
|
+
if k.lower() not in ("proxy-connection", "connection"):
|
|
243
|
+
raw_req += f"{k}: {v}\r\n"
|
|
244
|
+
raw_req += "Connection: close\r\n\r\n"
|
|
245
|
+
upstream.sendall(raw_req.encode("utf-8"))
|
|
246
|
+
|
|
247
|
+
content_len = int(self.headers.get("Content-Length", 0))
|
|
248
|
+
if content_len > 0:
|
|
249
|
+
body = self.rfile.read(content_len)
|
|
250
|
+
upstream.sendall(body)
|
|
251
|
+
|
|
252
|
+
self._tunnel(self.connection, upstream)
|
|
253
|
+
except Exception:
|
|
254
|
+
try:
|
|
255
|
+
self.send_error(502, "Bad Gateway")
|
|
256
|
+
except Exception:
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
def _tunnel(self, client, upstream):
|
|
260
|
+
sockets = [client, upstream]
|
|
261
|
+
try:
|
|
262
|
+
while True:
|
|
263
|
+
r, _, _ = select.select(sockets, [], [], 120)
|
|
264
|
+
if not r:
|
|
265
|
+
break
|
|
266
|
+
for sock in r:
|
|
267
|
+
other = upstream if sock is client else client
|
|
268
|
+
data = sock.recv(8192)
|
|
269
|
+
if not data:
|
|
270
|
+
return
|
|
271
|
+
other.sendall(data)
|
|
272
|
+
except Exception:
|
|
273
|
+
pass
|
|
274
|
+
finally:
|
|
275
|
+
try:
|
|
276
|
+
client.close()
|
|
277
|
+
except Exception:
|
|
278
|
+
pass
|
|
279
|
+
try:
|
|
280
|
+
upstream.close()
|
|
281
|
+
except Exception:
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
def start_proxy_server():
|
|
285
|
+
class ReusableTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
|
|
286
|
+
allow_reuse_address = True
|
|
287
|
+
daemon_threads = True
|
|
288
|
+
|
|
289
|
+
def server_bind(self):
|
|
290
|
+
if hasattr(socket, "SO_REUSEPORT"):
|
|
291
|
+
try:
|
|
292
|
+
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
|
293
|
+
except Exception:
|
|
294
|
+
pass
|
|
295
|
+
super().server_bind()
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
server = ReusableTCPServer(("127.0.0.1", LOCAL_PROXY_PORT), DoHProxyHandler)
|
|
299
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
300
|
+
threading.Thread(target=dns_flush_worker, daemon=True).start()
|
|
301
|
+
except Exception as e:
|
|
302
|
+
print(f"[Proxy Warning] Could not start local proxy server: {e}")
|
|
303
|
+
|
|
304
|
+
def dns_flush_worker():
|
|
305
|
+
while True:
|
|
306
|
+
time.sleep(30)
|
|
307
|
+
flush_dns_cache_to_disk()
|
|
308
|
+
|
|
309
|
+
def create_nw_proxy_config(host="127.0.0.1", port=LOCAL_PROXY_PORT):
|
|
310
|
+
"""Safely instantiates an NWProxyConfig instance via Network.framework."""
|
|
311
|
+
try:
|
|
312
|
+
try:
|
|
313
|
+
import Network
|
|
314
|
+
except ImportError:
|
|
315
|
+
pass
|
|
316
|
+
|
|
317
|
+
libnet = ctypes.cdll.LoadLibrary("/System/Library/Frameworks/Network.framework/Network")
|
|
318
|
+
libnet.nw_endpoint_create_host.restype = ctypes.c_void_p
|
|
319
|
+
libnet.nw_endpoint_create_host.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
|
320
|
+
libnet.nw_proxy_config_create_http_connect.restype = ctypes.c_void_p
|
|
321
|
+
libnet.nw_proxy_config_create_http_connect.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
|
|
322
|
+
|
|
323
|
+
ep = libnet.nw_endpoint_create_host(host.encode("utf-8"), str(port).encode("utf-8"))
|
|
324
|
+
if not ep:
|
|
325
|
+
return None
|
|
326
|
+
cfg = libnet.nw_proxy_config_create_http_connect(ep, None)
|
|
327
|
+
if not cfg:
|
|
328
|
+
return None
|
|
329
|
+
return objc.objc_object(c_void_p=cfg)
|
|
330
|
+
except Exception as e:
|
|
331
|
+
print(f"[DoH Proxy Config Error] Failed to bind proxy to WebKit: {e}")
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
def load_adblock_filters_from_disk():
|
|
335
|
+
global STEVENBLACK_DOMAINS
|
|
336
|
+
domains = set()
|
|
337
|
+
if os.path.exists(ADBLOCK_CACHE_FILE):
|
|
338
|
+
try:
|
|
339
|
+
with open(ADBLOCK_CACHE_FILE, "r", encoding="utf-8", errors="ignore") as f:
|
|
340
|
+
for line in f:
|
|
341
|
+
line = line.strip()
|
|
342
|
+
if line.startswith("0.0.0.0 ") or line.startswith("127.0.0.1 "):
|
|
343
|
+
parts = line.split()
|
|
344
|
+
if len(parts) >= 2:
|
|
345
|
+
d = parts[1].strip().lower()
|
|
346
|
+
if d and d not in ("localhost", "0.0.0.0", "broadcasthost", "local"):
|
|
347
|
+
domains.add(d)
|
|
348
|
+
except Exception as e:
|
|
349
|
+
print(f"[Adblock Disk Error] {e}")
|
|
350
|
+
with ADBLOCK_LOCK:
|
|
351
|
+
STEVENBLACK_DOMAINS = domains
|
|
352
|
+
rebuild_active_domains()
|
|
353
|
+
|
|
354
|
+
def compile_webkit_adblock_rules():
|
|
355
|
+
"""Compiles active blocklist domains into WebKit WKContentRuleList with instant cache lookup."""
|
|
356
|
+
def on_cached_lookup(rule_list, error):
|
|
357
|
+
global WK_RULE_LIST
|
|
358
|
+
if rule_list:
|
|
359
|
+
WK_RULE_LIST = rule_list
|
|
360
|
+
dispatch_to_main(apply_rules_to_all_tabs)
|
|
361
|
+
# Proceed with background compilation if not cached or updating
|
|
362
|
+
threading.Thread(target=compile_task, daemon=True).start()
|
|
363
|
+
|
|
364
|
+
def compile_task():
|
|
365
|
+
rebuild_active_domains()
|
|
366
|
+
with ADBLOCK_LOCK:
|
|
367
|
+
domains = list(ACTIVE_BLOCK_DOMAINS)[:48000]
|
|
368
|
+
|
|
369
|
+
rules = []
|
|
370
|
+
for d in domains:
|
|
371
|
+
if not d:
|
|
372
|
+
continue
|
|
373
|
+
rules.append({
|
|
374
|
+
"trigger": {"url-filter": f"^https?://([^/]+\\.)?{re.escape(d)}"},
|
|
375
|
+
"action": {"type": "block"}
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
json_str = json.dumps(rules) if rules else "[]"
|
|
379
|
+
|
|
380
|
+
def handler(rule_list, error):
|
|
381
|
+
global WK_RULE_LIST
|
|
382
|
+
if rule_list:
|
|
383
|
+
WK_RULE_LIST = rule_list
|
|
384
|
+
dispatch_to_main(apply_rules_to_all_tabs)
|
|
385
|
+
elif error:
|
|
386
|
+
print(f"[WebKit Rule Compile Error] {error}")
|
|
387
|
+
|
|
388
|
+
dispatch_to_main(lambda: WebKit.WKContentRuleListStore.defaultStore().compileContentRuleListForIdentifier_encodedContentRuleList_completionHandler_(
|
|
389
|
+
"PyBrowserAdblock", json_str, handler
|
|
390
|
+
))
|
|
391
|
+
|
|
392
|
+
dispatch_to_main(lambda: WebKit.WKContentRuleListStore.defaultStore().lookUpContentRuleListForIdentifier_completionHandler_(
|
|
393
|
+
"PyBrowserAdblock", on_cached_lookup
|
|
394
|
+
))
|
|
395
|
+
|
|
396
|
+
def apply_rules_to_all_tabs():
|
|
397
|
+
app_delegate = Cocoa.NSApp.delegate()
|
|
398
|
+
if not app_delegate or not hasattr(app_delegate, 'controllers'):
|
|
399
|
+
return
|
|
400
|
+
for wc in getattr(app_delegate, "controllers", []):
|
|
401
|
+
for tab in getattr(wc, "tabs", []):
|
|
402
|
+
wv = tab.get("webview")
|
|
403
|
+
if wv:
|
|
404
|
+
uc = wv.configuration().userContentController()
|
|
405
|
+
uc.removeAllContentRuleLists()
|
|
406
|
+
if WK_RULE_LIST and SETTINGS.settings.get("adblock_enabled", True):
|
|
407
|
+
uc.addContentRuleList_(WK_RULE_LIST)
|
|
408
|
+
|
|
409
|
+
def update_adblock_filters_from_web():
|
|
410
|
+
try:
|
|
411
|
+
req = urllib.request.Request(FILTER_LIST_URL, headers={"User-Agent": "PyBrowser/4.7 Adblock"})
|
|
412
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
413
|
+
content = resp.read().decode("utf-8", errors="ignore")
|
|
414
|
+
with open(ADBLOCK_CACHE_FILE, "w", encoding="utf-8") as f:
|
|
415
|
+
f.write(content)
|
|
416
|
+
load_adblock_filters_from_disk()
|
|
417
|
+
SETTINGS.settings["adblock_last_updated"] = time.strftime("%Y-%m-%d %H:%M")
|
|
418
|
+
SETTINGS.save()
|
|
419
|
+
compile_webkit_adblock_rules()
|
|
420
|
+
return len(STEVENBLACK_DOMAINS)
|
|
421
|
+
except Exception as e:
|
|
422
|
+
print(f"[Adblock Web Update Error] {e}")
|
|
423
|
+
return len(STEVENBLACK_DOMAINS)
|
pybrowser/incognito.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyBrowser Incognito & Private Browsing Module.
|
|
3
|
+
Manages non-persistent WebKit data stores and incognito session isolation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import WebKit
|
|
7
|
+
from pybrowser.settings import LOCAL_PROXY_PORT, SETTINGS
|
|
8
|
+
from pybrowser.doh_server import create_nw_proxy_config
|
|
9
|
+
|
|
10
|
+
def create_incognito_data_store():
|
|
11
|
+
"""Returns an isolated, non-persistent WKWebsiteDataStore with DoH proxy bound."""
|
|
12
|
+
ds = WebKit.WKWebsiteDataStore.nonPersistentDataStore()
|
|
13
|
+
pcfg = create_nw_proxy_config("127.0.0.1", LOCAL_PROXY_PORT)
|
|
14
|
+
if pcfg and hasattr(ds, "setProxyConfigurations_"):
|
|
15
|
+
try:
|
|
16
|
+
ds.setProxyConfigurations_([pcfg])
|
|
17
|
+
except Exception:
|
|
18
|
+
pass
|
|
19
|
+
return ds
|
|
20
|
+
|
|
21
|
+
def apply_incognito_preferences(config, shared_data_store=None):
|
|
22
|
+
"""Configures a WKWebViewConfiguration for zero-persistence incognito browsing."""
|
|
23
|
+
data_store = shared_data_store or create_incognito_data_store()
|
|
24
|
+
config.setWebsiteDataStore_(data_store)
|
|
25
|
+
try:
|
|
26
|
+
config.preferences().setValue_forKey_(True, "developerExtrasEnabled")
|
|
27
|
+
config.preferences().setValue_forKey_(True, "fullScreenEnabled")
|
|
28
|
+
if hasattr(config.preferences(), "setJavaScriptCanOpenWindowsAutomatically_"):
|
|
29
|
+
config.preferences().setJavaScriptCanOpenWindowsAutomatically_(not SETTINGS.settings.get("block_popups", True))
|
|
30
|
+
if hasattr(config.preferences(), "setJavaScriptEnabled_"):
|
|
31
|
+
config.preferences().setJavaScriptEnabled_(SETTINGS.settings.get("js_enabled", True))
|
|
32
|
+
if hasattr(config.preferences(), "setElementFullscreenEnabled_"):
|
|
33
|
+
config.preferences().setElementFullscreenEnabled_(True)
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
return config
|
pybrowser/main.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
PyBrowser: Native macOS Chromium/Material 3 Web Browser (PyObjC & WebKit).
|
|
4
|
+
Self-healing Application Launcher and Entry Point.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import os
|
|
9
|
+
import types
|
|
10
|
+
import signal
|
|
11
|
+
import traceback
|
|
12
|
+
|
|
13
|
+
# ============================================================================
|
|
14
|
+
# SELF-HEALING SYS.PATH RESOLUTION
|
|
15
|
+
# ============================================================================
|
|
16
|
+
current_dir = os.path.abspath(os.path.dirname(__file__))
|
|
17
|
+
parent_dir = os.path.abspath(os.path.join(current_dir, ".."))
|
|
18
|
+
|
|
19
|
+
# Case 1: Standard layout (main.py is in PyBrowser/, and pybrowser/ is a subfolder)
|
|
20
|
+
if os.path.isdir(os.path.join(current_dir, "pybrowser")):
|
|
21
|
+
if current_dir not in sys.path:
|
|
22
|
+
sys.path.insert(0, current_dir)
|
|
23
|
+
|
|
24
|
+
# Case 2: main.py is sitting inside the pybrowser/ folder itself
|
|
25
|
+
elif os.path.basename(current_dir).lower() == "pybrowser" or os.path.isfile(os.path.join(parent_dir, "pybrowser", "browser.py")):
|
|
26
|
+
if parent_dir not in sys.path:
|
|
27
|
+
sys.path.insert(0, parent_dir)
|
|
28
|
+
|
|
29
|
+
# Case 3: Flat layout (all files are in the same folder with no pybrowser/ subfolder)
|
|
30
|
+
elif os.path.isfile(os.path.join(current_dir, "browser.py")):
|
|
31
|
+
if "pybrowser" not in sys.modules:
|
|
32
|
+
pkg = types.ModuleType("pybrowser")
|
|
33
|
+
pkg.__path__ = [current_dir]
|
|
34
|
+
sys.modules["pybrowser"] = pkg
|
|
35
|
+
if current_dir not in sys.path:
|
|
36
|
+
sys.path.insert(0, current_dir)
|
|
37
|
+
|
|
38
|
+
import Cocoa
|
|
39
|
+
from pybrowser.browser import AppDelegate
|
|
40
|
+
|
|
41
|
+
def sigint_handler(sig, frame):
|
|
42
|
+
"""Handle Unix SIGINT (Ctrl+C) to terminate Cocoa cleanly."""
|
|
43
|
+
if Cocoa.NSApp:
|
|
44
|
+
Cocoa.NSApp.terminate_(None)
|
|
45
|
+
else:
|
|
46
|
+
sys.exit(0)
|
|
47
|
+
|
|
48
|
+
def main():
|
|
49
|
+
"""Initializes the NSApplication run loop and AppDelegate."""
|
|
50
|
+
signal.signal(signal.SIGINT, sigint_handler)
|
|
51
|
+
try:
|
|
52
|
+
app = Cocoa.NSApplication.sharedApplication()
|
|
53
|
+
app.setActivationPolicy_(Cocoa.NSApplicationActivationPolicyRegular)
|
|
54
|
+
|
|
55
|
+
delegate = AppDelegate.alloc().init()
|
|
56
|
+
app.setDelegate_(delegate)
|
|
57
|
+
app.activateIgnoringOtherApps_(True)
|
|
58
|
+
|
|
59
|
+
# Periodic timer allowing Python run loop to process Unix signals
|
|
60
|
+
Cocoa.NSTimer.scheduledTimerWithTimeInterval_repeats_block_(0.5, True, lambda t: None)
|
|
61
|
+
app.run()
|
|
62
|
+
except Exception:
|
|
63
|
+
traceback.print_exc()
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
main()
|