pyloid 0.24.9__py3-none-any.whl → 0.26.0__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.
pyloid/browser_window.py CHANGED
@@ -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 List, Optional, Dict, Callable
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 CustomUrlInterceptor
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.interceptor = CustomUrlInterceptor(rpc_url=self.rpc_url)
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.rpc_url)]
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 = "mycompany.myproduct.subproduct.version"
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)
pyloid/js_api/base.py CHANGED
@@ -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", rpc_url: Optional[str] = None
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.rpc_url: Optional[str] = rpc_url
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 getRpcUrl(self):
257
+ def getServerUrl(self):
258
258
  """Returns the RPC URL of the application."""
259
- return self.rpc_url
259
+ return self.server_url
pyloid/pyloid.py CHANGED
@@ -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
- 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.
@@ -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
- self.rpc_servers: Set[PyloidRPC] = set()
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
- # # Collect and deduplicate RPC servers
319
- # rpc_servers: Set[PyloidRPC] = set()
320
- # for window in self.windows_dict.values():
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.server = QLocalServer()
346
- self.server.listen(self.app_name)
347
- self.server.newConnection.connect(self._handle_new_connection)
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)
pyloid/rpc.py CHANGED
@@ -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
+
pyloid/serve.py CHANGED
@@ -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}"
pyloid/url_interceptor.py CHANGED
@@ -1,24 +1,25 @@
1
- from PySide6.QtWebEngineCore import QWebEngineUrlRequestInterceptor
2
- from PySide6.QtCore import QUrl
3
- from typing import Optional
1
+ from PySide6.QtWebEngineCore import QWebEngineUrlRequestInterceptor, QWebEngineUrlRequestInfo
4
2
 
5
3
  # interceptor ( all url request )
6
- class CustomUrlInterceptor(QWebEngineUrlRequestInterceptor):
7
- def __init__(self, rpc_url: Optional[str] = None):
4
+ class ServerUrlInterceptor(QWebEngineUrlRequestInterceptor):
5
+ def __init__(self, server_url: str, window_id: str):
8
6
  super().__init__()
9
- self.rpc_url = rpc_url
7
+ self.server_url = server_url
8
+ self.headers = {
9
+ "X-Pyloid-Window-Id": window_id,
10
+ }
11
+
12
+ print("interceptor init")
10
13
 
11
14
  def interceptRequest(self, info):
12
- host = info.requestUrl().host()
15
+ # host = info.requestUrl().host()
13
16
  url = info.requestUrl().toString()
14
-
15
- server_url = self.rpc_url
16
-
17
- if self.rpc_url is None:
18
- return
19
17
 
20
- if url.startswith(self.rpc_url):
21
- return
18
+ print(url)
22
19
 
23
- if host == "pyloid.rpc":
24
- info.redirect(QUrl(server_url))
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,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pyloid
3
- Version: 0.24.9
3
+ Version: 0.26.0
4
4
  Summary:
5
5
  Author: aesthetics-of-record
6
6
  Author-email: 111675679+aesthetics-of-record@users.noreply.github.com
@@ -1,23 +1,23 @@
1
1
  pyloid/__init__.py,sha256=YKwMCSOds1QVi9N7EGfY0Z7BEjJn8j6HGqRblZlZClA,235
2
2
  pyloid/api.py,sha256=A61Kmddh8BlpT3LfA6NbPQNzFmD95vQ4WKX53oKsGYU,2419
3
3
  pyloid/autostart.py,sha256=K7DQYl4LHItvPp0bt1V9WwaaZmVSTeGvadkcwG-KKrI,3899
4
- pyloid/browser_window.py,sha256=brCNmrQB-sTUvIC8VR5glpg-__DYmkrpVlIN0jf29Sw,104377
4
+ pyloid/browser_window.py,sha256=nw-N-v7znESskjyFy2L8vEa0e8v4I7482cM9a6ru6O4,103227
5
5
  pyloid/custom/titlebar.py,sha256=itzK9pJbZMQ7BKca9kdbuHMffurrw15UijR6OU03Xsk,3894
