initial commit

This commit is contained in:
Sudo-Ivan
2025-05-28 21:42:11 -05:00
commit 55e3c81206
18 changed files with 985 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
import RNS
import time
from dataclasses import dataclass
from typing import Optional, List
@dataclass
class Announce:
destination_hash: str
display_name: Optional[str]
timestamp: int
class AnnounceService:
"""
Service to listen for Reticulum announces and collect them.
Calls update_callback whenever a new announce is received.
"""
def __init__(self, update_callback):
# Accept all announce aspects
self.aspect_filter = "nomadnetwork.node"
self.receive_path_responses = True
self.announces: List[Announce] = []
self.update_callback = update_callback
# Initialize Reticulum transport once
try:
RNS.Reticulum()
except OSError:
# Already initialized
pass
# Register self as announce handler
RNS.Transport.register_announce_handler(self)
def received_announce(self, destination_hash, announced_identity, app_data):
# Called by RNS when an announce is received
ts = int(time.time())
display_name = None
if app_data:
try:
display_name = app_data.decode("utf-8")
except:
pass
announce = Announce(destination_hash.hex(), display_name, ts)
# Deduplicate and move announce to top
# Remove any existing announces with same destination_hash
self.announces = [ann for ann in self.announces if ann.destination_hash != announce.destination_hash]
# Insert new announce at front of list
self.announces.insert(0, announce)
# Notify UI of new announce
if self.update_callback:
self.update_callback(self.announces)
def get_announces(self) -> List[Announce]:
"""Return collected announces."""
return self.announces

71
ren_browser/app.py Normal file
View File

@@ -0,0 +1,71 @@
import flet as ft
from flet import Page, AppView
import argparse
import subprocess
import sys
from ren_browser.ui.ui import build_ui
# Current renderer name
RENDERER = "plaintext"
async def main(page: Page):
# Build the main UI layout
build_ui(page)
def run():
global RENDERER
parser = argparse.ArgumentParser(description="Ren Browser")
parser.add_argument("-r", "--renderer", choices=["plaintext", "micron"], default=RENDERER, help="Select renderer (plaintext or micron)")
parser.add_argument("-w", "--web", action="store_true", help="Launch in web browser mode")
parser.add_argument("-p", "--port", type=int, default=None, help="Port for web server")
args = parser.parse_args()
RENDERER = args.renderer
if args.web:
# Run web mode on optional fixed port
if args.port is not None:
ft.app(main, view=AppView.WEB_BROWSER, port=args.port)
else:
ft.app(main, view=AppView.WEB_BROWSER)
else:
ft.app(main)
if __name__ == "__main__":
run()
def web():
"""Launch Ren Browser in web mode via Flet CLI."""
rc = subprocess.call(["flet", "run", "ren_browser/app.py", "--web"])
sys.exit(rc)
def android():
"""Launch Ren Browser in Android mode via Flet CLI."""
rc = subprocess.call(["flet", "run", "ren_browser/app.py", "--android"])
sys.exit(rc)
def ios():
"""Launch Ren Browser in iOS mode via Flet CLI."""
rc = subprocess.call(["flet", "run", "ren_browser/app.py", "--ios"])
sys.exit(rc)
# Hot reload (dev) mode entrypoints
def run_dev():
"""Launch Ren Browser in desktop mode via Flet CLI with hot reload."""
rc = subprocess.call(["flet", "run", "-d", "-r", "ren_browser/app.py"])
sys.exit(rc)
def web_dev():
"""Launch Ren Browser in web mode via Flet CLI with hot reload."""
rc = subprocess.call(["flet", "run", "--web", "-d", "-r", "ren_browser/app.py"])
sys.exit(rc)
def android_dev():
"""Launch Ren Browser in Android mode via Flet CLI with hot reload."""
rc = subprocess.call(["flet", "run", "--android", "-d", "-r", "ren_browser/app.py"])
sys.exit(rc)
def ios_dev():
"""Launch Ren Browser in iOS mode via Flet CLI with hot reload."""
rc = subprocess.call(["flet", "run", "--ios", "-d", "-r", "ren_browser/app.py"])
sys.exit(rc)

View File

