pyloid 0.25.0__tar.gz → 0.26.1__tar.gz
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.
- {pyloid-0.25.0 → pyloid-0.26.1}/PKG-INFO +1 -1
- {pyloid-0.25.0 → pyloid-0.26.1}/pyproject.toml +1 -1
- {pyloid-0.25.0 → pyloid-0.26.1}/src/pyloid/browser_window.py +9 -13
- {pyloid-0.25.0 → pyloid-0.26.1}/src/pyloid/js_api/base.py +4 -4
- {pyloid-0.25.0 → pyloid-0.26.1}/src/pyloid/pyloid.py +9 -5
- {pyloid-0.25.0 → pyloid-0.26.1}/src/pyloid/serve.py +231 -231
- pyloid-0.26.1/src/pyloid/url_interceptor.py +25 -0
- pyloid-0.25.0/src/pyloid/url_interceptor.py +0 -24
- {pyloid-0.25.0 → pyloid-0.26.1}/LICENSE +0 -0
- {pyloid-0.25.0 → pyloid-0.26.1}/README.md +0 -0
- {pyloid-0.25.0 → pyloid-0.26.1}/src/pyloid/__init__.py +0 -0
- {pyloid-0.25.0 → pyloid-0.26.1}/src/pyloid/api.py +0 -0
- {pyloid-0.25.0 → pyloid-0.26.1}/src/pyloid/autostart.py +0 -0
- {pyloid-0.25.0 → pyloid-0.26.1}/src/pyloid/custom/titlebar.py +0 -0
- {pyloid-0.25.0 → pyloid-0.26.1}/src/pyloid/filewatcher.py +0 -0
- {pyloid-0.25.0 → pyloid-0.26.1}/src/pyloid/js_api/event_api.py +0 -0
- {pyloid-0.25.0 → pyloid-0.26.1}/src/pyloid/js_api/window_api.py +0 -0
- {pyloid-0.25.0 → pyloid-0.26.1}/src/pyloid/monitor.py +0 -0
- {pyloid-0.25.0 → pyloid-0.26.1}/src/pyloid/rpc.py +0 -0
- {pyloid-0.25.0 → pyloid-0.26.1}/src/pyloid/store.py +0 -0
- {pyloid-0.25.0 → pyloid-0.26.1}/src/pyloid/thread_pool.py +0 -0
- {pyloid-0.25.0 → pyloid-0.26.1}/src/pyloid/timer.py +0 -0
- {pyloid-0.25.0 → pyloid-0.26.1}/src/pyloid/tray.py +0 -0
- {pyloid-0.25.0 → pyloid-0.26.1}/src/pyloid/utils.py +0 -0
|
@@ -24,10 +24,10 @@ from PySide6.QtCore import (
|
|
|
24
24
|
from PySide6.QtWebEngineCore import (
|
|
25
25
|
QWebEnginePage,
|
|
26
26
|
QWebEngineSettings,
|
|
27
|
+
# QWebEngineUrlRequestInterceptor,
|
|
27
28
|
)
|
|
28
|
-
from .api import PyloidAPI
|
|
29
29
|
import uuid
|
|
30
|
-
from typing import
|
|
30
|
+
from typing import Optional, Dict, Callable
|
|
31
31
|
import json
|
|
32
32
|
from PySide6.QtWidgets import (
|
|
33
33
|
QWidget,
|
|
@@ -42,9 +42,8 @@ from PySide6.QtWebEngineCore import (
|
|
|
42
42
|
QWebEngineSettings,
|
|
43
43
|
# QWebEngineDesktopMediaRequest, # 6.8.3 부터
|
|
44
44
|
)
|
|
45
|
-
import threading
|
|
46
45
|
|
|
47
|
-
# from .url_interceptor import
|
|
46
|
+
# from .url_interceptor import ServerUrlInterceptor
|
|
48
47
|
|
|
49
48
|
if TYPE_CHECKING:
|
|
50
49
|
from .pyloid import _Pyloid, Pyloid
|
|
@@ -112,7 +111,7 @@ class CustomWebPage(QWebEnginePage):
|
|
|
112
111
|
# class CustomInterceptor(QWebEngineUrlRequestInterceptor):
|
|
113
112
|
# def __init__(self, index_path=None):
|
|
114
113
|
# super().__init__()
|
|
115
|
-
# self.index_path = get_production_path()
|
|
114
|
+
# # self.index_path = get_production_path()
|
|
116
115
|
# self.last_path = "/"
|
|
117
116
|
|
|
118
117
|
# def interceptRequest(self, info):
|
|
@@ -322,10 +321,8 @@ class _BrowserWindow:
|
|
|
322
321
|
self._window = QMainWindow()
|
|
323
322
|
self.web_view = CustomWebEngineView(self)
|
|
324
323
|
|
|
325
|
-
|
|
326
|
-
|
|
327
324
|
# interceptor ( all url request )
|
|
328
|
-
# self.web_view.page().setUrlRequestInterceptor(
|
|
325
|
+
# self.web_view.page().profile().setUrlRequestInterceptor(CustomInterceptor())
|
|
329
326
|
|
|
330
327
|
self._window.closeEvent = self.closeEvent # Override closeEvent method
|
|
331
328
|
###########################################################################################
|
|
@@ -477,7 +474,7 @@ class _BrowserWindow:
|
|
|
477
474
|
if sys.platform == "win32":
|
|
478
475
|
import ctypes
|
|
479
476
|
|
|
480
|
-
myappid = "
|
|
477
|
+
myappid = f"pyloid.{self.app.app_name}.com"
|
|
481
478
|
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
|
|
482
479
|
|
|
483
480
|
# Remove title bar and borders (if needed)
|
|
@@ -517,11 +514,10 @@ class _BrowserWindow:
|
|
|
517
514
|
self.set_dev_tools(self.dev_tools)
|
|
518
515
|
|
|
519
516
|
# get the profile and set the interceptor
|
|
520
|
-
profile = self.web_view.page().profile()
|
|
517
|
+
# profile = self.web_view.page().profile()
|
|
518
|
+
# profile.setUrlRequestInterceptor(ServerUrlInterceptor(self.app.server.url, self.id))
|
|
519
|
+
|
|
521
520
|
|
|
522
|
-
# # if the existing interceptor is present, remove it
|
|
523
|
-
# if self.interceptor:
|
|
524
|
-
# profile.setUrlRequestInterceptor(None)
|
|
525
521
|
|
|
526
522
|
def _on_load_finished(self, ok):
|
|
527
523
|
"""Handles the event when the web page finishes loading."""
|
|
@@ -11,13 +11,13 @@ if TYPE_CHECKING:
|
|
|
11
11
|
|
|
12
12
|
class BaseAPI(PyloidAPI):
|
|
13
13
|
def __init__(
|
|
14
|
-
self, window_id: str, data: dict, app: "_Pyloid",
|
|
14
|
+
self, window_id: str, data: dict, app: "_Pyloid", server_url: Optional[str] = None
|
|
15
15
|
):
|
|
16
16
|
super().__init__()
|
|
17
17
|
self.window_id: str = window_id
|
|
18
18
|
self.data: dict = data
|
|
19
19
|
self.app: "_Pyloid" = app
|
|
20
|
-
self.
|
|
20
|
+
self.server_url: Optional[str] = server_url
|
|
21
21
|
|
|
22
22
|
@Bridge(result=dict)
|
|
23
23
|
def getData(self):
|
|
@@ -254,6 +254,6 @@ class BaseAPI(PyloidAPI):
|
|
|
254
254
|
return get_production_path(path)
|
|
255
255
|
|
|
256
256
|
@Bridge(result=str)
|
|
257
|
-
def
|
|
257
|
+
def getServerUrl(self):
|
|
258
258
|
"""Returns the RPC URL of the application."""
|
|
259
|
-
return self.
|
|
259
|
+
return self.server_url
|
|
@@ -97,10 +97,10 @@ class _Pyloid(QApplication):
|
|
|
97
97
|
def __init__(
|
|
98
98
|
self,
|
|
99
99
|
pyloid_wrapper: "Pyloid",
|
|
100
|
-
app_name,
|
|
101
|
-
single_instance=True,
|
|
102
|
-
server=None,
|
|
103
|
-
data=None,
|
|
100
|
+
app_name: str,
|
|
101
|
+
single_instance: bool = True,
|
|
102
|
+
server = None,
|
|
103
|
+
data = None,
|
|
104
104
|
):
|
|
105
105
|
"""
|
|
106
106
|
Initializes the Pyloid application.
|
|
@@ -150,7 +150,7 @@ class _Pyloid(QApplication):
|
|
|
150
150
|
self.tray_menu_items = []
|
|
151
151
|
self.tray_actions = {}
|
|
152
152
|
|
|
153
|
-
self.app_name = app_name
|
|
153
|
+
self.app_name: str = app_name
|
|
154
154
|
self.app_path = sys.executable
|
|
155
155
|
|
|
156
156
|
self.auto_start = AutoStart(self.app_name, self.app_path)
|
|
@@ -1632,6 +1632,10 @@ class Pyloid(QObject):
|
|
|
1632
1632
|
super().__init__()
|
|
1633
1633
|
|
|
1634
1634
|
self.data = None # 나중에 데이터 필요 시 수정
|
|
1635
|
+
|
|
1636
|
+
# server 에 Pyloid 주입
|
|
1637
|
+
if server:
|
|
1638
|
+
server.pyloid = self
|
|
1635
1639
|
|
|
1636
1640
|
self.app = _Pyloid(self, app_name, single_instance, server, self.data)
|
|
1637
1641
|
|
|
@@ -1,232 +1,232 @@
|
|
|
1
|
-
import threading
|
|
2
|
-
import asyncio
|
|
3
|
-
import mimetypes
|
|
4
|
-
import aiofiles
|
|
5
|
-
from aiohttp import web
|
|
6
|
-
from aiohttp.web_fileresponse import FileResponse
|
|
7
|
-
from typing import Optional
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from .utils import get_free_port, is_production
|
|
10
|
-
import logging
|
|
11
|
-
|
|
12
|
-
# 로깅 설정
|
|
13
|
-
logging.getLogger('aiohttp').setLevel(logging.WARNING)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class ZeroCopyFileResponse(FileResponse):
|
|
17
|
-
"""zero-copy optimized file response class"""
|
|
18
|
-
|
|
19
|
-
def __init__(self, path, chunk_size=65536, headers=None):
|
|
20
|
-
"""
|
|
21
|
-
Args:
|
|
22
|
-
path: file path
|
|
23
|
-
chunk_size: streaming chunk size (64KB default)
|
|
24
|
-
headers: additional HTTP headers
|
|
25
|
-
"""
|
|
26
|
-
super().__init__(path, chunk_size=chunk_size, headers=headers)
|
|
27
|
-
self._enable_sendfile = True # sendfile() system call activation
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class ZeroCopyStaticHandler:
|
|
31
|
-
"""zero-copy optimized static file handler"""
|
|
32
|
-
|
|
33
|
-
def __init__(self, directory: str):
|
|
34
|
-
self.directory = Path(directory).resolve()
|
|
35
|
-
self.chunk_size = 65536 # 64KB chunk
|
|
36
|
-
|
|
37
|
-
async def handle_request(self, request):
|
|
38
|
-
"""HTTP request processing"""
|
|
39
|
-
try:
|
|
40
|
-
# URL path parsing
|
|
41
|
-
path = request.path_qs.split('?')[0] # 쿼리 파라미터 제거
|
|
42
|
-
if path.endswith('/'):
|
|
43
|
-
path += 'index.html'
|
|
44
|
-
|
|
45
|
-
# Security: prevent directory traversal attacks
|
|
46
|
-
file_path = (self.directory / path.lstrip('/')).resolve()
|
|
47
|
-
if not str(file_path).startswith(str(self.directory)):
|
|
48
|
-
return web.Response(status=403, text="Forbidden")
|
|
49
|
-
|
|
50
|
-
# Check if the file exists
|
|
51
|
-
if not file_path.exists() or not file_path.is_file():
|
|
52
|
-
return web.Response(status=404, text="File not found")
|
|
53
|
-
|
|
54
|
-
# Check the file size and type
|
|
55
|
-
stat = file_path.stat()
|
|
56
|
-
file_size = stat.st_size
|
|
57
|
-
content_type, _ = mimetypes.guess_type(str(file_path))
|
|
58
|
-
|
|
59
|
-
# Range request processing (partial download supported)
|
|
60
|
-
range_header = request.headers.get('Range')
|
|
61
|
-
if range_header:
|
|
62
|
-
return await self._handle_range_request(file_path, range_header, file_size, content_type, request)
|
|
63
|
-
|
|
64
|
-
if is_production():
|
|
65
|
-
cache_control = 'public, max-age=3600' # 1 hour caching
|
|
66
|
-
else:
|
|
67
|
-
cache_control = 'no-cache'
|
|
68
|
-
|
|
69
|
-
# zero-copy file response creation
|
|
70
|
-
headers = {
|
|
71
|
-
'Content-Type': content_type or 'application/octet-stream',
|
|
72
|
-
'Content-Length': str(file_size),
|
|
73
|
-
'Accept-Ranges': 'bytes',
|
|
74
|
-
'Cache-Control': cache_control,
|
|
75
|
-
'ETag': f'"{stat.st_mtime}-{file_size}"'
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
# ETag based cache check
|
|
79
|
-
if_none_match = request.headers.get('If-None-Match')
|
|
80
|
-
if if_none_match == headers['ETag']:
|
|
81
|
-
return web.Response(status=304, headers=headers)
|
|
82
|
-
|
|
83
|
-
# zero-copy response return (sendfile used)
|
|
84
|
-
return ZeroCopyFileResponse(
|
|
85
|
-
path=file_path,
|
|
86
|
-
chunk_size=self.chunk_size,
|
|
87
|
-
headers=headers
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
except Exception as e:
|
|
91
|
-
return web.Response(status=500, text=f"Internal Server Error: {str(e)}")
|
|
92
|
-
|
|
93
|
-
async def _handle_range_request(self, file_path, range_header, file_size, content_type, request):
|
|
94
|
-
"""Range request processing (partial download)"""
|
|
95
|
-
try:
|
|
96
|
-
# Range header parsing: bytes=start-end
|
|
97
|
-
range_match = range_header.replace('bytes=', '').split('-')
|
|
98
|
-
start = int(range_match[0]) if range_match[0] else 0
|
|
99
|
-
end = int(range_match[1]) if range_match[1] else file_size - 1
|
|
100
|
-
|
|
101
|
-
# Range validation
|
|
102
|
-
if start >= file_size or end >= file_size or start > end:
|
|
103
|
-
return web.Response(
|
|
104
|
-
status=416, # Range Not Satisfiable
|
|
105
|
-
headers={'Content-Range': f'bytes */{file_size}'}
|
|
106
|
-
)
|
|
107
|
-
|
|
108
|
-
content_length = end - start + 1
|
|
109
|
-
|
|
110
|
-
headers = {
|
|
111
|
-
'Content-Type': content_type or 'application/octet-stream',
|
|
112
|
-
'Content-Length': str(content_length),
|
|
113
|
-
'Content-Range': f'bytes {start}-{end}/{file_size}',
|
|
114
|
-
'Accept-Ranges': 'bytes'
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
# zero-copy partial response
|
|
118
|
-
response = web.StreamResponse(status=206, headers=headers)
|
|
119
|
-
await response.prepare(request)
|
|
120
|
-
|
|
121
|
-
# aiofiles to async zero-copy transfer
|
|
122
|
-
async with aiofiles.open(file_path, 'rb') as f:
|
|
123
|
-
await f.seek(start)
|
|
124
|
-
remaining = content_length
|
|
125
|
-
|
|
126
|
-
while remaining > 0:
|
|
127
|
-
chunk_size = min(self.chunk_size, remaining)
|
|
128
|
-
chunk = await f.read(chunk_size)
|
|
129
|
-
if not chunk:
|
|
130
|
-
break
|
|
131
|
-
await response.write(chunk)
|
|
132
|
-
remaining -= len(chunk)
|
|
133
|
-
|
|
134
|
-
await response.write_eof()
|
|
135
|
-
return response
|
|
136
|
-
|
|
137
|
-
except Exception as e:
|
|
138
|
-
return web.Response(status=500, text=f"Range Request Error: {str(e)}")
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
async def start_zero_copy_server(directory: str, port: int):
|
|
142
|
-
"""zero-copy optimized server starts"""
|
|
143
|
-
handler = ZeroCopyStaticHandler(directory)
|
|
144
|
-
|
|
145
|
-
# aiohttp app creation
|
|
146
|
-
app = web.Application()
|
|
147
|
-
app.router.add_route('GET', '/{path:.*}', handler.handle_request)
|
|
148
|
-
|
|
149
|
-
# server configuration optimization
|
|
150
|
-
runner = web.AppRunner(
|
|
151
|
-
app,
|
|
152
|
-
access_log=None, # access log disabled (performance improvement)
|
|
153
|
-
keepalive_timeout=75, # Keep-Alive timeout
|
|
154
|
-
client_timeout=600, # client timeout
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
await runner.setup()
|
|
158
|
-
|
|
159
|
-
site = web.TCPSite(
|
|
160
|
-
runner,
|
|
161
|
-
'127.0.0.1',
|
|
162
|
-
port,
|
|
163
|
-
backlog=1024, # connection backlog size
|
|
164
|
-
reuse_address=True # port reuse only
|
|
165
|
-
)
|
|
166
|
-
|
|
167
|
-
await site.start()
|
|
168
|
-
|
|
169
|
-
return runner, site
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
def pyloid_serve(
|
|
173
|
-
directory: str,
|
|
174
|
-
port: Optional[int] = None,
|
|
175
|
-
) -> str:
|
|
176
|
-
"""
|
|
177
|
-
zero-copy optimized static file server starts.
|
|
178
|
-
|
|
179
|
-
Args
|
|
180
|
-
----
|
|
181
|
-
directory (str): Path to the static file directory to serve
|
|
182
|
-
port (int, optional): Server port (default: None - will use a random free port)
|
|
183
|
-
|
|
184
|
-
Returns
|
|
185
|
-
-------
|
|
186
|
-
str
|
|
187
|
-
URL of the started server
|
|
188
|
-
|
|
189
|
-
Examples
|
|
190
|
-
--------
|
|
191
|
-
```python
|
|
192
|
-
from pyloid import Pyloid
|
|
193
|
-
from pyloid.serve import pyloid_serve
|
|
194
|
-
|
|
195
|
-
app = Pyloid("Pyloid-App")
|
|
196
|
-
url = pyloid_serve("dist")
|
|
197
|
-
window = app.create_window("Pyloid-App")
|
|
198
|
-
window.load_url(url)
|
|
199
|
-
window.show_and_focus()
|
|
200
|
-
```
|
|
201
|
-
"""
|
|
202
|
-
|
|
203
|
-
if port is None:
|
|
204
|
-
port = get_free_port()
|
|
205
|
-
|
|
206
|
-
def run_zero_copy_server():
|
|
207
|
-
"""run async server in a separate thread"""
|
|
208
|
-
loop = asyncio.new_event_loop()
|
|
209
|
-
asyncio.set_event_loop(loop)
|
|
210
|
-
|
|
211
|
-
try:
|
|
212
|
-
runner, site = loop.run_until_complete(
|
|
213
|
-
start_zero_copy_server(directory, port)
|
|
214
|
-
)
|
|
215
|
-
|
|
216
|
-
print(f"🚀 Zero-copy server started on http://127.0.0.1:{port}")
|
|
217
|
-
print(f"📁 Serving directory: {directory}")
|
|
218
|
-
print(f"⚡ Features: sendfile, Range requests, ETag caching")
|
|
219
|
-
|
|
220
|
-
# wait until the server starts
|
|
221
|
-
loop.run_forever()
|
|
222
|
-
|
|
223
|
-
except Exception as e:
|
|
224
|
-
print(f"Zero-copy server error: {e}")
|
|
225
|
-
finally:
|
|
226
|
-
loop.close()
|
|
227
|
-
|
|
228
|
-
# start server in a daemon thread
|
|
229
|
-
thread = threading.Thread(target=run_zero_copy_server, daemon=True)
|
|
230
|
-
thread.start()
|
|
231
|
-
|
|
1
|
+
import threading
|
|
2
|
+
import asyncio
|
|
3
|
+
import mimetypes
|
|
4
|
+
import aiofiles
|
|
5
|
+
from aiohttp import web
|
|
6
|
+
from aiohttp.web_fileresponse import FileResponse
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from .utils import get_free_port, is_production
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
# 로깅 설정
|
|
13
|
+
logging.getLogger('aiohttp').setLevel(logging.WARNING)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ZeroCopyFileResponse(FileResponse):
|
|
17
|
+
"""zero-copy optimized file response class"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, path, chunk_size=65536, headers=None):
|
|
20
|
+
"""
|
|
21
|
+
Args:
|
|
22
|
+
path: file path
|
|
23
|
+
chunk_size: streaming chunk size (64KB default)
|
|
24
|
+
headers: additional HTTP headers
|
|
25
|
+
"""
|
|
26
|
+
super().__init__(path, chunk_size=chunk_size, headers=headers)
|
|
27
|
+
self._enable_sendfile = True # sendfile() system call activation
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ZeroCopyStaticHandler:
|
|
31
|
+
"""zero-copy optimized static file handler"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, directory: str):
|
|
34
|
+
self.directory = Path(directory).resolve()
|
|
35
|
+
self.chunk_size = 65536 # 64KB chunk
|
|
36
|
+
|
|
37
|
+
async def handle_request(self, request):
|
|
38
|
+
"""HTTP request processing"""
|
|
39
|
+
try:
|
|
40
|
+
# URL path parsing
|
|
41
|
+
path = request.path_qs.split('?')[0] # 쿼리 파라미터 제거
|
|
42
|
+
if path.endswith('/'):
|
|
43
|
+
path += 'index.html'
|
|
44
|
+
|
|
45
|
+
# Security: prevent directory traversal attacks
|
|
46
|
+
file_path = (self.directory / path.lstrip('/')).resolve()
|
|
47
|
+
if not str(file_path).startswith(str(self.directory)):
|
|
48
|
+
return web.Response(status=403, text="Forbidden")
|
|
49
|
+
|
|
50
|
+
# Check if the file exists
|
|
51
|
+
if not file_path.exists() or not file_path.is_file():
|
|
52
|
+
return web.Response(status=404, text="File not found")
|
|
53
|
+
|
|
54
|
+
# Check the file size and type
|
|
55
|
+
stat = file_path.stat()
|
|
56
|
+
file_size = stat.st_size
|
|
57
|
+
content_type, _ = mimetypes.guess_type(str(file_path))
|
|
58
|
+
|
|
59
|
+
# Range request processing (partial download supported)
|
|
60
|
+
range_header = request.headers.get('Range')
|
|
61
|
+
if range_header:
|
|
62
|
+
return await self._handle_range_request(file_path, range_header, file_size, content_type, request)
|
|
63
|
+
|
|
64
|
+
if is_production():
|
|
65
|
+
cache_control = 'public, max-age=3600' # 1 hour caching
|
|
66
|
+
else:
|
|
67
|
+
cache_control = 'no-cache'
|
|
68
|
+
|
|
69
|
+
# zero-copy file response creation
|
|
70
|
+
headers = {
|
|
71
|
+
'Content-Type': content_type or 'application/octet-stream',
|
|
72
|
+
'Content-Length': str(file_size),
|
|
73
|
+
'Accept-Ranges': 'bytes',
|
|
74
|
+
'Cache-Control': cache_control,
|
|
75
|
+
'ETag': f'"{stat.st_mtime}-{file_size}"'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# ETag based cache check
|
|
79
|
+
if_none_match = request.headers.get('If-None-Match')
|
|
80
|
+
if if_none_match == headers['ETag']:
|
|
81
|
+
return web.Response(status=304, headers=headers)
|
|
82
|
+
|
|
83
|
+
# zero-copy response return (sendfile used)
|
|
84
|
+
return ZeroCopyFileResponse(
|
|
85
|
+
path=file_path,
|
|
86
|
+
chunk_size=self.chunk_size,
|
|
87
|
+
headers=headers
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
except Exception as e:
|
|
91
|
+
return web.Response(status=500, text=f"Internal Server Error: {str(e)}")
|
|
92
|
+
|
|
93
|
+
async def _handle_range_request(self, file_path, range_header, file_size, content_type, request):
|
|
94
|
+
"""Range request processing (partial download)"""
|
|
95
|
+
try:
|
|
96
|
+
# Range header parsing: bytes=start-end
|
|
97
|
+
range_match = range_header.replace('bytes=', '').split('-')
|
|
98
|
+
start = int(range_match[0]) if range_match[0] else 0
|
|
99
|
+
end = int(range_match[1]) if range_match[1] else file_size - 1
|
|
100
|
+
|
|
101
|
+
# Range validation
|
|
102
|
+
if start >= file_size or end >= file_size or start > end:
|
|
103
|
+
return web.Response(
|
|
104
|
+
status=416, # Range Not Satisfiable
|
|
105
|
+
headers={'Content-Range': f'bytes */{file_size}'}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
content_length = end - start + 1
|
|
109
|
+
|
|
110
|
+
headers = {
|
|
111
|
+
'Content-Type': content_type or 'application/octet-stream',
|
|
112
|
+
'Content-Length': str(content_length),
|
|
113
|
+
'Content-Range': f'bytes {start}-{end}/{file_size}',
|
|
114
|
+
'Accept-Ranges': 'bytes'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# zero-copy partial response
|
|
118
|
+
response = web.StreamResponse(status=206, headers=headers)
|
|
119
|
+
await response.prepare(request)
|
|
120
|
+
|
|
121
|
+
# aiofiles to async zero-copy transfer
|
|
122
|
+
async with aiofiles.open(file_path, 'rb') as f:
|
|
123
|
+
await f.seek(start)
|
|
124
|
+
remaining = content_length
|
|
125
|
+
|
|
126
|
+
while remaining > 0:
|
|
127
|
+
chunk_size = min(self.chunk_size, remaining)
|
|
128
|
+
chunk = await f.read(chunk_size)
|
|
129
|
+
if not chunk:
|
|
130
|
+
break
|
|
131
|
+
await response.write(chunk)
|
|
132
|
+
remaining -= len(chunk)
|
|
133
|
+
|
|
134
|
+
await response.write_eof()
|
|
135
|
+
return response
|
|
136
|
+
|
|
137
|
+
except Exception as e:
|
|
138
|
+
return web.Response(status=500, text=f"Range Request Error: {str(e)}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def start_zero_copy_server(directory: str, port: int):
|
|
142
|
+
"""zero-copy optimized server starts"""
|
|
143
|
+
handler = ZeroCopyStaticHandler(directory)
|
|
144
|
+
|
|
145
|
+
# aiohttp app creation
|
|
146
|
+
app = web.Application()
|
|
147
|
+
app.router.add_route('GET', '/{path:.*}', handler.handle_request)
|
|
148
|
+
|
|
149
|
+
# server configuration optimization
|
|
150
|
+
runner = web.AppRunner(
|
|
151
|
+
app,
|
|
152
|
+
access_log=None, # access log disabled (performance improvement)
|
|
153
|
+
keepalive_timeout=75, # Keep-Alive timeout
|
|
154
|
+
client_timeout=600, # client timeout
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
await runner.setup()
|
|
158
|
+
|
|
159
|
+
site = web.TCPSite(
|
|
160
|
+
runner,
|
|
161
|
+
'127.0.0.1',
|
|
162
|
+
port,
|
|
163
|
+
backlog=1024, # connection backlog size
|
|
164
|
+
reuse_address=True # port reuse only
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
await site.start()
|
|
168
|
+
|
|
169
|
+
return runner, site
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def pyloid_serve(
|
|
173
|
+
directory: str,
|
|
174
|
+
port: Optional[int] = None,
|
|
175
|
+
) -> str:
|
|
176
|
+
"""
|
|
177
|
+
zero-copy optimized static file server starts.
|
|
178
|
+
|
|
179
|
+
Args
|
|
180
|
+
----
|
|
181
|
+
directory (str): Path to the static file directory to serve
|
|
182
|
+
port (int, optional): Server port (default: None - will use a random free port)
|
|
183
|
+
|
|
184
|
+
Returns
|
|
185
|
+
-------
|
|
186
|
+
str
|
|
187
|
+
URL of the started server
|
|
188
|
+
|
|
189
|
+
Examples
|
|
190
|
+
--------
|
|
191
|
+
```python
|
|
192
|
+
from pyloid import Pyloid
|
|
193
|
+
from pyloid.serve import pyloid_serve
|
|
194
|
+
|
|
195
|
+
app = Pyloid("Pyloid-App")
|
|
196
|
+
url = pyloid_serve("dist")
|
|
197
|
+
window = app.create_window("Pyloid-App")
|
|
198
|
+
window.load_url(url)
|
|
199
|
+
window.show_and_focus()
|
|
200
|
+
```
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
if port is None:
|
|
204
|
+
port = get_free_port()
|
|
205
|
+
|
|
206
|
+
def run_zero_copy_server():
|
|
207
|
+
"""run async server in a separate thread"""
|
|
208
|
+
loop = asyncio.new_event_loop()
|
|
209
|
+
asyncio.set_event_loop(loop)
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
runner, site = loop.run_until_complete(
|
|
213
|
+
start_zero_copy_server(directory, port)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
print(f"🚀 Zero-copy server started on http://127.0.0.1:{port}")
|
|
217
|
+
print(f"📁 Serving directory: {directory}")
|
|
218
|
+
print(f"⚡ Features: sendfile, Range requests, ETag caching")
|
|
219
|
+
|
|
220
|
+
# wait until the server starts
|
|
221
|
+
loop.run_forever()
|
|
222
|
+
|
|
223
|
+
except Exception as e:
|
|
224
|
+
print(f"Zero-copy server error: {e}")
|
|
225
|
+
finally:
|
|
226
|
+
loop.close()
|
|
227
|
+
|
|
228
|
+
# start server in a daemon thread
|
|
229
|
+
thread = threading.Thread(target=run_zero_copy_server, daemon=True)
|
|
230
|
+
thread.start()
|
|
231
|
+
|
|
232
232
|
return f"http://127.0.0.1:{port}"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from PySide6.QtWebEngineCore import QWebEngineUrlRequestInterceptor, QWebEngineUrlRequestInfo
|
|
2
|
+
|
|
3
|
+
# interceptor ( all url request )
|
|
4
|
+
class ServerUrlInterceptor(QWebEngineUrlRequestInterceptor):
|
|
5
|
+
def __init__(self, server_url: str, window_id: str):
|
|
6
|
+
super().__init__()
|
|
7
|
+
self.server_url = server_url
|
|
8
|
+
self.headers = {
|
|
9
|
+
"X-Pyloid-Window-Id": window_id,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
print("interceptor init")
|
|
13
|
+
|
|
14
|
+
def interceptRequest(self, info):
|
|
15
|
+
# host = info.requestUrl().host()
|
|
16
|
+
url = info.requestUrl().toString()
|
|
17
|
+
|
|
18
|
+
print(url)
|
|
19
|
+
|
|
20
|
+
if url.startswith(self.server_url):
|
|
21
|
+
headers = info.httpHeaders()
|
|
22
|
+
print("before", headers)
|
|
23
|
+
headers.update(self.headers)
|
|
24
|
+
print("after", headers)
|
|
25
|
+
return
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
from PySide6.QtWebEngineCore import QWebEngineUrlRequestInterceptor
|
|
2
|
-
from PySide6.QtCore import QUrl
|
|
3
|
-
from typing import Optional
|
|
4
|
-
|
|
5
|
-
# interceptor ( all url request )
|
|
6
|
-
class CustomUrlInterceptor(QWebEngineUrlRequestInterceptor):
|
|
7
|
-
def __init__(self, rpc_url: Optional[str] = None):
|
|
8
|
-
super().__init__()
|
|
9
|
-
self.rpc_url = rpc_url
|
|
10
|
-
|
|
11
|
-
def interceptRequest(self, info):
|
|
12
|
-
host = info.requestUrl().host()
|
|
13
|
-
url = info.requestUrl().toString()
|
|
14
|
-
|
|
15
|
-
server_url = self.rpc_url
|
|
16
|
-
|
|
17
|
-
if self.rpc_url is None:
|
|
18
|
-
return
|
|
19
|
-
|
|
20
|
-
if url.startswith(self.rpc_url):
|
|
21
|
-
return
|
|
22
|
-
|
|
23
|
-
if host == "pyloid.rpc":
|
|
24
|
-
info.redirect(QUrl(server_url))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|