6
6
  pyloid/filewatcher.py,sha256=3M5zWVUf1OhlkWJcDFC8ZA9agO4Q-U8WdgGpy6kaVz0,4601
7
- pyloid/js_api/base.py,sha256=Z3ID-4AJ0eHusmljRltlSaK4m2RKvRNfmqX76NLF77o,8585
7
+ pyloid/js_api/base.py,sha256=VmoFIxwPj9inkMFYEspJvbn3iqiftxbg5Kja-6z-BzQ,8600
8
8
  pyloid/js_api/event_api.py,sha256=w0z1DcmwcmseqfcoZWgsQmFC2iBCgTMVJubTaHeXI1c,957
9
9
  pyloid/js_api/window_api.py,sha256=-isphU3m2wGB5U0yZrSuK_4XiBz2mG45HsjYTUq7Fxs,7348
10
10
  pyloid/monitor.py,sha256=1mXvHm5deohnNlTLcRx4sT4x-stnOIb0dUQnnxN50Uo,28295
11
- pyloid/pyloid.py,sha256=DSHpMRDW4WvZgAAo2nJMesMJuOkNTVEEsDhCFIvjB5w,84509
12
- pyloid/rpc.py,sha256=OnF1sRGok9OJ-Q5519eQARD4oZTohyPhsPAT2Mg4_Gg,20377
13
- pyloid/serve.py,sha256=KOs9984J2qboEXsquorkXgcQvvao_C2c9F6N2gSLfiA,8122
11
+ pyloid/pyloid.py,sha256=Q-ecZNbU9h78Cw_tSrYA_StyHy2cr_V3wLcO1tN-SH8,84174
12
+ pyloid/rpc.py,sha256=6UzQc-CDA72VYeF3Xy-7AbBculE-T0MIokR39jVdlcg,21726
13
+ pyloid/serve.py,sha256=ZXUZ5_-BDbOGOpdKSjMtV_k6o0Q60SU5D0F6GBgkQbk,7891
14
14
  pyloid/store.py,sha256=8PnBxtkUgbF8Lxh-iYlEuhbLE76bGBF8t5KV5u5NzoQ,4831
15
15
  pyloid/thread_pool.py,sha256=fKOBb8jMfZn_7crA_fJCno8dObBRZE31EIWaNQ759aw,14616
16
16
  pyloid/timer.py,sha256=RqMsChFUd93cxMVgkHWiIKrci0QDTBgJSTULnAtYT8M,8712
17
17
  pyloid/tray.py,sha256=D12opVEc2wc2T4tK9epaN1oOdeziScsIVNM2uCN7C-A,1710
18
- pyloid/url_interceptor.py,sha256=AFjPANDELc9-E-1TnVvkNVc-JZBJYf0677dWQ8LDaqw,726
18
+ pyloid/url_interceptor.py,sha256=DanXAGwwLlpn9cEiVRTdsX49udlIfYzBzaM-QRRpdkY,830
19
19
  pyloid/utils.py,sha256=J6owgVE1YDOEfcOPmoP9m9Q6nbYDyNEo9uqPsJs5p5g,6644
20
- pyloid-0.24.9.dist-info/LICENSE,sha256=F96EzotgWhhpnQTW2TcdoqrMDir1jyEo6H915tGQ-QE,11524
21
- pyloid-0.24.9.dist-info/METADATA,sha256=aUI7SwfcwgFgAoAfRfm1nfzgrtYX2vtH2BupDHqc8eo,2298
22
- pyloid-0.24.9.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
23
- pyloid-0.24.9.dist-info/RECORD,,
20
+ pyloid-0.26.0.dist-info/LICENSE,sha256=F96EzotgWhhpnQTW2TcdoqrMDir1jyEo6H915tGQ-QE,11524
21
+ pyloid-0.26.0.dist-info/METADATA,sha256=kKPKpZME0097sT-V1OMHZ-fBsYofzJV30JQMalY-yVs,2298
22
+ pyloid-0.26.0.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
23
+ pyloid-0.26.0.dist-info/RECORD,,