initial commit
This commit is contained in:
53
ren_browser/announces/announces.py
Normal file
53
ren_browser/announces/announces.py
Normal 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
71
ren_browser/app.py
Normal 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)
|
||||
74
ren_browser/pages/page_request.py
Normal file
74
ren_browser/pages/page_request.py
Normal 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'
|
||||
1
ren_browser/profiler/profiler.py
Normal file
1
ren_browser/profiler/profiler.py
Normal file
@@ -0,0 +1 @@
|
||||
# Add a profiler to the browser.
|
||||
0
ren_browser/renderer/__init__.py
Normal file
0
ren_browser/renderer/__init__.py
Normal file
0
ren_browser/renderer/micron/__init__.py
Normal file
0
ren_browser/renderer/micron/__init__.py
Normal file
13
ren_browser/renderer/micron/micron.py
Normal file
13
ren_browser/renderer/micron/micron.py
Normal 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,
|
||||
)
|
||||
0
ren_browser/renderer/plaintext/__init__.py
Normal file
0
ren_browser/renderer/plaintext/__init__.py
Normal file
14
ren_browser/renderer/plaintext/plaintext.py
Normal file
14
ren_browser/renderer/plaintext/plaintext.py
Normal 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,
|
||||
)
|
||||
1
ren_browser/storage/storage.py
Normal file
1
ren_browser/storage/storage.py
Normal 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
114
ren_browser/tabs/tabs.py
Normal 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
111
ren_browser/ui/ui.py
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user