@@ -0,0 +1,74 @@
from typing import Optional, Dict
from pydantic import BaseModel
import os, time, threading
import RNS
class PageRequest(BaseModel):
destination_hash: str
page_path: str
field_data: Optional[Dict] = None
class PageFetcher:
"""
Fetcher to download pages from the Reticulum network.
"""
def __init__(self):
# Initialize Reticulum with default config (singleton)
try:
RNS.Reticulum()
except OSError:
# Already initialized
pass
def fetch_page(self, req: PageRequest) -> str:
"""
Download page content for the given PageRequest.
Placeholder implementation: replace with real network logic.
"""
# Establish path and identity
dest_bytes = bytes.fromhex(req.destination_hash)
# Request path if needed, with timeout
if not RNS.Transport.has_path(dest_bytes):
RNS.Transport.request_path(dest_bytes)
start = time.time()
# Wait up to 30 seconds for path discovery
while not RNS.Transport.has_path(dest_bytes):
if time.time() - start > 30:
raise Exception(f"No path to destination {req.destination_hash}")
time.sleep(0.1)
# Recall identity
identity = RNS.Identity.recall(dest_bytes)
if not identity:
raise Exception('Identity not found')
# Create client destination and announce so the server learns our path
destination = RNS.Destination(
identity,
RNS.Destination.OUT,
RNS.Destination.SINGLE,
'nomadnetwork',
'node',
)
link = RNS.Link(destination)
# Prepare sync fetch
result = {'data': None}
ev = threading.Event()
def on_response(receipt):
data = receipt.response
if isinstance(data, bytes):
result['data'] = data.decode('utf-8')
else:
result['data'] = str(data)
ev.set()
def on_failed(_):
ev.set()
# Set up request on link establishment
link.set_link_established_callback(
lambda l: l.request(req.page_path, req.field_data, response_callback=on_response, failed_callback=on_failed)
)
# Wait for response or timeout
ev.wait(timeout=15)
return result['data'] or 'No content received'

View File

@@ -0,0 +1 @@
# Add a profiler to the browser.

View File

View File

View File

@@ -0,0 +1,13 @@
import flet as ft
def render_micron(content: str) -> ft.Control:
"""
Render micron markup content to a Flet control placeholder.
Currently displays raw content.
"""
return ft.Text(
content,
selectable=True,
font_family="monospace",
expand=True,
)

View File

View File

@@ -0,0 +1,14 @@
import flet as ft
def render_plaintext(content: str) -> ft.Control:
"""
Fallback plaintext renderer: displays raw text safely in a monospace, selectable control.
"""
# Use monospace font and make text selectable
return ft.Text(
content,
selectable=True,
font_family="monospace",
expand=True,
)

View File

@@ -0,0 +1 @@
# Add storage system/management, eg handling downloading files, saving bookmarks, caching, tabs and history.

114
ren_browser/tabs/tabs.py Normal file
View File

@@ -0,0 +1,114 @@
import flet as ft
from types import SimpleNamespace
from ren_browser.renderer.plaintext.plaintext import render_plaintext
from ren_browser.renderer.micron.micron import render_micron
class TabsManager:
def __init__(self, page: ft.Page):
import ren_browser.app as app_module
self.page = page
# State: list of tabs and current index
self.manager = SimpleNamespace(tabs=[], index=0)
# UI components
self.tab_bar = ft.Row(spacing=4)
self.content_container = ft.Container(expand=True)
# Initialize with default "Home" tab only, using selected renderer
default_content = render_micron("Welcome to Ren Browser") if app_module.RENDERER == "micron" else render_plaintext("Welcome to Ren Browser")
self._add_tab_internal("Home", default_content)
# Action buttons
self.add_btn = ft.IconButton(ft.Icons.ADD, tooltip="New Tab", on_click=self._on_add_click)
self.close_btn = ft.IconButton(ft.Icons.CLOSE, tooltip="Close Tab", on_click=self._on_close_click)
# Append add and close buttons
self.tab_bar.controls.extend([self.add_btn, self.close_btn])
# Select the first tab
self.select_tab(0)
def _add_tab_internal(self, title: str, content: ft.Control):
idx = len(self.manager.tabs)
# Create per-tab URL bar and GO button
url_field = ft.TextField(label="URL", value=title, expand=True)
go_btn = ft.IconButton(ft.Icons.OPEN_IN_BROWSER, tooltip="Load URL", on_click=lambda e, i=idx: self._on_tab_go(e, i))
# Wrap the content in a Column: URL bar + initial content
content_control = content
tab_content = ft.Column(
expand=True,
controls=[
ft.Row([url_field, go_btn]),
content_control,
],
)
# Store tab data
self.manager.tabs.append({
"title": title,
"url_field": url_field,
"content_control": content_control,
"content": tab_content,
})
# Create stylable tab button container
btn = ft.Container(
content=ft.Text(title),
on_click=lambda e, i=idx: self.select_tab(i),
padding=ft.padding.symmetric(horizontal=12, vertical=6),
border_radius=5,
bgcolor=ft.Colors.SURFACE_CONTAINER_HIGHEST,
)
# Insert before the add and close buttons
insert_pos = max(0, len(self.tab_bar.controls) - 2)
self.tab_bar.controls.insert(insert_pos, btn)
def _on_add_click(self, e):
title = f"Tab {len(self.manager.tabs) + 1}"
# Render new tab content based on selected renderer
content_text = f"Content for {title}"
import ren_browser.app as app_module
content = render_micron(content_text) if app_module.RENDERER == "micron" else render_plaintext(content_text)
self._add_tab_internal(title, content)
# Select the new tab
self.select_tab(len(self.manager.tabs) - 1)
self.page.update()
def _on_close_click(self, e):
# Do not allow closing all tabs
if len(self.manager.tabs) <= 1:
return
idx = self.manager.index
# Remove tab data and button
self.manager.tabs.pop(idx)
self.tab_bar.controls.pop(idx)
# Reassign on_click handlers to correct indices
for i, control in enumerate(self.tab_bar.controls[:-2]):
control.on_click = lambda e, i=i: self.select_tab(i)
# Adjust selected index
new_idx = min(idx, len(self.manager.tabs) - 1)
self.select_tab(new_idx)
self.page.update()
def select_tab(self, idx: int):
self.manager.index = idx
# Highlight active tab and dim others
for i, control in enumerate(self.tab_bar.controls[:-2]):
if i == idx:
control.bgcolor = ft.Colors.PRIMARY_CONTAINER
else:
control.bgcolor = ft.Colors.SURFACE_CONTAINER_HIGHEST
# Update displayed content
self.content_container.content = self.manager.tabs[idx]["content"]
self.page.update()
def _on_tab_go(self, e, idx: int):
"""Handle loading a new URL in a specific tab (placeholder logic)."""
tab = self.manager.tabs[idx]
url = tab["url_field"].value.strip()
if not url:
return
# Placeholder: update the content_control using selected renderer
placeholder_text = f"Loading content for {url}"
import ren_browser.app as app_module
new_control = render_micron(placeholder_text) if app_module.RENDERER == "micron" else render_plaintext(placeholder_text)
tab["content_control"] = new_control
tab["content"].controls[1] = new_control
# Refresh the displayed content if this tab is active
if self.manager.index == idx:
self.content_container.content = tab["content"]
self.page.update()

