pyloid 0.24.9__tar.gz → 0.26.0__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.24.9 → pyloid-0.26.0}/PKG-INFO +1 -1
- {pyloid-0.24.9 → pyloid-0.26.0}/pyproject.toml +1 -1
- {pyloid-0.24.9 → pyloid-0.26.0}/src/pyloid/browser_window.py +10 -43
- {pyloid-0.24.9 → pyloid-0.26.0}/src/pyloid/js_api/base.py +4 -4
- {pyloid-0.24.9 → pyloid-0.26.0}/src/pyloid/pyloid.py +24 -32
- {pyloid-0.24.9 → pyloid-0.26.0}/src/pyloid/rpc.py +29 -1
- {pyloid-0.24.9 → pyloid-0.26.0}/src/pyloid/serve.py +231 -231
- pyloid-0.26.0/src/pyloid/url_interceptor.py +25 -0
- pyloid-0.24.9/src/pyloid/url_interceptor.py +0 -24
- {pyloid-0.24.9 → pyloid-0.26.0}/LICENSE +0 -0
- {pyloid-0.24.9 → pyloid-0.26.0}/README.md +0 -0
- {pyloid-0.24.9 → pyloid-0.26.0}/src/pyloid/__init__.py +0 -0
- {pyloid-0.24.9 → pyloid-0.26.0}/src/pyloid/api.py +0 -0
- {pyloid-0.24.9 → pyloid-0.26.0}/src/pyloid/autostart.py +0 -0
- {pyloid-0.24.9 → pyloid-0.26.0}/src/pyloid/custom/titlebar.py +0 -0
- {pyloid-0.24.9 → pyloid-0.26.0}/src/pyloid/filewatcher.py +0 -0
- {pyloid-0.24.9 → pyloid-0.26.0}/src/pyloid/js_api/event_api.py +0 -0
- {pyloid-0.24.9 → pyloid-0.26.0}/src/pyloid/js_api/window_api.py +0 -0
- {pyloid-0.24.9 → pyloid-0.26.0}/src/pyloid/monitor.py +0 -0
- {pyloid-0.24.9 → pyloid-0.26.0}/src/pyloid/store.py +0 -0
- {pyloid-0.24.9 → pyloid-0.26.0}/src/pyloid/thread_pool.py +0 -0
- {pyloid-0.24.9 → pyloid-0.26.0}/src/pyloid/timer.py +0 -0
- {pyloid-0.24.9 → pyloid-0.26.0}/src/pyloid/tray.py +0 -0
- {pyloid-0.24.9 → pyloid-0.26.0}/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,10 +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
|
|
48
|
-
from .rpc import PyloidRPC
|
|
46
|
+
# from .url_interceptor import ServerUrlInterceptor
|
|
49
47
|
|
|
50
48
|
if TYPE_CHECKING:
|
|
51
49
|
from .pyloid import _Pyloid, Pyloid
|
|
@@ -113,7 +111,7 @@ class CustomWebPage(QWebEnginePage):
|
|
|
113
111
|
# class CustomInterceptor(QWebEngineUrlRequestInterceptor):
|
|
114
112
|
# def __init__(self, index_path=None):
|
|
115
113
|
# super().__init__()
|
|
116
|
-
# self.index_path = get_production_path()
|
|
114
|
+
# # self.index_path = get_production_path()
|
|
117
115
|
# self.last_path = "/"
|
|
118
116
|
|
|
119
117
|
# def interceptRequest(self, info):
|
|
@@ -316,7 +314,6 @@ class _BrowserWindow:
|
|
|
316
314
|
context_menu: bool = False,
|
|
317
315
|
dev_tools: bool = False,
|
|
318
316
|
# js_apis: List[PyloidAPI] = [],
|
|
319
|
-
rpc: Optional[PyloidRPC] = None,
|
|
320
317
|
transparent: bool = False,
|
|
321
318
|
):
|
|
322
319
|
###########################################################################################
|
|
@@ -324,16 +321,8 @@ class _BrowserWindow:
|
|
|
324
321
|
self._window = QMainWindow()
|
|
325
322
|
self.web_view = CustomWebEngineView(self)
|
|
326
323
|
|
|
327
|
-
if rpc:
|
|
328
|
-
self.rpc = rpc
|
|
329
|
-
self.rpc_url = rpc.url
|
|
330
|
-
else:
|
|
331
|
-
self.rpc = None
|
|
332
|
-
self.rpc_url = None
|
|
333
|
-
|
|
334
324
|
# interceptor ( all url request )
|
|
335
|
-
# self.
|
|
336
|
-
# self.web_view.page().setUrlRequestInterceptor(self.interceptor)
|
|
325
|
+
# self.web_view.page().profile().setUrlRequestInterceptor(CustomInterceptor())
|
|
337
326
|
|
|
338
327
|
self._window.closeEvent = self.closeEvent # Override closeEvent method
|
|
339
328
|
###########################################################################################
|
|
@@ -349,7 +338,7 @@ class _BrowserWindow:
|
|
|
349
338
|
self.context_menu = context_menu
|
|
350
339
|
self.dev_tools = dev_tools
|
|
351
340
|
|
|
352
|
-
self.js_apis = [BaseAPI(self.id, self.app.data, self.app, self.
|
|
341
|
+
self.js_apis = [BaseAPI(self.id, self.app.data, self.app, self.app.server.url)]
|
|
353
342
|
|
|
354
343
|
# for js_api in js_apis:
|
|
355
344
|
# self.js_apis.append(js_api)
|
|
@@ -357,25 +346,6 @@ class _BrowserWindow:
|
|
|
357
346
|
self.shortcuts = {}
|
|
358
347
|
self.close_on_load = True
|
|
359
348
|
self.splash_screen = None
|
|
360
|
-
###########################################################################################
|
|
361
|
-
# if the RPC server is not present, do not add it
|
|
362
|
-
if not self.rpc:
|
|
363
|
-
return
|
|
364
|
-
|
|
365
|
-
self.rpc.pyloid = self.app.pyloid_wrapper
|
|
366
|
-
# self.rpc.window = self.window_wrapper
|
|
367
|
-
|
|
368
|
-
# prevent duplicate RPC servers
|
|
369
|
-
if self.rpc in self.app.rpc_servers:
|
|
370
|
-
return
|
|
371
|
-
|
|
372
|
-
# add the RPC server
|
|
373
|
-
self.app.rpc_servers.add(self.rpc)
|
|
374
|
-
|
|
375
|
-
# Start unique RPC servers
|
|
376
|
-
server_thread = threading.Thread(target=self.rpc.start, daemon=True)
|
|
377
|
-
server_thread.start()
|
|
378
|
-
###########################################################################################
|
|
379
349
|
|
|
380
350
|
def _set_custom_frame(
|
|
381
351
|
self,
|
|
@@ -504,7 +474,7 @@ class _BrowserWindow:
|
|
|
504
474
|
if sys.platform == "win32":
|
|
505
475
|
import ctypes
|
|
506
476
|
|
|
507
|
-
myappid = "
|
|
477
|
+
myappid = f"pyloid.{self.app.app_name}.com"
|
|
508
478
|
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
|
|
509
479
|
|
|
510
480
|
# Remove title bar and borders (if needed)
|
|
@@ -544,11 +514,10 @@ class _BrowserWindow:
|
|
|
544
514
|
self.set_dev_tools(self.dev_tools)
|
|
545
515
|
|
|
546
516
|
# get the profile and set the interceptor
|
|
547
|
-
profile = self.web_view.page().profile()
|
|
517
|
+
# profile = self.web_view.page().profile()
|
|
518
|
+
# profile.setUrlRequestInterceptor(ServerUrlInterceptor(self.app.server.url, self.id))
|
|
519
|
+
|
|
548
520
|
|
|
549
|
-
# # if the existing interceptor is present, remove it
|
|
550
|
-
# if self.interceptor:
|
|
551
|
-
# profile.setUrlRequestInterceptor(None)
|
|
552
521
|
|
|
553
522
|
def _on_load_finished(self, ok):
|
|
554
523
|
"""Handles the event when the web page finishes loading."""
|
|
@@ -2125,7 +2094,6 @@ class BrowserWindow(QObject):
|
|
|
2125
2094
|
frame: bool = True,
|
|
2126
2095
|
context_menu: bool = False,
|
|
2127
2096
|
dev_tools: bool = False,
|
|
2128
|
-
rpc: Optional[PyloidRPC] = None,
|
|
2129
2097
|
transparent: bool = False,
|
|
2130
2098
|
):
|
|
2131
2099
|
super().__init__()
|
|
@@ -2140,7 +2108,6 @@ class BrowserWindow(QObject):
|
|
|
2140
2108
|
frame,
|
|
2141
2109
|
context_menu,
|
|
2142
2110
|
dev_tools,
|
|
2143
|
-
rpc,
|
|
2144
2111
|
transparent,
|
|
2145
2112
|
)
|
|
2146
2113
|
self.command_signal.connect(self._handle_command)
|
|
@@ -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
|
|
@@ -32,7 +32,6 @@ from PySide6.QtCore import QEventLoop
|
|
|
32
32
|
from typing import Any, Set
|
|
33
33
|
from platformdirs import PlatformDirs
|
|
34
34
|
from .store import Store
|
|
35
|
-
from .rpc import PyloidRPC
|
|
36
35
|
import threading
|
|
37
36
|
import signal
|
|
38
37
|
|
|
@@ -98,9 +97,10 @@ class _Pyloid(QApplication):
|
|
|
98
97
|
def __init__(
|
|
99
98
|
self,
|
|
100
99
|
pyloid_wrapper: "Pyloid",
|
|
101
|
-
app_name,
|
|
102
|
-
single_instance=True,
|
|
103
|
-
|
|
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.
|
|
@@ -132,7 +132,6 @@ class _Pyloid(QApplication):
|
|
|
132
132
|
self.data = data
|
|
133
133
|
|
|
134
134
|
self.windows_dict: Dict[str, BrowserWindow] = {} # 윈도우 ID를 키로 사용하는 딕셔너리
|
|
135
|
-
self.server = None
|
|
136
135
|
|
|
137
136
|
self.app_name = app_name
|
|
138
137
|
self.icon = None
|
|
@@ -151,7 +150,7 @@ class _Pyloid(QApplication):
|
|
|
151
150
|
self.tray_menu_items = []
|
|
152
151
|
self.tray_actions = {}
|
|
153
152
|
|
|
154
|
-
self.app_name = app_name
|
|
153
|
+
self.app_name: str = app_name
|
|
155
154
|
self.app_path = sys.executable
|
|
156
155
|
|
|
157
156
|
self.auto_start = AutoStart(self.app_name, self.app_path)
|
|
@@ -171,7 +170,14 @@ class _Pyloid(QApplication):
|
|
|
171
170
|
|
|
172
171
|
self.dirs = PlatformDirs(self.app_name, appauthor=False)
|
|
173
172
|
|
|
174
|
-
|
|
173
|
+
|
|
174
|
+
###################################################
|
|
175
|
+
# Pyloid Server Integration
|
|
176
|
+
if server:
|
|
177
|
+
self.server = server
|
|
178
|
+
else:
|
|
179
|
+
self.server = None
|
|
180
|
+
###################################################
|
|
175
181
|
|
|
176
182
|
# def set_theme(self, theme: Literal["system", "dark", "light"]):
|
|
177
183
|
# """
|
|
@@ -242,7 +248,6 @@ class _Pyloid(QApplication):
|
|
|
242
248
|
frame: bool = True,
|
|
243
249
|
context_menu: bool = False,
|
|
244
250
|
dev_tools: bool = False,
|
|
245
|
-
rpc: Optional[PyloidRPC] = None,
|
|
246
251
|
transparent: bool = False,
|
|
247
252
|
) -> BrowserWindow:
|
|
248
253
|
"""
|
|
@@ -266,8 +271,6 @@ class _Pyloid(QApplication):
|
|
|
266
271
|
Whether to use the context menu (default is False)
|
|
267
272
|
dev_tools : bool, optional
|
|
268
273
|
Whether to use developer tools (default is False)
|
|
269
|
-
rpc : PyloidRPC, optional
|
|
270
|
-
The RPC server instance to be used in the window
|
|
271
274
|
transparent : bool, optional
|
|
272
275
|
Whether the window is transparent (default is False)
|
|
273
276
|
|
|
@@ -292,7 +295,6 @@ class _Pyloid(QApplication):
|
|
|
292
295
|
frame,
|
|
293
296
|
context_menu,
|
|
294
297
|
dev_tools,
|
|
295
|
-
rpc,
|
|
296
298
|
transparent,
|
|
297
299
|
)
|
|
298
300
|
self.windows_dict[window._window.id] = window
|
|
@@ -315,17 +317,9 @@ class _Pyloid(QApplication):
|
|
|
315
317
|
```
|
|
316
318
|
"""
|
|
317
319
|
|
|
318
|
-
#
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
# if window._window.rpc is not None:
|
|
322
|
-
# rpc_servers.add(window._window.rpc)
|
|
323
|
-
|
|
324
|
-
# # Start unique RPC servers
|
|
325
|
-
# for rpc in rpc_servers:
|
|
326
|
-
# server_thread = threading.Thread(target=rpc.start, daemon=True)
|
|
327
|
-
# server_thread.start()
|
|
328
|
-
|
|
320
|
+
# Start Pyloid Integrated Server
|
|
321
|
+
if self.server:
|
|
322
|
+
self.server.run()
|
|
329
323
|
|
|
330
324
|
if is_production():
|
|
331
325
|
sys.exit(self.exec())
|
|
@@ -341,10 +335,10 @@ class _Pyloid(QApplication):
|
|
|
341
335
|
# Another instance is already running
|
|
342
336
|
sys.exit(1)
|
|
343
337
|
|
|
344
|
-
# Create a new server
|
|
345
|
-
self.
|
|
346
|
-
self.
|
|
347
|
-
self.
|
|
338
|
+
# Create a new Single Instance server
|
|
339
|
+
self.single_instance_server = QLocalServer()
|
|
340
|
+
self.single_instance_server.listen(self.app_name)
|
|
341
|
+
self.single_instance_server.newConnection.connect(self._handle_new_connection)
|
|
348
342
|
|
|
349
343
|
def _handle_new_connection(self):
|
|
350
344
|
"""Handles new connections for the single instance server."""
|
|
@@ -1616,6 +1610,7 @@ class Pyloid(QObject):
|
|
|
1616
1610
|
self,
|
|
1617
1611
|
app_name: str,
|
|
1618
1612
|
single_instance: bool = True,
|
|
1613
|
+
server = None,
|
|
1619
1614
|
# data: Optional[Dict[str, Any]] = None,
|
|
1620
1615
|
):
|
|
1621
1616
|
"""
|
|
@@ -1631,12 +1626,14 @@ class Pyloid(QObject):
|
|
|
1631
1626
|
The name of the application.
|
|
1632
1627
|
single_instance : bool, optional
|
|
1633
1628
|
Determines whether to run as a single instance. (Default is True)
|
|
1629
|
+
server : optional
|
|
1630
|
+
The pyloid server instance to be used in the application
|
|
1634
1631
|
"""
|
|
1635
1632
|
super().__init__()
|
|
1636
1633
|
|
|
1637
1634
|
self.data = None # 나중에 데이터 필요 시 수정
|
|
1638
1635
|
|
|
1639
|
-
self.app = _Pyloid(self, app_name, single_instance, self.data)
|
|
1636
|
+
self.app = _Pyloid(self, app_name, single_instance, server, self.data)
|
|
1640
1637
|
|
|
1641
1638
|
self.command_signal.connect(self._handle_command)
|
|
1642
1639
|
|
|
@@ -1657,7 +1654,6 @@ class Pyloid(QObject):
|
|
|
1657
1654
|
frame=params.get("frame", True),
|
|
1658
1655
|
context_menu=params.get("context_menu", False),
|
|
1659
1656
|
dev_tools=params.get("dev_tools", False),
|
|
1660
|
-
rpc=params.get("rpc", None),
|
|
1661
1657
|
transparent=params.get("transparent", False),
|
|
1662
1658
|
)
|
|
1663
1659
|
result = window
|
|
@@ -1833,7 +1829,6 @@ class Pyloid(QObject):
|
|
|
1833
1829
|
frame: bool = True,
|
|
1834
1830
|
context_menu: bool = False,
|
|
1835
1831
|
dev_tools: bool = False,
|
|
1836
|
-
rpc: Optional[PyloidRPC] = None,
|
|
1837
1832
|
transparent: bool = False,
|
|
1838
1833
|
) -> BrowserWindow:
|
|
1839
1834
|
"""
|
|
@@ -1857,8 +1852,6 @@ class Pyloid(QObject):
|
|
|
1857
1852
|
Whether to use the context menu (default is False)
|
|
1858
1853
|
dev_tools : bool, optional
|
|
1859
1854
|
Whether to use developer tools (default is False)
|
|
1860
|
-
rpc : PyloidRPC, optional
|
|
1861
|
-
The RPC server instance to be used in the window
|
|
1862
1855
|
transparent : bool, optional
|
|
1863
1856
|
Whether the window is transparent (default is False)
|
|
1864
1857
|
|
|
@@ -1881,7 +1874,6 @@ class Pyloid(QObject):
|
|
|
1881
1874
|
"frame": frame,
|
|
1882
1875
|
"context_menu": context_menu,
|
|
1883
1876
|
"dev_tools": dev_tools,
|
|
1884
|
-
"rpc": rpc,
|
|
1885
1877
|
"transparent": transparent,
|
|
1886
1878
|
}
|
|
1887
1879
|
return self.execute_command("create_window", params)
|
|
@@ -220,7 +220,7 @@ class PyloidRPC:
|
|
|
220
220
|
|
|
221
221
|
# Store the original function
|
|
222
222
|
self._functions[rpc_name] = func
|
|
223
|
-
log.info(f"RPC function registered: {rpc_name}")
|
|
223
|
+
# log.info(f"RPC function registered: {rpc_name}")
|
|
224
224
|
|
|
225
225
|
@wraps(func)
|
|
226
226
|
async def wrapper(*args, _pyloid_window_id=None, **kwargs):
|
|
@@ -518,3 +518,31 @@ class PyloidRPC:
|
|
|
518
518
|
except Exception as e:
|
|
519
519
|
log.exception(f"Failed to start or run the server: {e}")
|
|
520
520
|
raise
|
|
521
|
+
|
|
522
|
+
def run(self):
|
|
523
|
+
"""
|
|
524
|
+
Runs start_async in a separate thread.
|
|
525
|
+
|
|
526
|
+
This method is useful when you want to start the aiohttp server in the background
|
|
527
|
+
without blocking the main thread. It creates a new thread, sets up a new asyncio event loop
|
|
528
|
+
in that thread, and starts the asynchronous server. The thread is marked as daemon so that
|
|
529
|
+
it will not prevent the program from exiting if only daemon threads remain.
|
|
530
|
+
"""
|
|
531
|
+
import asyncio
|
|
532
|
+
|
|
533
|
+
def _run_asyncio():
|
|
534
|
+
# Create a new event loop for this thread.
|
|
535
|
+
loop = asyncio.new_event_loop()
|
|
536
|
+
# Set the newly created event loop as the current event loop for this thread.
|
|
537
|
+
asyncio.set_event_loop(loop)
|
|
538
|
+
# Start the asynchronous server; this coroutine will set up the server.
|
|
539
|
+
loop.run_until_complete(self.start_async())
|
|
540
|
+
# Keep the event loop running forever to handle incoming requests.
|
|
541
|
+
loop.run_forever()
|
|
542
|
+
|
|
543
|
+
# Create a new thread to run the event loop and server in the background.
|
|
544
|
+
# The thread is set as a daemon so it will not block program exit.
|
|
545
|
+
server_thread = threading.Thread(target=_run_asyncio, daemon=True)
|
|
546
|
+
# Start the background server thread.
|
|
547
|
+
server_thread.start()
|
|
548
|
+
|
|
@@ -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
|