2021-08-27 19:58:14 +02:00
import RNS
2024-11-15 16:29:46 +01:00
import LXMF
2025-05-11 16:42:50 +02:00
import io
2021-09-10 21:33:29 +02:00
import os
2021-08-27 19:58:14 +02:00
import time
2026-09-09 19:32:41 +02:00
import bisect
2021-08-27 19:58:14 +02:00
import urwid
2025-05-11 16:42:50 +02:00
import shutil
2021-08-27 19:58:14 +02:00
import nomadnet
2021-09-17 19:04:23 +02:00
import subprocess
2021-08-27 19:58:14 +02:00
import threading
2025-12-29 19:47:08 +01:00
from threading import Lock
2025-05-11 20:59:26 +02:00
from . MicronParser import markup_to_attrmaps , make_style , default_state
2022-07-04 16:31:01 +02:00
from nomadnet . Directory import DirectoryEntry
2026-09-02 22:48:31 +02:00
from nomadnet . Conversation import ConversationMessage
2021-08-27 19:58:14 +02:00
from nomadnet . vendor . Scrollable import *
2025-12-27 13:29:10 +01:00
from nomadnet . util import strip_modifiers
2026-05-06 18:44:17 +02:00
from nomadnet . util import sanitize_name
2026-09-05 23:41:50 +02:00
from nomadnet . ui . textui . images import _termlib
2026-05-21 16:16:06 -04:00
from . Helpers import ClickableIcon , osc52_copy
2026-05-22 20:17:47 -04:00
from . ReadlineEdit import ReadlineMixin , ReadlineEdit
2021-08-27 19:58:14 +02:00
2026-09-06 13:37:35 +02:00
IMGLOAD_NEVER = 0x00
IMGLOAD_MANUAL = 0x01
IMGLOAD_AUTO = 0x02
IMGLOAD_ALWAYS = 0x03
2021-08-27 19:58:14 +02:00
class BrowserFrame ( urwid . Frame ) :
def keypress ( self , size , key ) :
if key == " ctrl w " :
self . delegate . disconnect ( )
2021-09-17 11:47:41 +02:00
elif key == " ctrl d " :
self . delegate . back ( )
elif key == " ctrl f " :
self . delegate . forward ( )
elif key == " ctrl r " :
self . delegate . reload ( )
2021-09-17 12:23:13 +02:00
elif key == " ctrl u " :
self . delegate . url_dialog ( )
2026-09-06 12:29:52 +02:00
elif key == " ctrl l " :
self . delegate . load_images ( )
2026-09-06 18:23:22 +02:00
elif key == " ctrl x " :
self . delegate . load_images ( force_reload = True )
2022-07-04 16:31:01 +02:00
elif key == " ctrl s " :
self . delegate . save_node_dialog ( )
2023-08-13 20:54:29 +02:00
elif key == " ctrl b " :
self . delegate . save_node_dialog ( )
2022-07-04 17:34:12 +02:00
elif key == " ctrl g " :
nomadnet . NomadNetworkApp . get_shared_instance ( ) . ui . main_display . sub_displays . network_display . toggle_fullscreen ( )
2026-05-21 16:16:06 -04:00
elif key == " ctrl y " :
2026-05-21 21:24:15 -04:00
if self . delegate . app . config [ " textui " ] [ " clipboard_copy " ] :
self . delegate . copy_url ( )
2024-01-18 11:58:22 +01:00
elif self . focus_position == " body " :
2023-02-04 11:49:36 +01:00
if key == " down " or key == " up " :
try :
2026-09-09 19:32:41 +02:00
if hasattr ( self . delegate , " page_list " ) and self . delegate . page_list :
2023-02-04 11:49:36 +01:00
def df ( loop , user_data ) :
st = None
2026-09-09 19:32:41 +02:00
if self . delegate . page_list :
nf = self . delegate . page_list . focus
2025-12-28 23:41:08 +01:00
if hasattr ( nf , " key_timeout " ) :
st = nf
elif hasattr ( nf , " original_widget " ) :
no = nf . original_widget
if hasattr ( no , " original_widget " ) :
st = no . original_widget
else :
if hasattr ( no , " key_timeout " ) :
st = no
if st and hasattr ( st , " key_timeout " ) and hasattr ( st , " keypress " ) and callable ( st . keypress ) :
st . keypress ( None , None )
2023-02-04 11:49:36 +01:00
nomadnet . NomadNetworkApp . get_shared_instance ( ) . ui . loop . set_alarm_in ( 0.25 , df )
except Exception as e :
RNS . log ( " Error while setting up cursor timeout. The contained exception was: " + str ( e ) , RNS . LOG_ERROR )
2021-08-27 19:58:14 +02:00
return super ( BrowserFrame , self ) . keypress ( size , key )
2023-02-04 12:03:46 +01:00
2021-08-27 19:58:14 +02:00
else :
return super ( BrowserFrame , self ) . keypress ( size , key )
2026-09-09 19:32:41 +02:00
class PageList ( urwid . ListBox ) :
def __init__ ( self , body ) :
super ( ) . __init__ ( body )
self . _page_size = ( 0 , 0 )
self . _page_focus = None
self . _page_layouts = { }
def invalidate_layout ( self ) :
self . _page_layouts = { }
self . _invalidate ( )
def _layout ( self , maxcol ) :
2026-09-11 20:05:17 +02:00
offsets = self . _page_layouts . get ( maxcol )
if offsets is None or len ( offsets ) != len ( self . body ) + 1 :
2026-09-09 19:32:41 +02:00
layout_started = time . time ( )
offsets = [ 0 ]
for widget in self . body :
offsets . append ( offsets [ - 1 ] + widget . rows ( ( maxcol , ) , True ) )
self . _page_layouts [ maxcol ] = offsets
# RNS.log(f" rebuilt {len(self.body)} widgets, {offsets[-1]} rows, width {maxcol}, {(time.time()-layout_started)*1000:.1f}ms", RNS.LOG_DEBUG)
2026-09-11 20:05:17 +02:00
return offsets
def calculate_visible ( self , size , focus = False ) :
visible = super ( ) . calculate_visible ( size , focus )
if visible is not None and self . body :
self . _sync_layout ( size [ 0 ] , visible )
return visible
def _sync_layout ( self , maxcol , visible ) :
# Validate the cached layout against the live geometry of the
# currently visible widgets, and delta-update the cached offsets when a
# visible widget has changed height since the layout was built without
# rescanning the entire document on every render.
if not self . body or not self . _page_layouts : return
offsets = self . _page_layouts . get ( maxcol )
if offsets is None or len ( offsets ) != len ( self . body ) + 1 : return
def sync_position ( pos , live_rows ) :
if pos < 0 or pos > = len ( self . body ) or live_rows < 0 : return
cached_rows = offsets [ pos + 1 ] - offsets [ pos ]
if cached_rows == live_rows : return
delta = live_rows - cached_rows
for i in range ( pos + 1 , len ( offsets ) ) : offsets [ i ] + = delta
# Other cached widths may also be affected by this change. Sync
# them using the changed widget's live rows at their widths.
for width , other in list ( self . _page_layouts . items ( ) ) :
if width == maxcol or len ( other ) != len ( offsets ) : continue
other_cached = other [ pos + 1 ] - other [ pos ]
other_live = self . body [ pos ] . rows ( ( width , ) , True )
if other_cached == other_live : continue
other_delta = other_live - other_cached
for i in range ( pos + 1 , len ( other ) ) : other [ i ] + = other_delta
middle = visible . middle
sync_position ( middle . focus_pos , middle . focus_rows )
for item in visible . top . fill : sync_position ( item . position , item . rows )
for item in visible . bottom . fill : sync_position ( item . position , item . rows )
2026-09-09 19:32:41 +02:00
def render ( self , size , focus = False ) :
self . _page_size = size
if self . body and self . focus is not self . _page_focus :
self . _page_focus = self . focus
widget = self . focus
while widget is not None :
widget . _invalidate ( )
widget = getattr ( widget , " original_widget " , None )
return super ( ) . render ( size , focus )
def rows_max ( self , size = None , focus = False ) :
size = size or self . _page_size
return self . _layout ( size [ 0 ] ) [ - 1 ]
def get_scrollpos ( self , size = None , focus = False ) :
size = size or self . _page_size
if not self . body or not size [ 1 ] : return 0
middle , top , bottom = self . calculate_visible ( size , focus )
if middle is None : return 0
return self . _layout ( size [ 0 ] ) [ self . top_position ( size ) ] + top . trim
def set_scrollpos ( self , position ) :
size = self . _page_size
maxcol , maxrow = size
if not self . body or not maxrow : return
2026-09-11 20:05:17 +02:00
2026-09-09 19:32:41 +02:00
offsets = self . _layout ( maxcol )
position = max ( 0 , min ( int ( position ) , max ( 0 , offsets [ - 1 ] - maxrow ) ) )
pos = bisect . bisect_left ( offsets , position , 0 , len ( self . body ) )
if pos == len ( self . body ) or offsets [ pos ] - position > = maxrow : pos - = 1
2026-09-11 20:05:17 +02:00
# Guard against the cached layout being inconsistent
# with the live widget geometry. Without it, change_focus
# can be called with an offset that the live target widget
# cannot satisfy, raising ListBoxError.
pos = max ( 0 , min ( pos , len ( self . body ) - 1 ) )
offset_inset = offsets [ pos ] - position
if offset_inset > maxrow - 1 : offset_inset = maxrow - 1
elif offset_inset < 0 :
tgt_rows = max ( 1 , self . body [ pos ] . rows ( ( maxcol , ) , True ) )
if offset_inset < 1 - tgt_rows : offset_inset = 1 - tgt_rows
self . change_focus ( size , pos , offset_inset )
2026-09-09 19:32:41 +02:00
def top_position ( self , size = None ) :
size = size or self . _page_size
if not self . body or not size [ 1 ] : return 0
middle , top , bottom = self . calculate_visible ( size , True )
if middle is None : return 0
return top . fill [ - 1 ] . position if top . fill else middle . focus_pos
def scroll ( self , rows ) :
self . set_scrollpos ( self . get_scrollpos ( ) + rows )
def focused_widget_name ( self ) :
widget = self . focus
while getattr ( widget , " original_widget " , None ) is not None : widget = widget . original_widget
return type ( widget ) . __name__
def keypress ( self , size , key ) :
self . _page_size = size
key_started = time . time ( )
focus_before = self . focus_position if self . body else None
scroll_before = self . get_scrollpos ( size , True )
unhandled_key = self . handle_key ( size , key )
# RNS.log(f" {key}: {unhandled_key} focus {focus_before} -> {self.focus_position if self.body else None} ({self.focused_widget_name()}) scrollpos {scroll_before}->{self.get_scrollpos(size, True)}, {(time.time()-key_started) * 1000:.1f}ms", RNS.LOG_DEBUG)
return unhandled_key
def handle_key ( self , size , key ) :
2026-09-10 14:47:20 +02:00
try :
if key in ( " page up " , " page down " , " home " , " end " ) :
focus_widget = self . focus
if focus_widget is not None and focus_widget . selectable ( ) :
key = focus_widget . keypress ( ( size [ 0 ] , ) , key )
if key is None :
self . make_cursor_visible ( size )
return None
if key == " page down " : self . scroll ( size [ 1 ] - 1 )
elif key == " page up " : self . scroll ( 1 - size [ 1 ] )
elif key == " home " : self . set_scrollpos ( 0 )
elif key == " end " : self . set_scrollpos ( self . rows_max ( size ) )
return None
unhandled_key = super ( ) . keypress ( size , key )
if key in ( " up " , " down " ) : return None
return unhandled_key
except Exception as e :
RNS . log ( f " Error while handling browser keypress: { e } " , RNS . LOG_ERROR )
RNS . trace_exception ( e )
2026-09-09 19:32:41 +02:00
return None
def mouse_event ( self , size , event , button , col , row , focus ) :
self . _page_size = size
if button in ( 4 , 5 ) : return False
return super ( ) . mouse_event ( size , event , button , col , row , focus )
2021-08-27 19:58:14 +02:00
class Browser :
DEFAULT_PATH = " /page/index.mu "
2021-09-03 14:17:00 +02:00
DEFAULT_TIMEOUT = 10
2021-09-17 18:45:08 +02:00
DEFAULT_CACHE_TIME = 12 * 60 * 60
2021-08-27 19:58:14 +02:00
NO_PATH = 0x00
PATH_REQUESTED = 0x01
ESTABLISHING_LINK = 0x02
2021-09-03 18:55:34 +02:00
LINK_TIMEOUT = 0x03
LINK_ESTABLISHED = 0x04
REQUESTING = 0x05
REQUEST_SENT = 0x06
REQUEST_FAILED = 0x07
REQUEST_TIMEOUT = 0x08
RECEIVING_RESPONSE = 0x09
2021-08-27 19:58:14 +02:00
DISCONECTED = 0xFE
2021-09-03 23:34:39 +02:00
DONE = 0xFF
2021-08-27 19:58:14 +02:00
def __init__ ( self , app , app_name , aspects , destination_hash = None , path = None , auth_identity = None , delegate = None ) :
self . app = app
self . g = self . app . ui . glyphs
self . delegate = delegate
self . app_name = app_name
self . aspects = aspects
self . destination_hash = destination_hash
self . path = path
2023-02-15 10:09:25 +01:00
self . request_data = None
2021-08-27 19:58:14 +02:00
self . timeout = Browser . DEFAULT_TIMEOUT
self . last_keypress = None
self . link = None
2021-09-16 21:56:59 +02:00
self . loopback = None
2021-08-27 19:58:14 +02:00
self . status = Browser . DISCONECTED
2025-05-11 17:21:43 +02:00
self . progress_updated_at = None
self . previous_progress = 0
2021-08-28 20:12:03 +02:00
self . response_progress = 0
2025-05-11 17:21:43 +02:00
self . response_speed = None
2021-08-28 20:12:03 +02:00
self . response_size = None
self . response_transfer_size = None
2025-05-11 20:59:26 +02:00
self . page_background_color = None
self . page_foreground_color = None
2021-09-10 21:33:29 +02:00
self . saved_file_name = None
2026-05-09 15:13:56 +02:00
self . saved_file_size = 0
2026-05-04 23:35:23 +02:00
self . file_saved_at = 0
self . file_save_notice_timeout = 3
2021-08-27 19:58:14 +02:00
self . page_data = None
self . displayed_page_data = None
self . auth_identity = auth_identity
self . display_widget = None
2021-09-11 15:39:46 +02:00
self . link_status_showing = False
self . link_target = None
2021-08-27 19:58:14 +02:00
self . frame = None
self . attr_maps = [ ]
2026-09-09 19:32:41 +02:00
self . page_list = None
self . page_rows = [ ]
2025-12-29 19:47:08 +01:00
self . page_partials = { }
2026-09-05 22:15:10 +02:00
self . page_images = { }
2025-12-29 19:47:08 +01:00
self . updater_running = False
2026-09-05 22:15:10 +02:00
self . image_updater_running = False
2025-12-29 19:47:08 +01:00
self . partial_updater_lock = Lock ( )
2026-09-05 22:15:10 +02:00
self . image_updater_lock = Lock ( )
2026-09-10 18:48:08 +02:00
self . image_cache_lock = Lock ( )
2026-09-06 13:37:35 +02:00
self . image_mode = self . app . config [ " textui " ] [ " image_loading " ]
self . image_rendering_supported = _termlib . is_kitty_supported ( ) and self . image_mode != IMGLOAD_NEVER
2021-08-27 19:58:14 +02:00
self . build_display ( )
2021-09-17 11:47:41 +02:00
self . history = [ ]
self . history_ptr = 0
self . history_inc = False
self . history_dec = False
self . reloading = False
2021-09-17 18:45:08 +02:00
self . loaded_from_cache = False
2021-09-17 11:47:41 +02:00
2021-08-27 19:58:14 +02:00
if self . path == None :
self . path = Browser . DEFAULT_PATH
if self . destination_hash != None :
self . load_page ( )
2021-09-23 19:04:52 +02:00
self . clean_cache ( )
2026-09-05 23:41:50 +02:00
if self . image_rendering_supported : RNS . log ( f " In-browser image rendering supported " , RNS . LOG_DEBUG )
else : RNS . log ( f " In-browser image rendering is not supported " , RNS . LOG_DEBUG )
2021-08-27 19:58:14 +02:00
def current_url ( self ) :
2026-09-10 18:30:14 +02:00
if self . destination_hash == None : return " "
2026-05-20 22:28:32 -04:00
path = " " if self . path == None else self . path
url = RNS . hexrep ( self . destination_hash , delimit = False ) + " : " + path
if isinstance ( self . request_data , dict ) and self . request_data :
parts = [ ]
for k , v in self . request_data . items ( ) :
2026-09-10 18:30:14 +02:00
if not isinstance ( k , str ) : continue
if not k . startswith ( " var_ " ) : continue
2026-05-20 22:28:32 -04:00
parts . append ( k [ 4 : ] + " = " + str ( v ) )
2026-09-10 18:30:14 +02:00
if parts : url + = " ` " + " | " . join ( parts )
2026-05-20 22:28:32 -04:00
return url
2021-08-27 19:58:14 +02:00
2021-09-17 18:45:08 +02:00
def url_hash ( self , url ) :
2026-09-10 18:30:14 +02:00
if url == None : return None
2021-09-17 18:45:08 +02:00
else :
url = url . encode ( " utf-8 " )
return RNS . hexrep ( RNS . Identity . full_hash ( url ) , delimit = False )
2021-09-11 15:39:46 +02:00
2026-05-02 02:01:26 +02:00
def marked_link ( self , link_target , link_fields = None ) :
if link_fields :
fields_str = " | " . join ( link_fields )
link_target = f " { link_target } ` { fields_str } "
2021-09-16 19:54:32 +02:00
if self . status == Browser . DONE :
self . link_target = link_target
self . app . ui . loop . set_alarm_in ( 0.1 , self . marked_link_job )
2021-09-11 15:39:46 +02:00
def marked_link_job ( self , sender , event ) :
link_target = self . link_target
2026-05-04 23:35:23 +02:00
can_display = time . time ( ) > self . file_saved_at + self . file_save_notice_timeout
2021-09-11 15:39:46 +02:00
if link_target == None :
if self . link_status_showing :
self . browser_footer = self . make_status_widget ( )
self . frame . contents [ " footer " ] = ( self . browser_footer , self . frame . options ( ) )
self . link_status_showing = False
2025-05-11 20:59:26 +02:00
if self . page_background_color != None or self . page_foreground_color != None :
style_name = make_style ( default_state ( fg = self . page_foreground_color , bg = self . page_background_color ) )
self . browser_footer . set_attr_map ( { None : style_name } )
2021-09-11 15:39:46 +02:00
else :
2026-05-04 23:35:23 +02:00
if can_display :
self . link_status_showing = True
lt_str = str ( link_target )
2026-05-22 00:40:24 +02:00
lmax = self . _content_cols ( )
lstr = " Link to " + lt_str
if len ( lstr ) > lmax : lstr = lstr [ : lmax - 1 ] + " … "
self . browser_footer = urwid . AttrMap ( urwid . Pile ( [ urwid . Divider ( self . g [ " divider1 " ] ) , urwid . Text ( lstr ) ] ) , " browser_controls " )
2026-05-04 23:35:23 +02:00
self . frame . contents [ " footer " ] = ( self . browser_footer , self . frame . options ( ) )
if self . page_background_color != None or self . page_foreground_color != None :
style_name = make_style ( default_state ( fg = self . page_foreground_color , bg = self . page_background_color ) )
self . browser_footer . set_attr_map ( { None : style_name } )
2021-09-11 15:39:46 +02:00
2022-07-04 19:25:17 +02:00
def expand_shorthands ( self , destination_type ) :
if destination_type == " nnn " :
return " nomadnetwork.node "
elif destination_type == " lxmf " :
return " lxmf.delivery "
2026-05-11 00:51:26 -04:00
elif destination_type == " rrc " :
return " rrc.hub.session "
2022-07-04 19:25:17 +02:00
else :
return destination_type
2023-02-15 10:09:25 +01:00
def handle_link ( self , link_target , link_data = None ) :
2025-12-29 22:21:51 +01:00
partial_ids = None
2023-02-15 10:09:25 +01:00
request_data = None
if link_data != None :
link_fields = [ ]
request_data = { }
all_fields = True if " * " in link_data else False
for e in link_data :
if " = " in e :
c = e . split ( " = " )
if len ( c ) == 2 :
request_data [ " var_ " + str ( c [ 0 ] ) ] = str ( c [ 1 ] )
else :
link_fields . append ( e )
2024-11-25 18:15:42 -05:00
def recurse_down ( w ) :
if isinstance ( w , list ) :
for t in w :
recurse_down ( t )
elif isinstance ( w , tuple ) :
for t in w :
recurse_down ( t )
elif hasattr ( w , " contents " ) :
recurse_down ( w . contents )
elif hasattr ( w , " original_widget " ) :
recurse_down ( w . original_widget )
elif hasattr ( w , " _original_widget " ) :
recurse_down ( w . _original_widget )
else :
if hasattr ( w , " field_name " ) and ( all_fields or w . field_name in link_fields ) :
field_key = " field_ " + w . field_name
if isinstance ( w , urwid . Edit ) :
request_data [ field_key ] = w . edit_text
elif isinstance ( w , urwid . RadioButton ) :
if w . state :
user_data = getattr ( w , " field_value " , None )
if user_data is not None :
request_data [ field_key ] = user_data
elif isinstance ( w , urwid . CheckBox ) :
user_data = getattr ( w , " field_value " , " 1 " )
if w . state :
existing_value = request_data . get ( field_key , ' ' )
if existing_value :
# Concatenate the new value with the existing one
request_data [ field_key ] = existing_value + ' , ' + user_data
else :
# Initialize the field with the current value
request_data [ field_key ] = user_data
else :
pass # do nothing if checkbox is not check
2023-02-14 19:46:21 +01:00
recurse_down ( self . attr_maps )
2026-09-03 15:13:15 +02:00
RNS . log ( " Including request data: " + str ( request_data ) , RNS . LOG_EXTREME )
2023-02-14 19:46:21 +01:00
2026-05-20 16:16:24 -04:00
# In-document anchor link (#name or empty #)
if link_target . startswith ( " # " ) :
2026-05-28 14:11:34 +02:00
def df ( loop , user_data ) : self . _jump_to_anchor ( link_target [ 1 : ] )
nomadnet . NomadNetworkApp . get_shared_instance ( ) . ui . loop . set_alarm_in ( 0.0 , df )
2026-05-20 16:16:24 -04:00
return
2026-05-11 00:51:26 -04:00
# rrc://<hex>[:<dest_name>]/<room> URL form
if link_target . startswith ( " rrc:// " ) :
self . handle_rrc_link ( link_target [ 6 : ] )
return
2022-07-04 19:25:17 +02:00
components = link_target . split ( " @ " )
destination_type = None
if len ( components ) == 2 :
destination_type = self . expand_shorthands ( components [ 0 ] )
link_target = components [ 1 ]
2025-12-29 22:21:51 +01:00
elif link_target . startswith ( " p: " ) :
comps = link_target . split ( " : " )
if len ( comps ) > 1 : partial_ids = comps [ 1 : ]
destination_type = " partial "
2022-07-04 19:25:17 +02:00
else :
destination_type = " nomadnetwork.node "
link_target = components [ 0 ]
if destination_type == " nomadnetwork.node " :
if self . status > = Browser . DISCONECTED :
RNS . log ( " Browser handling link to: " + str ( link_target ) , RNS . LOG_DEBUG )
2023-02-04 12:03:46 +01:00
self . browser_footer = urwid . Text ( " Opening link to: " + str ( link_target ) )
2022-07-04 19:25:17 +02:00
try :
2023-02-15 10:09:25 +01:00
self . retrieve_url ( link_target , request_data )
2022-07-04 19:25:17 +02:00
except Exception as e :
self . browser_footer = urwid . Text ( " Could not open link: " + str ( e ) )
self . frame . contents [ " footer " ] = ( self . browser_footer , self . frame . options ( ) )
else :
RNS . log ( " Browser already handling link, cannot handle link to: " + str ( link_target ) , RNS . LOG_DEBUG )
elif destination_type == " lxmf.delivery " :
RNS . log ( " Passing LXMF link to handler " , RNS . LOG_DEBUG )
self . handle_lxmf_link ( link_target )
2026-05-11 00:51:26 -04:00
elif destination_type == " rrc.hub.session " :
RNS . log ( " Passing RRC link to handler " , RNS . LOG_DEBUG )
self . handle_rrc_link ( link_target )
2025-12-29 22:21:51 +01:00
elif destination_type == " partial " :
if partial_ids != None and len ( partial_ids ) > 0 : self . handle_partial_updates ( partial_ids )
2022-07-04 19:25:17 +02:00
else :
RNS . log ( " No known handler for destination type " + str ( destination_type ) , RNS . LOG_DEBUG )
self . browser_footer = urwid . Text ( " Could not open link: " + " No known handler for destination type " + str ( destination_type ) )
self . frame . contents [ " footer " ] = ( self . browser_footer , self . frame . options ( ) )
2026-05-20 16:16:24 -04:00
def _jump_to_anchor ( self , name ) :
anchors = getattr ( self . attr_maps , " anchors " , None ) or { }
header_rows = getattr ( self . attr_maps , " header_rows " , None ) or [ ]
target_idx = None
if name :
target_idx = anchors . get ( name )
if target_idx is None :
self . browser_footer = urwid . Text ( " Unknown anchor: # " + name )
self . frame . contents [ " footer " ] = ( self . browser_footer , self . frame . options ( ) )
return
else :
current = 0
try :
2026-09-09 19:32:41 +02:00
current = self . page_rows [ self . page_list . top_position ( ) ]
2026-05-20 16:16:24 -04:00
except Exception :
current = 0
for hr in header_rows :
2026-09-09 19:32:41 +02:00
if hr > current :
2026-05-20 16:16:24 -04:00
target_idx = hr
break
if target_idx is None :
return
2026-06-08 05:00:46 -04:00
self . reveal_index ( int ( target_idx ) )
2026-05-20 16:16:24 -04:00
try :
2026-09-09 19:32:41 +02:00
self . page_list . set_focus ( self . page_rows . index ( int ( target_idx ) ) )
self . page_list . set_focus_valign ( " top " )
2026-05-21 23:56:59 +02:00
except Exception as e : RNS . log ( " Anchor jump failed: " + str ( e ) , RNS . LOG_ERROR )
2026-05-20 16:16:24 -04:00
def _content_cols ( self ) :
try :
2026-05-21 23:56:59 +02:00
lw = self . delegate . given_list_width
screen_cols = self . app . ui . loop . screen . get_cols_rows ( ) [ 0 ]
browser_cols = screen_cols - lw - 2
cols = browser_cols
2026-06-08 05:00:46 -04:00
except Exception as e :
2026-05-21 23:56:59 +02:00
RNS . log ( f " Could not calculate browser width, anchors will be inaccurate: { e } " , RNS . LOG_WARNING )
RNS . trace_exception ( e )
2026-05-20 16:16:24 -04:00
cols = 100
2026-05-21 23:56:59 +02:00
return max ( 40 , cols )
2026-05-20 16:16:24 -04:00
2022-07-04 19:25:17 +02:00
def handle_lxmf_link ( self , link_target ) :
try :
2026-05-06 18:44:17 +02:00
def san ( name ) :
if self . app . config [ " textui " ] [ " sanitize_names " ] : return sanitize_name ( name )
else : return strip_modifiers ( name )
2022-07-04 19:25:17 +02:00
if not type ( link_target ) is str :
raise ValueError ( " Invalid data type for LXMF link " )
if len ( link_target ) != ( RNS . Reticulum . TRUNCATED_HASHLENGTH / / 8 ) * 2 :
raise ValueError ( " Invalid length for LXMF link " )
2021-09-03 23:34:39 +02:00
try :
2022-07-04 19:25:17 +02:00
bytes . fromhex ( link_target )
2021-09-03 23:34:39 +02:00
except Exception as e :
2022-07-04 19:25:17 +02:00
raise ValueError ( " Could not decode destination hash from LXMF link " )
existing_conversations = nomadnet . Conversation . conversation_list ( self . app )
source_hash_text = link_target
display_name_data = RNS . Identity . recall_app_data ( bytes . fromhex ( source_hash_text ) )
2021-09-03 23:34:39 +02:00
2022-07-04 19:25:17 +02:00
display_name = None
if display_name_data != None :
2026-05-06 18:44:17 +02:00
display_name = san ( LXMF . display_name_from_app_data ( display_name_data ) )
2022-07-04 19:25:17 +02:00
if not source_hash_text in [ c [ 0 ] for c in existing_conversations ] :
entry = DirectoryEntry ( bytes . fromhex ( source_hash_text ) , display_name = display_name )
self . app . directory . remember ( entry )
new_conversation = nomadnet . Conversation ( source_hash_text , nomadnet . NomadNetworkApp . get_shared_instance ( ) , initiator = True )
self . app . ui . main_display . sub_displays . conversations_display . update_conversation_list ( )
self . app . ui . main_display . sub_displays . conversations_display . display_conversation ( None , source_hash_text )
self . app . ui . main_display . show_conversations ( None )
except Exception as e :
RNS . log ( " Error while starting conversation from link. The contained exception was: " + str ( e ) , RNS . LOG_ERROR )
self . browser_footer = urwid . Text ( " Could not open LXMF link: " + str ( e ) )
self . frame . contents [ " footer " ] = ( self . browser_footer , self . frame . options ( ) )
2021-08-27 19:58:14 +02:00
2026-05-11 00:51:26 -04:00
def handle_rrc_link ( self , link_target ) :
try :
if not isinstance ( link_target , str ) :
raise ValueError ( " invalid RRC link payload " )
rest = link_target . strip ( )
if rest . startswith ( " / " ) :
rest = rest [ 1 : ]
hub_part , _ , room = rest . partition ( " / " )
hex_part , _ , dest = hub_part . partition ( " : " )
hex_part = hex_part . strip ( )
dest = dest . strip ( ) or None
try :
hub_hash = bytes . fromhex ( hex_part )
except Exception :
raise ValueError ( " invalid hub hash " )
expected_len = RNS . Reticulum . TRUNCATED_HASHLENGTH / / 8
if len ( hub_hash ) != expected_len :
raise ValueError ( " hub hash must be " + str ( expected_len ) + " bytes " )
room = room . strip ( ) . lstrip ( " # " ) . strip ( )
2026-05-14 12:08:42 -04:00
room_norm = room . lower ( ) if room else None
2026-05-11 00:51:26 -04:00
existing = self . app . rrc . find_hub ( hub_hash , dest_name = dest )
self . app . ui . main_display . show_channels ( None )
channels = self . app . ui . main_display . sub_displays . channels_display
if existing is not None :
channels . update_list ( )
if room_norm :
channels . _select_room ( None , ( existing , room_norm ) )
else :
channels . _select_hub ( None , existing )
return
channels . confirm_new_hub_dialog ( hub_hash , dest , room_norm )
except Exception as e :
RNS . log ( " Could not open RRC link: " + str ( e ) , RNS . LOG_ERROR )
self . browser_footer = urwid . Text ( " Could not open RRC link: " + str ( e ) )
self . frame . contents [ " footer " ] = ( self . browser_footer , self . frame . options ( ) )
2021-08-27 19:58:14 +02:00
def micron_released_focus ( self ) :
if self . delegate != None :
self . delegate . focus_lists ( )
def build_display ( self ) :
self . browser_header = urwid . Text ( " " )
self . browser_footer = urwid . Text ( " " )
2026-09-09 19:32:41 +02:00
self . page_list = None
self . page_rows = [ ]
2025-12-29 19:47:08 +01:00
self . page_partials = { }
2026-09-05 22:15:10 +02:00
self . page_images = { }
2024-01-18 11:58:22 +01:00
self . browser_body = urwid . Filler (
urwid . Text ( " Disconnected \n " + self . g [ " arrow_l " ] + " " + self . g [ " arrow_r " ] , align = urwid . CENTER ) ,
urwid . MIDDLE ,
)
2021-08-27 19:58:14 +02:00
self . frame = BrowserFrame ( self . browser_body , header = self . browser_header , footer = self . browser_footer )
self . frame . delegate = self
2021-09-02 18:45:17 +02:00
self . linebox = urwid . LineBox ( self . frame , title = " Remote Node " )
2026-05-06 18:44:17 +02:00
self . display_widget = urwid . AttrMap ( self . linebox , " browser_inactive " )
2021-08-27 19:58:14 +02:00
def make_status_widget ( self ) :
2021-08-28 20:12:03 +02:00
if self . response_progress > 0 :
2025-05-11 20:59:26 +02:00
if self . page_background_color != None or self . page_foreground_color != None :
style_name = make_style ( default_state ( fg = self . page_foreground_color , bg = self . page_background_color ) )
style_name_inverted = make_style ( default_state ( bg = self . page_foreground_color , fg = self . page_background_color ) )
else :
style_name = " progress_empty "
style_name_inverted = " progress_full "
pb = ResponseProgressBar ( style_name , style_name_inverted , current = self . response_progress , done = 1.0 , satt = None , owner = self )
2021-08-28 20:12:03 +02:00
widget = urwid . Pile ( [ urwid . Divider ( self . g [ " divider1 " ] ) , pb ] )
else :
widget = urwid . Pile ( [ urwid . Divider ( self . g [ " divider1 " ] ) , urwid . Text ( self . status_text ( ) ) ] )
2021-08-28 21:30:27 +02:00
return urwid . AttrMap ( widget , " browser_controls " )
2021-08-27 19:58:14 +02:00
def make_control_widget ( self ) :
2026-05-21 21:24:15 -04:00
clipboard_copy_enabled = self . app . config [ " textui " ] [ " clipboard_copy " ]
2026-05-21 16:16:06 -04:00
copy_glyph = self . g . get ( " copy " , " [C] " )
2026-05-21 21:24:15 -04:00
lstr = self . g [ " node " ] + " " + self . current_url ( )
if clipboard_copy_enabled :
lmax = self . _content_cols ( ) - len ( copy_glyph ) - len ( copy_glyph ) - 1
else :
lmax = self . _content_cols ( ) - 1
2026-05-22 00:40:24 +02:00
if len ( lstr ) > lmax : lstr = lstr [ : lmax - 1 ] + " … "
url_text = urwid . Text ( lstr )
2026-05-21 21:24:15 -04:00
if clipboard_copy_enabled :
copy_icon = ClickableIcon ( copy_glyph , on_click = lambda : self . copy_url ( ) )
copy_width = len ( copy_glyph ) + 2
header_row = urwid . Columns ( [
( " weight " , 1 , url_text ) ,
( copy_width , urwid . Padding ( copy_icon , left = 1 , right = 1 ) ) ,
] )
else :
header_row = url_text
2026-05-21 16:16:06 -04:00
return urwid . AttrMap ( urwid . Pile ( [ header_row , urwid . Divider ( self . g [ " divider1 " ] ) ] ) , " browser_controls " )
2021-08-27 19:58:14 +02:00
def make_request_failed_widget ( self ) :
def back_action ( sender ) :
self . status = Browser . DONE
self . destination_hash = self . previous_destination_hash
self . path = self . previous_path
self . update_display ( )
columns = urwid . Columns ( [
2024-01-18 11:58:22 +01:00
( urwid . WEIGHT , 0.5 , urwid . Text ( " " ) ) ,
2021-08-27 19:58:14 +02:00
( 8 , urwid . Button ( " Back " , on_press = back_action ) ) ,
2024-01-18 11:58:22 +01:00
( urwid . WEIGHT , 0.5 , urwid . Text ( " " ) ) ,
2021-08-27 19:58:14 +02:00
] )
2021-08-28 20:12:03 +02:00
if len ( self . attr_maps ) > 0 :
pile = urwid . Pile ( [
2024-01-18 11:58:22 +01:00
urwid . Text ( " ! \n \n " + self . status_text ( ) + " \n " , align = urwid . CENTER ) ,
columns
2021-08-28 20:12:03 +02:00
] )
else :
2024-01-18 11:58:22 +01:00
pile = urwid . Pile ( [ urwid . Text ( " ! \n \n " + self . status_text ( ) , align = urwid . CENTER ) ] )
2021-08-27 19:58:14 +02:00
2024-01-18 11:58:22 +01:00
return urwid . Filler ( pile , urwid . MIDDLE )
2021-08-27 19:58:14 +02:00
def update_display ( self ) :
if self . status == Browser . DISCONECTED :
2026-05-06 18:44:17 +02:00
self . display_widget . set_attr_map ( { None : " browser_inactive " } )
2026-09-09 19:32:41 +02:00
self . page_list = None
self . page_rows = [ ]
2025-12-29 19:47:08 +01:00
self . page_partials = { }
2026-09-05 22:15:10 +02:00
self . page_images = { }
2024-01-18 11:58:22 +01:00
self . browser_body = urwid . Filler (
urwid . Text ( " Disconnected \n " + self . g [ " arrow_l " ] + " " + self . g [ " arrow_r " ] , align = urwid . CENTER ) ,
urwid . MIDDLE ,
)
2021-08-27 19:58:14 +02:00
self . browser_footer = urwid . Text ( " " )
self . browser_header = urwid . Text ( " " )
2021-09-02 18:45:17 +02:00
self . linebox . set_title ( " Remote Node " )
2021-08-27 19:58:14 +02:00
else :
self . display_widget . set_attr_map ( { None : " body_text " } )
self . browser_header = self . make_control_widget ( )
2025-05-11 20:59:26 +02:00
if self . page_background_color != None or self . page_foreground_color != None :
style_name = make_style ( default_state ( fg = self . page_foreground_color , bg = self . page_background_color ) )
self . browser_header . set_attr_map ( { None : style_name } )
2021-09-25 15:47:42 +02:00
if self . destination_hash != None :
remote_display_string = self . app . directory . simplest_display_str ( self . destination_hash )
else :
remote_display_string = " "
2021-09-19 00:30:07 +02:00
if self . loopback != None and remote_display_string == RNS . prettyhexrep ( self . loopback ) :
2021-09-16 21:56:59 +02:00
remote_display_string = self . app . node . name
2021-09-17 11:47:41 +02:00
2021-09-16 21:56:59 +02:00
self . linebox . set_title ( remote_display_string )
2021-08-27 19:58:14 +02:00
if self . status == Browser . DONE :
self . browser_footer = self . make_status_widget ( )
self . update_page_display ( )
2025-05-11 20:59:26 +02:00
if self . page_background_color != None or self . page_foreground_color != None :
style_name = make_style ( default_state ( fg = self . page_foreground_color , bg = self . page_background_color ) )
self . browser_body . set_attr_map ( { None : style_name } )
self . browser_footer . set_attr_map ( { None : style_name } )
self . browser_header . set_attr_map ( { None : style_name } )
self . display_widget . set_attr_map ( { None : style_name } )
2021-08-28 20:12:03 +02:00
2021-09-03 18:55:34 +02:00
elif self . status == Browser . LINK_TIMEOUT :
self . browser_body = self . make_request_failed_widget ( )
self . browser_footer = urwid . Text ( " " )
2021-08-27 19:58:14 +02:00
elif self . status < = Browser . REQUEST_SENT :
if len ( self . attr_maps ) == 0 :
2024-01-18 11:58:22 +01:00
self . browser_body = urwid . Filler (
urwid . Text ( " Retrieving \n [ " + self . current_url ( ) + " ] " , align = urwid . CENTER ) ,
urwid . MIDDLE ,
)
2021-08-28 20:12:03 +02:00
2021-08-27 19:58:14 +02:00
self . browser_footer = self . make_status_widget ( )
2025-05-11 20:59:26 +02:00
if self . page_background_color != None or self . page_foreground_color != None :
style_name = make_style ( default_state ( fg = self . page_foreground_color , bg = self . page_background_color ) )
self . browser_footer . set_attr_map ( { None : style_name } )
self . browser_header . set_attr_map ( { None : style_name } )
self . display_widget . set_attr_map ( { None : style_name } )
2021-08-28 20:12:03 +02:00
2021-08-27 19:58:14 +02:00
elif self . status == Browser . REQUEST_FAILED :
self . browser_body = self . make_request_failed_widget ( )
self . browser_footer = urwid . Text ( " " )
2021-08-28 20:12:03 +02:00
2021-08-27 19:58:14 +02:00
elif self . status == Browser . REQUEST_TIMEOUT :
self . browser_body = self . make_request_failed_widget ( )
self . browser_footer = urwid . Text ( " " )
2021-08-28 20:12:03 +02:00
2021-08-27 19:58:14 +02:00
else :
pass
self . frame . contents [ " body " ] = ( self . browser_body , self . frame . options ( ) )
self . frame . contents [ " header " ] = ( self . browser_header , self . frame . options ( ) )
self . frame . contents [ " footer " ] = ( self . browser_footer , self . frame . options ( ) )
def update_page_display ( self ) :
2026-09-05 22:15:10 +02:00
self . _purge_page_images ( )
2026-09-09 19:32:41 +02:00
self . page_rows = list ( range ( len ( self . attr_maps ) ) )
self . page_list = PageList ( urwid . SimpleFocusListWalker ( list ( self . attr_maps ) ) )
2025-12-29 19:47:08 +01:00
self . page_partials = { }
2026-09-05 22:15:10 +02:00
self . page_images = { }
2026-09-09 19:32:41 +02:00
self . browser_body = urwid . AttrMap ( ScrollBar ( self . page_list , thumb_char = " \u2503 " , trough_char = " " ) , " scrollbar " )
2025-12-29 19:47:08 +01:00
self . detect_partials ( )
2026-09-06 12:29:52 +02:00
if self . should_load_images ( ) : self . detect_images ( )
2026-06-08 05:00:46 -04:00
self . init_folds ( )
2026-09-09 19:32:41 +02:00
def refresh_page_layout ( self ) :
if self . page_list is not None : self . page_list . invalidate_layout ( )
2026-09-05 22:15:10 +02:00
def image_cache_path ( self , url ) :
2026-09-10 18:30:14 +02:00
destination_hash , path = self . parse_url ( url )
url = f " { RNS . hexrep ( destination_hash , delimit = False ) } : { path } "
2026-09-05 22:15:10 +02:00
url_hash = self . url_hash ( url )
if not url_hash : return None
else :
2026-09-10 18:48:08 +02:00
path = f " { self . app . imagecachepath } / { url_hash } "
2026-09-05 22:15:10 +02:00
return path
def resolve_image ( self , url ) :
cachepath = self . image_cache_path ( url )
if not cachepath : return None
else :
if os . path . exists ( cachepath ) : return cachepath
else : return None
def _purge_page_images ( self ) :
2026-09-05 23:41:50 +02:00
if not self . image_rendering_supported : return
2026-09-05 22:15:10 +02:00
try :
screen = getattr ( getattr ( self . app . ui , " loop " , None ) , " screen " , None )
if hasattr ( screen , " purge_images " ) : screen . purge_images ( )
except Exception : pass
2026-09-06 12:29:52 +02:00
def should_load_images ( self ) :
2026-09-06 13:37:35 +02:00
if self . image_mode == IMGLOAD_NEVER : return False
elif self . image_mode == IMGLOAD_MANUAL : return False
elif self . image_mode == IMGLOAD_ALWAYS : return True
elif self . image_mode == IMGLOAD_AUTO :
if self . destination_hash == self . loopback : return True
rtt_limit = 1.5 ; edr_limit = 10000
if self . link :
rtt = self . link . rtt
edr = self . link . get_expected_rate ( ) or self . last_response_speed ( )
rtt_ok = rtt and rtt < rtt_limit
edr_ok = edr and edr > edr_limit
RNS . log ( f " Checking image load conditions, RTT is { RNS . prettyshorttime ( rtt ) if rtt else ' unknown ' } , EDR is { RNS . prettyspeed ( edr ) if edr else ' unknown ' } " , RNS . LOG_DEBUG )
if rtt_ok and edr_ok : return True
else : return False
2026-09-06 12:29:52 +02:00
return False
2026-09-06 18:23:22 +02:00
def load_images ( self , force_reload = False ) :
2026-09-06 12:29:52 +02:00
if not self . image_rendering_supported : return
2026-09-06 13:37:35 +02:00
elif self . image_mode == IMGLOAD_NEVER : return
2026-09-06 18:23:22 +02:00
else : self . detect_images ( force_reload = force_reload )
2026-09-06 12:29:52 +02:00
2026-09-06 18:23:22 +02:00
def detect_images ( self , force_reload = False ) :
2026-09-05 23:41:50 +02:00
if not self . image_rendering_supported : return
2026-09-05 22:15:10 +02:00
for w in self . attr_maps :
o = w . _original_widget
if hasattr ( o , " _contained_image " ) : o = o . _contained_image
if hasattr ( o , " image_url " ) :
resolved_image = self . resolve_image ( o . image_url )
2026-09-06 18:23:22 +02:00
if resolved_image and force_reload :
RNS . log ( f " Clearing image cache for { o . image_url } ( { resolved_image } ) " , RNS . LOG_DEBUG )
try : os . unlink ( resolved_image )
except Exception as e : RNS . log ( f " Could not clear image cache for { o . image_url } : { e } " , RNS . LOG_ERROR )
resolved_image = None
if force_reload : continue
elif resolved_image and self . image_rendering_supported : o . load ( resolved_image , remote_source = True )
2026-09-05 22:15:10 +02:00
else :
image = { " widget " : o , " id " : o . image_id , " url " : o . image_url , " refresh " : None , " updated " : None ,
" update_requested " : None , " request_id " : None , " link " : None , " failed " : False , " pr_throttle " : 0 ,
" progress_updated " : None , " previous_progress " : 0 }
self . page_images [ o . image_id ] = image
2026-09-09 19:32:41 +02:00
self . refresh_page_layout ( )
2026-09-06 18:23:22 +02:00
if force_reload : self . reload ( )
elif len ( self . page_images ) > 0 : self . start_image_updater ( )
2026-09-05 22:15:10 +02:00
def start_image_updater ( self ) :
2026-09-05 23:41:50 +02:00
if not self . image_rendering_supported : return
2026-09-05 22:15:10 +02:00
if not self . image_updater_running : self . update_images ( )
def update_images ( self , loop = None , user_data = None ) :
2026-09-05 23:41:50 +02:00
if not self . image_rendering_supported : return
2026-09-05 22:15:10 +02:00
def job ( ) :
if self . image_updater_lock . locked ( ) : return
with self . image_updater_lock :
self . image_updater_running = True
for iid in self . page_images :
try :
image = self . page_images [ iid ]
if image [ " failed " ] : continue
if not image [ " updated " ] or ( image [ " refresh " ] != None and time . time ( ) > image [ " updated " ] + image [ " refresh " ] ) :
image [ " update_requested " ] = time . time ( )
self . __load_image ( image )
while not image [ " updated " ] and not image [ " failed " ] and len ( self . page_images ) > 0 : time . sleep ( 0.2 )
except Exception as e : RNS . log ( f " Error updating page image: { e } " , RNS . LOG_ERROR )
if len ( self . page_images ) > 0 : self . app . ui . loop . set_alarm_in ( 1 , self . update_images )
else : self . image_updater_running = False
threading . Thread ( target = job , daemon = True ) . start ( )
def __load_image ( self , image ) :
if image [ " failed " ] == True : return
w = image [ " widget " ]
url = image [ " url " ]
try : image_destination_hash , path = self . parse_url ( image [ " url " ] )
except Exception as e :
RNS . log ( f " Could not parse image URL: { e } " , RNS . LOG_ERROR )
image [ " failed " ] = True
w . notice ( f " Could not load image { url } : { e } " )
return
if image_destination_hash == self . loopback :
local_image = path . replace ( " /media/ " , " " )
local_path = f " { self . app . pagespath } / { local_image } "
2026-09-10 19:14:51 +02:00
if self . app . node :
image_ext = os . path . splitext ( local_path ) [ - 1 ]
if not image_ext in self . app . node . NATIVE_MEDIA_EXTS :
RNS . log ( f " Non-native media type for { local_path } , attempting auto-conversion " , RNS . LOG_DEBUG )
local_path = self . app . node . convert_media_to_webp ( local_path )
2026-09-09 19:32:41 +02:00
if self . image_rendering_supported :
w . load ( local_path )
self . refresh_page_layout ( )
2026-09-05 22:15:10 +02:00
image [ " updated " ] = time . time ( )
return
if not RNS . Transport . has_path ( image_destination_hash ) :
if time . time ( ) < = image [ " pr_throttle " ] : return
else :
image [ " pr_throttle " ] = time . time ( ) + 15
RNS . log ( f " Requesting path for image: { path } " , RNS . LOG_DEBUG )
RNS . Transport . request_path ( image_destination_hash )
pr_time = time . time ( ) + self . path_timeout ( image_destination_hash )
while not RNS . Transport . has_path ( image_destination_hash ) :
if time . time ( ) > pr_time : return
time . sleep ( 0.25 )
if not image [ " link " ] :
if self . link and self . link . destination . hash == image_destination_hash and self . link . status == RNS . Link . ACTIVE :
image [ " link " ] = self . link
RNS . log ( f " Re-using existing page link for image { url } " , RNS . LOG_EXTREME )
if not image [ " link " ] :
for pid in self . page_images :
other_image = self . page_images [ pid ]
if other_image [ " link " ] :
existing_link = other_image [ " link " ]
if existing_link . destination . hash == image_destination_hash and existing_link . status == RNS . Link . ACTIVE :
RNS . log ( f " Re-using existing link for image { url } " , RNS . LOG_EXTREME )
image [ " link " ] = existing_link
break
2026-09-06 17:42:28 +02:00
if not image [ " link " ] or ( image [ " link " ] and image [ " link " ] . status ) == RNS . Link . CLOSED :
2026-09-05 22:15:10 +02:00
RNS . log ( f " Establishing link for image: { image_destination_hash } / { path } " , RNS . LOG_DEBUG )
identity = RNS . Identity . recall ( image_destination_hash )
destination = RNS . Destination ( identity , RNS . Destination . OUT , RNS . Destination . SINGLE , self . app_name , self . aspects )
w . notice ( f " Establishing link... " )
def f_established ( wd ) :
def established ( link ) :
RNS . log ( f " Link established for image: { path } " , RNS . LOG_DEBUG )
wd . notice ( f " Link established " )
return established
def f_closed ( wd ) :
def closed ( link ) :
RNS . log ( f " Link closed for image: { path } " , RNS . LOG_DEBUG )
image [ " link " ] = None
image [ " failed " ] = True
wd . notice ( f " Link closed during transfer " )
return closed
image [ " link " ] = RNS . Link ( destination , established_callback = f_established ( w ) , closed_callback = f_closed ( w ) )
timeout = time . time ( ) + max ( self . timeout , image [ " link " ] . establishment_timeout )
while image [ " link " ] . status != RNS . Link . ACTIVE and time . time ( ) < timeout : time . sleep ( 0.1 )
if image [ " link " ] and image [ " link " ] . status == RNS . Link . ACTIVE and image [ " request_id " ] == None :
RNS . log ( f " Sending request for image: { path } " , RNS . LOG_DEBUG )
receipt = image [ " link " ] . request ( " /media " , data = self . __get_image_request_data ( image ) , response_callback = self . image_received ,
failed_callback = self . image_failed , progress_callback = self . image_progressed )
w . notice ( f " Request sent " )
if receipt : image [ " request_id " ] = receipt . request_id
else :
RNS . log ( f " Image request failed " , RNS . LOG_ERROR )
image [ " failed " ] = True
w . notice ( f " Request failed " )
def __get_image_request_data ( self , image ) :
destination_hash , path = self . parse_url ( image [ " url " ] )
request_data = { " path " : path , " key " : None }
return request_data
def image_failed ( self , request_receipt ) :
RNS . log ( " Loading page image failed " , RNS . LOG_ERROR )
for iid in self . page_images :
image = self . page_images [ iid ]
if image [ " request_id " ] == request_receipt . request_id :
try :
image [ " updated " ] = time . time ( )
image [ " request_id " ] = None
image [ " failed " ] = True
url = image [ " url " ]
w = image [ " widget " ]
w . notice ( f " Could not load image { url } " )
except Exception as e :
RNS . log ( f " Error in image failed callback: { e } " , RNS . LOG_ERROR )
RNS . trace_exception ( e )
def image_progressed ( self , request_receipt ) :
for iid in self . page_images :
i = self . page_images [ iid ]
if i [ " request_id " ] == request_receipt . request_id :
try :
response_progress = request_receipt . progress
response_time = request_receipt . get_response_time ( )
response_size = request_receipt . response_size
response_transfer_size = request_receipt . response_transfer_size
w = i [ " widget " ]
now = time . time ( )
if i [ " progress_updated " ] == None : i [ " progress_updated " ] = now
if now > i [ " progress_updated " ] + 0.5 :
td = now - i [ " progress_updated " ]
pd = response_progress - i [ " previous_progress " ]
bd = pd * response_transfer_size
response_speed = ( bd / td ) * 8
i [ " previous_progress " ] = response_progress
i [ " progress_updated " ] = now
stats = f " { round ( response_progress * 100 , 2 ) } % ( { RNS . prettysize ( response_progress * response_size ) } of { RNS . prettysize ( response_size ) } ) - { RNS . prettyspeed ( response_speed ) } "
w . notice ( stats )
except Exception as e : RNS . trace_exception ( e )
def image_received ( self , request_receipt ) :
if not request_receipt . response : self . image_failed ( request_receipt )
for iid in self . page_images :
image = self . page_images [ iid ]
if image [ " request_id " ] == request_receipt . request_id :
try :
image [ " updated " ] = image [ " update_requested " ]
image [ " request_id " ] = None
w = image [ " widget " ]
w . notice ( f " Image loaded " )
except Exception as e :
RNS . log ( f " Error while loading received image: { e } " , RNS . LOG_ERROR )
RNS . trace_exception ( e )
image [ " failed " ] = True
try :
url = image [ " url " ]
cachepath = self . image_cache_path ( url )
if isinstance ( request_receipt . response , bytes ) :
with open ( cachepath , " wb " ) as f : f . write ( request_receipt . response )
elif type ( request_receipt . response ) == io . BufferedReader :
if request_receipt . metadata != None :
file_handle = request_receipt . response
file_destination = cachepath
shutil . move ( file_handle . name , file_destination )
2026-09-10 18:48:08 +02:00
threading . Thread ( target = self . clean_image_cache , daemon = True ) . start ( )
2026-09-05 22:15:10 +02:00
resolved_path = self . resolve_image ( url )
2026-09-09 19:32:41 +02:00
if resolved_path and self . image_rendering_supported :
w . load ( resolved_path , remote_source = True )
self . refresh_page_layout ( )
2026-09-05 22:15:10 +02:00
except Exception as e :
RNS . log ( " Error while handling image response: " + str ( e ) , RNS . LOG_ERROR )
image [ " failed " ] = True
2026-09-10 18:48:08 +02:00
IMAGE_CACHE_MAX_FILES = 256
IMAGE_CACHE_MAX_BYTES = 192 * 1024 * 1024
def clean_image_cache ( self ) :
if self . image_cache_lock . locked ( ) : return
with self . image_cache_lock :
try :
entries = [ ]
for name in os . listdir ( self . app . imagecachepath ) :
path = os . path . join ( self . app . imagecachepath , name )
try :
st = os . stat ( path )
if st . st_size > 0 : entries . append ( ( st . st_mtime , st . st_size , path ) )
except OSError as exc : RNS . log ( f " Could not get cached file information for { path } : { e } " , RNS . LOG_DEBUG )
cleaned_b = 0 ; cleaned_c = 0
total = sum ( e [ 1 ] for e in entries )
entries . sort ( )
while len ( entries ) > self . IMAGE_CACHE_MAX_FILES or total > self . IMAGE_CACHE_MAX_BYTES :
_ , size , path = entries . pop ( 0 )
total - = size
cleaned_b + = size
cleaned_c + = 1
try : os . unlink ( path )
except OSError as exc : RNS . log ( f " Could not remove cached file { path } : { exc } " )
if cleaned_b or cleaned_c : RNS . log ( f " Cleaned { RNS . prettysize ( cleaned_b ) } / { cleaned_c } image { ' s ' if cleaned_c != 1 else ' ' } from cache " , RNS . LOG_DEBUG )
except Exception as exc : RNS . log ( f " Error while cleaning browser image cache: { exc } " , RNS . LOG_ERROR )
2026-09-05 22:15:10 +02:00
# Render an arbitrary markup buffer (used by the page editor preview) and
2026-06-08 05:00:46 -04:00
# return the content widget, with partials/folds/fields wired as usual
def render_markup_buffer ( self , markup ) :
self . status = Browser . DONE
self . markup = markup
self . page_background_color = None
self . page_foreground_color = None
for tag , attr in ( ( " #!bg= " , " page_background_color " ) , ( " #!fg= " , " page_foreground_color " ) ) :
pos = markup . find ( tag )
if pos > = 0 :
endpos = markup . find ( " \n " , pos )
if endpos < 0 : endpos = len ( markup )
if endpos - ( pos + len ( tag ) ) in ( 3 , 6 ) :
setattr ( self , attr , markup [ pos + len ( tag ) : endpos ] )
try :
self . attr_maps = markup_to_attrmaps ( strip_modifiers ( markup ) , url_delegate = self , fg_color = self . page_foreground_color , bg_color = self . page_background_color )
except Exception as e :
self . attr_maps = [ urwid . AttrMap ( urwid . Text ( f " Could not render preview: { e } " ) , " browser_inactive " ) ]
self . update_page_display ( )
return self . browser_body
def init_folds ( self ) :
self . fold_sections = { }
self . collapsed_headings = set ( )
attrmaps = self . attr_maps
header_levels = getattr ( attrmaps , " header_levels " , None ) or { }
row_levels = getattr ( attrmaps , " row_levels " , None ) or { }
collapsibles = getattr ( attrmaps , " collapsibles " , None ) or { }
if not collapsibles :
return
n = len ( attrmaps )
for idx in sorted ( collapsibles . keys ( ) ) :
widget = self . collapsible_widget_at ( idx )
if widget is None : continue
depth = header_levels . get ( idx , 1 )
end = n
for i in range ( idx + 1 , n ) :
if row_levels . get ( i , depth ) < depth or \
( i in header_levels and header_levels . get ( i , 1 ) < = depth ) :
end = i ; break
self . fold_sections [ widget ] = ( idx + 1 , end )
if collapsibles [ idx ] :
self . collapsed_headings . add ( widget )
try : widget . set_collapsed ( True )
except Exception : pass
if self . collapsed_headings :
self . rebuild_visible ( )
def collapsible_widget_at ( self , idx ) :
try :
o = self . attr_maps [ idx ] . _original_widget
if getattr ( o , " is_collapsible_heading " , False ) : return o
except Exception : pass
return None
def hidden_indices ( self ) :
hidden = set ( )
for widget in getattr ( self , " collapsed_headings " , ( ) ) :
rng = getattr ( self , " fold_sections " , { } ) . get ( widget )
if rng : hidden . update ( range ( rng [ 0 ] , rng [ 1 ] ) )
return hidden
def rebuild_visible ( self ) :
2026-09-09 19:32:41 +02:00
page = getattr ( self , " page_list " , None )
if page is None : return
2026-06-08 05:00:46 -04:00
hidden = self . hidden_indices ( )
2026-09-09 19:32:41 +02:00
self . page_rows = [ i for i in range ( len ( self . attr_maps ) ) if i not in hidden ]
focus = page . focus_position if page . body else 0
page . body [ : ] = [ self . attr_maps [ i ] for i in self . page_rows ]
page . invalidate_layout ( )
if page . body : page . set_focus ( min ( focus , len ( page . body ) - 1 ) )
2026-06-08 05:00:46 -04:00
def fold_changed ( self , heading_widget ) :
if getattr ( heading_widget , " collapsed " , False ) :
self . collapsed_headings . add ( heading_widget )
else :
self . collapsed_headings . discard ( heading_widget )
self . rebuild_visible ( )
try :
2026-09-09 19:32:41 +02:00
for pos , w in enumerate ( self . page_list . body ) :
2026-06-08 05:00:46 -04:00
if getattr ( w , " _original_widget " , None ) is heading_widget :
2026-09-09 19:32:41 +02:00
self . page_list . set_focus ( pos )
2026-06-08 05:00:46 -04:00
break
except Exception : pass
def reveal_index ( self , idx ) :
changed = False
for widget in list ( getattr ( self , " collapsed_headings " , ( ) ) ) :
rng = getattr ( self , " fold_sections " , { } ) . get ( widget )
if rng and rng [ 0 ] < = idx < rng [ 1 ] :
self . collapsed_headings . discard ( widget )
try : widget . set_collapsed ( False )
except Exception : pass
changed = True
if changed : self . rebuild_visible ( )
2025-12-29 19:47:08 +01:00
def parse_url ( self , url ) :
path = None
destination_hash = None
components = url . split ( " : " )
if len ( components ) == 1 :
if len ( components [ 0 ] ) == ( RNS . Reticulum . TRUNCATED_HASHLENGTH / / 8 ) * 2 :
try : destination_hash = bytes . fromhex ( components [ 0 ] )
except Exception as e : raise ValueError ( " Malformed URL " )
path = Browser . DEFAULT_PATH
else : raise ValueError ( " Malformed URL " )
elif len ( components ) == 2 :
if len ( components [ 0 ] ) == ( RNS . Reticulum . TRUNCATED_HASHLENGTH / / 8 ) * 2 :
try : destination_hash = bytes . fromhex ( components [ 0 ] )
except Exception as e : raise ValueError ( " Malformed URL " )
path = components [ 1 ]
if len ( path ) == 0 : path = Browser . DEFAULT_PATH
else :
if len ( components [ 0 ] ) == 0 :
if self . destination_hash != None :
destination_hash = self . destination_hash
path = components [ 1 ]
if len ( path ) == 0 : path = Browser . DEFAULT_PATH
else : raise ValueError ( " Malformed URL " )
else : raise ValueError ( " Malformed URL " )
else : raise ValueError ( " Malformed URL " )
return destination_hash , path
def detect_partials ( self ) :
for w in self . attr_maps :
o = w . _original_widget
2025-12-29 22:21:51 +01:00
if hasattr ( o , " partial_hash " ) :
RNS . log ( f " Found partial: { o . partial_hash } / { o . partial_url } / { o . partial_refresh } " )
partial = { " hash " : o . partial_hash , " id " : o . partial_id , " url " : o . partial_url , " fields " : o . partial_fields ,
" refresh " : o . partial_refresh , " content " : None , " updated " : None , " update_requested " : None , " request_id " : None ,
" destination " : None , " link " : None , " pile " : o , " attr_maps " : None , " failed " : False , " pr_throttle " : 0 }
2025-12-29 19:47:08 +01:00
2025-12-29 22:21:51 +01:00
self . page_partials [ o . partial_hash ] = partial
2025-12-29 19:47:08 +01:00
if len ( self . page_partials ) > 0 : self . start_partial_updater ( )
def partial_failed ( self , request_receipt ) :
RNS . log ( " Loading page partial failed " , RNS . LOG_ERROR )
for pid in self . page_partials :
partial = self . page_partials [ pid ]
if partial [ " request_id " ] == request_receipt . request_id :
try :
partial [ " updated " ] = time . time ( )
partial [ " request_id " ] = None
partial [ " content " ] = None
partial [ " attr_maps " ] = None
url = partial [ " url " ]
pile = partial [ " pile " ]
pile . contents = [ ( urwid . Text ( f " Could not load partial { url } : The resource transfer failed " ) , pile . options ( ) ) ]
2026-09-09 19:32:41 +02:00
self . refresh_page_layout ( )
2025-12-29 19:47:08 +01:00
except Exception as e :
RNS . log ( f " Error in partial failed callback: { e } " , RNS . LOG_ERROR )
RNS . trace_exception ( e )
def partial_progressed ( self , request_receipt ) :
pass
def partial_received ( self , request_receipt ) :
for pid in self . page_partials :
partial = self . page_partials [ pid ]
if partial [ " request_id " ] == request_receipt . request_id :
try :
partial [ " updated " ] = partial [ " update_requested " ]
partial [ " request_id " ] = None
partial [ " content " ] = request_receipt . response . decode ( " utf-8 " ) . rstrip ( )
partial [ " attr_maps " ] = markup_to_attrmaps ( strip_modifiers ( partial [ " content " ] ) , url_delegate = self , fg_color = self . page_foreground_color , bg_color = self . page_background_color )
pile = partial [ " pile " ]
pile . contents = [ ( e , pile . options ( ) ) for e in partial [ " attr_maps " ] ]
2026-09-09 19:32:41 +02:00
self . refresh_page_layout ( )
2025-12-29 19:47:08 +01:00
except Exception as e :
RNS . trace_exception ( e )
def __load_partial ( self , partial ) :
if partial [ " failed " ] == True : return
try : partial_destination_hash , path = self . parse_url ( partial [ " url " ] )
except Exception as e :
RNS . log ( f " Could not parse partial URL: { e } " , RNS . LOG_ERROR )
partial [ " failed " ] = True
pile = partial [ " pile " ]
url = partial [ " url " ]
pile . contents = [ ( urwid . Text ( f " Could not load partial { url } : { e } " ) , pile . options ( ) ) ]
2026-09-09 19:32:41 +02:00
self . refresh_page_layout ( )
2025-12-29 19:47:08 +01:00
return
if partial_destination_hash != self . loopback and not RNS . Transport . has_path ( partial_destination_hash ) :
if time . time ( ) < = partial [ " pr_throttle " ] : return
else :
partial [ " pr_throttle " ] = time . time ( ) + 15
RNS . log ( f " Requesting path for partial: { partial_destination_hash } / { path } " , RNS . LOG_EXTREME )
RNS . Transport . request_path ( partial_destination_hash )
2026-09-02 22:48:31 +02:00
pr_time = time . time ( ) + self . path_timeout ( partial_destination_hash )
2025-12-29 19:47:08 +01:00
while not RNS . Transport . has_path ( partial_destination_hash ) :
2026-09-02 22:48:31 +02:00
if time . time ( ) > pr_time : return
2025-12-29 19:47:08 +01:00
time . sleep ( 0.25 )
for pid in self . page_partials :
other_partial = self . page_partials [ pid ]
if other_partial [ " link " ] :
existing_link = other_partial [ " link " ]
if existing_link . destination . hash == partial_destination_hash and existing_link . status == RNS . Link . ACTIVE :
RNS . log ( f " Re-using existing link: { existing_link } " , RNS . LOG_EXTREME )
partial [ " link " ] = existing_link
break
2025-12-29 22:21:51 +01:00
if not partial [ " link " ] or partial [ " link " ] . status == RNS . Link . CLOSED :
2025-12-29 19:47:08 +01:00
RNS . log ( f " Establishing link for partial: { partial_destination_hash } / { path } " , RNS . LOG_EXTREME )
identity = RNS . Identity . recall ( partial_destination_hash )
destination = RNS . Destination ( identity , RNS . Destination . OUT , RNS . Destination . SINGLE , self . app_name , self . aspects )
def established ( link ) :
RNS . log ( f " Link established for partial: { partial_destination_hash } / { path } " , RNS . LOG_EXTREME )
def closed ( link ) :
RNS . log ( f " Link closed for partial: { partial_destination_hash } / { path } " , RNS . LOG_EXTREME )
partial [ " link " ] = None
partial [ " link " ] = RNS . Link ( destination , established_callback = established , closed_callback = closed )
2026-09-02 22:48:31 +02:00
timeout = time . time ( ) + max ( self . timeout , partial [ " link " ] . establishment_timeout )
2025-12-29 19:47:08 +01:00
while partial [ " link " ] . status != RNS . Link . ACTIVE and time . time ( ) < timeout : time . sleep ( 0.1 )
if partial [ " link " ] and partial [ " link " ] . status == RNS . Link . ACTIVE and partial [ " request_id " ] == None :
RNS . log ( f " Sending request for partial: { partial_destination_hash } / { path } " , RNS . LOG_EXTREME )
2025-12-29 20:24:12 +01:00
receipt = partial [ " link " ] . request ( path , data = self . __get_partial_request_data ( partial ) , response_callback = self . partial_received ,
2025-12-29 19:47:08 +01:00
failed_callback = self . partial_failed , progress_callback = self . partial_progressed )
if receipt : partial [ " request_id " ] = receipt . request_id
else : RNS . log ( f " Partial request failed " , RNS . LOG_ERROR )
2025-12-29 20:24:12 +01:00
def __get_partial_request_data ( self , partial ) :
request_data = None
if partial [ " fields " ] != None :
link_data = partial [ " fields " ]
link_fields = [ ]
request_data = { }
all_fields = True if " * " in link_data else False
for e in link_data :
if " = " in e :
c = e . split ( " = " )
if len ( c ) == 2 :
request_data [ " var_ " + str ( c [ 0 ] ) ] = str ( c [ 1 ] )
else :
link_fields . append ( e )
def recurse_down ( w ) :
if isinstance ( w , list ) :
for t in w :
recurse_down ( t )
elif isinstance ( w , tuple ) :
for t in w :
recurse_down ( t )
elif hasattr ( w , " contents " ) :
recurse_down ( w . contents )
elif hasattr ( w , " original_widget " ) :
recurse_down ( w . original_widget )
elif hasattr ( w , " _original_widget " ) :
recurse_down ( w . _original_widget )
else :
if hasattr ( w , " field_name " ) and ( all_fields or w . field_name in link_fields ) :
field_key = " field_ " + w . field_name
if isinstance ( w , urwid . Edit ) :
request_data [ field_key ] = w . edit_text
elif isinstance ( w , urwid . RadioButton ) :
if w . state :
user_data = getattr ( w , " field_value " , None )
if user_data is not None :
request_data [ field_key ] = user_data
elif isinstance ( w , urwid . CheckBox ) :
user_data = getattr ( w , " field_value " , " 1 " )
if w . state :
existing_value = request_data . get ( field_key , ' ' )
if existing_value :
# Concatenate the new value with the existing one
request_data [ field_key ] = existing_value + ' , ' + user_data
else :
# Initialize the field with the current value
request_data [ field_key ] = user_data
else :
pass # do nothing if checkbox is not check
recurse_down ( self . attr_maps )
2026-09-03 15:13:15 +02:00
RNS . log ( " Including request data: " + str ( request_data ) , RNS . LOG_EXTREME )
2025-12-29 20:24:12 +01:00
return request_data
2025-12-29 19:47:08 +01:00
def start_partial_updater ( self ) :
if not self . updater_running : self . update_partials ( )
2025-12-29 22:21:51 +01:00
def handle_partial_updates ( self , partial_ids ) :
RNS . log ( f " Update partials: { partial_ids } " )
def job ( ) :
for pid in self . page_partials :
try :
partial = self . page_partials [ pid ]
if partial [ " id " ] in partial_ids :
partial [ " update_requested " ] = time . time ( )
self . __load_partial ( partial )
except Exception as e : RNS . log ( f " Error updating page partial: { e } " , RNS . LOG_ERROR )
threading . Thread ( target = job , daemon = True ) . start ( )
2025-12-29 19:47:08 +01:00
def update_partials ( self , loop = None , user_data = None ) :
with self . partial_updater_lock :
def job ( ) :
for pid in self . page_partials :
try :
partial = self . page_partials [ pid ]
if partial [ " failed " ] : continue
if not partial [ " updated " ] or ( partial [ " refresh " ] != None and time . time ( ) > partial [ " updated " ] + partial [ " refresh " ] ) :
partial [ " update_requested " ] = time . time ( )
self . __load_partial ( partial )
except Exception as e : RNS . log ( f " Error updating page partial: { e } " , RNS . LOG_ERROR )
threading . Thread ( target = job , daemon = True ) . start ( )
if len ( self . page_partials ) > 0 :
self . updater_running = True
self . app . ui . loop . set_alarm_in ( 1 , self . update_partials )
else :
self . updater_running = False
2021-08-27 19:58:14 +02:00
def identify ( self ) :
if self . link != None :
if self . link . status == RNS . Link . ACTIVE :
self . link . identify ( self . auth_identity )
def disconnect ( self ) :
if self . link != None :
self . link . teardown ( )
2023-02-15 10:09:25 +01:00
self . request_data = None
2021-08-27 19:58:14 +02:00
self . attr_maps = [ ]
self . status = Browser . DISCONECTED
2021-08-28 20:12:03 +02:00
self . response_progress = 0
2025-05-11 17:21:43 +02:00
self . response_speed = None
self . progress_updated_at = None
self . previous_progress = 0
2021-08-28 20:12:03 +02:00
self . response_size = None
self . response_transfer_size = None
2021-09-17 11:47:41 +02:00
self . history = [ ]
self . history_ptr = 0
self . history_inc = False
self . history_dec = False
2021-08-27 19:58:14 +02:00
self . update_display ( )
2023-02-15 10:09:25 +01:00
def retrieve_url ( self , url , request_data = None ) :
2026-05-02 02:01:26 +02:00
components = url . split ( " ` " )
if len ( components ) == 2 :
url = components [ 0 ]
try :
link_fields_str = components [ 1 ]
if link_fields_str != " " :
if not request_data : request_data = { }
for e in link_fields_str . split ( " | " ) :
if " = " in e :
c = e . split ( " = " )
if len ( c ) == 2 :
request_data [ " var_ " + str ( c [ 0 ] ) ] = str ( c [ 1 ] )
except Exception as e :
RNS . log ( f " Malformed URL: { e } " , RNS . LOG_WARNING )
raise ValueError ( f " Malformed URL: { e } " )
2021-08-27 19:58:14 +02:00
self . previous_destination_hash = self . destination_hash
self . previous_path = self . path
destination_hash = None
path = None
components = url . split ( " : " )
if len ( components ) == 1 :
2022-06-30 13:51:46 +02:00
if len ( components [ 0 ] ) == ( RNS . Reticulum . TRUNCATED_HASHLENGTH / / 8 ) * 2 :
2025-12-29 22:21:51 +01:00
try : destination_hash = bytes . fromhex ( components [ 0 ] )
except Exception as e : raise ValueError ( " Malformed URL " )
2021-08-27 19:58:14 +02:00
path = Browser . DEFAULT_PATH
2025-12-29 22:21:51 +01:00
else : raise ValueError ( " Malformed URL " )
2021-08-27 19:58:14 +02:00
elif len ( components ) == 2 :
2022-06-30 13:51:46 +02:00
if len ( components [ 0 ] ) == ( RNS . Reticulum . TRUNCATED_HASHLENGTH / / 8 ) * 2 :
2025-12-29 22:21:51 +01:00
try : destination_hash = bytes . fromhex ( components [ 0 ] )
except Exception as e : raise ValueError ( " Malformed URL " )
2021-08-27 19:58:14 +02:00
path = components [ 1 ]
2025-12-29 22:21:51 +01:00
if len ( path ) == 0 : path = Browser . DEFAULT_PATH
2021-08-27 19:58:14 +02:00
else :
if len ( components [ 0 ] ) == 0 :
if self . destination_hash != None :
destination_hash = self . destination_hash
path = components [ 1 ]
2025-12-29 22:21:51 +01:00
if len ( path ) == 0 : path = Browser . DEFAULT_PATH
else : raise ValueError ( " Malformed URL " )
else : raise ValueError ( " Malformed URL " )
else : raise ValueError ( " Malformed URL " )
2021-08-27 19:58:14 +02:00
if destination_hash != None and path != None :
2021-09-10 21:33:29 +02:00
if path . startswith ( " /file/ " ) :
2021-09-16 21:56:59 +02:00
if destination_hash != self . loopback :
if destination_hash == self . destination_hash :
2026-05-04 20:51:58 +02:00
self . download_file ( destination_hash , path , data = request_data )
2021-09-16 21:56:59 +02:00
else :
RNS . log ( " Cannot request file download from a node that is not currently connected. " , RNS . LOG_ERROR )
RNS . log ( " The requested URL was: " + str ( url ) , RNS . LOG_ERROR )
2021-09-10 21:33:29 +02:00
else :
2021-09-16 21:56:59 +02:00
self . download_local_file ( path )
2021-09-10 21:33:29 +02:00
else :
self . set_destination_hash ( destination_hash )
self . set_path ( path )
2023-02-15 10:09:25 +01:00
self . set_request_data ( request_data )
2021-09-10 21:33:29 +02:00
self . load_page ( )
2021-08-27 19:58:14 +02:00
def set_destination_hash ( self , destination_hash ) :
if len ( destination_hash ) == RNS . Identity . TRUNCATED_HASHLENGTH / / 8 :
self . destination_hash = destination_hash
return True
else :
return False
def set_path ( self , path ) :
self . path = path
2023-02-15 10:09:25 +01:00
def set_request_data ( self , request_data ) :
self . request_data = request_data
2021-08-27 19:58:14 +02:00
def set_timeout ( self , timeout ) :
self . timeout = timeout
2026-09-02 22:48:31 +02:00
def path_timeout ( self , destination_hash ) :
return max ( self . timeout + self . app . rns . get_first_hop_timeout ( destination_hash ) , self . app . rns . get_medium_path_timeout ( ) )
2021-09-16 21:56:59 +02:00
def download_local_file ( self , path ) :
try :
file_path = self . app . filespath + path . replace ( " /file " , " " , 1 )
if os . path . isfile ( file_path ) :
file_name = os . path . basename ( file_path )
file_destination = self . app . downloads_path + " / " + file_name
counter = 0
while os . path . isfile ( file_destination ) :
counter + = 1
file_destination = self . app . downloads_path + " / " + file_name + " . " + str ( counter )
fs = open ( file_path , " rb " )
fd = open ( file_destination , " wb " )
fd . write ( fs . read ( ) )
fd . close ( )
fs . close ( )
self . saved_file_name = file_destination . replace ( self . app . downloads_path + " / " , " " , 1 )
2026-05-09 15:13:56 +02:00
try : self . saved_file_size = os . path . getsize ( file_destination )
except : self . saved_file_size = 0
2021-09-16 21:56:59 +02:00
self . update_display ( )
else :
RNS . log ( " The requested local download file does not exist: " + str ( file_path ) , RNS . LOG_ERROR )
self . status = Browser . DONE
self . response_progress = 0
2025-05-11 17:21:43 +02:00
self . response_speed = None
self . progress_updated_at = None
self . previous_progress = 0
2021-09-16 21:56:59 +02:00
except Exception as e :
RNS . log ( " An error occurred while handling file response. The contained exception was: " + str ( e ) , RNS . LOG_ERROR )
2021-08-27 19:58:14 +02:00
2026-05-04 20:51:58 +02:00
def download_file ( self , destination_hash , path , data = None ) :
2022-04-06 18:06:51 +02:00
if self . link == None or self . link . destination . hash != self . destination_hash :
if not RNS . Transport . has_path ( self . destination_hash ) :
self . status = Browser . NO_PATH
self . update_display ( )
RNS . Transport . request_path ( self . destination_hash )
self . status = Browser . PATH_REQUESTED
self . update_display ( )
2026-09-02 22:48:31 +02:00
pr_time = time . time ( ) + self . path_timeout ( self . destination_hash )
2022-04-06 18:06:51 +02:00
while not RNS . Transport . has_path ( self . destination_hash ) :
2026-09-02 22:48:31 +02:00
if time . time ( ) > pr_time :
2022-04-06 18:06:51 +02:00
self . request_timeout ( )
return
time . sleep ( 0.25 )
self . status = Browser . ESTABLISHING_LINK
self . update_display ( )
identity = RNS . Identity . recall ( self . destination_hash )
destination = RNS . Destination (
identity ,
RNS . Destination . OUT ,
RNS . Destination . SINGLE ,
self . app_name ,
self . aspects
)
self . link = RNS . Link ( destination , established_callback = self . link_established , closed_callback = self . link_closed )
while self . status == Browser . ESTABLISHING_LINK :
time . sleep ( 0.1 )
if self . status != Browser . LINK_ESTABLISHED :
return
self . update_display ( )
2021-09-10 21:33:29 +02:00
if self . link != None and self . link . destination . hash == self . destination_hash :
# Send the request
self . status = Browser . REQUESTING
self . response_progress = 0
2025-05-11 17:21:43 +02:00
self . response_speed = None
self . progress_updated_at = None
self . previous_progress = 0
2021-09-10 21:33:29 +02:00
self . response_size = None
self . response_transfer_size = None
self . saved_file_name = None
2026-05-09 15:13:56 +02:00
self . saved_file_size = 0
2021-09-10 21:33:29 +02:00
self . update_display ( )
receipt = self . link . request (
path ,
2026-05-04 20:51:58 +02:00
data = data ,
2021-09-10 21:33:29 +02:00
response_callback = self . file_received ,
failed_callback = self . request_failed ,
progress_callback = self . response_progressed
)
if receipt :
self . last_request_receipt = receipt
self . last_request_id = receipt . request_id
self . status = Browser . REQUEST_SENT
self . update_display ( )
else :
self . link . teardown ( )
2021-09-17 11:47:41 +02:00
def write_history ( self ) :
2026-05-02 02:01:26 +02:00
entry = [ self . destination_hash , self . path , self . request_data ]
2021-09-17 11:47:41 +02:00
self . history . insert ( self . history_ptr , entry )
self . history_ptr + = 1
if len ( self . history ) > self . history_ptr :
self . history = self . history [ : self . history_ptr ]
def back ( self ) :
target_ptr = self . history_ptr - 1
if not self . history_inc and not self . history_dec :
if target_ptr > 0 :
self . history_dec = True
entry = self . history [ target_ptr - 1 ]
url = RNS . hexrep ( entry [ 0 ] , delimit = False ) + " : " + entry [ 1 ]
self . history_ptr = target_ptr
2026-05-02 02:01:26 +02:00
self . retrieve_url ( url , request_data = entry [ 2 ] )
2021-09-17 11:47:41 +02:00
def forward ( self ) :
target_ptr = self . history_ptr + 1
if not self . history_inc and not self . history_dec :
if target_ptr < = len ( self . history ) :
self . history_dec = True
entry = self . history [ target_ptr - 1 ]
url = RNS . hexrep ( entry [ 0 ] , delimit = False ) + " : " + entry [ 1 ]
self . history_ptr = target_ptr
2026-05-02 02:01:26 +02:00
self . retrieve_url ( url , request_data = entry [ 2 ] )
2021-09-17 11:47:41 +02:00
def reload ( self ) :
if not self . reloading and self . status == Browser . DONE :
self . reloading = True
2021-09-17 18:45:08 +02:00
self . uncache_page ( self . current_url ( ) )
2021-09-17 11:47:41 +02:00
self . load_page ( )
2021-09-10 21:33:29 +02:00
2026-05-21 16:16:06 -04:00
def copy_url ( self ) :
url = self . current_url ( )
if not url :
return
if not osc52_copy ( url ) :
return
try :
notice = urwid . AttrMap ( urwid . Pile ( [ urwid . Divider ( self . g [ " divider1 " ] ) , urwid . Text ( " Copied URL to clipboard " ) ] ) , " browser_controls " )
if self . page_background_color != None or self . page_foreground_color != None :
style_name = make_style ( default_state ( fg = self . page_foreground_color , bg = self . page_background_color ) )
notice . set_attr_map ( { None : style_name } )
self . browser_footer = notice
self . frame . contents [ " footer " ] = ( self . browser_footer , self . frame . options ( ) )
def restore_footer ( loop , user_data ) :
if self . status == Browser . DONE :
self . browser_footer = self . make_status_widget ( )
else :
self . browser_footer = urwid . Text ( " " )
if self . frame is not None :
self . frame . contents [ " footer " ] = ( self . browser_footer , self . frame . options ( ) )
self . app . ui . loop . set_alarm_in ( 2.0 , restore_footer )
except Exception as e :
RNS . log ( " Could not update footer after URL copy: " + str ( e ) , RNS . LOG_ERROR )
2021-09-17 12:23:13 +02:00
def close_dialogs ( self ) :
2024-01-18 11:58:22 +01:00
options = self . delegate . columns . options ( urwid . WEIGHT , self . delegate . right_area_width )
2021-09-17 12:23:13 +02:00
self . delegate . columns . contents [ 1 ] = ( self . display_widget , options )
def url_dialog ( self ) :
2021-09-18 15:09:41 +02:00
e_url = UrlEdit ( caption = " URL : " , edit_text = self . current_url ( ) )
2021-09-17 12:23:13 +02:00
def dismiss_dialog ( sender ) :
self . close_dialogs ( )
def confirmed ( sender ) :
2022-07-04 17:37:13 +02:00
try :
2026-05-21 03:59:21 +02:00
entered_url = e_url . get_edit_text ( ) . strip ( )
if not " ` " in entered_url and " : " in entered_url :
pos = entered_url . find ( " | " )
2026-05-21 17:30:05 -04:00
if pos > 0 : entered_url = entered_url [ : pos ] + " ` " + entered_url [ pos + 1 : ]
2026-05-21 03:59:21 +02:00
self . retrieve_url ( entered_url )
2022-07-04 17:37:13 +02:00
except Exception as e :
self . browser_footer = urwid . Text ( " Could not open link: " + str ( e ) )
self . frame . contents [ " footer " ] = ( self . browser_footer , self . frame . options ( ) )
2021-09-17 12:23:13 +02:00
self . close_dialogs ( )
dialog = UrlDialogLineBox (
urwid . Pile ( [
e_url ,
2024-01-18 11:58:22 +01:00
urwid . Columns ( [
( urwid . WEIGHT , 0.45 , urwid . Button ( " Cancel " , on_press = dismiss_dialog ) ) ,
( urwid . WEIGHT , 0.1 , urwid . Text ( " " ) ) ,
( urwid . WEIGHT , 0.45 , urwid . Button ( " Go " , on_press = confirmed ) ) ,
] )
2021-09-17 12:23:13 +02:00
] ) , title = " Enter URL "
)
2021-09-18 15:09:41 +02:00
e_url . confirmed = confirmed
2021-09-17 12:23:13 +02:00
dialog . confirmed = confirmed
dialog . delegate = self
bottom = self . display_widget
2024-01-18 11:58:22 +01:00
overlay = urwid . Overlay (
dialog ,
bottom ,
align = urwid . CENTER ,
width = ( urwid . RELATIVE , 65 ) ,
valign = urwid . MIDDLE ,
height = urwid . PACK ,
left = 2 ,
right = 2 ,
)
2021-09-17 12:23:13 +02:00
2024-01-18 11:58:22 +01:00
options = self . delegate . columns . options ( urwid . WEIGHT , self . delegate . right_area_width )
2022-07-04 16:31:01 +02:00
self . delegate . columns . contents [ 1 ] = ( overlay , options )
self . delegate . columns . focus_position = 1
def save_node_dialog ( self ) :
2024-02-29 18:42:47 +01:00
if self . destination_hash != None :
try :
def dismiss_dialog ( sender ) :
self . close_dialogs ( )
display_name = RNS . Identity . recall_app_data ( self . destination_hash )
disp_str = " "
if display_name != None :
display_name = display_name . decode ( " utf-8 " )
disp_str = " \" " + display_name + " \" "
def confirmed ( sender ) :
node_entry = DirectoryEntry ( self . destination_hash , display_name = display_name , hosts_node = True )
self . app . directory . remember ( node_entry )
self . app . ui . main_display . sub_displays . network_display . directory_change_callback ( )
self . close_dialogs ( )
dialog = UrlDialogLineBox (
urwid . Pile ( [
urwid . Text ( " Save connected node " + disp_str + " " + RNS . prettyhexrep ( self . destination_hash ) + " to Known Nodes? \n " ) ,
urwid . Columns ( [
( urwid . WEIGHT , 0.45 , urwid . Button ( " Cancel " , on_press = dismiss_dialog ) ) ,
( urwid . WEIGHT , 0.1 , urwid . Text ( " " ) ) ,
( urwid . WEIGHT , 0.45 , urwid . Button ( " Save " , on_press = confirmed ) ) ,
] )
] ) , title = " Save Node "
)
dialog . confirmed = confirmed
dialog . delegate = self
bottom = self . display_widget
overlay = urwid . Overlay (
dialog ,
bottom ,
align = urwid . CENTER ,
width = ( urwid . RELATIVE , 50 ) ,
valign = urwid . MIDDLE ,
height = urwid . PACK ,
left = 2 ,
right = 2 ,
)
options = self . delegate . columns . options ( urwid . WEIGHT , self . delegate . right_area_width )
self . delegate . columns . contents [ 1 ] = ( overlay , options )
self . delegate . columns . focus_position = 1
2021-09-17 12:23:13 +02:00
2024-02-29 18:42:47 +01:00
except Exception as e :
pass
2021-09-17 12:23:13 +02:00
2021-08-27 19:58:14 +02:00
def load_page ( self ) :
2023-02-15 10:09:25 +01:00
if self . request_data == None :
2023-02-14 19:46:21 +01:00
cached = self . get_cached ( self . current_url ( ) )
else :
cached = None
2021-09-17 18:45:08 +02:00
if cached :
2021-09-16 21:56:59 +02:00
self . status = Browser . DONE
2021-09-17 18:45:08 +02:00
self . page_data = cached
2021-09-16 21:56:59 +02:00
self . markup = self . page_data . decode ( " utf-8 " )
2025-05-11 20:59:26 +02:00
self . page_background_color = None
bgpos = self . markup . find ( " #!bg= " )
2025-12-22 12:42:32 +01:00
if bgpos > = 0 :
2025-05-11 20:59:26 +02:00
endpos = self . markup . find ( " \n " , bgpos )
if endpos - ( bgpos + 5 ) == 3 :
bg = self . markup [ bgpos + 5 : endpos ]
self . page_background_color = bg
2026-05-03 18:06:36 +02:00
elif endpos - ( bgpos + 5 ) == 6 :
bg = self . markup [ bgpos + 5 : endpos ]
self . page_background_color = bg
2025-05-11 20:59:26 +02:00
self . page_foreground_color = None
fgpos = self . markup . find ( " #!fg= " )
2025-12-22 12:42:32 +01:00
if fgpos > = 0 :
2025-05-11 20:59:26 +02:00
endpos = self . markup . find ( " \n " , fgpos )
if endpos - ( fgpos + 5 ) == 3 :
fg = self . markup [ fgpos + 5 : endpos ]
self . page_foreground_color = fg
2026-05-03 18:06:36 +02:00
elif endpos - ( fgpos + 5 ) == 6 :
fg = self . markup [ fgpos + 5 : endpos ]
self . page_foreground_color = fg
2025-05-11 20:59:26 +02:00
2025-12-30 12:16:24 +01:00
try : self . attr_maps = markup_to_attrmaps ( strip_modifiers ( self . markup ) , url_delegate = self , fg_color = self . page_foreground_color , bg_color = self . page_background_color )
2026-05-06 18:44:17 +02:00
except Exception as e : self . attr_maps = [ urwid . AttrMap ( urwid . Text ( f " Could not render page: { e } " ) , " browser_inactive " ) ]
2021-09-17 11:47:41 +02:00
2021-09-16 21:56:59 +02:00
self . response_progress = 0
2025-05-11 17:21:43 +02:00
self . response_speed = None
self . progress_updated_at = None
self . previous_progress = 0
2021-09-17 11:47:41 +02:00
self . response_size = None
self . response_transfer_size = None
self . saved_file_name = None
2026-05-09 15:13:56 +02:00
self . saved_file_size = 0
2021-09-17 18:45:08 +02:00
self . loaded_from_cache = True
2021-09-16 21:56:59 +02:00
self . update_display ( )
2021-08-27 19:58:14 +02:00
2021-09-17 11:47:41 +02:00
if not self . history_inc and not self . history_dec and not self . reloading :
self . write_history ( )
else :
self . history_dec = False
self . history_inc = False
self . reloading = False
2026-05-28 14:11:34 +02:00
if self . request_data and " var_anchor " in self . request_data :
def df ( loop , user_data ) : self . _jump_to_anchor ( self . request_data [ " var_anchor " ] )
nomadnet . NomadNetworkApp . get_shared_instance ( ) . ui . loop . set_alarm_in ( 0.0 , df )
2021-09-17 18:45:08 +02:00
else :
if self . destination_hash != self . loopback :
load_thread = threading . Thread ( target = self . __load )
load_thread . setDaemon ( True )
load_thread . start ( )
else :
RNS . log ( " Browser handling local page: " + str ( self . path ) , RNS . LOG_DEBUG )
page_path = self . app . pagespath + self . path . replace ( " /page " , " " , 1 )
page_data = b " The requested local page did not exist in the file system "
if os . path . isfile ( page_path ) :
2021-09-17 19:04:23 +02:00
if os . access ( page_path , os . X_OK ) :
2023-02-15 10:09:25 +01:00
if self . request_data != None :
env_map = self . request_data
2023-02-14 19:46:21 +01:00
else :
2023-02-15 10:09:25 +01:00
env_map = { }
2023-02-17 23:04:44 +01:00
if " PATH " in os . environ :
env_map [ " PATH " ] = os . environ [ " PATH " ]
2026-01-06 17:18:40 +01:00
generated = subprocess . run ( [ page_path ] , stdout = subprocess . PIPE , stderr = subprocess . DEVNULL , env = env_map )
2021-09-17 19:04:23 +02:00
page_data = generated . stdout
else :
file = open ( page_path , " rb " )
page_data = file . read ( )
file . close ( )
2021-09-17 18:45:08 +02:00
self . status = Browser . DONE
self . page_data = page_data
self . markup = self . page_data . decode ( " utf-8 " )
2025-05-11 20:59:26 +02:00
self . page_background_color = None
bgpos = self . markup . find ( " #!bg= " )
2025-12-22 12:42:32 +01:00
if bgpos > = 0 :
2025-05-11 20:59:26 +02:00
endpos = self . markup . find ( " \n " , bgpos )
if endpos - ( bgpos + 5 ) == 3 :
bg = self . markup [ bgpos + 5 : endpos ]
self . page_background_color = bg
2026-05-03 18:06:36 +02:00
elif endpos - ( bgpos + 5 ) == 6 :
bg = self . markup [ bgpos + 5 : endpos ]
self . page_background_color = bg
2025-05-11 20:59:26 +02:00
self . page_foreground_color = None
fgpos = self . markup . find ( " #!fg= " )
2025-12-22 12:42:32 +01:00
if fgpos > = 0 :
2025-05-11 20:59:26 +02:00
endpos = self . markup . find ( " \n " , fgpos )
if endpos - ( fgpos + 5 ) == 3 :
fg = self . markup [ fgpos + 5 : endpos ]
self . page_foreground_color = fg
2026-05-03 18:06:36 +02:00
elif endpos - ( fgpos + 5 ) == 6 :
fg = self . markup [ fgpos + 5 : endpos ]
self . page_foreground_color = fg
2025-05-11 20:59:26 +02:00
2025-12-30 12:16:24 +01:00
try : self . attr_maps = markup_to_attrmaps ( strip_modifiers ( self . markup ) , url_delegate = self , fg_color = self . page_foreground_color , bg_color = self . page_background_color )
2026-05-06 18:44:17 +02:00
except Exception as e : self . attr_maps = [ urwid . AttrMap ( urwid . Text ( f " Could not render page: { e } " ) , " browser_inactive " ) ]
2021-09-17 18:45:08 +02:00
self . response_progress = 0
2025-05-11 17:21:43 +02:00
self . response_speed = None
self . progress_updated_at = None
self . previous_progress = 0
2021-09-17 18:45:08 +02:00
self . response_size = None
self . response_transfer_size = None
self . saved_file_name = None
2026-05-09 15:13:56 +02:00
self . saved_file_size = 0
2021-09-17 18:45:08 +02:00
self . loaded_from_cache = False
self . update_display ( )
if not self . history_inc and not self . history_dec and not self . reloading :
self . write_history ( )
else :
self . history_dec = False
self . history_inc = False
self . reloading = False
2026-05-28 14:11:34 +02:00
if self . request_data and " var_anchor " in self . request_data :
def df ( loop , user_data ) : self . _jump_to_anchor ( self . request_data [ " var_anchor " ] )
nomadnet . NomadNetworkApp . get_shared_instance ( ) . ui . loop . set_alarm_in ( 0.0 , df )
2021-08-27 19:58:14 +02:00
def __load ( self ) :
# If an established link exists, but it doesn't match the target
# destination, we close and clear it.
2023-09-19 11:56:04 +02:00
if self . link != None and ( self . link . destination . hash != self . destination_hash or self . link . status != RNS . Link . ACTIVE ) :
2021-09-10 21:33:29 +02:00
self . link . teardown ( )
2021-08-27 19:58:14 +02:00
self . link = None
# If no link to the destination exists, we create one.
if self . link == None :
if not RNS . Transport . has_path ( self . destination_hash ) :
self . status = Browser . NO_PATH
self . update_display ( )
RNS . Transport . request_path ( self . destination_hash )
self . status = Browser . PATH_REQUESTED
self . update_display ( )
2026-09-02 22:48:31 +02:00
pr_time = time . time ( ) + self . path_timeout ( self . destination_hash )
2021-08-27 19:58:14 +02:00
while not RNS . Transport . has_path ( self . destination_hash ) :
2026-09-02 22:48:31 +02:00
if time . time ( ) > pr_time :
2021-08-27 19:58:14 +02:00
self . request_timeout ( )
2021-08-28 20:12:03 +02:00
return
2021-08-27 19:58:14 +02:00
time . sleep ( 0.25 )
self . status = Browser . ESTABLISHING_LINK
self . update_display ( )
identity = RNS . Identity . recall ( self . destination_hash )
destination = RNS . Destination (
identity ,
RNS . Destination . OUT ,
RNS . Destination . SINGLE ,
self . app_name ,
self . aspects
)
self . link = RNS . Link ( destination , established_callback = self . link_established , closed_callback = self . link_closed )
2021-09-03 18:55:34 +02:00
while self . status == Browser . ESTABLISHING_LINK :
time . sleep ( 0.1 )
2021-08-27 19:58:14 +02:00
2021-09-03 18:55:34 +02:00
if self . status != Browser . LINK_ESTABLISHED :
return
2021-08-27 19:58:14 +02:00
self . update_display ( )
# Send the request
self . status = Browser . REQUESTING
2021-08-28 20:12:03 +02:00
self . response_progress = 0
2025-05-11 17:21:43 +02:00
self . response_speed = None
self . progress_updated_at = None
self . previous_progress = 0
2021-08-28 20:12:03 +02:00
self . response_size = None
self . response_transfer_size = None
2021-09-10 21:33:29 +02:00
self . saved_file_name = None
2026-05-09 15:13:56 +02:00
self . saved_file_size = 0
2021-09-10 21:33:29 +02:00
2021-08-28 20:12:03 +02:00
2021-08-27 19:58:14 +02:00
self . update_display ( )
receipt = self . link . request (
self . path ,
2023-02-15 10:09:25 +01:00
data = self . request_data ,
2021-08-27 19:58:14 +02:00
response_callback = self . response_received ,
failed_callback = self . request_failed ,
2021-09-03 14:17:00 +02:00
progress_callback = self . response_progressed
2021-08-27 19:58:14 +02:00
)
2021-09-02 18:45:17 +02:00
if receipt :
self . last_request_receipt = receipt
self . last_request_id = receipt . request_id
self . status = Browser . REQUEST_SENT
self . update_display ( )
else :
self . link . teardown ( )
2021-08-27 19:58:14 +02:00
def link_established ( self , link ) :
self . status = Browser . LINK_ESTABLISHED
2022-04-06 20:34:49 +02:00
if self . app . directory . should_identify_on_connect ( self . destination_hash ) :
RNS . log ( " Link established, identifying to remote system... " , RNS . LOG_VERBOSE )
self . link . identify ( self . app . identity )
2021-08-27 19:58:14 +02:00
def link_closed ( self , link ) :
if self . status == Browser . DISCONECTED or self . status == Browser . DONE :
self . link = None
2021-09-03 18:55:34 +02:00
elif self . status == Browser . ESTABLISHING_LINK :
self . link_establishment_timeout ( )
2021-08-27 19:58:14 +02:00
else :
self . link = None
self . status = Browser . REQUEST_FAILED
self . update_display ( )
2021-09-03 18:55:34 +02:00
def link_establishment_timeout ( self ) :
self . status = Browser . LINK_TIMEOUT
self . response_progress = 0
2025-05-11 17:21:43 +02:00
self . response_speed = None
self . progress_updated_at = None
self . previous_progress = 0
2021-09-03 18:55:34 +02:00
self . response_size = None
self . response_transfer_size = None
self . link = None
self . update_display ( )
2021-08-27 19:58:14 +02:00
def response_received ( self , request_receipt ) :
try :
self . status = Browser . DONE
self . page_data = request_receipt . response
self . markup = self . page_data . decode ( " utf-8 " )
2025-05-11 20:59:26 +02:00
self . page_background_color = None
bgpos = self . markup . find ( " #!bg= " )
2025-12-22 12:42:32 +01:00
if bgpos > = 0 :
2025-05-11 20:59:26 +02:00
endpos = self . markup . find ( " \n " , bgpos )
if endpos - ( bgpos + 5 ) == 3 :
bg = self . markup [ bgpos + 5 : endpos ]
self . page_background_color = bg
2026-05-03 18:06:36 +02:00
elif endpos - ( bgpos + 5 ) == 6 :
bg = self . markup [ bgpos + 5 : endpos ]
self . page_background_color = bg
2025-05-11 20:59:26 +02:00
self . page_foreground_color = None
fgpos = self . markup . find ( " #!fg= " )
2025-12-22 12:42:32 +01:00
if fgpos > = 0 :
2025-05-11 20:59:26 +02:00
endpos = self . markup . find ( " \n " , fgpos )
if endpos - ( fgpos + 5 ) == 3 :
fg = self . markup [ fgpos + 5 : endpos ]
self . page_foreground_color = fg
2026-05-03 18:06:36 +02:00
elif endpos - ( fgpos + 5 ) == 6 :
fg = self . markup [ fgpos + 5 : endpos ]
self . page_foreground_color = fg
2025-05-11 20:59:26 +02:00
2025-12-30 12:16:24 +01:00
try : self . attr_maps = markup_to_attrmaps ( strip_modifiers ( self . markup ) , url_delegate = self , fg_color = self . page_foreground_color , bg_color = self . page_background_color )
2026-05-06 18:44:17 +02:00
except Exception as e : self . attr_maps = [ urwid . AttrMap ( urwid . Text ( f " Could not render page: { e } " ) , " browser_inactive " ) ]
2025-12-30 12:16:24 +01:00
2021-08-28 20:12:03 +02:00
self . response_progress = 0
2025-05-11 17:21:43 +02:00
self . response_speed = None
self . progress_updated_at = None
self . previous_progress = 0
2021-09-17 18:45:08 +02:00
self . loaded_from_cache = False
# Simple header handling. Should be expanded when more
# header tags are added.
cache_time = Browser . DEFAULT_CACHE_TIME
if self . markup [ : 4 ] == " #!c= " :
2026-05-23 00:18:13 +02:00
try :
endpos = self . markup . find ( " \n " )
if endpos == - 1 :
endpos = len ( self . markup )
cache_time = int ( self . markup [ 4 : endpos ] )
except Exception as e : RNS . log ( f " Invalid page cache time header: { e } " , RNS . LOG_DEBUG )
2021-08-28 20:12:03 +02:00
2021-08-27 19:58:14 +02:00
self . update_display ( )
2021-09-17 11:47:41 +02:00
if not self . history_inc and not self . history_dec and not self . reloading :
self . write_history ( )
else :
self . history_dec = False
self . history_inc = False
self . reloading = False
2021-09-17 18:45:08 +02:00
if cache_time == 0 :
RNS . log ( " Received page " + str ( self . current_url ( ) ) + " , not caching due to header. " , RNS . LOG_DEBUG )
else :
RNS . log ( " Received page " + str ( self . current_url ( ) ) + " , caching for %.3f hours. " % ( cache_time / 60 / 60 ) , RNS . LOG_DEBUG )
self . cache_page ( cache_time )
2026-05-21 02:57:04 +02:00
if self . request_data and " var_anchor " in self . request_data :
2026-05-28 14:11:34 +02:00
def df ( loop , user_data ) : self . _jump_to_anchor ( self . request_data [ " var_anchor " ] )
nomadnet . NomadNetworkApp . get_shared_instance ( ) . ui . loop . set_alarm_in ( 0.0 , df )
2026-05-21 02:57:04 +02:00
2021-08-27 19:58:14 +02:00
except Exception as e :
RNS . log ( " An error occurred while handling response. The contained exception was: " + str ( e ) )
2021-09-17 18:45:08 +02:00
def uncache_page ( self , url ) :
url_hash = self . url_hash ( url )
files = os . listdir ( self . app . cachepath )
for file in files :
if file . startswith ( url_hash ) :
cachefile = self . app . cachepath + " / " + file
os . unlink ( cachefile )
RNS . log ( " Removed " + str ( cachefile ) + " from cache. " , RNS . LOG_DEBUG )
def get_cached ( self , url ) :
url_hash = self . url_hash ( url )
files = os . listdir ( self . app . cachepath )
for file in files :
cachepath = self . app . cachepath + " / " + file
try :
components = file . split ( " _ " )
if len ( components ) == 2 and len ( components [ 0 ] ) == 64 and len ( components [ 1 ] ) > 0 :
expires = float ( components [ 1 ] )
if time . time ( ) > expires :
RNS . log ( " Removing stale cache entry " + str ( file ) , RNS . LOG_DEBUG )
os . unlink ( cachepath )
else :
if file . startswith ( url_hash ) :
RNS . log ( " Found " + str ( file ) + " in cache. " , RNS . LOG_DEBUG )
RNS . log ( " Returning cached page " , RNS . LOG_DEBUG )
file = open ( cachepath , " rb " )
data = file . read ( )
file . close ( )
return data
except Exception as e :
RNS . log ( " Error while parsing cache entry " + str ( cachepath ) + " , removing it. " , RNS . LOG_ERROR )
RNS . log ( " The contained exception was: " + str ( e ) , RNS . LOG_ERROR )
try :
os . unlink ( cachepath )
except Exception as e :
RNS . log ( " Additionally, an exception occurred while unlinking the entry: " + str ( e ) , RNS . LOG_ERROR )
RNS . log ( " You will probably need to remove this entry manually by deleting the file: " + str ( cachepath ) , RNS . LOG_ERROR )
return None
2021-09-23 19:04:52 +02:00
def clean_cache ( self ) :
files = os . listdir ( self . app . cachepath )
for file in files :
cachepath = self . app . cachepath + " / " + file
try :
components = file . split ( " _ " )
if len ( components ) == 2 and len ( components [ 0 ] ) == 64 and len ( components [ 1 ] ) > 0 :
expires = float ( components [ 1 ] )
if time . time ( ) > expires :
RNS . log ( " Removing stale cache entry " + str ( file ) , RNS . LOG_DEBUG )
os . unlink ( cachepath )
except Exception as e :
pass
2021-09-17 18:45:08 +02:00
def cache_page ( self , cache_time ) :
url_hash = self . url_hash ( self . current_url ( ) )
if url_hash == None :
RNS . log ( " Could not cache page " + str ( self . current_url ( ) ) , RNS . LOG_ERROR )
else :
try :
self . uncache_page ( self . current_url ( ) )
cache_expires = time . time ( ) + cache_time
filename = url_hash + " _ " + str ( cache_expires )
cachefile = self . app . cachepath + " / " + filename
file = open ( cachefile , " wb " )
file . write ( self . page_data )
file . close ( )
RNS . log ( " Cached page " + str ( self . current_url ( ) ) + " to " + str ( cachefile ) , RNS . LOG_DEBUG )
except Exception as e :
RNS . log ( " Could not write cache file for page " + str ( self . current_url ( ) ) , RNS . LOG_ERROR )
RNS . log ( " The contained exception was: " + str ( e ) , RNS . LOG_ERROR )
2021-09-10 21:33:29 +02:00
def file_received ( self , request_receipt ) :
try :
2026-09-02 22:48:31 +02:00
if isinstance ( request_receipt . response , bytes ) :
markup = request_receipt . response . decode ( " utf-8 " )
self . attr_maps = markup_to_attrmaps ( strip_modifiers ( markup ) , url_delegate = self , fg_color = self . page_foreground_color , bg_color = self . page_background_color )
elif type ( request_receipt . response ) == io . BufferedReader :
2025-05-11 16:42:50 +02:00
if request_receipt . metadata != None :
2026-09-02 22:48:31 +02:00
file_name = ConversationMessage . safe_attachment_name ( request_receipt . metadata [ " name " ] )
2025-05-11 16:42:50 +02:00
file_handle = request_receipt . response
file_destination = self . app . downloads_path + " / " + file_name
2023-11-29 03:23:35 -06:00
2025-05-11 16:42:50 +02:00
counter = 0
while os . path . isfile ( file_destination ) :
counter + = 1
file_destination = self . app . downloads_path + " / " + file_name + " . " + str ( counter )
shutil . move ( file_handle . name , file_destination )
2026-01-12 00:24:50 +01:00
self . saved_file_name = file_destination . replace ( self . app . downloads_path + " / " , " " , 1 )
2026-05-09 15:13:56 +02:00
try : self . saved_file_size = os . path . getsize ( file_destination )
except : self . saved_file_size = 0
2026-05-04 23:35:23 +02:00
self . file_saved_at = time . time ( )
2026-01-12 00:24:50 +01:00
2025-05-11 16:42:50 +02:00
else :
file_name = request_receipt . response [ 0 ]
file_data = request_receipt . response [ 1 ]
2026-09-02 22:48:31 +02:00
file_destination_name = ConversationMessage . safe_attachment_name ( file_name )
2025-05-11 16:42:50 +02:00
file_destination = self . app . downloads_path + " / " + file_destination_name
counter = 0
while os . path . isfile ( file_destination ) :
counter + = 1
2025-05-11 21:27:32 +02:00
file_destination = self . app . downloads_path + " / " + file_destination_name + " . " + str ( counter )
2021-09-10 21:33:29 +02:00
2025-05-11 16:42:50 +02:00
fh = open ( file_destination , " wb " )
fh . write ( file_data )
fh . close ( )
2021-09-10 21:33:29 +02:00
2025-05-11 16:42:50 +02:00
self . saved_file_name = file_destination . replace ( self . app . downloads_path + " / " , " " , 1 )
2026-05-09 15:13:56 +02:00
try : self . saved_file_size = os . path . getsize ( file_destination )
except : self . saved_file_size = 0
2025-05-11 21:27:32 +02:00
2021-09-10 21:33:29 +02:00
self . status = Browser . DONE
self . response_progress = 0
2025-05-11 17:21:43 +02:00
self . response_speed = None
self . progress_updated_at = None
self . previous_progress = 0
2021-09-10 21:33:29 +02:00
self . update_display ( )
2026-05-04 23:35:23 +02:00
2021-09-10 21:33:29 +02:00
except Exception as e :
2021-09-17 18:45:08 +02:00
RNS . log ( " An error occurred while handling file response. The contained exception was: " + str ( e ) , RNS . LOG_ERROR )
2021-09-10 21:33:29 +02:00
2021-08-27 19:58:14 +02:00
def request_failed ( self , request_receipt = None ) :
if request_receipt != None :
if request_receipt . request_id == self . last_request_id :
self . status = Browser . REQUEST_FAILED
2021-08-28 20:12:03 +02:00
self . response_progress = 0
2025-05-11 17:21:43 +02:00
self . response_speed = None
self . progress_updated_at = None
self . previous_progress = 0
2021-08-28 20:12:03 +02:00
self . response_size = None
self . response_transfer_size = None
2021-08-27 19:58:14 +02:00
self . update_display ( )
2023-09-19 11:56:04 +02:00
if self . link != None :
2026-09-05 22:15:10 +02:00
try : self . link . teardown ( )
except Exception as e : pass
2021-08-27 19:58:14 +02:00
else :
self . status = Browser . REQUEST_FAILED
2021-08-28 20:12:03 +02:00
self . response_progress = 0
2025-05-11 17:21:43 +02:00
self . response_speed = None
self . progress_updated_at = None
self . previous_progress = 0
2021-08-28 20:12:03 +02:00
self . response_size = None
self . response_transfer_size = None
2021-08-27 19:58:14 +02:00
self . update_display ( )
2023-09-19 11:56:04 +02:00
if self . link != None :
try :
self . link . teardown ( )
except Exception as e :
pass
2021-08-27 19:58:14 +02:00
def request_timeout ( self , request_receipt = None ) :
self . status = Browser . REQUEST_TIMEOUT
2021-08-28 20:12:03 +02:00
self . response_progress = 0
2025-05-11 17:21:43 +02:00
self . response_speed = None
self . progress_updated_at = None
self . previous_progress = 0
2021-08-28 20:12:03 +02:00
self . response_size = None
self . response_transfer_size = None
self . update_display ( )
2023-09-19 11:56:04 +02:00
if self . link != None :
try :
self . link . teardown ( )
except Exception as e :
pass
2021-08-28 20:12:03 +02:00
def response_progressed ( self , request_receipt ) :
self . response_progress = request_receipt . progress
2021-09-03 14:17:00 +02:00
self . response_time = request_receipt . get_response_time ( )
2021-08-28 20:12:03 +02:00
self . response_size = request_receipt . response_size
self . response_transfer_size = request_receipt . response_transfer_size
2025-05-11 17:21:43 +02:00
now = time . time ( )
if self . progress_updated_at == None : self . progress_updated_at = now
2026-05-04 23:35:23 +02:00
if now > self . progress_updated_at + 0.5 :
2025-05-11 17:21:43 +02:00
td = now - self . progress_updated_at
pd = self . response_progress - self . previous_progress
2026-05-04 23:05:22 +02:00
bd = pd * self . response_transfer_size
2025-05-11 17:21:43 +02:00
self . response_speed = ( bd / td ) * 8
self . previous_progress = self . response_progress
self . progress_updated_at = now
2021-08-27 19:58:14 +02:00
self . update_display ( )
2026-09-06 13:37:35 +02:00
def last_response_speed ( self ) :
if self . response_transfer_size and self . response_time :
return ( self . response_transfer_size / max ( self . response_time , 0.0001 ) ) * 8
else : return None
2021-08-27 19:58:14 +02:00
def status_text ( self ) :
2021-09-03 18:55:34 +02:00
if self . status == Browser . DONE and self . response_transfer_size != None :
2026-05-09 15:13:56 +02:00
if self . response_time != None : response_time_str = " {:.2f} " . format ( self . response_time )
else : response_time_str = " None "
if self . saved_file_name == None : resp_size = self . response_size
else : resp_size = self . saved_file_size
2021-09-03 14:17:00 +02:00
2026-08-28 17:05:36 +02:00
if resp_size : stats_string = " " + self . g [ " page " ] + size_str ( resp_size )
else : stats_string = " "
2026-05-11 20:00:13 +02:00
if self . response_transfer_size :
stats_string + = " " + self . g [ " arrow_d " ] + size_str ( self . response_transfer_size ) + " in " + response_time_str
if self . response_transfer_size and self . response_time :
stats_string + = " s " + self . g [ " speed " ] + size_str ( self . response_transfer_size / self . response_time , suffix = " b " ) + " /s "
2021-09-17 18:45:08 +02:00
elif self . loaded_from_cache :
stats_string = " (cached) "
2021-08-28 20:12:03 +02:00
else :
stats_string = " "
2021-08-27 19:58:14 +02:00
if self . status == Browser . NO_PATH :
return " No path to destination known "
elif self . status == Browser . PATH_REQUESTED :
return " Path requested, waiting for path... "
elif self . status == Browser . ESTABLISHING_LINK :
return " Establishing link... "
2021-09-03 18:55:34 +02:00
elif self . status == Browser . LINK_TIMEOUT :
return " Link establishment timed out "
2021-08-27 19:58:14 +02:00
elif self . status == Browser . LINK_ESTABLISHED :
return " Link established "
elif self . status == Browser . REQUESTING :
return " Sending request... "
elif self . status == Browser . REQUEST_SENT :
return " Request sent, awaiting response... "
elif self . status == Browser . REQUEST_FAILED :
return " Request failed "
elif self . status == Browser . REQUEST_TIMEOUT :
return " Request timed out "
elif self . status == Browser . RECEIVING_RESPONSE :
return " Receiving response... "
elif self . status == Browser . DONE :
2026-05-09 15:13:56 +02:00
if self . saved_file_name == None : return " Done " + stats_string
else : return " Saved " + str ( self . saved_file_name ) + stats_string
2021-08-27 19:58:14 +02:00
elif self . status == Browser . DISCONECTED :
return " Disconnected "
else :
return " Browser Status Unknown "
2021-08-28 20:12:03 +02:00
class ResponseProgressBar ( urwid . ProgressBar ) :
2025-05-11 17:21:43 +02:00
def __init__ ( self , empty , full , current = None , done = None , satt = None , owner = None ) :
super ( ) . __init__ ( empty , full , current = current , done = done , satt = satt )
self . owner = owner
2021-08-28 20:12:03 +02:00
def get_text ( self ) :
2025-05-11 17:21:43 +02:00
if self . owner . response_speed : speed_str = " " + RNS . prettyspeed ( self . owner . response_speed )
else : speed_str = " "
return " Receiving response " + super ( ) . get_text ( ) . replace ( " % " , " % " ) + speed_str
2021-08-28 20:12:03 +02:00
# A convenience function for printing a human-
# readable file size
def size_str ( num , suffix = ' B ' ) :
units = [ ' ' , ' K ' , ' M ' , ' G ' , ' T ' , ' P ' , ' E ' , ' Z ' ]
last_unit = ' Y '
if suffix == ' b ' :
num * = 8
units = [ ' ' , ' K ' , ' M ' , ' G ' , ' T ' , ' P ' , ' E ' , ' Z ' ]
last_unit = ' Y '
for unit in units :
if abs ( num ) < 1000.0 :
2021-08-28 20:53:57 +02:00
if unit == " " :
return " %.0f %s %s " % ( num , unit , suffix )
else :
return " %.2f %s %s " % ( num , unit , suffix )
2021-08-28 20:12:03 +02:00
num / = 1000.0
2021-08-28 20:53:57 +02:00
2021-09-17 12:23:13 +02:00
return " %.2f %s %s " % ( num , last_unit , suffix )
class UrlDialogLineBox ( urwid . LineBox ) :
2021-09-18 15:09:41 +02:00
def keypress ( self , size , key ) :
if key == " esc " :
self . delegate . close_dialogs ( )
else :
return super ( UrlDialogLineBox , self ) . keypress ( size , key )
2026-05-22 20:17:47 -04:00
class UrlEdit ( ReadlineMixin , urwid . Edit ) :
2021-09-18 15:09:41 +02:00
def keypress ( self , size , key ) :
if key == " enter " :
self . confirmed ( self )
else :
2023-02-17 23:04:44 +01:00
return super ( UrlEdit , self ) . keypress ( size , key )