111
ren_browser/ui/ui.py Normal file
View File

@@ -0,0 +1,111 @@
import flet as ft
from flet import Page
from ren_browser.tabs.tabs import TabsManager
from ren_browser.renderer.plaintext.plaintext import render_plaintext
from ren_browser.renderer.micron.micron import render_micron
from ren_browser.announces.announces import AnnounceService
from ren_browser.pages.page_request import PageFetcher, PageRequest
def build_ui(page: Page):
import ren_browser.app as app_module
# Page properties
page.title = "Ren Browser"
page.theme_mode = ft.ThemeMode.DARK
page.appbar = ft.AppBar(title=ft.Text("Ren Browser"))
page.padding = 20
page.window_width = 800
page.window_height = 600
# Initialize page fetcher and announce service
page_fetcher = PageFetcher()
# Sidebar announces list in a scrollable ListView within a NavigationDrawer
announce_list = ft.ListView(expand=True, spacing=1)
def update_announces(ann_list):
announce_list.controls.clear()
for ann in ann_list:
label = ann.display_name or ann.destination_hash
# Use display_name for tab title, fallback to "Anonymous"; set URL bar to full path
def on_click_ann(e, dest=ann.destination_hash, disp=ann.display_name):
title = disp or "Anonymous"
# Full URL including page path
full_url = f"{dest}:/page/index.mu"
placeholder = render_plaintext(f"Fetching content for {full_url}")
tab_manager._add_tab_internal(title, placeholder)
idx = len(tab_manager.manager.tabs) - 1
# Set URL bar to full URL
tab = tab_manager.manager.tabs[idx]
tab["url_field"].value = full_url
# Select the new tab and refresh UI
tab_manager.select_tab(idx)
page.update()
def fetch_and_update():
req = PageRequest(destination_hash=dest, page_path="/page/index.mu")
try:
result = page_fetcher.fetch_page(req)
except Exception as ex:
result = f"Error: {ex}"
tab = tab_manager.manager.tabs[idx]
# Use micron renderer for .mu pages, fallback to plaintext
if req.page_path.endswith(".mu"):
new_control = render_micron(result)
else:
new_control = render_plaintext(result)
tab["content_control"] = new_control
# Replace the content control in the tab's column
tab["content"].controls[1] = new_control
if tab_manager.manager.index == idx:
tab_manager.content_container.content = tab["content"]
page.update()
page.run_thread(fetch_and_update)
announce_list.controls.append(ft.TextButton(label, on_click=on_click_ann))
page.update()
AnnounceService(update_callback=update_announces)
# Make sidebar collapsible via drawer
page.drawer = ft.NavigationDrawer(
controls=[
ft.Text("Announcements", weight=ft.FontWeight.BOLD),
ft.Divider(),
announce_list,
],
)
# Add hamburger button to toggle drawer
page.appbar.leading = ft.IconButton(
ft.Icons.MENU,
tooltip="Toggle sidebar",
on_click=lambda e: (setattr(page.drawer, 'open', not page.drawer.open), page.update()),
)
# Dynamic tabs manager for pages
tab_manager = TabsManager(page)
# Main area: tab bar and content
main_area = ft.Column(
expand=True,
controls=[
tab_manager.tab_bar,
tab_manager.content_container,
],
)
# Layout: main content only (sidebar in drawer)
layout = ft.Row(expand=True, controls=[main_area])
# Render main layout with status
page.add(
ft.Column(
expand=True,
controls=[
layout,
ft.Row(
[
ft.Text(
f"Renderer: {app_module.RENDERER}",
color=ft.Colors.GREY,
size=12,
),
],
alignment=ft.MainAxisAlignment.END,
),
],
),
)