fluxlink 0.1.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.
- chennalink/__init__.py +8 -0
- chennalink/__main__.py +94 -0
- chennalink/app.py +419 -0
- chennalink/models/__init__.py +0 -0
- chennalink/models/code_file.py +13 -0
- chennalink/models/session.py +12 -0
- chennalink/screens/__init__.py +0 -0
- chennalink/screens/chat.py +503 -0
- chennalink/screens/code_viewer.py +81 -0
- chennalink/screens/connect_session.py +74 -0
- chennalink/screens/create_session.py +78 -0
- chennalink/screens/help.py +86 -0
- chennalink/screens/history.py +293 -0
- chennalink/screens/receive.py +36 -0
- chennalink/screens/send.py +270 -0
- chennalink/screens/session.py +85 -0
- chennalink/screens/waiting.py +57 -0
- chennalink/screens/welcome.py +19 -0
- chennalink/server.py +176 -0
- chennalink/services/__init__.py +0 -0
- chennalink/services/clipboard.py +12 -0
- chennalink/services/local_storage.py +99 -0
- chennalink/utils/__init__.py +0 -0
- chennalink/widgets/__init__.py +0 -0
- chennalink/widgets/code_card.py +114 -0
- chennalink/widgets/code_editor.py +67 -0
- chennalink/widgets/file_queue.py +80 -0
- chennalink/widgets/header.py +55 -0
- chennalink/widgets/status_bar.py +53 -0
- fluxlink/__init__.py +8 -0
- fluxlink/__main__.py +5 -0
- fluxlink-0.1.0.dist-info/METADATA +285 -0
- fluxlink-0.1.0.dist-info/RECORD +35 -0
- fluxlink-0.1.0.dist-info/WHEEL +4 -0
- fluxlink-0.1.0.dist-info/entry_points.txt +3 -0
chennalink/__init__.py
ADDED
chennalink/__main__.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
import argparse
|
|
4
|
+
from chennalink.app import main, ChennaLinkApp
|
|
5
|
+
|
|
6
|
+
CLI_HELP_TEXT = """
|
|
7
|
+
FluxLink CLI — Real-time lossless source-code and file transfer bridge
|
|
8
|
+
|
|
9
|
+
USAGE:
|
|
10
|
+
fluxlink [COMMAND] [OPTIONS]
|
|
11
|
+
|
|
12
|
+
COMMANDS:
|
|
13
|
+
(no command) Launch interactive TUI
|
|
14
|
+
send <filepath> Launch TUI and preload file for immediate transfer
|
|
15
|
+
receive Launch TUI directly in the incoming Receive queue
|
|
16
|
+
history Launch TUI directly in the Transfer History screen
|
|
17
|
+
session Launch TUI directly in the Active Session details screen
|
|
18
|
+
help Show this usage information
|
|
19
|
+
|
|
20
|
+
OPTIONS:
|
|
21
|
+
--backend, -b <url> Override backend server URL
|
|
22
|
+
(Default: https://fluxlinkbackend.chennareddy.in)
|
|
23
|
+
--version, -v Show FluxLink version
|
|
24
|
+
|
|
25
|
+
KEYBOARD SHORTCUTS IN TUI:
|
|
26
|
+
Ctrl + T WhatsApp-style Real-time Chat Timeline
|
|
27
|
+
Ctrl + 1 Send File / Source Code Screen
|
|
28
|
+
Ctrl + 2 Receive Files Queue
|
|
29
|
+
Ctrl + 3 Transfer History
|
|
30
|
+
Ctrl + 4 Active Session Details
|
|
31
|
+
Ctrl + H In-app Help & Cheatsheet
|
|
32
|
+
Ctrl + Q Quit Application
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def cli():
|
|
36
|
+
args = sys.argv[1:]
|
|
37
|
+
|
|
38
|
+
backend_url = None
|
|
39
|
+
initial_screen = None
|
|
40
|
+
send_file = None
|
|
41
|
+
|
|
42
|
+
# Parse --backend / -b flag if present
|
|
43
|
+
i = 0
|
|
44
|
+
clean_args = []
|
|
45
|
+
while i < len(args):
|
|
46
|
+
if args[i] in ("--backend", "-b") and i + 1 < len(args):
|
|
47
|
+
backend_url = args[i + 1]
|
|
48
|
+
i += 2
|
|
49
|
+
elif args[i].startswith("--backend="):
|
|
50
|
+
backend_url = args[i].split("=", 1)[1]
|
|
51
|
+
i += 1
|
|
52
|
+
elif args[i] in ("--help", "-h", "help"):
|
|
53
|
+
print(CLI_HELP_TEXT)
|
|
54
|
+
return
|
|
55
|
+
elif args[i] in ("--version", "-v"):
|
|
56
|
+
print("FluxLink CLI v0.1.0")
|
|
57
|
+
return
|
|
58
|
+
else:
|
|
59
|
+
clean_args.append(args[i])
|
|
60
|
+
i += 1
|
|
61
|
+
|
|
62
|
+
if clean_args:
|
|
63
|
+
cmd = clean_args[0].lower()
|
|
64
|
+
if cmd == "send":
|
|
65
|
+
if len(clean_args) > 1:
|
|
66
|
+
path = clean_args[1]
|
|
67
|
+
if os.path.isfile(path):
|
|
68
|
+
send_file = path
|
|
69
|
+
initial_screen = "send"
|
|
70
|
+
else:
|
|
71
|
+
print(f"Error: File not found: {path}")
|
|
72
|
+
sys.exit(1)
|
|
73
|
+
else:
|
|
74
|
+
initial_screen = "send"
|
|
75
|
+
elif cmd == "receive":
|
|
76
|
+
initial_screen = "receive"
|
|
77
|
+
elif cmd == "history":
|
|
78
|
+
initial_screen = "history"
|
|
79
|
+
elif cmd == "session":
|
|
80
|
+
initial_screen = "session"
|
|
81
|
+
elif os.path.isfile(clean_args[0]):
|
|
82
|
+
# Directly called with a file path: fluxlink script.py
|
|
83
|
+
send_file = clean_args[0]
|
|
84
|
+
initial_screen = "send"
|
|
85
|
+
|
|
86
|
+
app = ChennaLinkApp(
|
|
87
|
+
send_file_on_startup=send_file,
|
|
88
|
+
backend_url=backend_url,
|
|
89
|
+
initial_screen=initial_screen
|
|
90
|
+
)
|
|
91
|
+
app.run()
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
cli()
|
chennalink/app.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
from textual.app import App
|
|
2
|
+
from textual.widgets import Button
|
|
3
|
+
from textual.binding import Binding
|
|
4
|
+
import os
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import websockets
|
|
8
|
+
import httpx
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from chennalink.screens.chat import ChatScreen
|
|
12
|
+
from chennalink.screens.send import SendScreen
|
|
13
|
+
from chennalink.screens.receive import ReceiveScreen
|
|
14
|
+
from chennalink.screens.history import HistoryScreen
|
|
15
|
+
from chennalink.screens.session import SessionScreen
|
|
16
|
+
from chennalink.screens.welcome import WelcomeScreen
|
|
17
|
+
from chennalink.screens.create_session import CreateSessionScreen
|
|
18
|
+
from chennalink.screens.connect_session import ConnectSessionScreen
|
|
19
|
+
from chennalink.screens.waiting import WaitingScreen
|
|
20
|
+
from chennalink.screens.code_viewer import CodeViewerScreen
|
|
21
|
+
from chennalink.screens.help import HelpScreen
|
|
22
|
+
from chennalink.services.local_storage import LocalStorage
|
|
23
|
+
from chennalink.services.clipboard import copy_to_clipboard
|
|
24
|
+
from chennalink.widgets.code_card import CodeCard
|
|
25
|
+
from chennalink.models.session import Session
|
|
26
|
+
from chennalink.models.code_file import CodeFile
|
|
27
|
+
|
|
28
|
+
class ChennaLinkApp(App):
|
|
29
|
+
CSS = """
|
|
30
|
+
Screen { background: $surface; }
|
|
31
|
+
.left-pane { width: 60%; height: 100%; padding: 1; }
|
|
32
|
+
.right-pane { width: 40%; height: 100%; padding: 1; }
|
|
33
|
+
.editor-actions { height: auto; align: left middle; }
|
|
34
|
+
.editor-actions Button { margin-right: 1; }
|
|
35
|
+
.queue-actions { height: auto; align: right middle; }
|
|
36
|
+
.session-container { padding: 2; width: 60%; margin: 2 0; border: solid $accent; }
|
|
37
|
+
.session-title { text-style: bold; margin-bottom: 2; text-align: center; }
|
|
38
|
+
.session-subtitle { text-style: bold; margin-top: 1; }
|
|
39
|
+
.session-code-display { text-style: bold; color: $success; margin: 1 0; text-align: center; }
|
|
40
|
+
.session-info-text { margin-bottom: 1; }
|
|
41
|
+
.status-connecting { color: $warning; margin-top: 2; margin-bottom: 1; }
|
|
42
|
+
.session-info { height: 1; margin-bottom: 1; }
|
|
43
|
+
.session-info Label { width: 1fr; }
|
|
44
|
+
.session-value { text-align: right; }
|
|
45
|
+
.session-actions { height: auto; margin-top: 2; align: center middle; }
|
|
46
|
+
.session-actions Button { margin: 0 1; }
|
|
47
|
+
.status-offline { color: $warning; }
|
|
48
|
+
.status-connected { color: $success; }
|
|
49
|
+
.status-reconnecting { color: $warning; }
|
|
50
|
+
|
|
51
|
+
Notification {
|
|
52
|
+
dock: top;
|
|
53
|
+
layer: overlay;
|
|
54
|
+
width: 100%;
|
|
55
|
+
height: auto;
|
|
56
|
+
background: $primary;
|
|
57
|
+
color: $text;
|
|
58
|
+
text-align: center;
|
|
59
|
+
padding: 1;
|
|
60
|
+
}
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
BINDINGS = [
|
|
64
|
+
Binding("ctrl+t", "switch_screen('chat')", "Chat", show=True),
|
|
65
|
+
Binding("ctrl+1", "switch_screen('send')", "Send", show=True),
|
|
66
|
+
Binding("ctrl+2", "switch_screen('receive')", "Receive", show=True),
|
|
67
|
+
Binding("ctrl+3", "switch_screen('history')", "History", show=True),
|
|
68
|
+
Binding("ctrl+4", "switch_screen('session')", "Session", show=True),
|
|
69
|
+
Binding("ctrl+h", "switch_screen('help')", "Help", show=True),
|
|
70
|
+
Binding("ctrl+q", "quit", "Quit", show=True),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
SCREENS = {
|
|
74
|
+
"welcome": WelcomeScreen,
|
|
75
|
+
"create_session": CreateSessionScreen,
|
|
76
|
+
"connect_session": ConnectSessionScreen,
|
|
77
|
+
"waiting": WaitingScreen,
|
|
78
|
+
"chat": ChatScreen,
|
|
79
|
+
"send": SendScreen,
|
|
80
|
+
"receive": ReceiveScreen,
|
|
81
|
+
"history": HistoryScreen,
|
|
82
|
+
"session": SessionScreen,
|
|
83
|
+
"code_viewer": CodeViewerScreen,
|
|
84
|
+
"help": HelpScreen,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
def __init__(self, send_file_on_startup=None, backend_url=None, initial_screen=None, **kwargs):
|
|
88
|
+
super().__init__(**kwargs)
|
|
89
|
+
self.send_file_on_startup = send_file_on_startup
|
|
90
|
+
self.initial_screen = initial_screen
|
|
91
|
+
self.local_storage = LocalStorage()
|
|
92
|
+
import random
|
|
93
|
+
device_id = self.local_storage.get_device_id()
|
|
94
|
+
if not device_id:
|
|
95
|
+
device_id = f"LAPTOP-{random.randint(1000, 9999)}"
|
|
96
|
+
self.local_storage.save_device_id(device_id)
|
|
97
|
+
|
|
98
|
+
self.session_data = Session(device_id=device_id, device_name=device_id)
|
|
99
|
+
|
|
100
|
+
default_backend = "https://fluxlinkbackend.chennareddy.in"
|
|
101
|
+
if os.environ.get("FLUXLINK_ENV") == "development" or os.environ.get("CHENNALINK_ENV") == "development" or "PYTEST_CURRENT_TEST" in os.environ:
|
|
102
|
+
default_backend = "http://127.0.0.1:8000"
|
|
103
|
+
|
|
104
|
+
resolved_backend = (
|
|
105
|
+
backend_url
|
|
106
|
+
or os.environ.get("FLUXLINK_BACKEND_URL")
|
|
107
|
+
or os.environ.get("CHENNALINK_BACKEND_URL")
|
|
108
|
+
or default_backend
|
|
109
|
+
).rstrip("/")
|
|
110
|
+
|
|
111
|
+
self.backend_url = resolved_backend
|
|
112
|
+
self.api_base = f"{resolved_backend}/api"
|
|
113
|
+
ws_proto = "wss" if resolved_backend.startswith("https") else "ws"
|
|
114
|
+
host_port = resolved_backend.split("://")[-1]
|
|
115
|
+
self.ws_base = f"{ws_proto}://{host_port}/ws"
|
|
116
|
+
self.ws = None
|
|
117
|
+
self.ws_task = None
|
|
118
|
+
self.ping_task = None
|
|
119
|
+
|
|
120
|
+
def on_mount(self) -> None:
|
|
121
|
+
if self.initial_screen and self.initial_screen in self.SCREENS:
|
|
122
|
+
self.push_screen(self.initial_screen)
|
|
123
|
+
else:
|
|
124
|
+
self.push_screen("welcome")
|
|
125
|
+
self.update_status_bars()
|
|
126
|
+
|
|
127
|
+
if self.send_file_on_startup:
|
|
128
|
+
# We queue it once the UI loads and connect happens
|
|
129
|
+
pass # handled when SendScreen loads if we want, or we can push it to LocalStorage queue.
|
|
130
|
+
# Easiest way is to have SendScreen pick it up.
|
|
131
|
+
|
|
132
|
+
def get_startup_file(self):
|
|
133
|
+
f = self.send_file_on_startup
|
|
134
|
+
self.send_file_on_startup = None # only once
|
|
135
|
+
return f
|
|
136
|
+
|
|
137
|
+
def update_status_bars(self):
|
|
138
|
+
for screen in self.SCREENS.values():
|
|
139
|
+
if hasattr(screen, "status_bar"):
|
|
140
|
+
# We will update status bar reactive variables
|
|
141
|
+
pass
|
|
142
|
+
# simpler: just update via a message or broadcast
|
|
143
|
+
# Textual screens can query App's session_data.
|
|
144
|
+
|
|
145
|
+
def action_switch_screen(self, screen_name: str) -> None:
|
|
146
|
+
if not self.session_data.session_code and screen_name in ["chat", "send", "receive", "history", "session"]:
|
|
147
|
+
self.notify("You must connect to a session first.", severity="warning")
|
|
148
|
+
return
|
|
149
|
+
self.switch_screen(screen_name)
|
|
150
|
+
|
|
151
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
152
|
+
if event.button.id == "nav_chat":
|
|
153
|
+
self.action_switch_screen("chat")
|
|
154
|
+
elif event.button.id == "nav_send":
|
|
155
|
+
self.action_switch_screen("send")
|
|
156
|
+
elif event.button.id == "nav_receive":
|
|
157
|
+
self.action_switch_screen("receive")
|
|
158
|
+
elif event.button.id == "nav_history":
|
|
159
|
+
self.action_switch_screen("history")
|
|
160
|
+
elif event.button.id == "nav_session":
|
|
161
|
+
self.action_switch_screen("session")
|
|
162
|
+
elif event.button.id == "nav_quit":
|
|
163
|
+
self.exit()
|
|
164
|
+
|
|
165
|
+
def on_code_card_copy_code(self, message: CodeCard.CopyCode) -> None:
|
|
166
|
+
if copy_to_clipboard(message.content):
|
|
167
|
+
self.notify("✓ Code copied", timeout=2)
|
|
168
|
+
else:
|
|
169
|
+
self.notify("✗ Failed to copy code", severity="error")
|
|
170
|
+
|
|
171
|
+
def on_code_card_view_code(self, message: CodeCard.ViewCode) -> None:
|
|
172
|
+
self.push_screen(CodeViewerScreen(message.file))
|
|
173
|
+
|
|
174
|
+
def on_code_card_save_code(self, message: CodeCard.SaveCode) -> None:
|
|
175
|
+
import os
|
|
176
|
+
try:
|
|
177
|
+
with open(message.file.filename, "w", encoding="utf-8") as f:
|
|
178
|
+
f.write(message.file.content)
|
|
179
|
+
self.notify(f"✓ Saved to {os.path.abspath(message.file.filename)}", timeout=3)
|
|
180
|
+
except Exception as e:
|
|
181
|
+
self.notify(f"✗ Could not save file: {e}", severity="error")
|
|
182
|
+
|
|
183
|
+
async def connect_ws(self):
|
|
184
|
+
if not self.session_data.session_code or not self.session_data.device_id:
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
url = f"{self.ws_base}/{self.session_data.session_code}/{self.session_data.device_id}"
|
|
188
|
+
|
|
189
|
+
import os
|
|
190
|
+
max_mb = int(os.environ.get("CHENNALINK_MAX_FILE_SIZE_MB", "10"))
|
|
191
|
+
# Account for Base64 (+33%) and JSON envelope overhead
|
|
192
|
+
max_bytes = max(20 * 1024 * 1024, max_mb * 1024 * 1024 * 2)
|
|
193
|
+
|
|
194
|
+
while True:
|
|
195
|
+
try:
|
|
196
|
+
self.session_data.status = "CONNECTING"
|
|
197
|
+
self.update_status()
|
|
198
|
+
|
|
199
|
+
async with websockets.connect(url, max_size=max_bytes) as ws:
|
|
200
|
+
self.ws = ws
|
|
201
|
+
self.session_data.status = "CONNECTED"
|
|
202
|
+
self.update_status()
|
|
203
|
+
|
|
204
|
+
if hasattr(self.screen, "sync_from_server"):
|
|
205
|
+
self.run_worker(self.screen.sync_from_server())
|
|
206
|
+
|
|
207
|
+
if self.ping_task:
|
|
208
|
+
self.ping_task.cancel()
|
|
209
|
+
self.ping_task = asyncio.create_task(self.ws_ping_loop())
|
|
210
|
+
|
|
211
|
+
while True:
|
|
212
|
+
msg = await ws.recv()
|
|
213
|
+
await self.handle_ws_message(msg)
|
|
214
|
+
|
|
215
|
+
except websockets.exceptions.InvalidStatusCode as e:
|
|
216
|
+
self.ws = None
|
|
217
|
+
if e.status_code == 403:
|
|
218
|
+
self.session_data.status = "SESSION FULL"
|
|
219
|
+
self.update_status()
|
|
220
|
+
self.notify("SESSION FULL: Maximum 4 active connections.", severity="error")
|
|
221
|
+
return # Stop retrying
|
|
222
|
+
self.session_data.status = "CONNECTION LOST"
|
|
223
|
+
self.update_status()
|
|
224
|
+
await asyncio.sleep(5)
|
|
225
|
+
except websockets.exceptions.ConnectionClosed as e:
|
|
226
|
+
self.ws = None
|
|
227
|
+
if e.code == 1008:
|
|
228
|
+
self.session_data.status = "SESSION FULL"
|
|
229
|
+
self.update_status()
|
|
230
|
+
self.notify("SESSION FULL: Maximum 4 active connections.", severity="error")
|
|
231
|
+
return # Stop retrying
|
|
232
|
+
self.session_data.status = "⚠ Connection lost"
|
|
233
|
+
self.update_status()
|
|
234
|
+
self.notify("↻ Reconnecting...", severity="warning")
|
|
235
|
+
await asyncio.sleep(2) # Backoff
|
|
236
|
+
except Exception as e:
|
|
237
|
+
self.ws = None
|
|
238
|
+
self.session_data.status = "⚠ Connection lost"
|
|
239
|
+
self.update_status()
|
|
240
|
+
self.notify("↻ Reconnecting...", severity="warning")
|
|
241
|
+
await asyncio.sleep(5)
|
|
242
|
+
|
|
243
|
+
async def ws_ping_loop(self):
|
|
244
|
+
while self.ws:
|
|
245
|
+
try:
|
|
246
|
+
await asyncio.sleep(30)
|
|
247
|
+
if self.ws:
|
|
248
|
+
await self.ws.send(json.dumps({"type": "ping"}))
|
|
249
|
+
except:
|
|
250
|
+
break
|
|
251
|
+
|
|
252
|
+
def update_status(self):
|
|
253
|
+
status_text = self.session_data.status
|
|
254
|
+
status_display = f"[red]● {status_text}[/red]"
|
|
255
|
+
if status_text.startswith("CONNECTED"):
|
|
256
|
+
status_display = f"[green]● {status_text}[/green]"
|
|
257
|
+
elif status_text.startswith("RECONNECT"):
|
|
258
|
+
status_display = f"[yellow]↻ {status_text}[/yellow]"
|
|
259
|
+
elif status_text.startswith("CONNECTING") or status_text.startswith("WAITING"):
|
|
260
|
+
status_display = f"[yellow]● {status_text}[/yellow]"
|
|
261
|
+
elif status_text == "DISCONNECTED" or status_text == "OFFLINE" or status_text == "CONNECTION LOST":
|
|
262
|
+
status_display = f"[red]⚠ {status_text}[/red]"
|
|
263
|
+
|
|
264
|
+
if self.screen:
|
|
265
|
+
try:
|
|
266
|
+
for header in self.screen.query("AppHeader"):
|
|
267
|
+
header.status = status_display
|
|
268
|
+
except:
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
for bar in self.screen.query("StatusBar"):
|
|
273
|
+
bar.session = self.session_data.session_code
|
|
274
|
+
bar.device = self.session_data.device_name
|
|
275
|
+
bar.status = status_display
|
|
276
|
+
except:
|
|
277
|
+
pass
|
|
278
|
+
|
|
279
|
+
# Update waiting screen if it is active
|
|
280
|
+
if hasattr(self.screen, "update_status_display"):
|
|
281
|
+
self.screen.update_status_display()
|
|
282
|
+
|
|
283
|
+
async def handle_ws_message(self, data: str):
|
|
284
|
+
msg = json.loads(data)
|
|
285
|
+
msg_type = msg.get("type")
|
|
286
|
+
|
|
287
|
+
if msg_type == "status_update":
|
|
288
|
+
active = msg.get("active_devices", 1)
|
|
289
|
+
total = msg.get("total_devices", 4)
|
|
290
|
+
participants = msg.get("participants", [])
|
|
291
|
+
self.session_data.participants = participants
|
|
292
|
+
|
|
293
|
+
if active == total:
|
|
294
|
+
self.session_data.status = f"SESSION FULL ({active} / {total} DEVICES)"
|
|
295
|
+
else:
|
|
296
|
+
self.session_data.status = f"CONNECTED ({active} / {total} DEVICES)"
|
|
297
|
+
|
|
298
|
+
self.update_status()
|
|
299
|
+
|
|
300
|
+
if active >= 2:
|
|
301
|
+
if not isinstance(self.screen, (ChatScreen, SendScreen, ReceiveScreen, HistoryScreen, SessionScreen)):
|
|
302
|
+
self.notify("Connected to session! Entering chat...")
|
|
303
|
+
self.switch_screen("chat")
|
|
304
|
+
|
|
305
|
+
# If we are on the session screen, notify it to re-render participants
|
|
306
|
+
if isinstance(self.screen, SessionScreen) and hasattr(self.screen, "update_participants"):
|
|
307
|
+
self.screen.update_participants()
|
|
308
|
+
|
|
309
|
+
elif msg_type == "session_ended":
|
|
310
|
+
self.notify("The host has ended the session.", title="Session Ended", severity="warning")
|
|
311
|
+
if self.ws:
|
|
312
|
+
asyncio.create_task(self.ws.close())
|
|
313
|
+
self.session_data.status = "SESSION ENDED"
|
|
314
|
+
self.switch_screen("welcome")
|
|
315
|
+
|
|
316
|
+
elif msg_type == "code_file":
|
|
317
|
+
await self.process_incoming_file(msg)
|
|
318
|
+
|
|
319
|
+
elif msg_type == "file_bundle":
|
|
320
|
+
files = msg.get("files", [])
|
|
321
|
+
for f in files:
|
|
322
|
+
m = msg.copy()
|
|
323
|
+
m["file_name"] = f.get("file_name")
|
|
324
|
+
m["content"] = f.get("content")
|
|
325
|
+
m["language"] = f.get("language", "python")
|
|
326
|
+
await self.process_incoming_file(m)
|
|
327
|
+
|
|
328
|
+
elif msg_type == "device_joined":
|
|
329
|
+
self.notify("A device joined the session.")
|
|
330
|
+
|
|
331
|
+
async def process_incoming_file(self, msg):
|
|
332
|
+
import uuid
|
|
333
|
+
file = CodeFile(
|
|
334
|
+
id=msg.get("message_id", str(uuid.uuid4())),
|
|
335
|
+
filename=msg.get("file_name"),
|
|
336
|
+
content=msg.get("content"),
|
|
337
|
+
language=msg.get("language", "python"),
|
|
338
|
+
timestamp=msg.get("created_at"),
|
|
339
|
+
direction="received",
|
|
340
|
+
status="delivered",
|
|
341
|
+
sender=msg.get("sender_device_id", "REMOTE")
|
|
342
|
+
)
|
|
343
|
+
await self.local_storage.save_file(file)
|
|
344
|
+
|
|
345
|
+
if self.ws:
|
|
346
|
+
asyncio.create_task(self.ws.send(json.dumps({"type": "ack", "message_id": msg.get("message_id")})))
|
|
347
|
+
|
|
348
|
+
self.notify(f"🔵 NEW CODE RECEIVED: {file.filename}", title="New Message", timeout=5)
|
|
349
|
+
|
|
350
|
+
if hasattr(self.screen, "add_message"):
|
|
351
|
+
self.screen.add_message(file)
|
|
352
|
+
elif self.screen.name == "receive":
|
|
353
|
+
if hasattr(self.screen, "load_messages"):
|
|
354
|
+
self.screen.load_messages()
|
|
355
|
+
|
|
356
|
+
async def send_message(self, msg_dict: dict):
|
|
357
|
+
if self.ws and self.session_data.status.startswith("CONNECTED"):
|
|
358
|
+
await self.ws.send(json.dumps(msg_dict))
|
|
359
|
+
return True
|
|
360
|
+
else:
|
|
361
|
+
self.notify("Cannot send: not fully connected", severity="error")
|
|
362
|
+
return False
|
|
363
|
+
|
|
364
|
+
async def send_file_chunked(self, file_name: str, content: str, language: str = "python"):
|
|
365
|
+
if not self.ws or not self.session_data.status.startswith("CONNECTED"):
|
|
366
|
+
self.notify("Cannot send: not fully connected", severity="error")
|
|
367
|
+
return False
|
|
368
|
+
|
|
369
|
+
import uuid
|
|
370
|
+
import base64
|
|
371
|
+
import hashlib
|
|
372
|
+
import math
|
|
373
|
+
|
|
374
|
+
content_bytes = content.encode("utf-8")
|
|
375
|
+
file_size = len(content_bytes)
|
|
376
|
+
sha256 = hashlib.sha256(content_bytes).hexdigest()
|
|
377
|
+
transfer_id = str(uuid.uuid4())
|
|
378
|
+
|
|
379
|
+
chunk_size = 256 * 1024 # 256KB chunks
|
|
380
|
+
total_chunks = math.ceil(file_size / chunk_size)
|
|
381
|
+
|
|
382
|
+
# 1. Send file_start
|
|
383
|
+
await self.ws.send(json.dumps({
|
|
384
|
+
"type": "file_start",
|
|
385
|
+
"transfer_id": transfer_id,
|
|
386
|
+
"file_name": file_name,
|
|
387
|
+
"file_size": file_size,
|
|
388
|
+
"total_chunks": total_chunks,
|
|
389
|
+
"sha256": sha256,
|
|
390
|
+
"language": language
|
|
391
|
+
}))
|
|
392
|
+
|
|
393
|
+
# 2. Send chunks
|
|
394
|
+
for i in range(total_chunks):
|
|
395
|
+
start = i * chunk_size
|
|
396
|
+
end = min(start + chunk_size, file_size)
|
|
397
|
+
chunk_data = base64.b64encode(content_bytes[start:end]).decode("ascii")
|
|
398
|
+
|
|
399
|
+
await self.ws.send(json.dumps({
|
|
400
|
+
"type": "file_chunk",
|
|
401
|
+
"transfer_id": transfer_id,
|
|
402
|
+
"index": i,
|
|
403
|
+
"chunk": chunk_data
|
|
404
|
+
}))
|
|
405
|
+
|
|
406
|
+
# 3. Send file_end
|
|
407
|
+
await self.ws.send(json.dumps({
|
|
408
|
+
"type": "file_end",
|
|
409
|
+
"transfer_id": transfer_id
|
|
410
|
+
}))
|
|
411
|
+
|
|
412
|
+
return transfer_id
|
|
413
|
+
|
|
414
|
+
def main():
|
|
415
|
+
app = ChennaLinkApp()
|
|
416
|
+
app.run()
|
|
417
|
+
|
|
418
|
+
if __name__ == "__main__":
|
|
419
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
class CodeFile(BaseModel):
|
|
6
|
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
7
|
+
filename: str
|
|
8
|
+
language: str = "python"
|
|
9
|
+
content: str
|
|
10
|
+
timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())
|
|
11
|
+
direction: str = "sent" # "sent" or "received"
|
|
12
|
+
status: str = "sent" # "queued", "sent", "received"
|
|
13
|
+
sender: str = "LOCAL"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
class Session(BaseModel):
|
|
5
|
+
session_id: Optional[str] = None
|
|
6
|
+
session_code: str = ""
|
|
7
|
+
device_id: Optional[str] = None
|
|
8
|
+
device_name: str = ""
|
|
9
|
+
email: str = ""
|
|
10
|
+
status: str = "OFFLINE"
|
|
11
|
+
participants: list = []
|
|
12
|
+
is_host: bool = False
|
|
File without changes
|