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 ADDED
@@ -0,0 +1,8 @@
1
+ """
2
+ FluxLink: Real-time lossless source-code and file transfer bridge.
3
+ """
4
+ from chennalink.app import ChennaLinkApp
5
+ from chennalink.__main__ import cli
6
+
7
+ __version__ = "0.1.0"
8
+ __all__ = ["ChennaLinkApp", "cli", "__version__"]
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