flet-web 0.25.0.dev3487__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.
Potentially problematic release.
This version of flet-web might be problematic. Click here for more details.
- flet_web/__init__.py +9 -0
- flet_web/fastapi/README.md +146 -0
- flet_web/fastapi/__init__.py +6 -0
- flet_web/fastapi/app.py +120 -0
- flet_web/fastapi/flet_app.py +431 -0
- flet_web/fastapi/flet_app_manager.py +166 -0
- flet_web/fastapi/flet_fastapi.py +128 -0
- flet_web/fastapi/flet_oauth.py +66 -0
- flet_web/fastapi/flet_static_files.py +188 -0
- flet_web/fastapi/flet_upload.py +95 -0
- flet_web/fastapi/oauth_state.py +11 -0
- flet_web/fastapi/serve_fastapi_web_app.py +93 -0
- flet_web/patch_index.py +104 -0
- flet_web/uploads.py +54 -0
- flet_web/version.py +5 -0
- flet_web/web/.last_build_id +1 -0
- flet_web/web/assets/AssetManifest.bin +2 -0
- flet_web/web/assets/AssetManifest.bin.json +1 -0
- flet_web/web/assets/AssetManifest.json +1 -0
- flet_web/web/assets/FontManifest.json +1 -0
- flet_web/web/assets/NOTICES +37060 -0
- flet_web/web/assets/fonts/MaterialIcons-Regular.otf +0 -0
- flet_web/web/assets/packages/cupertino_icons/assets/CupertinoIcons.ttf +0 -0
- flet_web/web/assets/packages/flutter_map/lib/assets/flutter_map_logo.png +0 -0
- flet_web/web/assets/packages/media_kit/assets/web/hls1.4.10.js +2 -0
- flet_web/web/assets/packages/record_web/assets/js/record.fixwebmduration.js +507 -0
- flet_web/web/assets/packages/record_web/assets/js/record.worklet.js +400 -0
- flet_web/web/assets/packages/wakelock_plus/assets/no_sleep.js +230 -0
- flet_web/web/assets/packages/window_manager/images/ic_chrome_close.png +0 -0
- flet_web/web/assets/packages/window_manager/images/ic_chrome_maximize.png +0 -0
- flet_web/web/assets/packages/window_manager/images/ic_chrome_minimize.png +0 -0
- flet_web/web/assets/packages/window_manager/images/ic_chrome_unmaximize.png +0 -0
- flet_web/web/assets/shaders/ink_sparkle.frag +126 -0
- flet_web/web/favicon.png +0 -0
- flet_web/web/flutter.js +4 -0
- flet_web/web/flutter_bootstrap.js +28 -0
- flet_web/web/flutter_service_worker.js +218 -0
- flet_web/web/icons/apple-touch-icon-192.png +0 -0
- flet_web/web/icons/icon-192.png +0 -0
- flet_web/web/icons/icon-512.png +0 -0
- flet_web/web/icons/icon-maskable-192.png +0 -0
- flet_web/web/icons/icon-maskable-512.png +0 -0
- flet_web/web/icons/loading-animation.png +0 -0
- flet_web/web/index.html +99 -0
- flet_web/web/main.dart.js +225762 -0
- flet_web/web/manifest.json +35 -0
- flet_web/web/python-worker.js +47 -0
- flet_web/web/python.js +28 -0
- flet_web/web/version.json +1 -0
- flet_web-0.25.0.dev3487.dist-info/METADATA +25 -0
- flet_web-0.25.0.dev3487.dist-info/RECORD +52 -0
- flet_web-0.25.0.dev3487.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import copy
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import traceback
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
import flet_web.fastapi as flet_fastapi
|
|
11
|
+
from fastapi import WebSocket, WebSocketDisconnect
|
|
12
|
+
from flet_core.event import Event
|
|
13
|
+
from flet_core.local_connection import LocalConnection
|
|
14
|
+
from flet_core.page import Page, PageDisconnectedException
|
|
15
|
+
from flet_core.protocol import (
|
|
16
|
+
ClientActions,
|
|
17
|
+
ClientMessage,
|
|
18
|
+
Command,
|
|
19
|
+
CommandEncoder,
|
|
20
|
+
PageCommandResponsePayload,
|
|
21
|
+
PageCommandsBatchResponsePayload,
|
|
22
|
+
RegisterWebClientRequestPayload,
|
|
23
|
+
)
|
|
24
|
+
from flet_core.pubsub import PubSubHub
|
|
25
|
+
from flet_core.utils import random_string, sha1
|
|
26
|
+
from flet_web.fastapi.flet_app_manager import app_manager
|
|
27
|
+
from flet_web.fastapi.oauth_state import OAuthState
|
|
28
|
+
from flet_web.uploads import build_upload_url
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(flet_fastapi.__name__)
|
|
31
|
+
|
|
32
|
+
DEFAULT_FLET_SESSION_TIMEOUT = 3600
|
|
33
|
+
DEFAULT_FLET_OAUTH_STATE_TIMEOUT = 600
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FletApp(LocalConnection):
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
loop: asyncio.AbstractEventLoop,
|
|
40
|
+
session_handler,
|
|
41
|
+
session_timeout_seconds: int = DEFAULT_FLET_SESSION_TIMEOUT,
|
|
42
|
+
oauth_state_timeout_seconds: int = DEFAULT_FLET_OAUTH_STATE_TIMEOUT,
|
|
43
|
+
upload_endpoint_path: Optional[str] = None,
|
|
44
|
+
secret_key: Optional[str] = None,
|
|
45
|
+
):
|
|
46
|
+
"""
|
|
47
|
+
Handle Flet app WebSocket connections.
|
|
48
|
+
|
|
49
|
+
Parameters:
|
|
50
|
+
|
|
51
|
+
* `session_handler` (Coroutine) - application entry point - an async method called for newly connected user. Handler coroutine must have 1 parameter: `page` - `Page` instance.
|
|
52
|
+
* `session_timeout_seconds` (int, optional) - session lifetime, in seconds, after user disconnected.
|
|
53
|
+
* `oauth_state_timeout_seconds` (int, optional) - OAuth state lifetime, in seconds, which is a maximum allowed time between starting OAuth flow and redirecting to OAuth callback URL.
|
|
54
|
+
* `upload_endpoint_path` (str, optional) - absolute URL of upload endpoint, e.g. `/upload`.
|
|
55
|
+
* `secret_key` (str, optional) - secret key to sign upload requests.
|
|
56
|
+
"""
|
|
57
|
+
super().__init__()
|
|
58
|
+
self.__id = random_string(8)
|
|
59
|
+
logger.info(f"New FletApp: {self.__id}")
|
|
60
|
+
|
|
61
|
+
self.__page = None
|
|
62
|
+
self.__loop = loop
|
|
63
|
+
self.__session_handler = session_handler
|
|
64
|
+
self.__session_timeout_seconds = session_timeout_seconds
|
|
65
|
+
self.__oauth_state_timeout_seconds = oauth_state_timeout_seconds
|
|
66
|
+
|
|
67
|
+
env_session_timeout_seconds = os.getenv("FLET_SESSION_TIMEOUT")
|
|
68
|
+
if env_session_timeout_seconds:
|
|
69
|
+
self.__session_timeout_seconds = int(env_session_timeout_seconds)
|
|
70
|
+
|
|
71
|
+
env_oauth_state_timeout_seconds = os.getenv("FLET_OAUTH_STATE_TIMEOUT")
|
|
72
|
+
if env_oauth_state_timeout_seconds:
|
|
73
|
+
self.__oauth_state_timeout_seconds = int(env_oauth_state_timeout_seconds)
|
|
74
|
+
|
|
75
|
+
self.__upload_endpoint_path = upload_endpoint_path
|
|
76
|
+
self.__secret_key = secret_key
|
|
77
|
+
|
|
78
|
+
async def handle(self, websocket: WebSocket):
|
|
79
|
+
"""
|
|
80
|
+
Handle WebSocket connection.
|
|
81
|
+
|
|
82
|
+
Parameters:
|
|
83
|
+
|
|
84
|
+
* `websocket` (WebSocket) - Websocket instance.
|
|
85
|
+
"""
|
|
86
|
+
self.__websocket = websocket
|
|
87
|
+
|
|
88
|
+
self.client_ip = (
|
|
89
|
+
self.__websocket.client.host if self.__websocket.client else ""
|
|
90
|
+
).split(":")[0]
|
|
91
|
+
self.client_user_agent = (
|
|
92
|
+
self.__websocket.headers["user-agent"]
|
|
93
|
+
if "user-agent" in self.__websocket.headers
|
|
94
|
+
else ""
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
self.pubsubhub = app_manager.get_pubsubhub(
|
|
98
|
+
self.__session_handler, loop=self.__loop
|
|
99
|
+
)
|
|
100
|
+
self.page_url = str(websocket.url).rsplit("/", 1)[0]
|
|
101
|
+
self.page_name = websocket.url.path.rsplit("/", 1)[0].lstrip("/")
|
|
102
|
+
|
|
103
|
+
if not self.__upload_endpoint_path:
|
|
104
|
+
self.__upload_endpoint_path = (
|
|
105
|
+
f"{'' if self.page_name == '' else '/'}{self.page_name}/upload"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
await self.__websocket.accept()
|
|
109
|
+
self.__send_queue = asyncio.Queue()
|
|
110
|
+
st = asyncio.create_task(self.__send_loop())
|
|
111
|
+
await self.__receive_loop()
|
|
112
|
+
st.cancel()
|
|
113
|
+
|
|
114
|
+
async def __on_event(self, e):
|
|
115
|
+
session = await app_manager.get_session(
|
|
116
|
+
self.__get_unique_session_id(e.sessionID)
|
|
117
|
+
)
|
|
118
|
+
if session is not None:
|
|
119
|
+
try:
|
|
120
|
+
await session.on_event_async(
|
|
121
|
+
Event(e.eventTarget, e.eventName, e.eventData)
|
|
122
|
+
)
|
|
123
|
+
except PageDisconnectedException:
|
|
124
|
+
logger.debug(
|
|
125
|
+
f"Event handler attempted to update disconnected page: {e.sessionID}"
|
|
126
|
+
)
|
|
127
|
+
if e.eventTarget == "page" and e.eventName == "close":
|
|
128
|
+
logger.info(f"Session closed: {e.sessionID}")
|
|
129
|
+
await app_manager.delete_session(
|
|
130
|
+
self.__get_unique_session_id(e.sessionID)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
async def __on_session_created(self, session_data):
|
|
134
|
+
logger.info(f"Start session: {session_data.sessionID}")
|
|
135
|
+
session_id = session_data.sessionID
|
|
136
|
+
try:
|
|
137
|
+
assert self.__session_handler is not None
|
|
138
|
+
if asyncio.iscoroutinefunction(self.__session_handler):
|
|
139
|
+
await self.__session_handler(self.__page)
|
|
140
|
+
else:
|
|
141
|
+
# run in thread pool
|
|
142
|
+
await asyncio.get_running_loop().run_in_executor(
|
|
143
|
+
app_manager.executor, self.__session_handler, self.__page
|
|
144
|
+
)
|
|
145
|
+
except PageDisconnectedException:
|
|
146
|
+
logger.debug(
|
|
147
|
+
f"Session handler attempted to update disconnected page: {session_id}"
|
|
148
|
+
)
|
|
149
|
+
except BrokenPipeError:
|
|
150
|
+
logger.info(f"Session handler terminated: {session_id}")
|
|
151
|
+
except Exception as e:
|
|
152
|
+
print(
|
|
153
|
+
f"Unhandled error processing page session {session_id}:",
|
|
154
|
+
traceback.format_exc(),
|
|
155
|
+
)
|
|
156
|
+
assert self.__page
|
|
157
|
+
self.__page.error(f"There was an error while processing your request: {e}")
|
|
158
|
+
|
|
159
|
+
async def __send_loop(self):
|
|
160
|
+
assert self.__websocket
|
|
161
|
+
assert self.__send_queue
|
|
162
|
+
while True:
|
|
163
|
+
message = await self.__send_queue.get()
|
|
164
|
+
try:
|
|
165
|
+
await self.__websocket.send_text(message)
|
|
166
|
+
except Exception:
|
|
167
|
+
# re-enqueue the message to repeat it when re-connected
|
|
168
|
+
self.__send_queue.put_nowait(message)
|
|
169
|
+
raise
|
|
170
|
+
|
|
171
|
+
async def __receive_loop(self):
|
|
172
|
+
assert self.__websocket
|
|
173
|
+
try:
|
|
174
|
+
while True:
|
|
175
|
+
await self.__on_message(await self.__websocket.receive_text())
|
|
176
|
+
except Exception as e:
|
|
177
|
+
if not isinstance(e, WebSocketDisconnect):
|
|
178
|
+
logger.warning(f"Receive loop error: {e}")
|
|
179
|
+
if self.__page:
|
|
180
|
+
await app_manager.disconnect_session(
|
|
181
|
+
self.__get_unique_session_id(self.__page.session_id),
|
|
182
|
+
self.__session_timeout_seconds,
|
|
183
|
+
)
|
|
184
|
+
self.__websocket = None
|
|
185
|
+
self.__send_queue = None
|
|
186
|
+
|
|
187
|
+
async def __on_message(self, data: str):
|
|
188
|
+
logger.debug(f"_on_message: {data}")
|
|
189
|
+
msg_dict = json.loads(data)
|
|
190
|
+
msg = ClientMessage(**msg_dict)
|
|
191
|
+
if msg.action == ClientActions.REGISTER_WEB_CLIENT:
|
|
192
|
+
self._client_details = RegisterWebClientRequestPayload(**msg.payload)
|
|
193
|
+
|
|
194
|
+
new_session = True
|
|
195
|
+
if (
|
|
196
|
+
not self._client_details.sessionId
|
|
197
|
+
or await app_manager.get_session(
|
|
198
|
+
self.__get_unique_session_id(self._client_details.sessionId)
|
|
199
|
+
)
|
|
200
|
+
is None
|
|
201
|
+
):
|
|
202
|
+
# generate session ID
|
|
203
|
+
self._client_details.sessionId = random_string(16)
|
|
204
|
+
|
|
205
|
+
# create new Page object
|
|
206
|
+
self.__page = Page(
|
|
207
|
+
self,
|
|
208
|
+
self._client_details.sessionId,
|
|
209
|
+
executor=app_manager.executor,
|
|
210
|
+
loop=asyncio.get_running_loop(),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# register session
|
|
214
|
+
await app_manager.add_session(
|
|
215
|
+
self.__get_unique_session_id(self._client_details.sessionId),
|
|
216
|
+
self.__page,
|
|
217
|
+
)
|
|
218
|
+
else:
|
|
219
|
+
# existing session
|
|
220
|
+
logger.info(
|
|
221
|
+
f"Existing session requested: {self._client_details.sessionId}"
|
|
222
|
+
)
|
|
223
|
+
self.__page = await app_manager.get_session(
|
|
224
|
+
self.__get_unique_session_id(self._client_details.sessionId)
|
|
225
|
+
)
|
|
226
|
+
new_session = False
|
|
227
|
+
|
|
228
|
+
# update page props
|
|
229
|
+
assert self.__page
|
|
230
|
+
original_route = self.__page.route
|
|
231
|
+
self.__page._set_attr("route", self._client_details.pageRoute, False)
|
|
232
|
+
self.__page._set_attr("pwa", self._client_details.isPWA, False)
|
|
233
|
+
self.__page._set_attr("web", self._client_details.isWeb, False)
|
|
234
|
+
self.__page._set_attr("debug", self._client_details.isDebug, False)
|
|
235
|
+
self.__page._set_attr("platform", self._client_details.platform, False)
|
|
236
|
+
self.__page._set_attr(
|
|
237
|
+
"platformBrightness", self._client_details.platformBrightness, False
|
|
238
|
+
)
|
|
239
|
+
self.__page._set_attr("media", self._client_details.media, False)
|
|
240
|
+
self.__page._set_attr("width", self._client_details.pageWidth, False)
|
|
241
|
+
self.__page._set_attr("height", self._client_details.pageHeight, False)
|
|
242
|
+
self.__page._set_attr(
|
|
243
|
+
"windowWidth", self._client_details.windowWidth, False
|
|
244
|
+
)
|
|
245
|
+
self.__page._set_attr(
|
|
246
|
+
"windowHeight", self._client_details.windowHeight, False
|
|
247
|
+
)
|
|
248
|
+
self.__page._set_attr("windowTop", self._client_details.windowTop, False)
|
|
249
|
+
self.__page._set_attr("windowLeft", self._client_details.windowLeft, False)
|
|
250
|
+
self.__page._set_attr("clientIP", self.client_ip, False)
|
|
251
|
+
self.__page._set_attr("clientUserAgent", self.client_user_agent, False)
|
|
252
|
+
|
|
253
|
+
p = self.__page.snapshot.get("page")
|
|
254
|
+
if not p:
|
|
255
|
+
p = {
|
|
256
|
+
"i": "page",
|
|
257
|
+
"t": "page",
|
|
258
|
+
"p": "",
|
|
259
|
+
"c": [],
|
|
260
|
+
}
|
|
261
|
+
self.__page.snapshot["page"] = p
|
|
262
|
+
self.__page.copy_attrs(p)
|
|
263
|
+
|
|
264
|
+
# send register response
|
|
265
|
+
self.__send(
|
|
266
|
+
self._create_register_web_client_response(controls=self.__page.snapshot)
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# start session
|
|
270
|
+
if new_session:
|
|
271
|
+
asyncio.create_task(
|
|
272
|
+
self.__on_session_created(self._create_session_handler_arg())
|
|
273
|
+
)
|
|
274
|
+
else:
|
|
275
|
+
await app_manager.reconnect_session(
|
|
276
|
+
self.__get_unique_session_id(self._client_details.sessionId), self
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
if original_route != self.__page.route:
|
|
280
|
+
self.__page.go(self.__page.route)
|
|
281
|
+
|
|
282
|
+
elif msg.action == ClientActions.PAGE_EVENT_FROM_WEB:
|
|
283
|
+
if self.__on_event is not None:
|
|
284
|
+
asyncio.create_task(
|
|
285
|
+
self.__on_event(self._create_page_event_handler_arg(msg))
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
elif msg.action == ClientActions.UPDATE_CONTROL_PROPS:
|
|
289
|
+
if self.__on_event is not None:
|
|
290
|
+
asyncio.create_task(
|
|
291
|
+
self.__on_event(self._create_update_control_props_handler_arg(msg))
|
|
292
|
+
)
|
|
293
|
+
else:
|
|
294
|
+
# it's something else
|
|
295
|
+
raise Exception(f'Unknown message "{msg.action}": {msg.payload}')
|
|
296
|
+
|
|
297
|
+
def _process_get_upload_url_command(self, attrs):
|
|
298
|
+
assert len(attrs) == 2, '"getUploadUrl" command has wrong number of attrs'
|
|
299
|
+
assert (
|
|
300
|
+
self.__upload_endpoint_path
|
|
301
|
+
), "upload_path should be specified to enable uploads"
|
|
302
|
+
return (
|
|
303
|
+
build_upload_url(
|
|
304
|
+
self.__upload_endpoint_path,
|
|
305
|
+
attrs["file"],
|
|
306
|
+
int(attrs["expires"]),
|
|
307
|
+
self.__secret_key,
|
|
308
|
+
),
|
|
309
|
+
None,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def __process_oauth_authorize_command(self, attrs: Dict[str, Any]):
|
|
313
|
+
state_id = attrs["state"]
|
|
314
|
+
state = OAuthState(
|
|
315
|
+
session_id=self.__get_unique_session_id(self._client_details.sessionId),
|
|
316
|
+
expires_at=datetime.now(timezone.utc)
|
|
317
|
+
+ timedelta(seconds=self.__oauth_state_timeout_seconds),
|
|
318
|
+
complete_page_html=attrs.get("completePageHtml", None),
|
|
319
|
+
complete_page_url=attrs.get("completePageUrl", None),
|
|
320
|
+
)
|
|
321
|
+
app_manager.store_state(state_id, state)
|
|
322
|
+
return (
|
|
323
|
+
"",
|
|
324
|
+
None,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def _process_add_command(self, command: Command):
|
|
328
|
+
assert self.__page
|
|
329
|
+
result, message = super()._process_add_command(command)
|
|
330
|
+
if message:
|
|
331
|
+
for oc in message.payload.controls:
|
|
332
|
+
control = copy.deepcopy(oc)
|
|
333
|
+
id = control["i"]
|
|
334
|
+
pid = control["p"]
|
|
335
|
+
parent = self.__page.snapshot[pid]
|
|
336
|
+
assert parent, f"parent control not found: {pid}"
|
|
337
|
+
if id not in parent["c"]:
|
|
338
|
+
if "at" in control:
|
|
339
|
+
parent["c"].insert(int(control["at"]), id)
|
|
340
|
+
else:
|
|
341
|
+
parent["c"].append(id)
|
|
342
|
+
self.__page.snapshot[id] = control
|
|
343
|
+
return result, message
|
|
344
|
+
|
|
345
|
+
def _process_set_command(self, values, attrs):
|
|
346
|
+
assert self.__page
|
|
347
|
+
result, message = super()._process_set_command(values, attrs)
|
|
348
|
+
control = self.__page.snapshot.get(values[0])
|
|
349
|
+
if control:
|
|
350
|
+
for k, v in attrs.items():
|
|
351
|
+
control[k] = v
|
|
352
|
+
return result, message
|
|
353
|
+
|
|
354
|
+
def _process_remove_command(self, values):
|
|
355
|
+
assert self.__page
|
|
356
|
+
result, message = super()._process_remove_command(values)
|
|
357
|
+
for id in values:
|
|
358
|
+
control = self.__page.snapshot.get(id)
|
|
359
|
+
assert (
|
|
360
|
+
control is not None
|
|
361
|
+
), f"_process_remove_command: control with ID '{id}' not found."
|
|
362
|
+
for cid in self.__get_all_descendant_ids(id):
|
|
363
|
+
self.__page.snapshot.pop(cid, None)
|
|
364
|
+
# delete control itself
|
|
365
|
+
self.__page.snapshot.pop(id, None)
|
|
366
|
+
# remove id from parent
|
|
367
|
+
parent = self.__page.snapshot.get(control["p"])
|
|
368
|
+
if parent:
|
|
369
|
+
parent["c"].remove(id)
|
|
370
|
+
return result, message
|
|
371
|
+
|
|
372
|
+
def _process_clean_command(self, values):
|
|
373
|
+
assert self.__page
|
|
374
|
+
result, message = super()._process_clean_command(values)
|
|
375
|
+
for id in values:
|
|
376
|
+
for cid in self.__get_all_descendant_ids(id):
|
|
377
|
+
self.__page.snapshot.pop(cid, None)
|
|
378
|
+
return result, message
|
|
379
|
+
|
|
380
|
+
def __get_all_descendant_ids(self, id):
|
|
381
|
+
assert self.__page
|
|
382
|
+
ids = []
|
|
383
|
+
control = self.__page.snapshot.get(id)
|
|
384
|
+
if control:
|
|
385
|
+
for cid in control["c"]:
|
|
386
|
+
ids.append(cid)
|
|
387
|
+
ids.extend(self.__get_all_descendant_ids(cid))
|
|
388
|
+
return ids
|
|
389
|
+
|
|
390
|
+
def send_command(self, session_id: str, command: Command):
|
|
391
|
+
if command.name == "oauthAuthorize":
|
|
392
|
+
result, message = self.__process_oauth_authorize_command(command.attrs)
|
|
393
|
+
else:
|
|
394
|
+
result, message = self._process_command(command)
|
|
395
|
+
if message:
|
|
396
|
+
self.__send(message)
|
|
397
|
+
return PageCommandResponsePayload(result=result, error="")
|
|
398
|
+
|
|
399
|
+
def send_commands(self, session_id: str, commands: List[Command]):
|
|
400
|
+
results = []
|
|
401
|
+
messages = []
|
|
402
|
+
for command in commands:
|
|
403
|
+
if command.name == "oauthAuthorize":
|
|
404
|
+
result, message = self.__process_oauth_authorize_command(command.attrs)
|
|
405
|
+
else:
|
|
406
|
+
result, message = self._process_command(command)
|
|
407
|
+
if command.name in ["add", "get"]:
|
|
408
|
+
results.append(result)
|
|
409
|
+
if message:
|
|
410
|
+
messages.append(message)
|
|
411
|
+
if len(messages) > 0:
|
|
412
|
+
self.__send(ClientMessage(ClientActions.PAGE_CONTROLS_BATCH, messages))
|
|
413
|
+
return PageCommandsBatchResponsePayload(results=results, error="")
|
|
414
|
+
|
|
415
|
+
def __send(self, message: ClientMessage):
|
|
416
|
+
m = json.dumps(message, cls=CommandEncoder, separators=(",", ":"))
|
|
417
|
+
logger.debug(f"__send: {m}")
|
|
418
|
+
if self.__send_queue:
|
|
419
|
+
self.__loop.call_soon_threadsafe(self.__send_queue.put_nowait, m)
|
|
420
|
+
|
|
421
|
+
def _get_next_control_id(self):
|
|
422
|
+
assert self.__page
|
|
423
|
+
return self.__page.get_next_control_id()
|
|
424
|
+
|
|
425
|
+
def __get_unique_session_id(self, session_id: str):
|
|
426
|
+
client_hash = sha1(f"{self.client_ip}{self.client_user_agent}")
|
|
427
|
+
return f"{self.page_name}_{session_id}_{client_hash}"
|
|
428
|
+
|
|
429
|
+
def dispose(self):
|
|
430
|
+
logger.info(f"Disposing FletApp: {self.__id}")
|
|
431
|
+
self.__page = None
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import shutil
|
|
4
|
+
import threading
|
|
5
|
+
import traceback
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import flet_web.fastapi as flet_fastapi
|
|
11
|
+
from flet_core.connection import Connection
|
|
12
|
+
from flet_core.locks import NopeLock
|
|
13
|
+
from flet_core.page import Page
|
|
14
|
+
from flet_core.pubsub.pubsub_hub import PubSubHub
|
|
15
|
+
from flet_core.utils import is_pyodide
|
|
16
|
+
from flet_web.fastapi.oauth_state import OAuthState
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(flet_fastapi.__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FletAppManager:
|
|
22
|
+
"""
|
|
23
|
+
Manage application sessions and their lifetime.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self):
|
|
27
|
+
self.__sessions_lock = asyncio.Lock()
|
|
28
|
+
self.__sessions: dict[str, Page] = {}
|
|
29
|
+
self.__evict_sessions_task = None
|
|
30
|
+
self.__states: dict[str, OAuthState] = {}
|
|
31
|
+
self.__states_lock = threading.Lock() if not is_pyodide() else NopeLock()
|
|
32
|
+
self.__evict_oauth_states_task = None
|
|
33
|
+
self.__temp_dirs = {}
|
|
34
|
+
self.__executor = ThreadPoolExecutor(thread_name_prefix="flet_fastapi")
|
|
35
|
+
self.__pubsubhubs_lock = threading.Lock() if not is_pyodide() else NopeLock()
|
|
36
|
+
self.__pubsubhubs = {}
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def executor(self):
|
|
40
|
+
return self.__executor
|
|
41
|
+
|
|
42
|
+
def get_pubsubhub(
|
|
43
|
+
self, session_handler, loop: Optional[asyncio.AbstractEventLoop] = None
|
|
44
|
+
):
|
|
45
|
+
with self.__pubsubhubs_lock:
|
|
46
|
+
psh = self.__pubsubhubs.get(session_handler, None)
|
|
47
|
+
if psh is None:
|
|
48
|
+
psh = PubSubHub(
|
|
49
|
+
loop=loop or asyncio.get_running_loop(),
|
|
50
|
+
executor=self.__executor,
|
|
51
|
+
)
|
|
52
|
+
self.__pubsubhubs[session_handler] = psh
|
|
53
|
+
return psh
|
|
54
|
+
|
|
55
|
+
async def start(self):
|
|
56
|
+
"""
|
|
57
|
+
Background task evicting expired app data. Must be called at FastAPI application startup.
|
|
58
|
+
"""
|
|
59
|
+
if not self.__evict_sessions_task:
|
|
60
|
+
logger.info("Starting up Flet App Manager")
|
|
61
|
+
self.__evict_sessions_task = asyncio.create_task(
|
|
62
|
+
self.__evict_expired_sessions()
|
|
63
|
+
)
|
|
64
|
+
self.__evict_oauth_states_task = asyncio.create_task(
|
|
65
|
+
self.__evict_expired_oauth_states()
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
async def shutdown(self):
|
|
69
|
+
"""
|
|
70
|
+
Cleanup temporary Flet resources on application shutdown.
|
|
71
|
+
"""
|
|
72
|
+
logger.info("Shutting down Flet App Manager")
|
|
73
|
+
self.delete_temp_dirs()
|
|
74
|
+
if self.__evict_sessions_task:
|
|
75
|
+
self.__evict_sessions_task.cancel()
|
|
76
|
+
if self.__evict_oauth_states_task:
|
|
77
|
+
self.__evict_oauth_states_task.cancel()
|
|
78
|
+
|
|
79
|
+
async def get_session(self, session_id: str) -> Optional[Page]:
|
|
80
|
+
async with self.__sessions_lock:
|
|
81
|
+
return self.__sessions.get(session_id)
|
|
82
|
+
|
|
83
|
+
async def add_session(self, session_id: str, conn: Page):
|
|
84
|
+
async with self.__sessions_lock:
|
|
85
|
+
self.__sessions[session_id] = conn
|
|
86
|
+
logger.info(
|
|
87
|
+
f"New session created ({len(self.__sessions)} total): {session_id}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
async def reconnect_session(self, session_id: str, conn: Connection):
|
|
91
|
+
logger.info(f"Session reconnected: {session_id}")
|
|
92
|
+
async with self.__sessions_lock:
|
|
93
|
+
if session_id in self.__sessions:
|
|
94
|
+
page = self.__sessions[session_id]
|
|
95
|
+
old_conn = page.connection
|
|
96
|
+
await page._connect(conn)
|
|
97
|
+
if old_conn:
|
|
98
|
+
old_conn.dispose()
|
|
99
|
+
|
|
100
|
+
async def disconnect_session(self, session_id: str, session_timeout_seconds: int):
|
|
101
|
+
logger.info(f"Session disconnected: {session_id}")
|
|
102
|
+
async with self.__sessions_lock:
|
|
103
|
+
if session_id in self.__sessions:
|
|
104
|
+
await self.__sessions[session_id]._disconnect(session_timeout_seconds)
|
|
105
|
+
|
|
106
|
+
async def delete_session(self, session_id: str):
|
|
107
|
+
async with self.__sessions_lock:
|
|
108
|
+
page = self.__sessions.pop(session_id, None)
|
|
109
|
+
total = len(self.__sessions)
|
|
110
|
+
if page is not None:
|
|
111
|
+
logger.info(f"Delete session ({total} left): {session_id}")
|
|
112
|
+
try:
|
|
113
|
+
old_conn = page.connection
|
|
114
|
+
page._close()
|
|
115
|
+
if old_conn:
|
|
116
|
+
old_conn.dispose()
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.error(
|
|
119
|
+
f"Error deleting expired session: {e} {traceback.format_exc()}"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def store_state(self, state_id: str, state: OAuthState):
|
|
123
|
+
logger.info(f"Store oauth state: {state_id}")
|
|
124
|
+
with self.__states_lock:
|
|
125
|
+
self.__states[state_id] = state
|
|
126
|
+
|
|
127
|
+
def retrieve_state(self, state_id: str) -> Optional[OAuthState]:
|
|
128
|
+
with self.__states_lock:
|
|
129
|
+
return self.__states.pop(state_id, None)
|
|
130
|
+
|
|
131
|
+
def add_temp_dir(self, temp_dir: str):
|
|
132
|
+
self.__temp_dirs[temp_dir] = True
|
|
133
|
+
|
|
134
|
+
async def __evict_expired_sessions(self):
|
|
135
|
+
while True:
|
|
136
|
+
await asyncio.sleep(10)
|
|
137
|
+
session_ids = []
|
|
138
|
+
async with self.__sessions_lock:
|
|
139
|
+
for session_id, page in self.__sessions.items():
|
|
140
|
+
if page.expires_at and datetime.now(timezone.utc) > page.expires_at:
|
|
141
|
+
session_ids.append(session_id)
|
|
142
|
+
for session_id in session_ids:
|
|
143
|
+
await self.delete_session(session_id)
|
|
144
|
+
|
|
145
|
+
async def __evict_expired_oauth_states(self):
|
|
146
|
+
while True:
|
|
147
|
+
await asyncio.sleep(10)
|
|
148
|
+
with self.__states_lock:
|
|
149
|
+
ids = []
|
|
150
|
+
for id, state in self.__states.items():
|
|
151
|
+
if (
|
|
152
|
+
state.expires_at
|
|
153
|
+
and datetime.now(timezone.utc) > state.expires_at
|
|
154
|
+
):
|
|
155
|
+
ids.append(id)
|
|
156
|
+
for id in ids:
|
|
157
|
+
logger.info(f"Delete expired oauth state: {id}")
|
|
158
|
+
self.retrieve_state(id)
|
|
159
|
+
|
|
160
|
+
def delete_temp_dirs(self):
|
|
161
|
+
for temp_dir in self.__temp_dirs.keys():
|
|
162
|
+
logger.info(f"Deleting temp dir: {temp_dir}")
|
|
163
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
app_manager = FletAppManager()
|