39 Commits

Author SHA1 Message Date
b41c966bf3 Update control handling 2025-09-28 19:33:33 -05:00
a15f03ad8a Update Micron renderer to return Container instead of ListView 2025-09-28 19:29:07 -05:00
ad567cfac0 Add more tests for Micron rendering features 2025-09-28 18:58:49 -05:00
615a54852d Improved MicronParser styling and formatting logic
- Updated dark and light theme styles for headings to include bold formatting.
- Improved handling of backticks for text formatting, allowing for better toggling of styles.
- Refactored text accumulation logic to streamline character processing and ensure proper formatting.
2025-09-28 18:58:22 -05:00
dc18d57547 Refactor text extraction in Micron renderer for improved attribute access
- Updated the text extraction logic to use public attributes instead of private ones, enhancing readability and maintainability.
- Adjusted corresponding unit tests to reflect the changes in attribute access.
2025-09-28 18:29:25 -05:00
e8ee623a82 Update tests to work with new Micron renderer improvements. 2025-09-28 18:26:05 -05:00
79c3351fb4 Fix header background color to extend whole line,
- Add docstrings,
- Add section/header indentation
- Add color validation regex.
2025-09-28 18:22:57 -05:00
6508a89443 Update Micron renderer tests to validate Column structure and properties
- Updated test cases to reflect the new functionality of the Micron renderer, ensuring it returns a Column containing styled Text controls.
- Improved assertions to check for proper formatting, scroll behavior, and control properties.
- Adjusted test descriptions for clarity and accuracy.
2025-09-28 16:34:58 -05:00
219e6822e8 Update MicronParser methods to staticmethods 2025-09-28 16:31:25 -05:00
910fe3c8aa Add ASCII art scaling to MicronParser
- Introduced ascii_art_scale parameter to adjust font size for ASCII art.
- Enhanced text rendering logic to detect and scale ASCII art appropriately.
- Updated _color_to_flet method to be static for better accessibility.
2025-09-28 16:28:31 -05:00
3f40828707 Ruff formatting and fixes 2025-09-28 16:17:08 -05:00
4d3e3f6688 Add Micron markup renderer with Flet integration
- Implement MicronParser class with inline formatting support
- Support for headings, dividers, colors, and ASCII art
- Add monospace font for proper text rendering
- Enable text selection and scrolling in rendered content
2025-09-28 16:14:47 -05:00
c0f60d52db Update storage path in tests to reflect user-accessible external storage and change button label to "Save Config". 2025-09-28 15:51:05 -05:00
57c6b8ce3d Update CONTRIBUTING.md 2025-09-28 15:45:39 -05:00
9fc912fba4 Add config saving feedback in settings.py with print statements and update button label to "Save Config". 2025-09-28 15:38:25 -05:00
0d878e8491 Update Android storage path to use user-accessible external storage instead of the app's private files directory. 2025-09-28 15:38:05 -05:00
2796059aef Update 2025-09-28 15:37:50 -05:00
a5ae444b6c Update 2025-09-28 15:28:36 -05:00
d354b96334 Update 2025-09-25 12:15:35 -05:00
f511b60361 remove 2025-09-25 12:15:27 -05:00
b8386a60c6 Update GitHub workflows to use latest action versions for Python, Docker, Safety CLI, and caching 2025-09-22 19:02:28 -05:00
e20d6fe214 Update Dockerfile 2025-09-22 15:40:38 -05:00
8b45e5d72b add Makefile 2025-09-22 15:37:00 -05:00
047169f3af Remove development-specific entries from pyproject.toml to streamline configuration. 2025-09-22 15:31:16 -05:00
e0939e70f8 Add config file writing logic to ensure saved configurations are persisted before RNS initialization. Includes error handling for potential write failures. 2025-09-22 15:25:11 -05:00
63e93d0cff Add reticulum default config file 2025-09-22 15:20:44 -05:00
926b3a198d update hashes 2025-09-22 14:40:01 -05:00
8db441612f Bump version to 0.2.2
- Updated build workflow to fix fetching artifacts
- Ruff formatting and fixes
- Fix config path handling for android
- Codebase performance and bug risk fixes (Deepsource)
2025-09-22 14:13:30 -05:00
b34b8f23ff Fix log setup condition in setup_rns_logging to use 'is not' for comparison 2025-09-22 14:10:02 -05:00
13ad0bcef6 Fix config path handling in StorageManager for Android 2025-09-22 14:01:24 -05:00
64b9ac3df4 ruff formatting and fixes 2025-09-22 13:56:04 -05:00
ee521a9f60 ruff formatting and fixes 2025-09-22 13:55:52 -05:00
fd4e0c8a14 Update build workflow 2025-09-22 13:48:43 -05:00
b056271da7 Bump version to 0.2.1
- Fix broken APK and Linux builds due to webview_flutter_android compatibility issue
- Add manual workflow trigger for easier testing and deployment
- Add automated draft release creation with build artifacts
- Override webview_flutter_android to v4.10.1 for Dart 3.7 compatibility
2025-09-20 16:31:55 -05:00
189256edd7 Update README 2025-09-20 16:25:09 -05:00
62d3502f99 Add create-release job to GitHub Actions workflow for automated draft releases with Linux and APK artifacts 2025-09-20 16:24:59 -05:00
cb218f2b29 add manual trigger 2025-09-20 16:14:11 -05:00
871f626555 possible fix for broken apk/linux builds (flutter) 2025-09-20 16:13:59 -05:00
9a20152a70 update README 2025-09-20 16:08:22 -05:00
31 changed files with 1546 additions and 292 deletions

View File

@@ -4,6 +4,7 @@ on:
push: push:
tags: tags:
- 'v*.*.*' - 'v*.*.*'
workflow_dispatch:
jobs: jobs:
build-linux: build-linux:
@@ -20,7 +21,7 @@ jobs:
sudo apt-get install -y libgtk-3-dev cmake ninja-build clang pkg-config libgtk-3-dev liblzma-dev sudo apt-get install -y libgtk-3-dev cmake ninja-build clang pkg-config libgtk-3-dev liblzma-dev
- name: Set up Python - name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with: with:
python-version: '3.13' python-version: '3.13'
@@ -49,7 +50,7 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- name: Set up JDK - name: Set up JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with: with:
distribution: 'zulu' distribution: 'zulu'
java-version: '17' java-version: '17'
@@ -60,7 +61,7 @@ jobs:
sudo apt-get install -y cmake ninja-build clang pkg-config sudo apt-get install -y cmake ninja-build clang pkg-config
- name: Set up Python - name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with: with:
python-version: '3.13' python-version: '3.13'
@@ -78,4 +79,42 @@ jobs:
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with: with:
name: ren-browser-apk name: ren-browser-apk
path: build/apk path: build/apk
create-release:
needs: [build-linux, build-android]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- name: Download Linux artifact
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0
with:
name: ren-browser-linux
path: ./artifacts/linux
- name: Download APK artifact
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0
with:
name: ren-browser-apk
path: ./artifacts/apk
- name: Create draft release
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836
if: github.ref_type == 'tag'
with:
draft: true
files: |
./artifacts/linux/*
./artifacts/apk/*
name: Release ${{ github.ref_name }}
body: |
## Release ${{ github.ref_name }}
This release contains:
- Linux binary package
- Android APK package

View File

@@ -44,7 +44,7 @@ jobs:
type=sha,format=short type=sha,format=short
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with: with:
context: . context: .
file: Dockerfile file: Dockerfile

View File

@@ -12,6 +12,6 @@ jobs:
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- name: Run Safety CLI to check for vulnerabilities - name: Run Safety CLI to check for vulnerabilities
uses: pyupio/safety-action@7baf6605473beffc874c1313ddf2db085c0cacf2 uses: pyupio/safety-action@2591cf2f3e67ba68b923f4c92f0d36e281c65023 # v1.0.1
with: with:
api-key: ${{ secrets.SAFETY_API_KEY }} api-key: ${{ secrets.SAFETY_API_KEY }}

View File

@@ -24,7 +24,7 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
@@ -44,7 +44,7 @@ jobs:
poetry config virtualenvs.in-project true poetry config virtualenvs.in-project true
- name: Cache Poetry dependencies - name: Cache Poetry dependencies
uses: actions/cache@2f8e54208210a422b2efd51efaa6bd6d7ca8920f uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: .venv path: .venv
key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
@@ -64,7 +64,7 @@ jobs:
poetry run pytest -v --cov=ren_browser --cov-report=xml --cov-report=term poetry run pytest -v --cov=ren_browser --cov-report=xml --cov-report=term
- name: Upload coverage reports - name: Upload coverage reports
uses: codecov/codecov-action@ab904c41d6ece82784817410c45d8b8c02684457 uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
if: matrix.python-version == '3.13' if: matrix.python-version == '3.13'
with: with:
file: ./coverage.xml file: ./coverage.xml

View File

@@ -7,13 +7,51 @@ I welcome all contributions to the project.
- Styling/Design (I am bad at this) - Styling/Design (I am bad at this)
- Documentation - Documentation
- Micron Renderer/Parser - Micron Renderer/Parser
- Android and Flet (config/permissions/etc)
## Project Structure
Last Updated: 2025-09-28
```
Ren-Browser/
├── ren_browser/ # Main Python application package
│ ├── announces/ # Reticulum network announce handling
│ │ ├── announces.py
│ ├── app.py # Main application entry point
│ ├── controls/ # UI controls and interactions
│ │ ├── shortcuts.py # Keyboard shortcuts handling
│ ├── logs.py # Centralized logging system
│ ├── pages/ # Page fetching and request handling
│ │ ├── page_request.py
│ ├── profiler/ # Performance profiling (placeholder)
│ ├── renderer/ # Content rendering system
│ │ ├── micron.py # Micron markup renderer (WIP)
│ │ └── plaintext.py # Plaintext fallback renderer
│ ├── storage/ # Cross-platform storage management
│ │ ├── storage.py
│ ├── tabs/ # Tab management system
│ │ ├── tabs.py
│ ├── ui/ # User interface components
│ │ ├── settings.py # Settings interface
│ │ └── ui.py # Main UI construction
├── tests/ # Test suite
│ ├── unit/ # Unit tests
│ ├── integration/ # Integration tests
│ └── conftest.py # Test configuration
```
## Rules ## Rules
1. Be nice to each other. 1. Be nice to each other.
2. If you use an AI tool that generates the code, such as a LLM, please indicate that in the PR.
3. Add or update docstrings and tests if necessary. ## Generative AI Usage
4. Make sure you run the tests before submitting the PR.
You are allowed to use generative AI tools to help learn and contribute. You do not need to disclose you used a AI tool, although that would help me scrutinize the PR more for bugs, errors or security flaws.
## Linting, Security and Tests
You are not required to run the linting, security and tests before submitting the PR as those will be run by the CI/CD pipeline.
## Testing ## Testing

View File

@@ -1,18 +1,29 @@
FROM python:3.13-alpine ARG PYTHON_VERSION=3.13
FROM python:${PYTHON_VERSION}-alpine
# Install build dependencies for cryptography LABEL org.opencontainers.image.source="https://github.com/Sudo-Ivan/Ren-Browser"
RUN apk add --no-cache gcc musl-dev libffi-dev openssl-dev LABEL org.opencontainers.image.description="A browser for the Reticulum Network."
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.authors="Sudo-Ivan"
# Upgrade pip and install application dependencies
RUN pip install --upgrade pip \
&& pip install --no-cache-dir "flet>=0.28.3,<0.29.0" "rns>=0.9.6,<0.10.0"
# Copy application source
WORKDIR /app WORKDIR /app
COPY . /app
# Expose the web port RUN apk add --no-cache gcc python3-dev musl-dev linux-headers libffi-dev openssl-dev
RUN pip install --no-cache poetry
ENV POETRY_VIRTUALENVS_IN_PROJECT=true
COPY pyproject.toml poetry.lock* ./
COPY README.md ./
COPY ren_browser ./ren_browser
RUN poetry install --no-interaction --no-ansi --no-cache
ENV PATH="/app/.venv/bin:$PATH"
ENV FLET_WEB_PORT=8550
ENV FLET_WEB_HOST=0.0.0.0
ENV DISPLAY=:99
EXPOSE 8550 EXPOSE 8550
# Run the web version of Ren Browser ENTRYPOINT ["poetry", "run", "ren-browser-web"]
CMD ["python3", "-u", "-m", "ren_browser.app", "--web", "--port", "8550"]

80
Makefile Normal file
View File

@@ -0,0 +1,80 @@
# Ren Browser Makefile
.PHONY: help build poetry-build linux apk docker-build docker-build-multi docker-run docker-stop clean test lint format
# Default target
help:
@echo "Ren Browser Build System"
@echo ""
@echo "Available targets:"
@echo " build - Build the project (alias for poetry-build)"
@echo " poetry-build - Build project with Poetry"
@echo " linux - Build Linux package"
@echo " apk - Build Android APK"
@echo " docker-build - Build Docker image with Buildx"
@echo " docker-build-multi - Build multi-platform Docker image"
@echo " docker-run - Run Docker container"
@echo " docker-stop - Stop Docker container"
@echo " test - Run tests"
@echo " lint - Run linter"
@echo " format - Format code"
@echo " clean - Clean build artifacts"
@echo " help - Show this help"
# Main build target
build: poetry-build
# Poetry build
poetry-build:
@echo "Building project with Poetry..."
poetry build
# Linux package build
linux:
@echo "Building Linux package..."
poetry run flet build linux
# Android APK build
apk:
@echo "Building Android APK..."
poetry run flet build apk
# Docker targets
docker-build:
@echo "Building Docker image with Buildx..."
docker buildx build -t ren-browser --load .
docker-build-multi:
@echo "Building multi-platform Docker image..."
docker buildx build -t ren-browser-multi --platform linux/amd64,linux/arm64 --push .
docker-run:
@echo "Running Docker container..."
docker run -p 8550:8550 --name ren-browser-container ren-browser
docker-stop:
@echo "Stopping Docker container..."
docker stop ren-browser-container || true
docker rm ren-browser-container || true
# Development targets
test:
@echo "Running tests..."
poetry run pytest
lint:
@echo "Running linter..."
poetry run ruff check .
format:
@echo "Formatting code..."
poetry run ruff format .
# Clean build artifacts
clean:
@echo "Cleaning build artifacts..."
rm -rf build/
rm -rf dist/
rm -rf *.egg-info/
find . -type d -name __pycache__ -exec rm -rf {} +
find . -type f -name "*.pyc" -delete
docker rmi ren-browser || true

View File

@@ -1,13 +1,14 @@
# Ren Browser # Ren Browser
A browser for the [Reticulum Network](https://reticulum.network/). Work-in-progress. A browser for the [Reticulum Network](https://reticulum.network/).
> [!WARNING]
> This is a work-in-progress.
Target platforms: Web, Linux, Windows, MacOS, Android, iOS. Target platforms: Web, Linux, Windows, MacOS, Android, iOS.
Built using [Flet](https://flet.dev/). Built using [Flet](https://flet.dev/).
Currently, you can find `Linux` and `Android` builds in action artifacts in the [GitHub Actions](https://github.com/Sudo-Ivan/Ren-Browser/actions/workflows/build.yml) page, click on the latest workflow run. More platforms will be added in the future.
## Renderers ## Renderers
- Micron (default) (WIP) - Micron (default) (WIP)
@@ -31,13 +32,13 @@ poetry install
### Desktop ### Desktop
```bash ```bash
poetry run ren-browser-dev poetry run ren-browser
``` ```
### Web ### Web
```bash ```bash
poetry run ren-browser-web-dev poetry run ren-browser-web
``` ```
### Mobile ### Mobile
@@ -45,13 +46,13 @@ poetry run ren-browser-web-dev
**Android** **Android**
```bash ```bash
poetry run ren-browser-android-dev poetry run ren-browser-android
``` ```
**iOS** **iOS**
```bash ```bash
poetry run ren-browser-ios-dev poetry run ren-browser-ios
``` ```
### Docker/Podman ### Docker/Podman

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "ren-browser" name = "ren-browser"
version = "0.2.0" version = "0.2.2"
description = "A browser for the Reticulum Network." description = "A browser for the Reticulum Network."
authors = [ authors = [
{name = "Sudo-Ivan"} {name = "Sudo-Ivan"}
@@ -22,10 +22,6 @@ ren-browser = "ren_browser.app:run"
ren-browser-web = "ren_browser.app:web" ren-browser-web = "ren_browser.app:web"
ren-browser-android = "ren_browser.app:android" ren-browser-android = "ren_browser.app:android"
ren-browser-ios = "ren_browser.app:ios" ren-browser-ios = "ren_browser.app:ios"
ren-browser-dev = "ren_browser.app:run_dev"
ren-browser-web-dev = "ren_browser.app:web_dev"
ren-browser-android-dev = "ren_browser.app:android_dev"
ren-browser-ios-dev = "ren_browser.app:ios_dev"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
ruff = "^0.11.11" ruff = "^0.11.11"
@@ -34,3 +30,6 @@ pytest-cov = "^7.0.0"
pytest-mock = "^3.15.1" pytest-mock = "^3.15.1"
pytest-asyncio = "^1.2.0" pytest-asyncio = "^1.2.0"
[tool.flet.flutter.pubspec.dependency_overrides]
webview_flutter_android = "4.10.1"

View File

@@ -3,13 +3,13 @@
This module provides services for listening to and collecting network This module provides services for listening to and collecting network
announces from the Reticulum network. announces from the Reticulum network.
""" """
import time import time
from dataclasses import dataclass from dataclasses import dataclass
import RNS import RNS
@dataclass @dataclass
class Announce: class Announce:
"""Represents a Reticulum network announce. """Represents a Reticulum network announce.
@@ -21,6 +21,7 @@ class Announce:
display_name: str | None display_name: str | None
timestamp: int timestamp: int
class AnnounceService: class AnnounceService:
"""Service to listen for Reticulum announces and collect them. """Service to listen for Reticulum announces and collect them.
@@ -60,7 +61,11 @@ class AnnounceService:
except UnicodeDecodeError: except UnicodeDecodeError:
pass pass
announce = Announce(destination_hash.hex(), display_name, ts) announce = Announce(destination_hash.hex(), display_name, ts)
self.announces = [ann for ann in self.announces if ann.destination_hash != announce.destination_hash] self.announces = [
ann
for ann in self.announces
if ann.destination_hash != announce.destination_hash
]
self.announces.insert(0, announce) self.announces.insert(0, announce)
if self.update_callback: if self.update_callback:
self.update_callback(self.announces) self.update_callback(self.announces)

View File

@@ -3,6 +3,7 @@
This module provides the entry point and platform-specific launchers for the This module provides the entry point and platform-specific launchers for the
Ren Browser, a browser for the Reticulum Network built with Flet. Ren Browser, a browser for the Reticulum Network built with Flet.
""" """
import argparse import argparse
import flet as ft import flet as ft
@@ -15,36 +16,68 @@ from ren_browser.ui.ui import build_ui
RENDERER = "plaintext" RENDERER = "plaintext"
RNS_CONFIG_DIR = None RNS_CONFIG_DIR = None
async def main(page: Page): async def main(page: Page):
"""Initialize and launch the Ren Browser application. """Initialize and launch the Ren Browser application.
Sets up the loading screen, initializes Reticulum network, Sets up the loading screen, initializes Reticulum network,
and builds the main UI. and builds the main UI.
""" """
page.title = "Ren Browser"
page.theme_mode = ft.ThemeMode.DARK
loader = ft.Container( loader = ft.Container(
expand=True, expand=True,
alignment=ft.alignment.center, alignment=ft.alignment.center,
bgcolor=ft.Colors.SURFACE,
content=ft.Column( content=ft.Column(
[ft.ProgressRing(), ft.Text("Initializing reticulum network")], [
ft.ProgressRing(color=ft.Colors.PRIMARY, width=50, height=50),
ft.Container(height=20),
ft.Text(
"Initializing Reticulum Network...",
size=16,
color=ft.Colors.ON_SURFACE,
text_align=ft.TextAlign.CENTER,
),
],
alignment=ft.MainAxisAlignment.CENTER, alignment=ft.MainAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER,
spacing=10,
), ),
) )
page.add(loader) page.add(loader)
page.update() page.update()
def init_ret(): def init_ret():
import time
time.sleep(0.5)
# Initialize storage system # Initialize storage system
storage = initialize_storage(page) storage = initialize_storage(page)
# Get Reticulum config directory # Get Reticulum config directory from storage manager
if RNS_CONFIG_DIR: config_dir = storage.get_reticulum_config_path()
config_dir = RNS_CONFIG_DIR
else: # Update the global RNS_CONFIG_DIR so RNS uses the right path
config_dir = storage.get_reticulum_config_path() global RNS_CONFIG_DIR
RNS_CONFIG_DIR = str(config_dir)
# Ensure any saved config is written to filesystem before RNS init
try:
saved_config = storage.load_config()
if saved_config and saved_config.strip():
config_file_path = config_dir / "config"
config_file_path.parent.mkdir(parents=True, exist_ok=True)
config_file_path.write_text(saved_config, encoding="utf-8")
except Exception as e:
print(f"Warning: Failed to write config file: {e}")
try: try:
# Set up logging capture first, before RNS init # Set up logging capture first, before RNS init
import ren_browser.logs import ren_browser.logs
ren_browser.logs.setup_rns_logging() ren_browser.logs.setup_rns_logging()
RNS.Reticulum(str(config_dir)) RNS.Reticulum(str(config_dir))
except (OSError, ValueError): except (OSError, ValueError):
@@ -55,14 +88,31 @@ async def main(page: Page):
page.run_thread(init_ret) page.run_thread(init_ret)
def run(): def run():
"""Run Ren Browser with command line argument parsing.""" """Run Ren Browser with command line argument parsing."""
global RENDERER, RNS_CONFIG_DIR global RENDERER, RNS_CONFIG_DIR
parser = argparse.ArgumentParser(description="Ren Browser") parser = argparse.ArgumentParser(description="Ren Browser")
parser.add_argument("-r", "--renderer", choices=["plaintext", "micron"], default=RENDERER, help="Select renderer (plaintext or micron)") parser.add_argument(
parser.add_argument("-w", "--web", action="store_true", help="Launch in web browser mode") "-r",
parser.add_argument("-p", "--port", type=int, default=None, help="Port for web server") "--renderer",
parser.add_argument("-c", "--config-dir", type=str, default=None, help="RNS config directory (default: ~/.reticulum/)") choices=["plaintext", "micron"],
default=RENDERER,
help="Select renderer (plaintext or micron)",
)
parser.add_argument(
"-w", "--web", action="store_true", help="Launch in web browser mode"
)
parser.add_argument(
"-p", "--port", type=int, default=None, help="Port for web server"
)
parser.add_argument(
"-c",
"--config-dir",
type=str,
default=None,
help="RNS config directory (default: ~/.reticulum/)",
)
args = parser.parse_args() args = parser.parse_args()
RENDERER = args.renderer RENDERER = args.renderer
@@ -71,6 +121,7 @@ def run():
RNS_CONFIG_DIR = args.config_dir RNS_CONFIG_DIR = args.config_dir
else: else:
import pathlib import pathlib
RNS_CONFIG_DIR = str(pathlib.Path.home() / ".reticulum") RNS_CONFIG_DIR = str(pathlib.Path.home() / ".reticulum")
if args.web: if args.web:
@@ -81,33 +132,41 @@ def run():
else: else:
ft.app(main) ft.app(main)
if __name__ == "__main__": if __name__ == "__main__":
run() run()
def web(): def web():
"""Launch Ren Browser in web mode.""" """Launch Ren Browser in web mode."""
ft.app(main, view=AppView.WEB_BROWSER) ft.app(main, view=AppView.WEB_BROWSER)
def android(): def android():
"""Launch Ren Browser in Android mode.""" """Launch Ren Browser in Android mode."""
ft.app(main, view=AppView.FLET_APP_WEB) ft.app(main, view=AppView.FLET_APP_WEB)
def ios(): def ios():
"""Launch Ren Browser in iOS mode.""" """Launch Ren Browser in iOS mode."""
ft.app(main, view=AppView.FLET_APP_WEB) ft.app(main, view=AppView.FLET_APP_WEB)
def run_dev(): def run_dev():
"""Launch Ren Browser in desktop mode.""" """Launch Ren Browser in desktop mode."""
ft.app(main) ft.app(main)
def web_dev(): def web_dev():
"""Launch Ren Browser in web mode.""" """Launch Ren Browser in web mode."""
ft.app(main, view=AppView.WEB_BROWSER) ft.app(main, view=AppView.WEB_BROWSER)
def android_dev(): def android_dev():
"""Launch Ren Browser in Android mode.""" """Launch Ren Browser in Android mode."""
ft.app(main, view=AppView.FLET_APP_WEB) ft.app(main, view=AppView.FLET_APP_WEB)
def ios_dev(): def ios_dev():
"""Launch Ren Browser in iOS mode.""" """Launch Ren Browser in iOS mode."""
ft.app(main, view=AppView.FLET_APP_WEB) ft.app(main, view=AppView.FLET_APP_WEB)

View File

@@ -3,6 +3,7 @@
Provides keyboard event handling and delegation to tab manager Provides keyboard event handling and delegation to tab manager
and UI components. and UI components.
""" """
import flet as ft import flet as ft

View File

@@ -3,6 +3,7 @@
Provides centralized logging for application events, errors, and Provides centralized logging for application events, errors, and
Reticulum network activities. Reticulum network activities.
""" """
import datetime import datetime
import RNS import RNS
@@ -12,6 +13,7 @@ ERROR_LOGS: list[str] = []
RET_LOGS: list[str] = [] RET_LOGS: list[str] = []
_original_rns_log = RNS.log _original_rns_log = RNS.log
def log_ret(msg, *args, **kwargs): def log_ret(msg, *args, **kwargs):
"""Log Reticulum messages with timestamp. """Log Reticulum messages with timestamp.
@@ -25,14 +27,16 @@ def log_ret(msg, *args, **kwargs):
RET_LOGS.append(f"[{timestamp}] {msg}") RET_LOGS.append(f"[{timestamp}] {msg}")
return _original_rns_log(msg, *args, **kwargs) return _original_rns_log(msg, *args, **kwargs)
def setup_rns_logging(): def setup_rns_logging():
"""Set up RNS log replacement. Call this after RNS.Reticulum initialization.""" """Set up RNS log replacement. Call this after RNS.Reticulum initialization."""
global _original_rns_log global _original_rns_log
# Only set up if not already done and if RNS.log is not already our function # Only set up if not already done and if RNS.log is not already our function
if RNS.log != log_ret and _original_rns_log != log_ret: if RNS.log is not log_ret and _original_rns_log is not log_ret:
_original_rns_log = RNS.log _original_rns_log = RNS.log
RNS.log = log_ret RNS.log = log_ret
def log_error(msg: str): def log_error(msg: str):
"""Log error messages to both error and application logs. """Log error messages to both error and application logs.
@@ -44,6 +48,7 @@ def log_error(msg: str):
ERROR_LOGS.append(f"[{timestamp}] {msg}") ERROR_LOGS.append(f"[{timestamp}] {msg}")
APP_LOGS.append(f"[{timestamp}] ERROR: {msg}") APP_LOGS.append(f"[{timestamp}] ERROR: {msg}")
def log_app(msg: str): def log_app(msg: str):
"""Log application messages. """Log application messages.

View File

@@ -3,6 +3,7 @@
Handles downloading pages from the Reticulum network using Handles downloading pages from the Reticulum network using
the nomadnetwork protocol. the nomadnetwork protocol.
""" """
import threading import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
@@ -10,7 +11,6 @@ from dataclasses import dataclass
import RNS import RNS
@dataclass @dataclass
class PageRequest: class PageRequest:
"""Represents a request for a page from the Reticulum network. """Represents a request for a page from the Reticulum network.
@@ -22,6 +22,7 @@ class PageRequest:
page_path: str page_path: str
field_data: dict | None = None field_data: dict | None = None
class PageFetcher: class PageFetcher:
"""Fetcher to download pages from the Reticulum network.""" """Fetcher to download pages from the Reticulum network."""
@@ -43,7 +44,9 @@ class PageFetcher:
Exception: If no path to destination or identity not found. Exception: If no path to destination or identity not found.
""" """
RNS.log(f"PageFetcher: starting fetch of {req.page_path} from {req.destination_hash}") RNS.log(
f"PageFetcher: starting fetch of {req.page_path} from {req.destination_hash}"
)
dest_bytes = bytes.fromhex(req.destination_hash) dest_bytes = bytes.fromhex(req.destination_hash)
if not RNS.Transport.has_path(dest_bytes): if not RNS.Transport.has_path(dest_bytes):
RNS.Transport.request_path(dest_bytes) RNS.Transport.request_path(dest_bytes)
@@ -79,9 +82,16 @@ class PageFetcher:
ev.set() ev.set()
link.set_link_established_callback( link.set_link_established_callback(
lambda link: link.request(req.page_path, req.field_data, response_callback=on_response, failed_callback=on_failed) lambda link: link.request(
req.page_path,
req.field_data,
response_callback=on_response,
failed_callback=on_failed,
)
) )
ev.wait(timeout=15) ev.wait(timeout=15)
data_str = result["data"] or "No content received" data_str = result["data"] or "No content received"
RNS.log(f"PageFetcher: received data for {req.destination_hash}:{req.page_path}") RNS.log(
f"PageFetcher: received data for {req.destination_hash}:{req.page_path}"
)
return data_str return data_str

View File

@@ -3,24 +3,541 @@
Provides rendering capabilities for micron markup content, Provides rendering capabilities for micron markup content,
currently implemented as a placeholder. currently implemented as a placeholder.
""" """
import re
import flet as ft import flet as ft
def render_micron(content: str) -> ft.Control: class MicronParser:
"""Render micron markup content to a Flet control placeholder. """Parses micron markup and converts it to Flet controls.
Currently displays raw content. Supports headings, dividers, inline formatting, ASCII art detection,
and color/formatting codes.
"""
def __init__(self, dark_theme=True, enable_force_monospace=True, ascii_art_scale=0.75):
"""Initialize the MicronParser.
Args:
dark_theme (bool): Whether to use dark theme styles.
enable_force_monospace (bool): If True, force monospace font.
ascii_art_scale (float): Scale factor for ASCII art font size.
"""
self.dark_theme = dark_theme
self.enable_force_monospace = enable_force_monospace
self.ascii_art_scale = ascii_art_scale
self.DEFAULT_FG_DARK = "ddd"
self.DEFAULT_FG_LIGHT = "222"
self.DEFAULT_BG = "default"
self.SELECTED_STYLES = None
self.STYLES_DARK = {
"plain": {"fg": self.DEFAULT_FG_DARK, "bg": self.DEFAULT_BG, "bold": False, "underline": False, "italic": False},
"heading1": {"fg": "000", "bg": "bbb", "bold": True, "underline": False, "italic": False},
"heading2": {"fg": "000", "bg": "999", "bold": True, "underline": False, "italic": False},
"heading3": {"fg": "fff", "bg": "777", "bold": True, "underline": False, "italic": False},
}
self.STYLES_LIGHT = {
"plain": {"fg": self.DEFAULT_FG_LIGHT, "bg": self.DEFAULT_BG, "bold": False, "underline": False, "italic": False},
"heading1": {"fg": "fff", "bg": "777", "bold": True, "underline": False, "italic": False},
"heading2": {"fg": "000", "bg": "aaa", "bold": True, "underline": False, "italic": False},
"heading3": {"fg": "000", "bg": "ccc", "bold": True, "underline": False, "italic": False},
}
if self.dark_theme:
self.SELECTED_STYLES = self.STYLES_DARK
else:
self.SELECTED_STYLES = self.STYLES_LIGHT
def convert_micron_to_controls(self, markup: str) -> list[ft.Control]:
"""Convert micron markup to a list of Flet controls.
Args:
markup (str): The micron markup string.
Returns:
list[ft.Control]: List of Flet controls representing the markup.
"""
controls = []
state = self._init_state()
lines = markup.split("\n")
for line in lines:
line_controls = self._parse_line(line, state)
if line_controls:
controls.extend(line_controls)
return controls
def _init_state(self) -> dict:
"""Initialize the parsing state for a new document.
Returns:
dict: The initial state dictionary.
"""
return {
"literal": False,
"depth": 0,
"fg_color": self.SELECTED_STYLES["plain"]["fg"],
"bg_color": self.DEFAULT_BG,
"formatting": {
"bold": False,
"underline": False,
"italic": False,
},
"default_align": "left",
"align": "left",
}
def _parse_line(self, line: str, state: dict) -> list[ft.Control]:
"""Parse a single line of micron markup.
Args:
line (str): The line to parse.
state (dict): The current parsing state.
Returns:
list[ft.Control]: Controls for this line, or empty if none.
"""
if not line:
return []
if line == "`=":
state["literal"] = not state["literal"]
return []
if not state["literal"] and line.startswith("#"):
return []
if not state["literal"] and line.startswith("<"):
state["depth"] = 0
return self._parse_line(line[1:], state)
if not state["literal"] and line.startswith(">"):
return self._parse_heading(line, state)
if not state["literal"] and line.startswith("-"):
return self._parse_divider(line, state)
return self._parse_inline_formatting(line, state)
def _parse_inline_formatting(self, line: str, state: dict) -> list[ft.Control]:
"""Parse inline formatting codes in a line and return Flet controls.
Args:
line (str): The line to parse.
state (dict): The current parsing state.
Returns:
list[ft.Control]: Controls for the formatted line.
"""
spans = []
current_text = ""
current_style = {
"fg": state["fg_color"],
"bg": state["bg_color"],
"bold": state["formatting"]["bold"],
"underline": state["formatting"]["underline"],
"italic": state["formatting"]["italic"],
}
mode = "text"
i = 0
skip = 0
def flush_current():
"""Flush the current text buffer into a span."""
nonlocal current_text
if current_text:
spans.append(MicronParser._create_span(current_text, current_style))
current_text = ""
while i < len(line):
if skip > 0:
skip -= 1
i += 1
continue
char = line[i]
"""
Handle backticks for formatting:
- Double backtick (``) resets formatting and alignment.
- Single backtick toggles formatting mode.
"""
if char == "`":
flush_current()
if i + 1 < len(line) and line[i + 1] == "`":
current_style["bold"] = False
current_style["underline"] = False
current_style["italic"] = False
current_style["fg"] = self.SELECTED_STYLES["plain"]["fg"]
current_style["bg"] = self.DEFAULT_BG
state["align"] = state["default_align"]
i += 2
continue
mode = "formatting" if mode == "text" else "text"
i += 1
continue
if mode == "formatting":
handled = False
if char == "_":
current_style["underline"] = not current_style["underline"]
handled = True
elif char == "!":
current_style["bold"] = not current_style["bold"]
handled = True
elif char == "*":
current_style["italic"] = not current_style["italic"]
handled = True
elif char == "F":
if len(line) >= i + 4:
current_style["fg"] = line[i + 1:i + 4]
skip = 3
handled = True
elif char == "f":
current_style["fg"] = self.SELECTED_STYLES["plain"]["fg"]
handled = True
elif char == "B":
if len(line) >= i + 4:
current_style["bg"] = line[i + 1:i + 4]
skip = 3
handled = True
elif char == "b":
current_style["bg"] = self.DEFAULT_BG
handled = True
elif char == "c":
state["align"] = "center"
handled = True
elif char == "l":
state["align"] = "left"
handled = True
elif char == "r":
state["align"] = "right"
handled = True
elif char == "a":
state["align"] = state["default_align"]
handled = True
if not handled:
current_text += char
else:
current_text += char
i += 1
flush_current()
if spans:
is_art = MicronParser._is_ascii_art("".join(span.text for span in spans))
font_size = 12 * self.ascii_art_scale if is_art else None
text_control = ft.Text(spans=spans, text_align=state["align"], selectable=True, enable_interactive_selection=True, expand=True, font_family="monospace", size=font_size)
else:
is_art = MicronParser._is_ascii_art(line)
font_size = 12 * self.ascii_art_scale if is_art else None
text_control = ft.Text(line, text_align=state["align"], selectable=True, enable_interactive_selection=True, expand=True, font_family="monospace", size=font_size)
if state["depth"] > 0:
indent_em = (state["depth"] - 1) * 1.2
text_control = ft.Container(
content=text_control,
margin=ft.margin.only(left=indent_em * 16),
)
return [text_control]
@staticmethod
def _create_span(text: str, style: dict) -> ft.TextSpan:
"""Create a Flet TextSpan with the given style.
Args:
text (str): The text for the span.
style (dict): The style dictionary.
Returns:
ft.TextSpan: The styled text span.
"""
flet_style = ft.TextStyle(
color=MicronParser._color_to_flet(style["fg"]),
bgcolor=MicronParser._color_to_flet(style["bg"]),
weight=ft.FontWeight.BOLD if style["bold"] else ft.FontWeight.NORMAL,
decoration=ft.TextDecoration.UNDERLINE if style["underline"] else ft.TextDecoration.NONE,
italic=style["italic"],
)
return ft.TextSpan(text, flet_style)
def _apply_format_code_to_style(self, code: str, style: dict, state: dict):
"""Apply a micron format code to a style dictionary.
Args:
code (str): The format code.
style (dict): The style dictionary to modify.
state (dict): The current parsing state.
"""
if not code:
return
if code == "`":
style["bold"] = False
style["underline"] = False
style["italic"] = False
style["fg"] = self.SELECTED_STYLES["plain"]["fg"]
style["bg"] = self.DEFAULT_BG
return
if "!" in code:
style["bold"] = not style["bold"]
if "_" in code:
style["underline"] = not style["underline"]
if "*" in code:
style["italic"] = not style["italic"]
if code.startswith("F") and len(code) >= 4:
style["fg"] = code[1:4]
elif code.startswith("B") and len(code) >= 4:
style["bg"] = code[1:4]
if "f" in code:
style["fg"] = self.SELECTED_STYLES["plain"]["fg"]
if "b" in code:
style["bg"] = self.DEFAULT_BG
def _apply_format_code(self, code: str, state: dict):
"""Apply a micron format code to the parsing state.
Args:
code (str): The format code.
state (dict): The state dictionary to modify.
"""
if not code:
return
if code == "`":
state["formatting"]["bold"] = False
state["formatting"]["underline"] = False
state["formatting"]["italic"] = False
state["fg_color"] = self.SELECTED_STYLES["plain"]["fg"]
state["bg_color"] = self.DEFAULT_BG
state["align"] = state["default_align"]
return
if "!" in code:
state["formatting"]["bold"] = not state["formatting"]["bold"]
if "_" in code:
state["formatting"]["underline"] = not state["formatting"]["underline"]
if "*" in code:
state["formatting"]["italic"] = not state["formatting"]["italic"]
if code.startswith("F") and len(code) >= 4:
state["fg_color"] = code[1:4]
elif code.startswith("B") and len(code) >= 4:
state["bg_color"] = code[1:4]
if "f" in code:
state["fg_color"] = self.SELECTED_STYLES["plain"]["fg"]
if "b" in code:
state["bg_color"] = self.DEFAULT_BG
if "c" in code:
state["align"] = "center"
elif "l" in code:
state["align"] = "left"
elif "r" in code:
state["align"] = "right"
elif "a" in code:
state["align"] = state["default_align"]
def _parse_divider(self, line: str, state: dict) -> list[ft.Control]:
"""Parse a divider line and return a Flet Divider or styled Text.
Args:
line (str): The divider line.
state (dict): The current parsing state.
Returns:
list[ft.Control]: Controls for the divider.
"""
if len(line) == 1:
return [ft.Divider()]
divider_char = line[1] if len(line) > 1 else "-"
repeated = divider_char * 80
is_art = MicronParser._is_ascii_art(repeated)
font_size = 12 * self.ascii_art_scale if is_art else None
divider = ft.Text(
repeated,
font_family="monospace",
color=MicronParser._color_to_flet(state["fg_color"]),
bgcolor=MicronParser._color_to_flet(state["bg_color"]),
no_wrap=True,
overflow=ft.TextOverflow.CLIP,
selectable=False,
enable_interactive_selection=False,
size=font_size,
)
return [divider]
@staticmethod
def _color_to_flet(color: str) -> str | None:
"""Convert micron color format to Flet color format.
Args:
color (str): The micron color string.
Returns:
str | None: The Flet color string or None if default/invalid.
"""
if not color or color == "default":
return None
if len(color) == 3 and re.match(r"^[0-9a-fA-F]{3}$", color):
return f"#{color[0]*2}{color[1]*2}{color[2]*2}"
if len(color) == 6 and re.match(r"^[0-9a-fA-F]{6}$", color):
return f"#{color}"
if len(color) == 3 and color[0] == "g":
try:
val = int(color[1:])
if 0 <= val <= 99:
h = hex(int(val * 2.55))[2:].zfill(2)
return f"#{h}{h}{h}"
except ValueError:
pass
return None
@staticmethod
def _is_ascii_art(text: str) -> bool:
"""Detect if text appears to be ASCII art.
Args:
text (str): The text to check.
Returns:
bool: True if the text is likely ASCII art, False otherwise.
"""
if not text or len(text) < 10:
return False
special_chars = set("│─┌┐└┘├┤┬┴┼═║╔╗╚╝╠╣╦╩╬█▄▀▌▐■□▪▫▲▼◄►◆◇○●◎◢◣◥◤")
special_count = sum(1 for char in text if char in special_chars or (ord(char) > 127))
other_special = sum(1 for char in text if not char.isalnum() and char not in " \t")
total_chars = len(text.replace(" ", "").replace("\t", ""))
if total_chars == 0:
return False
special_ratio = (special_count + other_special) / total_chars
return special_ratio > 0.3
def _parse_heading(self, line: str, state: dict) -> list[ft.Control]:
"""Parse heading lines (starting with '>') and return styled controls.
Args:
line (str): The heading line.
state (dict): The current parsing state.
Returns:
list[ft.Control]: Controls for the heading.
"""
heading_level = 0
for char in line:
if char == ">":
heading_level += 1
else:
break
state["depth"] = heading_level
heading_text = line[heading_level:].strip()
if heading_text:
style_key = f"heading{min(heading_level, 3)}"
style = self.SELECTED_STYLES.get(style_key, self.SELECTED_STYLES["plain"])
is_art = MicronParser._is_ascii_art(heading_text)
base_size = 20 - heading_level * 2
font_size = base_size * self.ascii_art_scale if is_art else base_size
indent_em = max(0, (state["depth"] - 1) * 1.2)
heading = ft.Text(
heading_text,
style=ft.TextStyle(
color=MicronParser._color_to_flet(style["fg"]),
weight=ft.FontWeight.BOLD if style["bold"] else ft.FontWeight.NORMAL,
size=font_size,
),
selectable=True,
enable_interactive_selection=True,
expand=True,
font_family="monospace",
)
bg_color = MicronParser._color_to_flet(style["bg"])
if bg_color:
heading = ft.Container(
content=ft.Container(
content=heading,
margin=ft.margin.only(left=indent_em * 16) if indent_em > 0 else None,
padding=ft.padding.symmetric(horizontal=4),
),
bgcolor=bg_color,
width=float("inf"),
)
elif indent_em > 0:
heading = ft.Container(
content=heading,
margin=ft.margin.only(left=indent_em * 16),
padding=ft.padding.symmetric(horizontal=4),
)
return [heading]
return []
def render_micron(content: str, ascii_art_scale: float = 0.75) -> ft.Control:
"""Render micron markup content to a Flet control.
Args: Args:
content: Micron markup content to render. content: Micron markup content to render.
ascii_art_scale: Scale factor for ASCII art (0.0-1.0). Default 0.75.
Returns: Returns:
ft.Control: Rendered content as a Flet control. ft.Control: Rendered content as a Flet control.
This function parses the micron markup, merges adjacent text controls
with the same style, and returns a Flet ListView containing the result.
""" """
return ft.Text( parser = MicronParser(ascii_art_scale=ascii_art_scale)
content, controls = parser.convert_micron_to_controls(content)
selectable=True,
font_family="monospace", return ft.Container(
content=ft.ListView(
controls=controls,
spacing=2,
expand=True,
),
expand=True, expand=True,
) )

View File

@@ -2,6 +2,7 @@
Provides fallback rendering for plaintext content and source viewing. Provides fallback rendering for plaintext content and source viewing.
""" """
import flet as ft import flet as ft

View File

@@ -3,6 +3,7 @@
Provides persistent storage for configuration, bookmarks, history, Provides persistent storage for configuration, bookmarks, history,
and other application data across different platforms. and other application data across different platforms.
""" """
import json import json
import os import os
import pathlib import pathlib
@@ -36,9 +37,11 @@ class StorageManager:
pass pass
if os.name == "posix" and "ANDROID_ROOT" in os.environ: if os.name == "posix" and "ANDROID_ROOT" in os.environ:
# Android - use app's private files directory # Android - use user-accessible external storage
storage_dir = pathlib.Path("/data/data/com.ren_browser/files") storage_dir = pathlib.Path("/storage/emulated/0/Documents/ren_browser")
elif hasattr(os, "uname") and "iOS" in str(getattr(os, "uname", lambda: "")()).replace("iPhone", "iOS"): elif hasattr(os, "uname") and "iOS" in str(
getattr(os, "uname", lambda: "")()
).replace("iPhone", "iOS"):
# iOS - use app's documents directory # iOS - use app's documents directory
storage_dir = pathlib.Path.home() / "Documents" / "ren_browser" storage_dir = pathlib.Path.home() / "Documents" / "ren_browser"
else: else:
@@ -46,7 +49,9 @@ class StorageManager:
if "APPDATA" in os.environ: # Windows if "APPDATA" in os.environ: # Windows
storage_dir = pathlib.Path(os.environ["APPDATA"]) / "ren_browser" storage_dir = pathlib.Path(os.environ["APPDATA"]) / "ren_browser"
elif "XDG_CONFIG_HOME" in os.environ: # Linux XDG standard elif "XDG_CONFIG_HOME" in os.environ: # Linux XDG standard
storage_dir = pathlib.Path(os.environ["XDG_CONFIG_HOME"]) / "ren_browser" storage_dir = (
pathlib.Path(os.environ["XDG_CONFIG_HOME"]) / "ren_browser"
)
else: else:
storage_dir = pathlib.Path.home() / ".ren_browser" storage_dir = pathlib.Path.home() / ".ren_browser"
@@ -58,6 +63,7 @@ class StorageManager:
self._storage_dir.mkdir(parents=True, exist_ok=True) self._storage_dir.mkdir(parents=True, exist_ok=True)
except (OSError, PermissionError): except (OSError, PermissionError):
import tempfile import tempfile
self._storage_dir = pathlib.Path(tempfile.gettempdir()) / "ren_browser" self._storage_dir = pathlib.Path(tempfile.gettempdir()) / "ren_browser"
self._storage_dir.mkdir(parents=True, exist_ok=True) self._storage_dir.mkdir(parents=True, exist_ok=True)
@@ -65,17 +71,21 @@ class StorageManager:
"""Get the path to the main configuration file.""" """Get the path to the main configuration file."""
return self._storage_dir / "config" return self._storage_dir / "config"
@staticmethod def get_reticulum_config_path(self) -> pathlib.Path:
def get_reticulum_config_path() -> pathlib.Path:
"""Get the path to the Reticulum configuration directory.""" """Get the path to the Reticulum configuration directory."""
# Check for global override from app # Check for global override from app
try: try:
from ren_browser.app import RNS_CONFIG_DIR from ren_browser.app import RNS_CONFIG_DIR
if RNS_CONFIG_DIR: if RNS_CONFIG_DIR:
return pathlib.Path(RNS_CONFIG_DIR) return pathlib.Path(RNS_CONFIG_DIR)
except ImportError: except ImportError:
pass pass
# On Android, use app storage directory instead of ~/.reticulum
if os.name == "posix" and "ANDROID_ROOT" in os.environ:
return self._storage_dir / "reticulum"
# Default to standard RNS config directory # Default to standard RNS config directory
return pathlib.Path.home() / ".reticulum" return pathlib.Path.home() / ".reticulum"
@@ -90,6 +100,7 @@ class StorageManager:
""" """
try: try:
# Always save to client storage first (most reliable on mobile)
if self.page and hasattr(self.page, "client_storage"): if self.page and hasattr(self.page, "client_storage"):
self.page.client_storage.set("ren_browser_config", config_content) self.page.client_storage.set("ren_browser_config", config_content)
@@ -100,6 +111,7 @@ class StorageManager:
# Also save to local config path as backup # Also save to local config path as backup
config_path = self.get_config_path() config_path = self.get_config_path()
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(config_content, encoding="utf-8") config_path.write_text(config_content, encoding="utf-8")
return True return True
@@ -111,7 +123,9 @@ class StorageManager:
try: try:
if self.page and hasattr(self.page, "client_storage"): if self.page and hasattr(self.page, "client_storage"):
self.page.client_storage.set("ren_browser_config", config_content) self.page.client_storage.set("ren_browser_config", config_content)
self.page.client_storage.set("ren_browser_config_error", f"File save failed: {error}") self.page.client_storage.set(
"ren_browser_config_error", f"File save failed: {error}"
)
return True return True
try: try:
@@ -122,6 +136,7 @@ class StorageManager:
pass pass
import tempfile import tempfile
temp_path = pathlib.Path(tempfile.gettempdir()) / "ren_browser_config.txt" temp_path = pathlib.Path(tempfile.gettempdir()) / "ren_browser_config.txt"
temp_path.write_text(config_content, encoding="utf-8") temp_path.write_text(config_content, encoding="utf-8")
return True return True
@@ -136,6 +151,13 @@ class StorageManager:
Configuration text, or empty string if not found Configuration text, or empty string if not found
""" """
# On Android, prioritize client storage first as it's more reliable
if os.name == "posix" and "ANDROID_ROOT" in os.environ:
if self.page and hasattr(self.page, "client_storage"):
stored_config = self.page.client_storage.get("ren_browser_config")
if stored_config:
return stored_config
try: try:
reticulum_config_path = self.get_reticulum_config_path() / "config" reticulum_config_path = self.get_reticulum_config_path() / "config"
if reticulum_config_path.exists(): if reticulum_config_path.exists():
@@ -145,13 +167,18 @@ class StorageManager:
if config_path.exists(): if config_path.exists():
return config_path.read_text(encoding="utf-8") return config_path.read_text(encoding="utf-8")
# Fallback to client storage for non-Android or if files don't exist
if self.page and hasattr(self.page, "client_storage"): if self.page and hasattr(self.page, "client_storage"):
stored_config = self.page.client_storage.get("ren_browser_config") stored_config = self.page.client_storage.get("ren_browser_config")
if stored_config: if stored_config:
return stored_config return stored_config
except (OSError, PermissionError, UnicodeDecodeError): except (OSError, PermissionError, UnicodeDecodeError):
pass # If file access fails, try client storage as fallback
if self.page and hasattr(self.page, "client_storage"):
stored_config = self.page.client_storage.get("ren_browser_config")
if stored_config:
return stored_config
return "" return ""
@@ -163,7 +190,9 @@ class StorageManager:
json.dump(bookmarks, f, indent=2) json.dump(bookmarks, f, indent=2)
if self.page and hasattr(self.page, "client_storage"): if self.page and hasattr(self.page, "client_storage"):
self.page.client_storage.set("ren_browser_bookmarks", json.dumps(bookmarks)) self.page.client_storage.set(
"ren_browser_bookmarks", json.dumps(bookmarks)
)
return True return True
except Exception: except Exception:
@@ -267,6 +296,7 @@ def get_rns_config_directory() -> str:
"""Get the RNS config directory, checking for global override.""" """Get the RNS config directory, checking for global override."""
try: try:
from ren_browser.app import RNS_CONFIG_DIR from ren_browser.app import RNS_CONFIG_DIR
if RNS_CONFIG_DIR: if RNS_CONFIG_DIR:
return RNS_CONFIG_DIR return RNS_CONFIG_DIR
except ImportError: except ImportError:

View File

@@ -3,6 +3,7 @@
Provides tab creation, switching, and content management functionality Provides tab creation, switching, and content management functionality
for the browser interface. for the browser interface.
""" """
from types import SimpleNamespace from types import SimpleNamespace
import flet as ft import flet as ft
@@ -25,15 +26,26 @@ class TabsManager:
""" """
import ren_browser.app as app_module import ren_browser.app as app_module
self.page = page self.page = page
self.manager = SimpleNamespace(tabs=[], index=0) self.manager = SimpleNamespace(tabs=[], index=0)
self.tab_bar = ft.Row(spacing=4) self.tab_bar = ft.Row(spacing=4)
self.content_container = ft.Container(expand=True, bgcolor=ft.Colors.BLACK, padding=ft.padding.all(5)) self.content_container = ft.Container(
expand=True, bgcolor=ft.Colors.BLACK, padding=ft.padding.all(5)
)
default_content = render_micron("Welcome to Ren Browser") if app_module.RENDERER == "micron" else render_plaintext("Welcome to Ren Browser") default_content = (
render_micron("Welcome to Ren Browser")
if app_module.RENDERER == "micron"
else render_plaintext("Welcome to Ren Browser")
)
self._add_tab_internal("Home", default_content) self._add_tab_internal("Home", default_content)
self.add_btn = ft.IconButton(ft.Icons.ADD, tooltip="New Tab", on_click=self._on_add_click) self.add_btn = ft.IconButton(
self.close_btn = ft.IconButton(ft.Icons.CLOSE, tooltip="Close Tab", on_click=self._on_close_click) ft.Icons.ADD, tooltip="New Tab", on_click=self._on_add_click
)
self.close_btn = ft.IconButton(
ft.Icons.CLOSE, tooltip="Close Tab", on_click=self._on_close_click
)
self.tab_bar.controls.extend([self.add_btn, self.close_btn]) self.tab_bar.controls.extend([self.add_btn, self.close_btn])
self.select_tab(0) self.select_tab(0)
@@ -43,9 +55,13 @@ class TabsManager:
value=title, value=title,
expand=True, expand=True,
text_style=ft.TextStyle(size=12), text_style=ft.TextStyle(size=12),
content_padding=ft.padding.only(top=8, bottom=8, left=8, right=8) content_padding=ft.padding.only(top=8, bottom=8, left=8, right=8),
)
go_btn = ft.IconButton(
ft.Icons.OPEN_IN_BROWSER,
tooltip="Load URL",
on_click=lambda e, i=idx: self._on_tab_go(e, i),
) )
go_btn = ft.IconButton(ft.Icons.OPEN_IN_BROWSER, tooltip="Load URL", on_click=lambda e, i=idx: self._on_tab_go(e, i))
content_control = content content_control = content
tab_content = ft.Column( tab_content = ft.Column(
expand=True, expand=True,
@@ -53,13 +69,15 @@ class TabsManager:
content_control, content_control,
], ],
) )
self.manager.tabs.append({ self.manager.tabs.append(
"title": title, {
"url_field": url_field, "title": title,
"go_btn": go_btn, "url_field": url_field,
"content_control": content_control, "go_btn": go_btn,
"content": tab_content, "content_control": content_control,
}) "content": tab_content,
}
)
btn = ft.Container( btn = ft.Container(
content=ft.Text(title), content=ft.Text(title),
on_click=lambda e, i=idx: self.select_tab(i), on_click=lambda e, i=idx: self.select_tab(i),
@@ -74,7 +92,12 @@ class TabsManager:
title = f"Tab {len(self.manager.tabs) + 1}" title = f"Tab {len(self.manager.tabs) + 1}"
content_text = f"Content for {title}" content_text = f"Content for {title}"
import ren_browser.app as app_module import ren_browser.app as app_module
content = render_micron(content_text) if app_module.RENDERER == "micron" else render_plaintext(content_text)
content = (
render_micron(content_text)
if app_module.RENDERER == "micron"
else render_plaintext(content_text)
)
self._add_tab_internal(title, content) self._add_tab_internal(title, content)
self.select_tab(len(self.manager.tabs) - 1) self.select_tab(len(self.manager.tabs) - 1)
self.page.update() self.page.update()
@@ -114,7 +137,12 @@ class TabsManager:
return return
placeholder_text = f"Loading content for {url}" placeholder_text = f"Loading content for {url}"
import ren_browser.app as app_module import ren_browser.app as app_module
new_control = render_micron(placeholder_text) if app_module.RENDERER == "micron" else render_plaintext(placeholder_text)
new_control = (
render_micron(placeholder_text)
if app_module.RENDERER == "micron"
else render_plaintext(placeholder_text)
)
tab["content_control"] = new_control tab["content_control"] = new_control
tab["content"].controls[0] = new_control tab["content"].controls[0] = new_control
if self.manager.index == idx: if self.manager.index == idx:

View File

@@ -3,6 +3,7 @@
Provides configuration management, log viewing, and storage Provides configuration management, log viewing, and storage
information display. information display.
""" """
import flet as ft import flet as ft
from ren_browser.logs import ERROR_LOGS, RET_LOGS from ren_browser.logs import ERROR_LOGS, RET_LOGS
@@ -35,13 +36,23 @@ def open_settings_tab(page: ft.Page, tab_manager):
try: try:
success = storage.save_config(config_field.value) success = storage.save_config(config_field.value)
if success: if success:
page.snack_bar = ft.SnackBar(ft.Text("Config saved successfully. Please restart the app."), open=True) print("Config saved successfully. Please restart the app.")
page.snack_bar = ft.SnackBar(
ft.Text("Config saved successfully. Please restart the app."),
open=True,
)
else: else:
page.snack_bar = ft.SnackBar(ft.Text("Error saving config: Storage operation failed"), open=True) print("Error saving config: Storage operation failed")
page.snack_bar = ft.SnackBar(
ft.Text("Error saving config: Storage operation failed"), open=True
)
except Exception as ex: except Exception as ex:
page.snack_bar = ft.SnackBar(ft.Text(f"Error saving config: {ex}"), open=True) print(f"Error saving config: {ex}")
page.update() page.snack_bar = ft.SnackBar(
save_btn = ft.ElevatedButton("Save and Restart", on_click=on_save_config) ft.Text(f"Error saving config: {ex}"), open=True
)
save_btn = ft.ElevatedButton("Save Config", on_click=on_save_config)
error_field = ft.TextField( error_field = ft.TextField(
label="Error Logs", label="Error Logs",
value="", value="",
@@ -69,22 +80,29 @@ def open_settings_tab(page: ft.Page, tab_manager):
) )
content_placeholder = ft.Container(expand=True) content_placeholder = ft.Container(expand=True)
def show_config(ev): def show_config(ev):
content_placeholder.content = config_field content_placeholder.content = config_field
page.update() page.update()
def show_errors(ev): def show_errors(ev):
error_field.value = "\n".join(ERROR_LOGS) or "No errors logged." error_field.value = "\n".join(ERROR_LOGS) or "No errors logged."
content_placeholder.content = error_field content_placeholder.content = error_field
page.update() page.update()
def show_ret_logs(ev): def show_ret_logs(ev):
ret_field.value = "\n".join(RET_LOGS) or "No Reticulum logs." ret_field.value = "\n".join(RET_LOGS) or "No Reticulum logs."
content_placeholder.content = ret_field content_placeholder.content = ret_field
page.update() page.update()
def show_storage_info(ev): def show_storage_info(ev):
storage_info = storage.get_storage_info() storage_info = storage.get_storage_info()
storage_field.value = "\n".join([f"{key}: {value}" for key, value in storage_info.items()]) storage_field.value = "\n".join(
[f"{key}: {value}" for key, value in storage_info.items()]
)
content_placeholder.content = storage_field content_placeholder.content = storage_field
page.update() page.update()
def refresh_current_view(ev): def refresh_current_view(ev):
# Refresh the currently displayed content # Refresh the currently displayed content
if content_placeholder.content == error_field: if content_placeholder.content == error_field:
@@ -95,12 +113,15 @@ def open_settings_tab(page: ft.Page, tab_manager):
show_storage_info(ev) show_storage_info(ev)
elif content_placeholder.content == config_field: elif content_placeholder.content == config_field:
show_config(ev) show_config(ev)
btn_config = ft.ElevatedButton("Config", on_click=show_config) btn_config = ft.ElevatedButton("Config", on_click=show_config)
btn_errors = ft.ElevatedButton("Errors", on_click=show_errors) btn_errors = ft.ElevatedButton("Errors", on_click=show_errors)
btn_ret = ft.ElevatedButton("Ret Logs", on_click=show_ret_logs) btn_ret = ft.ElevatedButton("Ret Logs", on_click=show_ret_logs)
btn_storage = ft.ElevatedButton("Storage", on_click=show_storage_info) btn_storage = ft.ElevatedButton("Storage", on_click=show_storage_info)
btn_refresh = ft.ElevatedButton("Refresh", on_click=refresh_current_view) btn_refresh = ft.ElevatedButton("Refresh", on_click=refresh_current_view)
button_row = ft.Row(controls=[btn_config, btn_errors, btn_ret, btn_storage, btn_refresh]) button_row = ft.Row(
controls=[btn_config, btn_errors, btn_ret, btn_storage, btn_refresh]
)
content_placeholder.content = config_field content_placeholder.content = config_field
settings_content = ft.Column( settings_content = ft.Column(
expand=True, expand=True,

View File

@@ -3,6 +3,7 @@
Builds the complete browser interface including tabs, navigation, Builds the complete browser interface including tabs, navigation,
announce handling, and content rendering. announce handling, and content rendering.
""" """
import flet as ft import flet as ft
from flet import Page from flet import Page
@@ -27,10 +28,12 @@ def build_ui(page: Page):
page_fetcher = PageFetcher() page_fetcher = PageFetcher()
announce_list = ft.ListView(expand=True, spacing=1) announce_list = ft.ListView(expand=True, spacing=1)
def update_announces(ann_list): def update_announces(ann_list):
announce_list.controls.clear() announce_list.controls.clear()
for ann in ann_list: for ann in ann_list:
label = ann.display_name or ann.destination_hash label = ann.display_name or ann.destination_hash
def on_click_ann(e, dest=ann.destination_hash, disp=ann.display_name): def on_click_ann(e, dest=ann.destination_hash, disp=ann.display_name):
title = disp or "Anonymous" title = disp or "Anonymous"
full_url = f"{dest}:/page/index.mu" full_url = f"{dest}:/page/index.mu"
@@ -41,12 +44,14 @@ def build_ui(page: Page):
tab["url_field"].value = full_url tab["url_field"].value = full_url
tab_manager.select_tab(idx) tab_manager.select_tab(idx)
page.update() page.update()
def fetch_and_update(): def fetch_and_update():
req = PageRequest(destination_hash=dest, page_path="/page/index.mu") req = PageRequest(destination_hash=dest, page_path="/page/index.mu")
try: try:
result = page_fetcher.fetch_page(req) result = page_fetcher.fetch_page(req)
except Exception as ex: except Exception as ex:
import ren_browser.app as app_module import ren_browser.app as app_module
app_module.log_error(str(ex)) app_module.log_error(str(ex))
result = f"Error: {ex}" result = f"Error: {ex}"
try: try:
@@ -62,13 +67,21 @@ def build_ui(page: Page):
if tab_manager.manager.index == idx: if tab_manager.manager.index == idx:
tab_manager.content_container.content = tab["content"] tab_manager.content_container.content = tab["content"]
page.update() page.update()
page.run_thread(fetch_and_update) page.run_thread(fetch_and_update)
announce_list.controls.append(ft.TextButton(label, on_click=on_click_ann)) announce_list.controls.append(ft.TextButton(label, on_click=on_click_ann))
page.update() page.update()
AnnounceService(update_callback=update_announces) AnnounceService(update_callback=update_announces)
page.drawer = ft.NavigationDrawer( page.drawer = ft.NavigationDrawer(
controls=[ controls=[
ft.Text("Announcements", weight=ft.FontWeight.BOLD, text_align=ft.TextAlign.CENTER, expand=True), ft.Text(
"Announcements",
weight=ft.FontWeight.BOLD,
text_align=ft.TextAlign.CENTER,
expand=True,
),
ft.Divider(), ft.Divider(),
announce_list, announce_list,
], ],
@@ -76,12 +89,22 @@ def build_ui(page: Page):
page.appbar.leading = ft.IconButton( page.appbar.leading = ft.IconButton(
ft.Icons.MENU, ft.Icons.MENU,
tooltip="Toggle sidebar", tooltip="Toggle sidebar",
on_click=lambda e: (setattr(page.drawer, "open", not page.drawer.open), page.update()), on_click=lambda e: (
setattr(page.drawer, "open", not page.drawer.open),
page.update(),
),
) )
tab_manager = TabsManager(page) tab_manager = TabsManager(page)
from ren_browser.ui.settings import open_settings_tab from ren_browser.ui.settings import open_settings_tab
page.appbar.actions = [ft.IconButton(ft.Icons.SETTINGS, tooltip="Settings", on_click=lambda e: open_settings_tab(page, tab_manager))]
page.appbar.actions = [
ft.IconButton(
ft.Icons.SETTINGS,
tooltip="Settings",
on_click=lambda e: open_settings_tab(page, tab_manager),
)
]
Shortcuts(page, tab_manager) Shortcuts(page, tab_manager)
url_bar = ft.Row( url_bar = ft.Row(
controls=[ controls=[
@@ -91,15 +114,19 @@ def build_ui(page: Page):
) )
page.appbar.title = url_bar page.appbar.title = url_bar
orig_select_tab = tab_manager.select_tab orig_select_tab = tab_manager.select_tab
def _select_tab_and_update_url(i): def _select_tab_and_update_url(i):
orig_select_tab(i) orig_select_tab(i)
tab = tab_manager.manager.tabs[i] tab = tab_manager.manager.tabs[i]
url_bar.controls.clear() url_bar.controls.clear()
url_bar.controls.extend([tab["url_field"], tab["go_btn"]]) url_bar.controls.extend([tab["url_field"], tab["go_btn"]])
page.update() page.update()
tab_manager.select_tab = _select_tab_and_update_url tab_manager.select_tab = _select_tab_and_update_url
def _update_content_width(e=None): def _update_content_width(e=None):
tab_manager.content_container.width = page.width tab_manager.content_container.width = page.width
_update_content_width() _update_content_width()
page.on_resized = lambda e: (_update_content_width(), page.update()) page.on_resized = lambda e: (_update_content_width(), page.update())
main_area = ft.Column( main_area = ft.Column(

View File

@@ -36,6 +36,7 @@ def mock_rns():
# Mock at the module level for all imports # Mock at the module level for all imports
import sys import sys
sys.modules["RNS"] = mock_rns sys.modules["RNS"] = mock_rns
yield mock_rns yield mock_rns
@@ -51,7 +52,7 @@ def sample_announce_data():
return { return {
"destination_hash": "1234567890abcdef", "destination_hash": "1234567890abcdef",
"display_name": "Test Node", "display_name": "Test Node",
"timestamp": 1234567890 "timestamp": 1234567890,
} }
@@ -59,10 +60,9 @@ def sample_announce_data():
def sample_page_request(): def sample_page_request():
"""Sample page request for testing.""" """Sample page request for testing."""
from ren_browser.pages.page_request import PageRequest from ren_browser.pages.page_request import PageRequest
return PageRequest( return PageRequest(
destination_hash="1234567890abcdef", destination_hash="1234567890abcdef", page_path="/page/index.mu", field_data=None,
page_path="/page/index.mu",
field_data=None
) )
@@ -75,11 +75,11 @@ def mock_storage_manager():
mock_storage.get_config_path.return_value = Mock() mock_storage.get_config_path.return_value = Mock()
mock_storage.get_reticulum_config_path.return_value = Mock() mock_storage.get_reticulum_config_path.return_value = Mock()
mock_storage.get_storage_info.return_value = { mock_storage.get_storage_info.return_value = {
'storage_dir': '/mock/storage', "storage_dir": "/mock/storage",
'config_path': '/mock/storage/config.txt', "config_path": "/mock/storage/config.txt",
'reticulum_config_path': '/mock/storage/reticulum', "reticulum_config_path": "/mock/storage/reticulum",
'storage_dir_exists': True, "storage_dir_exists": True,
'storage_dir_writable': True, "storage_dir_writable": True,
'has_client_storage': True, "has_client_storage": True,
} }
return mock_storage return mock_storage

View File

@@ -28,8 +28,14 @@ class TestAppIntegration:
def test_entry_points_exist(self): def test_entry_points_exist(self):
"""Test that all expected entry points exist and are callable.""" """Test that all expected entry points exist and are callable."""
entry_points = [ entry_points = [
"run", "web", "android", "ios", "run",
"run_dev", "web_dev", "android_dev", "ios_dev" "web",
"android",
"ios",
"run_dev",
"web_dev",
"android_dev",
"ios_dev",
] ]
for entry_point in entry_points: for entry_point in entry_points:

View File

@@ -1,4 +1,3 @@
from ren_browser.announces.announces import Announce from ren_browser.announces.announces import Announce
@@ -10,7 +9,7 @@ class TestAnnounce:
announce = Announce( announce = Announce(
destination_hash="1234567890abcdef", destination_hash="1234567890abcdef",
display_name="Test Node", display_name="Test Node",
timestamp=1234567890 timestamp=1234567890,
) )
assert announce.destination_hash == "1234567890abcdef" assert announce.destination_hash == "1234567890abcdef"
@@ -20,18 +19,17 @@ class TestAnnounce:
def test_announce_with_none_display_name(self): def test_announce_with_none_display_name(self):
"""Test Announce creation with None display name.""" """Test Announce creation with None display name."""
announce = Announce( announce = Announce(
destination_hash="1234567890abcdef", destination_hash="1234567890abcdef", display_name=None, timestamp=1234567890,
display_name=None,
timestamp=1234567890
) )
assert announce.destination_hash == "1234567890abcdef" assert announce.destination_hash == "1234567890abcdef"
assert announce.display_name is None assert announce.display_name is None
assert announce.timestamp == 1234567890 assert announce.timestamp == 1234567890
class TestAnnounceService: class TestAnnounceService:
"""Test cases for the AnnounceService class. """Test cases for the AnnounceService class.
Note: These tests are simplified due to complex RNS integration. Note: These tests are simplified due to complex RNS integration.
Full integration tests will be added in the future. Full integration tests will be added in the future.
""" """

View File

@@ -35,9 +35,7 @@ class TestApp:
def test_run_with_default_args(self, mock_rns): def test_run_with_default_args(self, mock_rns):
"""Test run function with default arguments.""" """Test run function with default arguments."""
with patch("sys.argv", ["ren-browser"]), \ with patch("sys.argv", ["ren-browser"]), patch("flet.app") as mock_ft_app:
patch("flet.app") as mock_ft_app:
app.run() app.run()
mock_ft_app.assert_called_once() mock_ft_app.assert_called_once()
@@ -46,9 +44,10 @@ class TestApp:
def test_run_with_web_flag(self, mock_rns): def test_run_with_web_flag(self, mock_rns):
"""Test run function with web flag.""" """Test run function with web flag."""
with patch("sys.argv", ["ren-browser", "--web"]), \ with (
patch("flet.app") as mock_ft_app: patch("sys.argv", ["ren-browser", "--web"]),
patch("flet.app") as mock_ft_app,
):
app.run() app.run()
mock_ft_app.assert_called_once() mock_ft_app.assert_called_once()
@@ -58,9 +57,10 @@ class TestApp:
def test_run_with_web_and_port(self, mock_rns): def test_run_with_web_and_port(self, mock_rns):
"""Test run function with web flag and custom port.""" """Test run function with web flag and custom port."""
with patch("sys.argv", ["ren-browser", "--web", "--port", "8080"]), \ with (
patch("flet.app") as mock_ft_app: patch("sys.argv", ["ren-browser", "--web", "--port", "8080"]),
patch("flet.app") as mock_ft_app,
):
app.run() app.run()
mock_ft_app.assert_called_once() mock_ft_app.assert_called_once()
@@ -71,9 +71,10 @@ class TestApp:
def test_run_with_renderer_flag(self, mock_rns): def test_run_with_renderer_flag(self, mock_rns):
"""Test run function with renderer selection.""" """Test run function with renderer selection."""
with patch("sys.argv", ["ren-browser", "--renderer", "micron"]), \ with (
patch("flet.app"): patch("sys.argv", ["ren-browser", "--renderer", "micron"]),
patch("flet.app"),
):
app.run() app.run()
assert app.RENDERER == "micron" assert app.RENDERER == "micron"
@@ -131,8 +132,10 @@ class TestApp:
"""Test that RENDERER global is properly updated.""" """Test that RENDERER global is properly updated."""
original_renderer = app.RENDERER original_renderer = app.RENDERER
with patch("sys.argv", ["ren-browser", "--renderer", "micron"]), \ with (
patch("flet.app"): patch("sys.argv", ["ren-browser", "--renderer", "micron"]),
patch("flet.app"),
):
app.run() app.run()
assert app.RENDERER == "micron" assert app.RENDERER == "micron"

View File

@@ -58,7 +58,9 @@ class TestLogsModule:
assert len(logs.RET_LOGS) == 1 assert len(logs.RET_LOGS) == 1
assert logs.RET_LOGS[0] == "[2023-01-01T12:00:00] Test RNS message" assert logs.RET_LOGS[0] == "[2023-01-01T12:00:00] Test RNS message"
logs._original_rns_log.assert_called_once_with("Test RNS message", "arg1", kwarg1="value1") logs._original_rns_log.assert_called_once_with(
"Test RNS message", "arg1", kwarg1="value1",
)
assert result == "original_result" assert result == "original_result"
def test_multiple_log_calls(self): def test_multiple_log_calls(self):

View File

@@ -7,8 +7,7 @@ class TestPageRequest:
def test_page_request_creation(self): def test_page_request_creation(self):
"""Test basic PageRequest creation.""" """Test basic PageRequest creation."""
request = PageRequest( request = PageRequest(
destination_hash="1234567890abcdef", destination_hash="1234567890abcdef", page_path="/page/index.mu",
page_path="/page/index.mu"
) )
assert request.destination_hash == "1234567890abcdef" assert request.destination_hash == "1234567890abcdef"
@@ -21,7 +20,7 @@ class TestPageRequest:
request = PageRequest( request = PageRequest(
destination_hash="1234567890abcdef", destination_hash="1234567890abcdef",
page_path="/page/form.mu", page_path="/page/form.mu",
field_data=field_data field_data=field_data,
) )
assert request.destination_hash == "1234567890abcdef" assert request.destination_hash == "1234567890abcdef"
@@ -48,7 +47,7 @@ class TestPageRequest:
# These will be implemented when the networking layer is more stable. # These will be implemented when the networking layer is more stable.
class TestPageFetcher: class TestPageFetcher:
"""Test cases for the PageFetcher class. """Test cases for the PageFetcher class.
Note: These tests are simplified due to complex RNS networking integration. Note: These tests are simplified due to complex RNS networking integration.
Full integration tests will be added when the networking layer is stable. Full integration tests will be added when the networking layer is stable.
""" """
@@ -59,7 +58,7 @@ class TestPageFetcher:
requests = [ requests = [
PageRequest("hash1", "/index.mu"), PageRequest("hash1", "/index.mu"),
PageRequest("hash2", "/about.mu", {"form": "data"}), PageRequest("hash2", "/about.mu", {"form": "data"}),
PageRequest("hash3", "/contact.mu") PageRequest("hash3", "/contact.mu"),
] ]
# Test that requests have the expected structure # Test that requests have the expected structure

View File

@@ -57,54 +57,304 @@ class TestPlaintextRenderer:
class TestMicronRenderer: class TestMicronRenderer:
"""Test cases for the micron renderer. """Test cases for the micron renderer.
Note: The micron renderer is currently a placeholder implementation The micron renderer parses Micron markup format and returns a ListView
that displays raw content without markup processing. containing styled controls with proper formatting, colors, and layout.
""" """
def test_render_micron_basic(self): def test_render_micron_basic(self):
"""Test basic micron rendering (currently displays raw content).""" """Test basic micron rendering."""
content = "# Heading\n\nSome content" content = "# Heading\n\nSome content"
result = render_micron(content) result = render_micron(content)
assert isinstance(result, ft.Text) # Should return a Container with ListView
assert result.value == "# Heading\n\nSome content" assert isinstance(result, ft.Container)
assert result.selectable is True assert isinstance(result.content, ft.ListView)
assert result.font_family == "monospace" assert result.content.spacing == 2
assert result.expand is True
# Should contain controls
assert len(result.content.controls) > 0
for control in result.content.controls:
assert control.selectable is True
assert control.font_family == "monospace"
def test_render_micron_empty(self): def test_render_micron_empty(self):
"""Test micron rendering with empty content.""" """Test micron rendering with empty content."""
content = "" content = ""
result = render_micron(content) result = render_micron(content)
assert isinstance(result, ft.Text) # Should return a Container
assert result.value == "" assert isinstance(result, ft.Container)
assert result.selectable is True
# May contain empty controls
def test_render_micron_unicode(self): def test_render_micron_unicode(self):
"""Test micron rendering with Unicode characters.""" """Test micron rendering with Unicode characters."""
content = "Unicode content: 你好 🌍 αβγ" content = "Unicode content: 你好 🌍 αβγ"
result = render_micron(content) result = render_micron(content)
assert isinstance(result, ft.Text) # Should return a Container
assert result.value == content assert isinstance(result, ft.Container)
assert result.selectable is True
# Should contain controls with the content
assert len(result.content.controls) > 0
all_text = ""
for control in result.content.controls:
if isinstance(control, ft.Text):
# Extract text from the control
text_content = ""
if hasattr(control, "value") and control.value:
text_content = control.value
elif hasattr(control, "spans") and control.spans:
text_content = "".join(span.text for span in control.spans)
if text_content:
all_text += text_content + "\n"
elif isinstance(control, ft.Container) and hasattr(control, "content"):
# Handle indented text controls
if isinstance(control.content, ft.Text):
text_content = ""
if hasattr(control.content, "value") and control.content.value:
text_content = control.content.value
elif hasattr(control.content, "spans") and control.content.spans:
text_content = "".join(span.text for span in control.content.spans)
if text_content:
all_text += text_content + "\n"
# Remove trailing newline and should preserve the content
all_text = all_text.rstrip("\n")
assert content in all_text
def test_render_micron_headings(self):
"""Test micron rendering with different heading levels."""
content = "> Level 1\n>> Level 2\n>>> Level 3"
result = render_micron(content)
assert isinstance(result, ft.Container)
assert len(result.content.controls) == 3
# Check that headings are wrapped in containers with backgrounds
for control in result.content.controls:
assert isinstance(control, ft.Container)
assert control.bgcolor is not None # Should have background color
assert control.width == float("inf") # Should be full width
def test_render_micron_formatting(self):
"""Test micron rendering with text formatting."""
content = "`!Bold text!` and `_underline_` and `*italic*`"
result = render_micron(content)
assert isinstance(result, ft.Container)
assert len(result.content.controls) >= 1
# Should produce some text content
all_text = ""
for control in result.content.controls:
if isinstance(control, ft.Text):
text_content = ""
if hasattr(control, "value") and control.value:
text_content = control.value
elif hasattr(control, "spans") and control.spans:
text_content = "".join(span.text for span in control.spans)
if text_content:
all_text += text_content + "\n"
assert len(all_text.strip()) > 0 # Should have some processed content
def test_render_micron_colors(self):
"""Test micron rendering with color codes."""
content = "`FffRed text` and `B00Blue background`"
result = render_micron(content)
assert isinstance(result, ft.Container)
assert len(result.content.controls) >= 1
# Should produce some text content (color codes may consume characters)
all_text = ""
for control in result.content.controls:
if isinstance(control, ft.Text):
text_content = ""
if hasattr(control, "value") and control.value:
text_content = control.value
elif hasattr(control, "spans") and control.spans:
text_content = "".join(span.text for span in control.spans)
if text_content:
all_text += text_content + "\n"
assert len(all_text.strip()) > 0 # Should have some processed content
def test_render_micron_alignment(self):
"""Test micron rendering with alignment."""
content = "`cCentered text`"
result = render_micron(content)
assert isinstance(result, ft.Container)
assert len(result.content.controls) >= 1
# Should have some text content
all_text = ""
for control in result.content.controls:
if isinstance(control, ft.Text):
text_content = ""
if hasattr(control, "value") and control.value:
text_content = control.value
elif hasattr(control, "spans") and control.spans:
text_content = "".join(span.text for span in control.spans)
if text_content:
all_text += text_content + "\n"
assert len(all_text.strip()) > 0
def test_render_micron_comments(self):
"""Test that comments are ignored."""
content = "# This is a comment\nVisible text"
result = render_micron(content)
assert isinstance(result, ft.Container)
# Should only contain the visible text, not the comment
all_text = ""
for control in result.content.controls:
if isinstance(control, ft.Text):
text_content = ""
if hasattr(control, "value") and control.value:
text_content = control.value
elif hasattr(control, "spans") and control.spans:
text_content = "".join(span.text for span in control.spans)
if text_content:
all_text += text_content + "\n"
all_text = all_text.strip()
assert "Visible text" in all_text
assert "This is a comment" not in all_text
def test_render_micron_section_depth(self):
"""Test micron rendering with section depth/indentation."""
content = "> Main section\n>> Subsection\n>>> Sub-subsection"
result = render_micron(content)
assert isinstance(result, ft.Container)
assert len(result.content.controls) == 3
# Check indentation increases with depth
for i, control in enumerate(result.content.controls):
assert isinstance(control, ft.Container)
# The inner container should have margin for indentation
inner_container = control.content
if hasattr(inner_container, "margin") and inner_container.margin:
# Should have left margin based on depth: (depth-1) * 1.2 * 16
# depth = i + 1, so margin = i * 1.2 * 16
expected_margin = i * 1.2 * 16 # 19.2px per depth level above 1
assert inner_container.margin.left == expected_margin
def test_render_micron_ascii_art(self):
"""Test micron rendering with ASCII art scaling."""
# Create content with ASCII art characters
ascii_art = "┌───┐\n│Box│\n└───┘"
content = f"Normal text\n{ascii_art}\nMore text"
result = render_micron(content)
assert isinstance(result, ft.Container)
# Each line is kept as separate control
assert len(result.content.controls) >= 3
# Should contain the ASCII art content
all_text = ""
for control in result.content.controls:
if isinstance(control, ft.Text):
text_content = ""
if hasattr(control, "value") and control.value:
text_content = control.value
elif hasattr(control, "spans") and control.spans:
text_content = "".join(span.text for span in control.spans)
if text_content:
all_text += text_content + "\n"
all_text = all_text.strip()
assert "┌───┐" in all_text
assert "Normal text" in all_text
def test_render_micron_literal_mode(self):
"""Test micron literal mode."""
content = "`=Literal mode`\n# This should be visible\n`=Back to normal`"
result = render_micron(content)
assert isinstance(result, ft.Container)
# Should contain the processed content (literal mode may not be fully implemented)
all_text = ""
for control in result.content.controls:
if isinstance(control, ft.Text):
text_content = ""
if hasattr(control, "value") and control.value:
text_content = control.value
elif hasattr(control, "spans") and control.spans:
text_content = "".join(span.text for span in control.spans)
if text_content:
all_text += text_content + "\n"
# At minimum, should contain some text content
assert len(all_text.strip()) > 0
def test_render_micron_dividers(self):
"""Test micron rendering with dividers."""
content = "Text above\n-\nText below"
result = render_micron(content)
assert isinstance(result, ft.Container)
# Should contain controls for text
assert len(result.content.controls) >= 2
def test_render_micron_complex_formatting(self):
"""Test complex combination of micron formatting."""
content = """# Comment (ignored)
> Heading
Regular text.
>> Subsection
Centered text
Final paragraph."""
result = render_micron(content)
assert isinstance(result, ft.Container)
assert len(result.content.controls) >= 3 # Should have multiple elements
# Check for heading containers
heading_containers = [c for c in result.content.controls if isinstance(c, ft.Container)]
assert len(heading_containers) >= 2 # At least 2 headings
# Check that we have some text content
def extract_all_text(control):
"""Recursively extract text from control and its children."""
text = ""
if hasattr(control, "value") and control.value:
text += control.value
elif hasattr(control, "_Control__attrs") and "value" in control._Control__attrs:
text += control._Control__attrs["value"][0]
elif hasattr(control, "spans") and control.spans:
text += "".join(span.text for span in control.spans)
elif hasattr(control, "content"):
text += extract_all_text(control.content)
return text
all_text = ""
for control in result.content.controls:
all_text += extract_all_text(control)
assert "Heading" in all_text
assert "Subsection" in all_text
assert "Regular text" in all_text
class TestRendererComparison: class TestRendererComparison:
"""Test cases comparing both renderers.""" """Test cases comparing both renderers."""
def test_renderers_return_same_type(self): def test_renderers_return_correct_types(self):
"""Test that both renderers return the same control type.""" """Test that both renderers return the expected control types."""
content = "Test content" content = "Test content"
plaintext_result = render_plaintext(content) plaintext_result = render_plaintext(content)
micron_result = render_micron(content) micron_result = render_micron(content)
assert type(plaintext_result) is type(micron_result) # Plaintext returns Text, Micron returns Container
assert isinstance(plaintext_result, ft.Text) assert isinstance(plaintext_result, ft.Text)
assert isinstance(micron_result, ft.Text) assert isinstance(micron_result, ft.Container)
def test_renderers_preserve_content(self): def test_renderers_preserve_content(self):
"""Test that both renderers preserve the original content.""" """Test that both renderers preserve the original content."""
@@ -114,7 +364,33 @@ class TestRendererComparison:
micron_result = render_micron(content) micron_result = render_micron(content)
assert plaintext_result.value == content assert plaintext_result.value == content
assert micron_result.value == content
# For micron result (Container), extract text from controls
micron_text = ""
for control in micron_result.content.controls:
if isinstance(control, ft.Text):
# Extract text from the control
text_content = ""
if hasattr(control, "value") and control.value:
text_content = control.value
elif hasattr(control, "spans") and control.spans:
text_content = "".join(span.text for span in control.spans)
if text_content:
micron_text += text_content + "\n"
elif isinstance(control, ft.Container) and hasattr(control, "content"):
# Handle indented text controls
if isinstance(control.content, ft.Text):
text_content = ""
if hasattr(control.content, "value") and control.content.value:
text_content = control.content.value
elif hasattr(control.content, "spans") and control.content.spans:
text_content = "".join(span.text for span in control.content.spans)
if text_content:
micron_text += text_content + "\n"
# Remove trailing newline and compare
micron_text = micron_text.rstrip("\n")
assert micron_text == content
def test_renderers_same_properties(self): def test_renderers_same_properties(self):
"""Test that both renderers set the same basic properties.""" """Test that both renderers set the same basic properties."""
@@ -123,6 +399,17 @@ class TestRendererComparison:
plaintext_result = render_plaintext(content) plaintext_result = render_plaintext(content)
micron_result = render_micron(content) micron_result = render_micron(content)
assert plaintext_result.selectable == micron_result.selectable # Check basic properties
assert plaintext_result.font_family == micron_result.font_family assert plaintext_result.selectable is True
assert plaintext_result.expand == micron_result.expand assert plaintext_result.font_family == "monospace"
assert plaintext_result.expand is True
# For micron result (Container), check properties
assert isinstance(micron_result.content, ft.ListView)
assert micron_result.content.spacing == 2
# Check that all Text controls in the ListView have the expected properties
for control in micron_result.content.controls:
if isinstance(control, ft.Text):
assert control.selectable is True
assert control.font_family == "monospace"

View File

@@ -215,7 +215,7 @@ class TestShortcuts:
url_field2 = Mock() url_field2 = Mock()
mock_tab_manager.manager.tabs = [ mock_tab_manager.manager.tabs = [
{"url_field": url_field1}, {"url_field": url_field1},
{"url_field": url_field2} {"url_field": url_field2},
] ]
mock_tab_manager.manager.index = 1 # Second tab mock_tab_manager.manager.index = 1 # Second tab

View File

@@ -5,7 +5,11 @@ from unittest.mock import Mock, patch
import pytest import pytest
from ren_browser.storage.storage import StorageManager, get_storage_manager, initialize_storage from ren_browser.storage.storage import (
StorageManager,
get_storage_manager,
initialize_storage,
)
class TestStorageManager: class TestStorageManager:
@@ -13,13 +17,15 @@ class TestStorageManager:
def test_storage_manager_init_without_page(self): def test_storage_manager_init_without_page(self):
"""Test StorageManager initialization without a page.""" """Test StorageManager initialization without a page."""
with patch('ren_browser.storage.storage.StorageManager._get_storage_directory') as mock_get_dir: with patch(
mock_dir = Path('/mock/storage') "ren_browser.storage.storage.StorageManager._get_storage_directory",
) as mock_get_dir:
mock_dir = Path("/mock/storage")
mock_get_dir.return_value = mock_dir mock_get_dir.return_value = mock_dir
with patch('pathlib.Path.mkdir') as mock_mkdir: with patch("pathlib.Path.mkdir") as mock_mkdir:
storage = StorageManager() storage = StorageManager()
assert storage.page is None assert storage.page is None
assert storage._storage_dir == mock_dir assert storage._storage_dir == mock_dir
mock_mkdir.assert_called_once_with(parents=True, exist_ok=True) mock_mkdir.assert_called_once_with(parents=True, exist_ok=True)
@@ -27,27 +33,34 @@ class TestStorageManager:
def test_storage_manager_init_with_page(self): def test_storage_manager_init_with_page(self):
"""Test StorageManager initialization with a page.""" """Test StorageManager initialization with a page."""
mock_page = Mock() mock_page = Mock()
with patch('ren_browser.storage.storage.StorageManager._get_storage_directory') as mock_get_dir: with patch(
mock_dir = Path('/mock/storage') "ren_browser.storage.storage.StorageManager._get_storage_directory",
) as mock_get_dir:
mock_dir = Path("/mock/storage")
mock_get_dir.return_value = mock_dir mock_get_dir.return_value = mock_dir
with patch('pathlib.Path.mkdir'): with patch("pathlib.Path.mkdir"):
storage = StorageManager(mock_page) storage = StorageManager(mock_page)
assert storage.page == mock_page assert storage.page == mock_page
assert storage._storage_dir == mock_dir assert storage._storage_dir == mock_dir
def test_get_storage_directory_desktop(self): def test_get_storage_directory_desktop(self):
"""Test storage directory detection for desktop platforms.""" """Test storage directory detection for desktop platforms."""
with patch('os.name', 'posix'), \ with (
patch.dict('os.environ', {'XDG_CONFIG_HOME': '/home/user/.config'}, clear=True), \ patch("os.name", "posix"),
patch('pathlib.Path.mkdir'): patch.dict(
"os.environ", {"XDG_CONFIG_HOME": "/home/user/.config"}, clear=True,
with patch('ren_browser.storage.storage.StorageManager._ensure_storage_directory'): ),
patch("pathlib.Path.mkdir"),
):
with patch(
"ren_browser.storage.storage.StorageManager._ensure_storage_directory",
):
storage = StorageManager() storage = StorageManager()
storage._storage_dir = storage._get_storage_directory() storage._storage_dir = storage._get_storage_directory()
expected_dir = Path('/home/user/.config') / 'ren_browser' expected_dir = Path("/home/user/.config") / "ren_browser"
assert storage._storage_dir == expected_dir assert storage._storage_dir == expected_dir
def test_get_storage_directory_windows(self): def test_get_storage_directory_windows(self):
@@ -57,14 +70,17 @@ class TestStorageManager:
def test_get_storage_directory_android(self): def test_get_storage_directory_android(self):
"""Test storage directory detection for Android.""" """Test storage directory detection for Android."""
with patch('os.name', 'posix'), \ with (
patch.dict('os.environ', {'ANDROID_ROOT': '/system'}, clear=True), \ patch("os.name", "posix"),
patch('pathlib.Path.mkdir'): patch.dict("os.environ", {"ANDROID_ROOT": "/system"}, clear=True),
patch("pathlib.Path.mkdir"),
with patch('ren_browser.storage.storage.StorageManager._ensure_storage_directory'): ):
with patch(
"ren_browser.storage.storage.StorageManager._ensure_storage_directory",
):
storage = StorageManager() storage = StorageManager()
storage._storage_dir = storage._get_storage_directory() storage._storage_dir = storage._get_storage_directory()
expected_dir = Path('/data/data/com.ren_browser/files') expected_dir = Path("/storage/emulated/0/Documents/ren_browser")
assert storage._storage_dir == expected_dir assert storage._storage_dir == expected_dir
def test_get_config_path(self): def test_get_config_path(self):
@@ -72,17 +88,17 @@ class TestStorageManager:
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager() storage = StorageManager()
storage._storage_dir = Path(temp_dir) storage._storage_dir = Path(temp_dir)
config_path = storage.get_config_path() config_path = storage.get_config_path()
expected_path = Path(temp_dir) / 'config' expected_path = Path(temp_dir) / "config"
assert config_path == expected_path assert config_path == expected_path
def test_get_reticulum_config_path(self): def test_get_reticulum_config_path(self):
"""Test getting Reticulum config directory path.""" """Test getting Reticulum config directory path."""
storage = StorageManager() storage = StorageManager()
config_path = storage.get_reticulum_config_path() config_path = storage.get_reticulum_config_path()
expected_path = Path.home() / '.reticulum' expected_path = Path.home() / ".reticulum"
assert config_path == expected_path assert config_path == expected_path
def test_save_config_success(self): def test_save_config_success(self):
@@ -90,52 +106,71 @@ class TestStorageManager:
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager() storage = StorageManager()
storage._storage_dir = Path(temp_dir) storage._storage_dir = Path(temp_dir)
# Mock the reticulum config path to use temp dir # Mock the reticulum config path to use temp dir
with patch.object(storage, 'get_reticulum_config_path', return_value=Path(temp_dir) / "reticulum"): with patch.object(
storage,
"get_reticulum_config_path",
return_value=Path(temp_dir) / "reticulum",
):
config_content = "test config content" config_content = "test config content"
result = storage.save_config(config_content) result = storage.save_config(config_content)
assert result is True assert result is True
config_path = storage.get_config_path() config_path = storage.get_config_path()
assert config_path.exists() assert config_path.exists()
assert config_path.read_text(encoding='utf-8') == config_content assert config_path.read_text(encoding="utf-8") == config_content
def test_save_config_with_client_storage(self): def test_save_config_with_client_storage(self):
"""Test config saving with client storage.""" """Test config saving with client storage."""
mock_page = Mock() mock_page = Mock()
mock_page.client_storage.set = Mock() mock_page.client_storage.set = Mock()
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager(mock_page) storage = StorageManager(mock_page)
storage._storage_dir = Path(temp_dir) storage._storage_dir = Path(temp_dir)
# Mock the reticulum config path to use temp dir # Mock the reticulum config path to use temp dir
with patch.object(storage, 'get_reticulum_config_path', return_value=Path(temp_dir) / "reticulum"): with patch.object(
storage,
"get_reticulum_config_path",
return_value=Path(temp_dir) / "reticulum",
):
config_content = "test config content" config_content = "test config content"
result = storage.save_config(config_content) result = storage.save_config(config_content)
assert result is True assert result is True
mock_page.client_storage.set.assert_called_with('ren_browser_config', config_content) mock_page.client_storage.set.assert_called_with(
"ren_browser_config", config_content,
)
def test_save_config_fallback(self): def test_save_config_fallback(self):
"""Test config saving fallback when file system fails.""" """Test config saving fallback when file system fails."""
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
mock_page = Mock() mock_page = Mock()
mock_page.client_storage.set = Mock() mock_page.client_storage.set = Mock()
storage = StorageManager(mock_page) storage = StorageManager(mock_page)
storage._storage_dir = Path(temp_dir) storage._storage_dir = Path(temp_dir)
# Mock the reticulum config path to use temp dir and cause failure # Mock the reticulum config path to use temp dir and cause failure
with patch.object(storage, 'get_reticulum_config_path', return_value=Path(temp_dir) / "reticulum"): with patch.object(
with patch('pathlib.Path.write_text', side_effect=PermissionError("Access denied")): storage,
"get_reticulum_config_path",
return_value=Path(temp_dir) / "reticulum",
):
with patch(
"pathlib.Path.write_text",
side_effect=PermissionError("Access denied"),
):
config_content = "test config content" config_content = "test config content"
result = storage.save_config(config_content) result = storage.save_config(config_content)
assert result is True assert result is True
# Check that the config was set to client storage # Check that the config was set to client storage
mock_page.client_storage.set.assert_any_call('ren_browser_config', config_content) mock_page.client_storage.set.assert_any_call(
"ren_browser_config", config_content,
)
# Verify that client storage was called at least once # Verify that client storage was called at least once
assert mock_page.client_storage.set.call_count >= 1 assert mock_page.client_storage.set.call_count >= 1
@@ -144,13 +179,17 @@ class TestStorageManager:
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager() storage = StorageManager()
storage._storage_dir = Path(temp_dir) storage._storage_dir = Path(temp_dir)
# Mock the reticulum config path to use temp dir # Mock the reticulum config path to use temp dir
with patch.object(storage, 'get_reticulum_config_path', return_value=Path(temp_dir) / "reticulum"): with patch.object(
storage,
"get_reticulum_config_path",
return_value=Path(temp_dir) / "reticulum",
):
config_content = "test config content" config_content = "test config content"
config_path = storage.get_config_path() config_path = storage.get_config_path()
config_path.write_text(config_content, encoding='utf-8') config_path.write_text(config_content, encoding="utf-8")
loaded_config = storage.load_config() loaded_config = storage.load_config()
assert loaded_config == config_content assert loaded_config == config_content
@@ -158,25 +197,33 @@ class TestStorageManager:
"""Test loading config from client storage when file doesn't exist.""" """Test loading config from client storage when file doesn't exist."""
mock_page = Mock() mock_page = Mock()
mock_page.client_storage.get = Mock(return_value="client storage config") mock_page.client_storage.get = Mock(return_value="client storage config")
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager(mock_page) storage = StorageManager(mock_page)
storage._storage_dir = Path(temp_dir) storage._storage_dir = Path(temp_dir)
# Mock the reticulum config path to also be in temp dir # Mock the reticulum config path to also be in temp dir
with patch.object(storage, 'get_reticulum_config_path', return_value=Path(temp_dir) / "reticulum"): with patch.object(
storage,
"get_reticulum_config_path",
return_value=Path(temp_dir) / "reticulum",
):
loaded_config = storage.load_config() loaded_config = storage.load_config()
assert loaded_config == "client storage config" assert loaded_config == "client storage config"
mock_page.client_storage.get.assert_called_with('ren_browser_config') mock_page.client_storage.get.assert_called_with("ren_browser_config")
def test_load_config_default(self): def test_load_config_default(self):
"""Test loading default config when no config exists.""" """Test loading default config when no config exists."""
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager() storage = StorageManager()
storage._storage_dir = Path(temp_dir) storage._storage_dir = Path(temp_dir)
# Mock the reticulum config path to also be in temp dir # Mock the reticulum config path to also be in temp dir
with patch.object(storage, 'get_reticulum_config_path', return_value=Path(temp_dir) / "reticulum"): with patch.object(
storage,
"get_reticulum_config_path",
return_value=Path(temp_dir) / "reticulum",
):
loaded_config = storage.load_config() loaded_config = storage.load_config()
assert loaded_config == "" assert loaded_config == ""
@@ -185,15 +232,15 @@ class TestStorageManager:
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager() storage = StorageManager()
storage._storage_dir = Path(temp_dir) storage._storage_dir = Path(temp_dir)
bookmarks = [{"name": "Test", "url": "test://example"}] bookmarks = [{"name": "Test", "url": "test://example"}]
result = storage.save_bookmarks(bookmarks) result = storage.save_bookmarks(bookmarks)
assert result is True assert result is True
bookmarks_path = storage._storage_dir / 'bookmarks.json' bookmarks_path = storage._storage_dir / "bookmarks.json"
assert bookmarks_path.exists() assert bookmarks_path.exists()
with open(bookmarks_path, 'r', encoding='utf-8') as f: with open(bookmarks_path, encoding="utf-8") as f:
loaded_bookmarks = json.load(f) loaded_bookmarks = json.load(f)
assert loaded_bookmarks == bookmarks assert loaded_bookmarks == bookmarks
@@ -202,13 +249,13 @@ class TestStorageManager:
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager() storage = StorageManager()
storage._storage_dir = Path(temp_dir) storage._storage_dir = Path(temp_dir)
bookmarks = [{"name": "Test", "url": "test://example"}] bookmarks = [{"name": "Test", "url": "test://example"}]
bookmarks_path = storage._storage_dir / 'bookmarks.json' bookmarks_path = storage._storage_dir / "bookmarks.json"
with open(bookmarks_path, 'w', encoding='utf-8') as f: with open(bookmarks_path, "w", encoding="utf-8") as f:
json.dump(bookmarks, f) json.dump(bookmarks, f)
loaded_bookmarks = storage.load_bookmarks() loaded_bookmarks = storage.load_bookmarks()
assert loaded_bookmarks == bookmarks assert loaded_bookmarks == bookmarks
@@ -217,7 +264,7 @@ class TestStorageManager:
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager() storage = StorageManager()
storage._storage_dir = Path(temp_dir) storage._storage_dir = Path(temp_dir)
loaded_bookmarks = storage.load_bookmarks() loaded_bookmarks = storage.load_bookmarks()
assert loaded_bookmarks == [] assert loaded_bookmarks == []
@@ -226,15 +273,15 @@ class TestStorageManager:
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager() storage = StorageManager()
storage._storage_dir = Path(temp_dir) storage._storage_dir = Path(temp_dir)
history = [{"url": "test://example", "timestamp": 1234567890}] history = [{"url": "test://example", "timestamp": 1234567890}]
result = storage.save_history(history) result = storage.save_history(history)
assert result is True assert result is True
history_path = storage._storage_dir / 'history.json' history_path = storage._storage_dir / "history.json"
assert history_path.exists() assert history_path.exists()
with open(history_path, 'r', encoding='utf-8') as f: with open(history_path, encoding="utf-8") as f:
loaded_history = json.load(f) loaded_history = json.load(f)
assert loaded_history == history assert loaded_history == history
@@ -243,13 +290,13 @@ class TestStorageManager:
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager() storage = StorageManager()
storage._storage_dir = Path(temp_dir) storage._storage_dir = Path(temp_dir)
history = [{"url": "test://example", "timestamp": 1234567890}] history = [{"url": "test://example", "timestamp": 1234567890}]
history_path = storage._storage_dir / 'history.json' history_path = storage._storage_dir / "history.json"
with open(history_path, 'w', encoding='utf-8') as f: with open(history_path, "w", encoding="utf-8") as f:
json.dump(history, f) json.dump(history, f)
loaded_history = storage.load_history() loaded_history = storage.load_history()
assert loaded_history == history assert loaded_history == history
@@ -258,33 +305,36 @@ class TestStorageManager:
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
mock_page = Mock() mock_page = Mock()
mock_page.client_storage = Mock() mock_page.client_storage = Mock()
storage = StorageManager(mock_page) storage = StorageManager(mock_page)
storage._storage_dir = Path(temp_dir) storage._storage_dir = Path(temp_dir)
info = storage.get_storage_info() info = storage.get_storage_info()
assert 'storage_dir' in info assert "storage_dir" in info
assert 'config_path' in info assert "config_path" in info
assert 'reticulum_config_path' in info assert "reticulum_config_path" in info
assert 'storage_dir_exists' in info assert "storage_dir_exists" in info
assert 'storage_dir_writable' in info assert "storage_dir_writable" in info
assert 'has_client_storage' in info assert "has_client_storage" in info
assert info['storage_dir'] == str(Path(temp_dir)) assert info["storage_dir"] == str(Path(temp_dir))
assert info['storage_dir_exists'] is True assert info["storage_dir_exists"] is True
assert info['has_client_storage'] is True assert info["has_client_storage"] is True
def test_storage_directory_fallback(self): def test_storage_directory_fallback(self):
"""Test fallback to temp directory when storage creation fails.""" """Test fallback to temp directory when storage creation fails."""
with patch.object(StorageManager, '_get_storage_directory') as mock_get_dir: with patch.object(StorageManager, "_get_storage_directory") as mock_get_dir:
mock_get_dir.return_value = Path('/nonexistent/path') mock_get_dir.return_value = Path("/nonexistent/path")
with patch('pathlib.Path.mkdir', side_effect=[PermissionError("Access denied"), None]): with patch(
with patch('tempfile.gettempdir', return_value='/tmp'): "pathlib.Path.mkdir",
side_effect=[PermissionError("Access denied"), None],
):
with patch("tempfile.gettempdir", return_value="/tmp"):
storage = StorageManager() storage = StorageManager()
expected_fallback = Path('/tmp') / 'ren_browser' expected_fallback = Path("/tmp") / "ren_browser"
assert storage._storage_dir == expected_fallback assert storage._storage_dir == expected_fallback
@@ -293,28 +343,28 @@ class TestStorageGlobalFunctions:
def test_get_storage_manager_singleton(self): def test_get_storage_manager_singleton(self):
"""Test that get_storage_manager returns the same instance.""" """Test that get_storage_manager returns the same instance."""
with patch('ren_browser.storage.storage._storage_manager', None): with patch("ren_browser.storage.storage._storage_manager", None):
storage1 = get_storage_manager() storage1 = get_storage_manager()
storage2 = get_storage_manager() storage2 = get_storage_manager()
assert storage1 is storage2 assert storage1 is storage2
def test_get_storage_manager_with_page(self): def test_get_storage_manager_with_page(self):
"""Test get_storage_manager with page parameter.""" """Test get_storage_manager with page parameter."""
mock_page = Mock() mock_page = Mock()
with patch('ren_browser.storage.storage._storage_manager', None): with patch("ren_browser.storage.storage._storage_manager", None):
storage = get_storage_manager(mock_page) storage = get_storage_manager(mock_page)
assert storage.page == mock_page assert storage.page == mock_page
def test_initialize_storage(self): def test_initialize_storage(self):
"""Test initialize_storage function.""" """Test initialize_storage function."""
mock_page = Mock() mock_page = Mock()
with patch('ren_browser.storage.storage._storage_manager', None): with patch("ren_browser.storage.storage._storage_manager", None):
storage = initialize_storage(mock_page) storage = initialize_storage(mock_page)
assert storage.page == mock_page assert storage.page == mock_page
assert get_storage_manager() is storage assert get_storage_manager() is storage
@@ -327,11 +377,18 @@ class TestStorageManagerEdgeCases:
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager() storage = StorageManager()
storage._storage_dir = Path(temp_dir) storage._storage_dir = Path(temp_dir)
# Mock the reticulum config path to use temp dir # Mock the reticulum config path to use temp dir
with patch.object(storage, 'get_reticulum_config_path', return_value=Path(temp_dir) / "reticulum"): with patch.object(
storage,
"get_reticulum_config_path",
return_value=Path(temp_dir) / "reticulum",
):
# Test with content that might cause encoding issues # Test with content that might cause encoding issues
with patch('pathlib.Path.write_text', side_effect=UnicodeEncodeError('utf-8', '', 0, 1, 'error')): with patch(
"pathlib.Path.write_text",
side_effect=UnicodeEncodeError("utf-8", "", 0, 1, "error"),
):
result = storage.save_config("test content") result = storage.save_config("test content")
# Should still succeed due to fallback # Should still succeed due to fallback
assert result is False assert result is False
@@ -341,13 +398,17 @@ class TestStorageManagerEdgeCases:
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager() storage = StorageManager()
storage._storage_dir = Path(temp_dir) storage._storage_dir = Path(temp_dir)
# Create a config file with invalid encoding # Create a config file with invalid encoding
config_path = storage.get_config_path() config_path = storage.get_config_path()
config_path.write_bytes(b'\xff\xfe invalid utf-8') config_path.write_bytes(b"\xff\xfe invalid utf-8")
# Mock the reticulum config path to also be in temp dir # Mock the reticulum config path to also be in temp dir
with patch.object(storage, 'get_reticulum_config_path', return_value=Path(temp_dir) / "reticulum"): with patch.object(
storage,
"get_reticulum_config_path",
return_value=Path(temp_dir) / "reticulum",
):
# Should return empty string when encoding fails # Should return empty string when encoding fails
config = storage.load_config() config = storage.load_config()
assert config == "" assert config == ""
@@ -355,9 +416,11 @@ class TestStorageManagerEdgeCases:
def test_is_writable_permission_denied(self): def test_is_writable_permission_denied(self):
"""Test _is_writable when permission is denied.""" """Test _is_writable when permission is denied."""
storage = StorageManager() storage = StorageManager()
with patch('pathlib.Path.write_text', side_effect=PermissionError("Access denied")): with patch(
test_path = Path('/mock/path') "pathlib.Path.write_text", side_effect=PermissionError("Access denied"),
):
test_path = Path("/mock/path")
result = storage._is_writable(test_path) result = storage._is_writable(test_path)
assert result is False assert result is False
@@ -366,6 +429,6 @@ class TestStorageManagerEdgeCases:
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager() storage = StorageManager()
test_path = Path(temp_dir) test_path = Path(temp_dir)
result = storage._is_writable(test_path) result = storage._is_writable(test_path)
assert result is True assert result is True

View File

@@ -13,17 +13,19 @@ class TestTabsManager:
@pytest.fixture @pytest.fixture
def tabs_manager(self, mock_page): def tabs_manager(self, mock_page):
"""Create a TabsManager instance for testing.""" """Create a TabsManager instance for testing."""
with patch("ren_browser.app.RENDERER", "plaintext"), \ with (
patch("ren_browser.renderer.plaintext.render_plaintext") as mock_render: patch("ren_browser.app.RENDERER", "plaintext"),
patch("ren_browser.renderer.plaintext.render_plaintext") as mock_render,
):
mock_render.return_value = Mock(spec=ft.Text) mock_render.return_value = Mock(spec=ft.Text)
return TabsManager(mock_page) return TabsManager(mock_page)
def test_tabs_manager_init(self, mock_page): def test_tabs_manager_init(self, mock_page):
"""Test TabsManager initialization.""" """Test TabsManager initialization."""
with patch("ren_browser.app.RENDERER", "plaintext"), \ with (
patch("ren_browser.renderer.plaintext.render_plaintext") as mock_render: patch("ren_browser.app.RENDERER", "plaintext"),
patch("ren_browser.renderer.plaintext.render_plaintext") as mock_render,
):
mock_render.return_value = Mock(spec=ft.Text) mock_render.return_value = Mock(spec=ft.Text)
manager = TabsManager(mock_page) manager = TabsManager(mock_page)
@@ -55,9 +57,10 @@ class TestTabsManager:
def test_on_add_click(self, tabs_manager): def test_on_add_click(self, tabs_manager):
"""Test adding a new tab via button click.""" """Test adding a new tab via button click."""
with patch("ren_browser.app.RENDERER", "plaintext"), \ with (
patch("ren_browser.renderer.plaintext.render_plaintext") as mock_render: patch("ren_browser.app.RENDERER", "plaintext"),
patch("ren_browser.renderer.plaintext.render_plaintext") as mock_render,
):
mock_render.return_value = Mock(spec=ft.Text) mock_render.return_value = Mock(spec=ft.Text)
initial_count = len(tabs_manager.manager.tabs) initial_count = len(tabs_manager.manager.tabs)
@@ -198,7 +201,7 @@ class TestTabsManager:
"""Test management of multiple tabs.""" """Test management of multiple tabs."""
# Add several tabs # Add several tabs
for i in range(3): for i in range(3):
tabs_manager._add_tab_internal(f"Tab {i+2}", Mock()) tabs_manager._add_tab_internal(f"Tab {i + 2}", Mock())
assert len(tabs_manager.manager.tabs) == 4 assert len(tabs_manager.manager.tabs) == 4
@@ -220,7 +223,13 @@ class TestTabsManager:
tabs_manager._add_tab_internal("Tab 3", content2) tabs_manager._add_tab_internal("Tab 3", content2)
tabs_manager.select_tab(1) tabs_manager.select_tab(1)
assert tabs_manager.content_container.content == tabs_manager.manager.tabs[1]["content"] assert (
tabs_manager.content_container.content
== tabs_manager.manager.tabs[1]["content"]
)
tabs_manager.select_tab(2) tabs_manager.select_tab(2)
assert tabs_manager.content_container.content == tabs_manager.manager.tabs[2]["content"] assert (
tabs_manager.content_container.content
== tabs_manager.manager.tabs[2]["content"]
)

View File

@@ -28,7 +28,9 @@ class TestBuildUI:
@patch("ren_browser.pages.page_request.PageFetcher") @patch("ren_browser.pages.page_request.PageFetcher")
@patch("ren_browser.tabs.tabs.TabsManager") @patch("ren_browser.tabs.tabs.TabsManager")
@patch("ren_browser.controls.shortcuts.Shortcuts") @patch("ren_browser.controls.shortcuts.Shortcuts")
def test_build_ui_appbar_setup(self, mock_shortcuts, mock_tabs, mock_fetcher, mock_announce_service, mock_page): def test_build_ui_appbar_setup(
self, mock_shortcuts, mock_tabs, mock_fetcher, mock_announce_service, mock_page,
):
"""Test that build_ui sets up the app bar correctly.""" """Test that build_ui sets up the app bar correctly."""
mock_tab_manager = Mock() mock_tab_manager = Mock()
mock_tabs.return_value = mock_tab_manager mock_tabs.return_value = mock_tab_manager
@@ -48,7 +50,9 @@ class TestBuildUI:
@patch("ren_browser.pages.page_request.PageFetcher") @patch("ren_browser.pages.page_request.PageFetcher")
@patch("ren_browser.tabs.tabs.TabsManager") @patch("ren_browser.tabs.tabs.TabsManager")
@patch("ren_browser.controls.shortcuts.Shortcuts") @patch("ren_browser.controls.shortcuts.Shortcuts")
def test_build_ui_drawer_setup(self, mock_shortcuts, mock_tabs, mock_fetcher, mock_announce_service, mock_page): def test_build_ui_drawer_setup(
self, mock_shortcuts, mock_tabs, mock_fetcher, mock_announce_service, mock_page,
):
"""Test that build_ui sets up the drawer correctly.""" """Test that build_ui sets up the drawer correctly."""
mock_tab_manager = Mock() mock_tab_manager = Mock()
mock_tabs.return_value = mock_tab_manager mock_tabs.return_value = mock_tab_manager
@@ -116,9 +120,10 @@ class TestOpenSettingsTab:
mock_tab_manager._add_tab_internal = Mock() mock_tab_manager._add_tab_internal = Mock()
mock_tab_manager.select_tab = Mock() mock_tab_manager.select_tab = Mock()
with patch("pathlib.Path.read_text", return_value="config"), \ with (
patch("pathlib.Path.write_text"): patch("pathlib.Path.read_text", return_value="config"),
patch("pathlib.Path.write_text"),
):
open_settings_tab(mock_page, mock_tab_manager) open_settings_tab(mock_page, mock_tab_manager)
# Get the settings content that was added # Get the settings content that was added
@@ -129,7 +134,10 @@ class TestOpenSettingsTab:
for control in settings_content.controls: for control in settings_content.controls:
if hasattr(control, "controls"): if hasattr(control, "controls"):
for sub_control in control.controls: for sub_control in control.controls:
if hasattr(sub_control, "text") and sub_control.text == "Save and Restart": if (
hasattr(sub_control, "text")
and sub_control.text == "Save Config"
):
save_btn = sub_control save_btn = sub_control
break break
@@ -142,7 +150,10 @@ class TestOpenSettingsTab:
mock_tab_manager._add_tab_internal = Mock() mock_tab_manager._add_tab_internal = Mock()
mock_tab_manager.select_tab = Mock() mock_tab_manager.select_tab = Mock()
with patch('ren_browser.ui.settings.get_storage_manager', return_value=mock_storage_manager): with patch(
"ren_browser.ui.settings.get_storage_manager",
return_value=mock_storage_manager,
):
open_settings_tab(mock_page, mock_tab_manager) open_settings_tab(mock_page, mock_tab_manager)
settings_content = mock_tab_manager._add_tab_internal.call_args[0][1] settings_content = mock_tab_manager._add_tab_internal.call_args[0][1]
@@ -155,10 +166,14 @@ class TestOpenSettingsTab:
mock_tab_manager._add_tab_internal = Mock() mock_tab_manager._add_tab_internal = Mock()
mock_tab_manager.select_tab = Mock() mock_tab_manager.select_tab = Mock()
with patch('ren_browser.ui.settings.get_storage_manager', return_value=mock_storage_manager), \ with (
patch("ren_browser.logs.ERROR_LOGS", ["Error 1", "Error 2"]), \ patch(
patch("ren_browser.logs.RET_LOGS", ["RNS log 1", "RNS log 2"]): "ren_browser.ui.settings.get_storage_manager",
return_value=mock_storage_manager,
),
patch("ren_browser.logs.ERROR_LOGS", ["Error 1", "Error 2"]),
patch("ren_browser.logs.RET_LOGS", ["RNS log 1", "RNS log 2"]),
):
open_settings_tab(mock_page, mock_tab_manager) open_settings_tab(mock_page, mock_tab_manager)
mock_tab_manager._add_tab_internal.assert_called_once() mock_tab_manager._add_tab_internal.assert_called_once()