Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
859c15653c
|
|||
|
b063c0cbec
|
|||
|
3bb64d0533
|
|||
|
64fa46f60f
|
|||
|
e05a94e152
|
|||
|
b5ad57ad8f
|
|||
|
3c149cdd0c
|
|||
|
a1480a5c1b
|
|||
|
1e39fe277e
|
|||
|
d8de2b1150
|
|||
|
d1536aa05a
|
|||
|
1882325224
|
|||
|
cce6471534
|
|||
|
8bd46f50f3
|
|||
|
1e9b53934f
|
|||
|
c2921876f7
|
|||
|
8d723a8944
|
|||
|
f3d0da08a2
|
|||
|
5571b810ae
|
|||
|
df72547bde
|
|||
|
5ec677437e
|
|||
|
3cddaeb2b9
|
|||
|
e36bfec4a0
|
|||
|
0aaa7938e6
|
|||
|
379f85c792
|
|||
|
bfbfb22312
|
|||
|
5f9d7784a8
|
|||
|
9c0564d253
|
|||
|
5da3be18cb
|
|||
|
9eb9aafd35
|
|||
| 263e5a92bf | |||
| 6b5d476d74 | |||
| 5d640032ee | |||
| 0d2d595867 | |||
| 0b531bba54 | |||
| 3809ac8274 | |||
| a32a542c54 | |||
| e77faa5105 | |||
| c0f60d52db | |||
| 57c6b8ce3d | |||
| 9fc912fba4 | |||
| 0d878e8491 | |||
| 2796059aef | |||
| a5ae444b6c | |||
| d354b96334 | |||
| f511b60361 | |||
| b8386a60c6 | |||
| e20d6fe214 | |||
| 8b45e5d72b | |||
| 047169f3af | |||
| e0939e70f8 | |||
| 63e93d0cff | |||
| 926b3a198d | |||
| 8db441612f | |||
| b34b8f23ff | |||
| 13ad0bcef6 | |||
| 64b9ac3df4 | |||
| ee521a9f60 | |||
| fd4e0c8a14 |
@@ -9,6 +9,3 @@ name = "python"
|
||||
|
||||
[analyzers.meta]
|
||||
runtime_version = "3.x.x"
|
||||
|
||||
[[analyzers]]
|
||||
name = "docker"
|
||||
@@ -1,7 +0,0 @@
|
||||
config/
|
||||
__pycache__/
|
||||
build/
|
||||
dist/
|
||||
.ruff_cache/
|
||||
.env
|
||||
To-Do.md
|
||||
178
.gitea/workflows/build.yml
Normal file
178
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,178 @@
|
||||
name: Build Packages
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platform:
|
||||
description: 'Platform to build'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- linux
|
||||
- windows
|
||||
- android
|
||||
default: 'all'
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref_type == 'tag' || github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux'
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
|
||||
- name: Install Linux dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev cmake ninja-build clang pkg-config libgtk-3-dev liblzma-dev
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install Poetry and dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install poetry
|
||||
poetry config virtualenvs.create false
|
||||
poetry install --without dev
|
||||
|
||||
- name: Build Linux package
|
||||
run: poetry run flet build linux --no-rich-output
|
||||
|
||||
- name: Upload Linux artifact
|
||||
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
|
||||
with:
|
||||
name: ren-browser-linux
|
||||
path: build/linux
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
if: github.ref_type == 'tag' || github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows'
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install Poetry and dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install poetry
|
||||
poetry config virtualenvs.create false
|
||||
poetry install --without dev
|
||||
|
||||
- name: Build Windows package
|
||||
run: poetry run flet build windows --no-rich-output
|
||||
env:
|
||||
PYTHONIOENCODING: utf-8
|
||||
PYTHONUTF8: 1
|
||||
|
||||
- name: Upload Windows artifact
|
||||
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
|
||||
with:
|
||||
name: ren-browser-windows
|
||||
path: build/windows
|
||||
|
||||
build-android:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref_type == 'tag' || github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android'
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: Install Android dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cmake ninja-build clang pkg-config
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install Poetry and dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install poetry
|
||||
poetry config virtualenvs.create false
|
||||
poetry install --without dev
|
||||
|
||||
- name: Build Android APK
|
||||
run: poetry run flet build apk --no-rich-output --exclude watchdog
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
|
||||
with:
|
||||
name: ren-browser-apk
|
||||
path: build/apk
|
||||
|
||||
create-release:
|
||||
needs: [build-linux, build-windows, build-android]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref_type == 'tag' && !cancelled()
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
|
||||
- name: Download Linux artifact
|
||||
if: needs.build-linux.result == 'success'
|
||||
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a
|
||||
with:
|
||||
name: ren-browser-linux
|
||||
path: ./artifacts/linux
|
||||
|
||||
- name: Download Windows artifact
|
||||
if: needs.build-windows.result == 'success'
|
||||
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a
|
||||
with:
|
||||
name: ren-browser-windows
|
||||
path: ./artifacts/windows
|
||||
|
||||
- name: Download APK artifact
|
||||
if: needs.build-android.result == 'success'
|
||||
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a
|
||||
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/windows/*
|
||||
./artifacts/apk/*
|
||||
name: Release ${{ github.ref_name }}
|
||||
body: |
|
||||
## Release ${{ github.ref_name }}
|
||||
|
||||
This release contains:
|
||||
- Linux binary package
|
||||
- Windows binary package
|
||||
- Android APK package
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Safety
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [ master ]
|
||||
schedule:
|
||||
- cron: '0 0 * * 0' # weekly
|
||||
jobs:
|
||||
@@ -12,6 +12,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
- name: Run Safety CLI to check for vulnerabilities
|
||||
uses: pyupio/safety-action@7baf6605473beffc874c1313ddf2db085c0cacf2
|
||||
uses: pyupio/safety-action@2591cf2f3e67ba68b923f4c92f0d36e281c65023 # v1.0.1
|
||||
with:
|
||||
api-key: ${{ secrets.SAFETY_API_KEY }}
|
||||
@@ -1,12 +1,10 @@
|
||||
# TODO: Update to use specific commit hashes for the actions for better supply chain security.
|
||||
|
||||
name: Run Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -24,7 +22,7 @@ jobs:
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@@ -44,7 +42,7 @@ jobs:
|
||||
poetry config virtualenvs.in-project true
|
||||
|
||||
- name: Cache Poetry dependencies
|
||||
uses: actions/cache@2f8e54208210a422b2efd51efaa6bd6d7ca8920f
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
|
||||
@@ -62,12 +60,3 @@ jobs:
|
||||
- name: Run tests with pytest
|
||||
run: |
|
||||
poetry run pytest -v --cov=ren_browser --cov-report=xml --cov-report=term
|
||||
|
||||
- name: Upload coverage reports
|
||||
uses: codecov/codecov-action@ab904c41d6ece82784817410c45d8b8c02684457
|
||||
if: matrix.python-version == '3.13'
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
flags: unittests
|
||||
name: codecov-umbrella
|
||||
fail_ci_if_error: false
|
||||
120
.github/workflows/build.yml
vendored
120
.github/workflows/build.yml
vendored
@@ -1,120 +0,0 @@
|
||||
name: Build APK and Linux
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
|
||||
- name: Install Linux dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev cmake ninja-build clang pkg-config libgtk-3-dev liblzma-dev
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install Poetry and dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install poetry
|
||||
poetry config virtualenvs.create false
|
||||
poetry install --without dev
|
||||
|
||||
- name: Build Linux package
|
||||
run: poetry run flet build linux
|
||||
|
||||
- name: Upload Linux artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: ren-browser-linux
|
||||
path: build/linux
|
||||
|
||||
build-android:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: Install Android dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cmake ninja-build clang pkg-config
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install Poetry and dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install poetry
|
||||
poetry config virtualenvs.create false
|
||||
poetry install --without dev
|
||||
|
||||
- name: Build Android APK
|
||||
run: poetry run flet build apk
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: ren-browser-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@f44cd7b40bfd40b6aa1cc1b9b5b7bf03d3d2d08f
|
||||
with:
|
||||
name: ren-browser-linux
|
||||
path: ./artifacts/linux
|
||||
|
||||
- name: Download APK artifact
|
||||
uses: actions/download-artifact@f44cd7b40bfd40b6aa1cc1b9b5b7bf03d3d2d08f
|
||||
with:
|
||||
name: ren-browser-apk
|
||||
path: ./artifacts/apk
|
||||
|
||||
- name: Create draft release
|
||||
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836
|
||||
with:
|
||||
draft: true
|
||||
files: |
|
||||
./artifacts/linux/**/*
|
||||
./artifacts/apk/**/*
|
||||
name: Release ${{ github.ref_name }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
body: |
|
||||
## Release ${{ github.ref_name }}
|
||||
|
||||
This release contains:
|
||||
- Linux binary package
|
||||
- Android APK package
|
||||
55
.github/workflows/docker.yml
vendored
55
.github/workflows/docker.yml
vendored
@@ -1,55 +0,0 @@
|
||||
name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
tags: [ 'v*' ]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch,prefix=,suffix=,enable={{is_default_branch}}
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,format=short
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -7,13 +7,19 @@ I welcome all contributions to the project.
|
||||
- Styling/Design (I am bad at this)
|
||||
- Documentation
|
||||
- Micron Renderer/Parser
|
||||
- Android and Flet (config/permissions/etc)
|
||||
|
||||
## Rules
|
||||
|
||||
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.
|
||||
4. Make sure you run the tests before submitting the PR.
|
||||
|
||||
## Generative AI Usage
|
||||
|
||||
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
|
||||
|
||||
|
||||
18
Dockerfile
18
Dockerfile
@@ -1,18 +0,0 @@
|
||||
FROM python:3.13-alpine
|
||||
|
||||
# Install build dependencies for cryptography
|
||||
RUN apk add --no-cache gcc musl-dev libffi-dev openssl-dev
|
||||
|
||||
# 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
|
||||
COPY . /app
|
||||
|
||||
# Expose the web port
|
||||
EXPOSE 8550
|
||||
|
||||
# Run the web version of Ren Browser
|
||||
CMD ["python3", "-u", "-m", "ren_browser.app", "--web", "--port", "8550"]
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Sudo-Ivan
|
||||
Copyright (c) 2025 Sudo-Ivan / Quad4.io
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
63
Makefile
Normal file
63
Makefile
Normal file
@@ -0,0 +1,63 @@
|
||||
# Ren Browser Makefile
|
||||
.PHONY: help build poetry-build linux apk clean test lint format run
|
||||
|
||||
# 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 " run - Launch Ren Browser via Poetry"
|
||||
@echo " linux - Build Linux package"
|
||||
@echo " apk - Build Android APK"
|
||||
@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 --cleanup-packages --exclude watchdog
|
||||
|
||||
# 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 .
|
||||
|
||||
# Run application
|
||||
run:
|
||||
@echo "Starting Ren Browser..."
|
||||
poetry run ren-browser
|
||||
|
||||
# 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
|
||||
77
README.md
77
README.md
@@ -1,8 +1,11 @@
|
||||
# Ren Browser
|
||||
|
||||
A browser for the [Reticulum Network](https://reticulum.network/). Work-in-progress.
|
||||
A browser for the [Reticulum Network](https://reticulum.network/).
|
||||
|
||||
Target platforms: Web, Linux, Windows, MacOS, Android, iOS.
|
||||
> [!WARNING]
|
||||
> This is still a work-in-progress. Please be patient while I work on it.
|
||||
|
||||
Due to runner limitations for the time being, I can only build: Linux and Android. Windows and MacOS are coming eventually.
|
||||
|
||||
Built using [Flet](https://flet.dev/).
|
||||
|
||||
@@ -18,57 +21,109 @@ Built using [Flet](https://flet.dev/).
|
||||
- Python 3.13+
|
||||
- Flet
|
||||
- Reticulum 1.0.0+
|
||||
- Poetry
|
||||
- UV or Poetry
|
||||
|
||||
**Setup**
|
||||
|
||||
Using UV:
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
Or using Poetry:
|
||||
```bash
|
||||
poetry install
|
||||
```
|
||||
|
||||
### Desktop
|
||||
|
||||
Using UV:
|
||||
```bash
|
||||
poetry run ren-browser-dev
|
||||
# From local development
|
||||
uv run ren-browser
|
||||
```
|
||||
|
||||
Using Poetry:
|
||||
```bash
|
||||
poetry run ren-browser
|
||||
```
|
||||
|
||||
### Web
|
||||
|
||||
Using UV:
|
||||
```bash
|
||||
poetry run ren-browser-web-dev
|
||||
# From local development
|
||||
uv run ren-browser-web
|
||||
```
|
||||
|
||||
Using Poetry:
|
||||
```bash
|
||||
poetry run ren-browser-web
|
||||
```
|
||||
|
||||
### Mobile
|
||||
|
||||
**Android**
|
||||
|
||||
Using UV:
|
||||
```bash
|
||||
poetry run ren-browser-android-dev
|
||||
# From local development
|
||||
uv run ren-browser-android
|
||||
```
|
||||
|
||||
Using Poetry:
|
||||
```bash
|
||||
poetry run ren-browser-android
|
||||
```
|
||||
|
||||
**iOS**
|
||||
|
||||
Using UV:
|
||||
```bash
|
||||
poetry run ren-browser-ios-dev
|
||||
# From local development
|
||||
uv run ren-browser-ios
|
||||
```
|
||||
|
||||
### Docker/Podman
|
||||
Using Poetry:
|
||||
```bash
|
||||
poetry run ren-browser-ios
|
||||
```
|
||||
|
||||
To run directly from the GitHub repository without cloning:
|
||||
|
||||
```bash
|
||||
docker build -t ren-browser .
|
||||
docker run -p 8550:8550 -v ./config:/app/config ren-browser
|
||||
# Using uvx (temporary environment)
|
||||
uvx --from git+https://git.quad4.io/Ren/Browser.git ren-browser-web
|
||||
|
||||
# Or clone and run locally
|
||||
git clone https://git.quad4.io/Ren/Browser.git
|
||||
cd Ren-Browser
|
||||
uv sync
|
||||
uv run ren-browser-web
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
### Linux
|
||||
|
||||
Using UV:
|
||||
```bash
|
||||
uv run flet build linux
|
||||
```
|
||||
|
||||
Using Poetry:
|
||||
```bash
|
||||
poetry run flet build linux
|
||||
```
|
||||
|
||||
### Android
|
||||
|
||||
Using UV:
|
||||
```bash
|
||||
poetry run flet build android
|
||||
uv run flet build apk
|
||||
```
|
||||
|
||||
Using Poetry:
|
||||
```bash
|
||||
poetry run flet build apk
|
||||
```
|
||||
3
To-Do.md
3
To-Do.md
@@ -2,7 +2,7 @@
|
||||
|
||||
## Bugs
|
||||
|
||||
- [ ] Test Config Saving on Android. In my testing and also reported via Email.
|
||||
- [ ] Test Config Saving on Android.
|
||||
- [ ] Fix persisting app state in background on Android. https://github.com/Sudo-Ivan/Ren-Browser/issues/1
|
||||
- [ ] Fix tabs dragging/reordering and overflow issues. https://github.com/Sudo-Ivan/Ren-Browser/issues/1
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
|
||||
## Distribution
|
||||
|
||||
- [ ] Add Docker images to build Windows, Linux, MacOS, Android, iOS.
|
||||
- [ ] Add/Update build workflow to build Windows, MacOS and iOS.
|
||||
- [ ] Appimage
|
||||
- [ ] Flatpak
|
||||
392
poetry.lock
generated
392
poetry.lock
generated
@@ -1,36 +1,37 @@
|
||||
# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.10.0"
|
||||
version = "4.11.0"
|
||||
description = "High-level concurrency and networking framework on top of asyncio or Trio"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "platform_system != \"Pyodide\""
|
||||
files = [
|
||||
{file = "anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1"},
|
||||
{file = "anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6"},
|
||||
{file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"},
|
||||
{file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
idna = ">=2.8"
|
||||
sniffio = ">=1.1"
|
||||
typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
|
||||
|
||||
[package.extras]
|
||||
trio = ["trio (>=0.26.1)"]
|
||||
trio = ["trio (>=0.31.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.8.3"
|
||||
version = "2025.10.5"
|
||||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
markers = "platform_system != \"Pyodide\""
|
||||
files = [
|
||||
{file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"},
|
||||
{file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"},
|
||||
{file = "certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"},
|
||||
{file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -146,100 +147,104 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.10.6"
|
||||
version = "7.11.0"
|
||||
description = "Code coverage measurement for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "coverage-7.10.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70e7bfbd57126b5554aa482691145f798d7df77489a177a6bef80de78860a356"},
|
||||
{file = "coverage-7.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301"},
|
||||
{file = "coverage-7.10.6-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c61fc91ab80b23f5fddbee342d19662f3d3328173229caded831aa0bd7595460"},
|
||||
{file = "coverage-7.10.6-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10356fdd33a7cc06e8051413140bbdc6f972137508a3572e3f59f805cd2832fd"},
|
||||
{file = "coverage-7.10.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80b1695cf7c5ebe7b44bf2521221b9bb8cdf69b1f24231149a7e3eb1ae5fa2fb"},
|
||||
{file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2e4c33e6378b9d52d3454bd08847a8651f4ed23ddbb4a0520227bd346382bbc6"},
|
||||
{file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c8a3ec16e34ef980a46f60dc6ad86ec60f763c3f2fa0db6d261e6e754f72e945"},
|
||||
{file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7d79dabc0a56f5af990cc6da9ad1e40766e82773c075f09cc571e2076fef882e"},
|
||||
{file = "coverage-7.10.6-cp310-cp310-win32.whl", hash = "sha256:86b9b59f2b16e981906e9d6383eb6446d5b46c278460ae2c36487667717eccf1"},
|
||||
{file = "coverage-7.10.6-cp310-cp310-win_amd64.whl", hash = "sha256:e132b9152749bd33534e5bd8565c7576f135f157b4029b975e15ee184325f528"},
|
||||
{file = "coverage-7.10.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c706db3cabb7ceef779de68270150665e710b46d56372455cd741184f3868d8f"},
|
||||
{file = "coverage-7.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e0c38dc289e0508ef68ec95834cb5d2e96fdbe792eaccaa1bccac3966bbadcc"},
|
||||
{file = "coverage-7.10.6-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:752a3005a1ded28f2f3a6e8787e24f28d6abe176ca64677bcd8d53d6fe2ec08a"},
|
||||
{file = "coverage-7.10.6-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:689920ecfd60f992cafca4f5477d55720466ad2c7fa29bb56ac8d44a1ac2b47a"},
|
||||
{file = "coverage-7.10.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec98435796d2624d6905820a42f82149ee9fc4f2d45c2c5bc5a44481cc50db62"},
|
||||
{file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b37201ce4a458c7a758ecc4efa92fa8ed783c66e0fa3c42ae19fc454a0792153"},
|
||||
{file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2904271c80898663c810a6b067920a61dd8d38341244a3605bd31ab55250dad5"},
|
||||
{file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5aea98383463d6e1fa4e95416d8de66f2d0cb588774ee20ae1b28df826bcb619"},
|
||||
{file = "coverage-7.10.6-cp311-cp311-win32.whl", hash = "sha256:e3fb1fa01d3598002777dd259c0c2e6d9d5e10e7222976fc8e03992f972a2cba"},
|
||||
{file = "coverage-7.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:f35ed9d945bece26553d5b4c8630453169672bea0050a564456eb88bdffd927e"},
|
||||
{file = "coverage-7.10.6-cp311-cp311-win_arm64.whl", hash = "sha256:99e1a305c7765631d74b98bf7dbf54eeea931f975e80f115437d23848ee8c27c"},
|
||||
{file = "coverage-7.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b2dd6059938063a2c9fee1af729d4f2af28fd1a545e9b7652861f0d752ebcea"},
|
||||
{file = "coverage-7.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:388d80e56191bf846c485c14ae2bc8898aa3124d9d35903fef7d907780477634"},
|
||||
{file = "coverage-7.10.6-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:90cb5b1a4670662719591aa92d0095bb41714970c0b065b02a2610172dbf0af6"},
|
||||
{file = "coverage-7.10.6-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:961834e2f2b863a0e14260a9a273aff07ff7818ab6e66d2addf5628590c628f9"},
|
||||
{file = "coverage-7.10.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf9a19f5012dab774628491659646335b1928cfc931bf8d97b0d5918dd58033c"},
|
||||
{file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:99c4283e2a0e147b9c9cc6bc9c96124de9419d6044837e9799763a0e29a7321a"},
|
||||
{file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:282b1b20f45df57cc508c1e033403f02283adfb67d4c9c35a90281d81e5c52c5"},
|
||||
{file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cdbe264f11afd69841bd8c0d83ca10b5b32853263ee62e6ac6a0ab63895f972"},
|
||||
{file = "coverage-7.10.6-cp312-cp312-win32.whl", hash = "sha256:a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d"},
|
||||
{file = "coverage-7.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:856986eadf41f52b214176d894a7de05331117f6035a28ac0016c0f63d887629"},
|
||||
{file = "coverage-7.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80"},
|
||||
{file = "coverage-7.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6"},
|
||||
{file = "coverage-7.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80"},
|
||||
{file = "coverage-7.10.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003"},
|
||||
{file = "coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27"},
|
||||
{file = "coverage-7.10.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4"},
|
||||
{file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d"},
|
||||
{file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc"},
|
||||
{file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc"},
|
||||
{file = "coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e"},
|
||||
{file = "coverage-7.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32"},
|
||||
{file = "coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2"},
|
||||
{file = "coverage-7.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b"},
|
||||
{file = "coverage-7.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393"},
|
||||
{file = "coverage-7.10.6-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27"},
|
||||
{file = "coverage-7.10.6-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df"},
|
||||
{file = "coverage-7.10.6-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb"},
|
||||
{file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282"},
|
||||
{file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4"},
|
||||
{file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21"},
|
||||
{file = "coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0"},
|
||||
{file = "coverage-7.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5"},
|
||||
{file = "coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b"},
|
||||
{file = "coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e"},
|
||||
{file = "coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb"},
|
||||
{file = "coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034"},
|
||||
{file = "coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1"},
|
||||
{file = "coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a"},
|
||||
{file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb"},
|
||||
{file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d"},
|
||||
{file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747"},
|
||||
{file = "coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5"},
|
||||
{file = "coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713"},
|
||||
{file = "coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32"},
|
||||
{file = "coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65"},
|
||||
{file = "coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6"},
|
||||
{file = "coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0"},
|
||||
{file = "coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e"},
|
||||
{file = "coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5"},
|
||||
{file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7"},
|
||||
{file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5"},
|
||||
{file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0"},
|
||||
{file = "coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7"},
|
||||
{file = "coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930"},
|
||||
{file = "coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b"},
|
||||
{file = "coverage-7.10.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90558c35af64971d65fbd935c32010f9a2f52776103a259f1dee865fe8259352"},
|
||||
{file = "coverage-7.10.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8953746d371e5695405806c46d705a3cd170b9cc2b9f93953ad838f6c1e58612"},
|
||||
{file = "coverage-7.10.6-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c83f6afb480eae0313114297d29d7c295670a41c11b274e6bca0c64540c1ce7b"},
|
||||
{file = "coverage-7.10.6-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7eb68d356ba0cc158ca535ce1381dbf2037fa8cb5b1ae5ddfc302e7317d04144"},
|
||||
{file = "coverage-7.10.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b15a87265e96307482746d86995f4bff282f14b027db75469c446da6127433b"},
|
||||
{file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fc53ba868875bfbb66ee447d64d6413c2db91fddcfca57025a0e7ab5b07d5862"},
|
||||
{file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:efeda443000aa23f276f4df973cb82beca682fd800bb119d19e80504ffe53ec2"},
|
||||
{file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9702b59d582ff1e184945d8b501ffdd08d2cee38d93a2206aa5f1365ce0b8d78"},
|
||||
{file = "coverage-7.10.6-cp39-cp39-win32.whl", hash = "sha256:2195f8e16ba1a44651ca684db2ea2b2d4b5345da12f07d9c22a395202a05b23c"},
|
||||
{file = "coverage-7.10.6-cp39-cp39-win_amd64.whl", hash = "sha256:f32ff80e7ef6a5b5b606ea69a36e97b219cd9dc799bcf2963018a4d8f788cfbf"},
|
||||
{file = "coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3"},
|
||||
{file = "coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90"},
|
||||
{file = "coverage-7.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eb53f1e8adeeb2e78962bade0c08bfdc461853c7969706ed901821e009b35e31"},
|
||||
{file = "coverage-7.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9a03ec6cb9f40a5c360f138b88266fd8f58408d71e89f536b4f91d85721d075"},
|
||||
{file = "coverage-7.11.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d7f0616c557cbc3d1c2090334eddcbb70e1ae3a40b07222d62b3aa47f608fab"},
|
||||
{file = "coverage-7.11.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e44a86a47bbdf83b0a3ea4d7df5410d6b1a0de984fbd805fa5101f3624b9abe0"},
|
||||
{file = "coverage-7.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:596763d2f9a0ee7eec6e643e29660def2eef297e1de0d334c78c08706f1cb785"},
|
||||
{file = "coverage-7.11.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef55537ff511b5e0a43edb4c50a7bf7ba1c3eea20b4f49b1490f1e8e0e42c591"},
|
||||
{file = "coverage-7.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9cbabd8f4d0d3dc571d77ae5bdbfa6afe5061e679a9d74b6797c48d143307088"},
|
||||
{file = "coverage-7.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e24045453384e0ae2a587d562df2a04d852672eb63051d16096d3f08aa4c7c2f"},
|
||||
{file = "coverage-7.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:7161edd3426c8d19bdccde7d49e6f27f748f3c31cc350c5de7c633fea445d866"},
|
||||
{file = "coverage-7.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d4ed4de17e692ba6415b0587bc7f12bc80915031fc9db46a23ce70fc88c9841"},
|
||||
{file = "coverage-7.11.0-cp310-cp310-win32.whl", hash = "sha256:765c0bc8fe46f48e341ef737c91c715bd2a53a12792592296a095f0c237e09cf"},
|
||||
{file = "coverage-7.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:24d6f3128f1b2d20d84b24f4074475457faedc3d4613a7e66b5e769939c7d969"},
|
||||
{file = "coverage-7.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d58ecaa865c5b9fa56e35efc51d1014d4c0d22838815b9fce57a27dd9576847"},
|
||||
{file = "coverage-7.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b679e171f1c104a5668550ada700e3c4937110dbdd153b7ef9055c4f1a1ee3cc"},
|
||||
{file = "coverage-7.11.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca61691ba8c5b6797deb221a0d09d7470364733ea9c69425a640f1f01b7c5bf0"},
|
||||
{file = "coverage-7.11.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aef1747ede4bd8ca9cfc04cc3011516500c6891f1b33a94add3253f6f876b7b7"},
|
||||
{file = "coverage-7.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1839d08406e4cba2953dcc0ffb312252f14d7c4c96919f70167611f4dee2623"},
|
||||
{file = "coverage-7.11.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e0eb0a2dcc62478eb5b4cbb80b97bdee852d7e280b90e81f11b407d0b81c4287"},
|
||||
{file = "coverage-7.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc1fbea96343b53f65d5351d8fd3b34fd415a2670d7c300b06d3e14a5af4f552"},
|
||||
{file = "coverage-7.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:214b622259dd0cf435f10241f1333d32caa64dbc27f8790ab693428a141723de"},
|
||||
{file = "coverage-7.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:258d9967520cca899695d4eb7ea38be03f06951d6ca2f21fb48b1235f791e601"},
|
||||
{file = "coverage-7.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cf9e6ff4ca908ca15c157c409d608da77a56a09877b97c889b98fb2c32b6465e"},
|
||||
{file = "coverage-7.11.0-cp311-cp311-win32.whl", hash = "sha256:fcc15fc462707b0680cff6242c48625da7f9a16a28a41bb8fd7a4280920e676c"},
|
||||
{file = "coverage-7.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:865965bf955d92790f1facd64fe7ff73551bd2c1e7e6b26443934e9701ba30b9"},
|
||||
{file = "coverage-7.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:5693e57a065760dcbeb292d60cc4d0231a6d4b6b6f6a3191561e1d5e8820b745"},
|
||||
{file = "coverage-7.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9c49e77811cf9d024b95faf86c3f059b11c0c9be0b0d61bc598f453703bd6fd1"},
|
||||
{file = "coverage-7.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a61e37a403a778e2cda2a6a39abcc895f1d984071942a41074b5c7ee31642007"},
|
||||
{file = "coverage-7.11.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c79cae102bb3b1801e2ef1511fb50e91ec83a1ce466b2c7c25010d884336de46"},
|
||||
{file = "coverage-7.11.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16ce17ceb5d211f320b62df002fa7016b7442ea0fd260c11cec8ce7730954893"},
|
||||
{file = "coverage-7.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80027673e9d0bd6aef86134b0771845e2da85755cf686e7c7c59566cf5a89115"},
|
||||
{file = "coverage-7.11.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d3ffa07a08657306cd2215b0da53761c4d73cb54d9143b9303a6481ec0cd415"},
|
||||
{file = "coverage-7.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a3b6a5f8b2524fd6c1066bc85bfd97e78709bb5e37b5b94911a6506b65f47186"},
|
||||
{file = "coverage-7.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fcc0a4aa589de34bc56e1a80a740ee0f8c47611bdfb28cd1849de60660f3799d"},
|
||||
{file = "coverage-7.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dba82204769d78c3fd31b35c3d5f46e06511936c5019c39f98320e05b08f794d"},
|
||||
{file = "coverage-7.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81b335f03ba67309a95210caf3eb43bd6fe75a4e22ba653ef97b4696c56c7ec2"},
|
||||
{file = "coverage-7.11.0-cp312-cp312-win32.whl", hash = "sha256:037b2d064c2f8cc8716fe4d39cb705779af3fbf1ba318dc96a1af858888c7bb5"},
|
||||
{file = "coverage-7.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:d66c0104aec3b75e5fd897e7940188ea1892ca1d0235316bf89286d6a22568c0"},
|
||||
{file = "coverage-7.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:d91ebeac603812a09cf6a886ba6e464f3bbb367411904ae3790dfe28311b15ad"},
|
||||
{file = "coverage-7.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1"},
|
||||
{file = "coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be"},
|
||||
{file = "coverage-7.11.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d"},
|
||||
{file = "coverage-7.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82"},
|
||||
{file = "coverage-7.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52"},
|
||||
{file = "coverage-7.11.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b"},
|
||||
{file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4"},
|
||||
{file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd"},
|
||||
{file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc"},
|
||||
{file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48"},
|
||||
{file = "coverage-7.11.0-cp313-cp313-win32.whl", hash = "sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040"},
|
||||
{file = "coverage-7.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05"},
|
||||
{file = "coverage-7.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a"},
|
||||
{file = "coverage-7.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b"},
|
||||
{file = "coverage-7.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37"},
|
||||
{file = "coverage-7.11.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de"},
|
||||
{file = "coverage-7.11.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f"},
|
||||
{file = "coverage-7.11.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c"},
|
||||
{file = "coverage-7.11.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa"},
|
||||
{file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740"},
|
||||
{file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef"},
|
||||
{file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0"},
|
||||
{file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca"},
|
||||
{file = "coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2"},
|
||||
{file = "coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268"},
|
||||
{file = "coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836"},
|
||||
{file = "coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497"},
|
||||
{file = "coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e"},
|
||||
{file = "coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1"},
|
||||
{file = "coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca"},
|
||||
{file = "coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd"},
|
||||
{file = "coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43"},
|
||||
{file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777"},
|
||||
{file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2"},
|
||||
{file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d"},
|
||||
{file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4"},
|
||||
{file = "coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721"},
|
||||
{file = "coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad"},
|
||||
{file = "coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479"},
|
||||
{file = "coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f"},
|
||||
{file = "coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e"},
|
||||
{file = "coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44"},
|
||||
{file = "coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3"},
|
||||
{file = "coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b"},
|
||||
{file = "coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d"},
|
||||
{file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2"},
|
||||
{file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e"},
|
||||
{file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996"},
|
||||
{file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11"},
|
||||
{file = "coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73"},
|
||||
{file = "coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547"},
|
||||
{file = "coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3"},
|
||||
{file = "coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68"},
|
||||
{file = "coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
@@ -247,66 +252,66 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.1"
|
||||
version = "46.0.3"
|
||||
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
|
||||
optional = false
|
||||
python-versions = "!=3.9.0,!=3.9.1,>=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "cryptography-46.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:1cd6d50c1a8b79af1a6f703709d8973845f677c8e97b1268f5ff323d38ce8475"},
|
||||
{file = "cryptography-46.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ff483716be32690c14636e54a1f6e2e1b7bf8e22ca50b989f88fa1b2d287080"},
|
||||
{file = "cryptography-46.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9873bf7c1f2a6330bdfe8621e7ce64b725784f9f0c3a6a55c3047af5849f920e"},
|
||||
{file = "cryptography-46.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0dfb7c88d4462a0cfdd0d87a3c245a7bc3feb59de101f6ff88194f740f72eda6"},
|
||||
{file = "cryptography-46.0.1-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e22801b61613ebdebf7deb18b507919e107547a1d39a3b57f5f855032dd7cfb8"},
|
||||
{file = "cryptography-46.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:757af4f6341ce7a1e47c326ca2a81f41d236070217e5fbbad61bbfe299d55d28"},
|
||||
{file = "cryptography-46.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f7a24ea78de345cfa7f6a8d3bde8b242c7fac27f2bd78fa23474ca38dfaeeab9"},
|
||||
{file = "cryptography-46.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e8776dac9e660c22241b6587fae51a67b4b0147daa4d176b172c3ff768ad736"},
|
||||
{file = "cryptography-46.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9f40642a140c0c8649987027867242b801486865277cbabc8c6059ddef16dc8b"},
|
||||
{file = "cryptography-46.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:449ef2b321bec7d97ef2c944173275ebdab78f3abdd005400cc409e27cd159ab"},
|
||||
{file = "cryptography-46.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2dd339ba3345b908fa3141ddba4025568fa6fd398eabce3ef72a29ac2d73ad75"},
|
||||
{file = "cryptography-46.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7411c910fb2a412053cf33cfad0153ee20d27e256c6c3f14d7d7d1d9fec59fd5"},
|
||||
{file = "cryptography-46.0.1-cp311-abi3-win32.whl", hash = "sha256:cbb8e769d4cac884bb28e3ff620ef1001b75588a5c83c9c9f1fdc9afbe7f29b0"},
|
||||
{file = "cryptography-46.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:92e8cfe8bd7dd86eac0a677499894862cd5cc2fd74de917daa881d00871ac8e7"},
|
||||
{file = "cryptography-46.0.1-cp311-abi3-win_arm64.whl", hash = "sha256:db5597a4c7353b2e5fb05a8e6cb74b56a4658a2b7bf3cb6b1821ae7e7fd6eaa0"},
|
||||
{file = "cryptography-46.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:4c49eda9a23019e11d32a0eb51a27b3e7ddedde91e099c0ac6373e3aacc0d2ee"},
|
||||
{file = "cryptography-46.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9babb7818fdd71394e576cf26c5452df77a355eac1a27ddfa24096665a27f8fd"},
|
||||
{file = "cryptography-46.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9f2c4cc63be3ef43c0221861177cee5d14b505cd4d4599a89e2cd273c4d3542a"},
|
||||
{file = "cryptography-46.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:41c281a74df173876da1dc9a9b6953d387f06e3d3ed9284e3baae3ab3f40883a"},
|
||||
{file = "cryptography-46.0.1-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0a17377fa52563d730248ba1f68185461fff36e8bc75d8787a7dd2e20a802b7a"},
|
||||
{file = "cryptography-46.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:0d1922d9280e08cde90b518a10cd66831f632960a8d08cb3418922d83fce6f12"},
|
||||
{file = "cryptography-46.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:af84e8e99f1a82cea149e253014ea9dc89f75b82c87bb6c7242203186f465129"},
|
||||
{file = "cryptography-46.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ef648d2c690703501714588b2ba640facd50fd16548133b11b2859e8655a69da"},
|
||||
{file = "cryptography-46.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:e94eb5fa32a8a9f9bf991f424f002913e3dd7c699ef552db9b14ba6a76a6313b"},
|
||||
{file = "cryptography-46.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:534b96c0831855e29fc3b069b085fd185aa5353033631a585d5cd4dd5d40d657"},
|
||||
{file = "cryptography-46.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9b55038b5c6c47559aa33626d8ecd092f354e23de3c6975e4bb205df128a2a0"},
|
||||
{file = "cryptography-46.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ec13b7105117dbc9afd023300fb9954d72ca855c274fe563e72428ece10191c0"},
|
||||
{file = "cryptography-46.0.1-cp314-cp314t-win32.whl", hash = "sha256:504e464944f2c003a0785b81668fe23c06f3b037e9cb9f68a7c672246319f277"},
|
||||
{file = "cryptography-46.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c52fded6383f7e20eaf70a60aeddd796b3677c3ad2922c801be330db62778e05"},
|
||||
{file = "cryptography-46.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9495d78f52c804b5ec8878b5b8c7873aa8e63db9cd9ee387ff2db3fffe4df784"},
|
||||
{file = "cryptography-46.0.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:d84c40bdb8674c29fa192373498b6cb1e84f882889d21a471b45d1f868d8d44b"},
|
||||
{file = "cryptography-46.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9ed64e5083fa806709e74fc5ea067dfef9090e5b7a2320a49be3c9df3583a2d8"},
|
||||
{file = "cryptography-46.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:341fb7a26bc9d6093c1b124b9f13acc283d2d51da440b98b55ab3f79f2522ead"},
|
||||
{file = "cryptography-46.0.1-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6ef1488967e729948d424d09c94753d0167ce59afba8d0f6c07a22b629c557b2"},
|
||||
{file = "cryptography-46.0.1-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7823bc7cdf0b747ecfb096d004cc41573c2f5c7e3a29861603a2871b43d3ef32"},
|
||||
{file = "cryptography-46.0.1-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:f736ab8036796f5a119ff8211deda416f8c15ce03776db704a7a4e17381cb2ef"},
|
||||
{file = "cryptography-46.0.1-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:e46710a240a41d594953012213ea8ca398cd2448fbc5d0f1be8160b5511104a0"},
|
||||
{file = "cryptography-46.0.1-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:84ef1f145de5aee82ea2447224dc23f065ff4cc5791bb3b506615957a6ba8128"},
|
||||
{file = "cryptography-46.0.1-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9394c7d5a7565ac5f7d9ba38b2617448eba384d7b107b262d63890079fad77ca"},
|
||||
{file = "cryptography-46.0.1-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ed957044e368ed295257ae3d212b95456bd9756df490e1ac4538857f67531fcc"},
|
||||
{file = "cryptography-46.0.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f7de12fa0eee6234de9a9ce0ffcfa6ce97361db7a50b09b65c63ac58e5f22fc7"},
|
||||
{file = "cryptography-46.0.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7fab1187b6c6b2f11a326f33b036f7168f5b996aedd0c059f9738915e4e8f53a"},
|
||||
{file = "cryptography-46.0.1-cp38-abi3-win32.whl", hash = "sha256:45f790934ac1018adeba46a0f7289b2b8fe76ba774a88c7f1922213a56c98bc1"},
|
||||
{file = "cryptography-46.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:7176a5ab56fac98d706921f6416a05e5aff7df0e4b91516f450f8627cda22af3"},
|
||||
{file = "cryptography-46.0.1-cp38-abi3-win_arm64.whl", hash = "sha256:efc9e51c3e595267ff84adf56e9b357db89ab2279d7e375ffcaf8f678606f3d9"},
|
||||
{file = "cryptography-46.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:fd4b5e2ee4e60425711ec65c33add4e7a626adef79d66f62ba0acfd493af282d"},
|
||||
{file = "cryptography-46.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:48948940d0ae00483e85e9154bb42997d0b77c21e43a77b7773c8c80de532ac5"},
|
||||
{file = "cryptography-46.0.1-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b9c79af2c3058430d911ff1a5b2b96bbfe8da47d5ed961639ce4681886614e70"},
|
||||
{file = "cryptography-46.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0ca4be2af48c24df689a150d9cd37404f689e2968e247b6b8ff09bff5bcd786f"},
|
||||
{file = "cryptography-46.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:13e67c4d3fb8b6bc4ef778a7ccdd8df4cd15b4bcc18f4239c8440891a11245cc"},
|
||||
{file = "cryptography-46.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:15b5fd9358803b0d1cc42505a18d8bca81dabb35b5cfbfea1505092e13a9d96d"},
|
||||
{file = "cryptography-46.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:e34da95e29daf8a71cb2841fd55df0511539a6cdf33e6f77c1e95e44006b9b46"},
|
||||
{file = "cryptography-46.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:34f04b7311174469ab3ac2647469743720f8b6c8b046f238e5cb27905695eb2a"},
|
||||
{file = "cryptography-46.0.1.tar.gz", hash = "sha256:ed570874e88f213437f5cf758f9ef26cbfc3f336d889b1e592ee11283bb8d1c7"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372"},
|
||||
{file = "cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32"},
|
||||
{file = "cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c"},
|
||||
{file = "cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea"},
|
||||
{file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b"},
|
||||
{file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb"},
|
||||
{file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717"},
|
||||
{file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9"},
|
||||
{file = "cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c"},
|
||||
{file = "cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -319,7 +324,7 @@ nox = ["nox[uv] (>=2024.4.15)"]
|
||||
pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"]
|
||||
sdist = ["build (>=1.0.0)"]
|
||||
ssh = ["bcrypt (>=3.1.5)"]
|
||||
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.1)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
|
||||
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
|
||||
test-randomorder = ["pytest-randomly"]
|
||||
|
||||
[[package]]
|
||||
@@ -408,15 +413,15 @@ zstd = ["zstandard (>=0.18.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
version = "3.11"
|
||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
markers = "platform_system != \"Pyodide\""
|
||||
files = [
|
||||
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
|
||||
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
|
||||
{file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"},
|
||||
{file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
@@ -424,14 +429,14 @@ all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.1.0"
|
||||
version = "2.3.0"
|
||||
description = "brain-dead simple config-ini parsing"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"},
|
||||
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
|
||||
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
|
||||
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -559,6 +564,7 @@ files = [
|
||||
|
||||
[package.dependencies]
|
||||
pytest = ">=8.2,<9"
|
||||
typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""}
|
||||
|
||||
[package.extras]
|
||||
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"]
|
||||
@@ -619,14 +625,14 @@ six = ">=1.9.0"
|
||||
|
||||
[[package]]
|
||||
name = "rns"
|
||||
version = "1.0.0"
|
||||
version = "1.0.2"
|
||||
description = "Self-configuring, encrypted and resilient mesh networking stack for LoRa, packet radio, WiFi and everything in between"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "rns-1.0.0-py3-none-any.whl", hash = "sha256:5a9f18840510b69f89c6706d130177e2843c9e19c774707ae2661030d693dfc1"},
|
||||
{file = "rns-1.0.0.tar.gz", hash = "sha256:9f1c594e4eabd64dea4c1bd59ad1b9291e6a28b1d8ab5689a19708f13100735b"},
|
||||
{file = "rns-1.0.2-py3-none-any.whl", hash = "sha256:723bcf0a839025060ff680c4202b09fa766b35093a4a08506bb85485b8a1f154"},
|
||||
{file = "rns-1.0.2.tar.gz", hash = "sha256:19c025dadc4a85fc37c751e0e892f446456800ca8c434e007c25d8fd6939687e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -635,30 +641,31 @@ pyserial = ">=3.5"
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.11.13"
|
||||
version = "0.14.3"
|
||||
description = "An extremely fast Python linter and code formatter, written in Rust."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46"},
|
||||
{file = "ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48"},
|
||||
{file = "ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b"},
|
||||
{file = "ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a"},
|
||||
{file = "ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc"},
|
||||
{file = "ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629"},
|
||||
{file = "ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933"},
|
||||
{file = "ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165"},
|
||||
{file = "ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71"},
|
||||
{file = "ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9"},
|
||||
{file = "ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc"},
|
||||
{file = "ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7"},
|
||||
{file = "ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432"},
|
||||
{file = "ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492"},
|
||||
{file = "ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250"},
|
||||
{file = "ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3"},
|
||||
{file = "ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b"},
|
||||
{file = "ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514"},
|
||||
{file = "ruff-0.14.3-py3-none-linux_armv6l.whl", hash = "sha256:876b21e6c824f519446715c1342b8e60f97f93264012de9d8d10314f8a79c371"},
|
||||
{file = "ruff-0.14.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6fd8c79b457bedd2abf2702b9b472147cd860ed7855c73a5247fa55c9117654"},
|
||||
{file = "ruff-0.14.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:71ff6edca490c308f083156938c0c1a66907151263c4abdcb588602c6e696a14"},
|
||||
{file = "ruff-0.14.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:786ee3ce6139772ff9272aaf43296d975c0217ee1b97538a98171bf0d21f87ed"},
|
||||
{file = "ruff-0.14.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cd6291d0061811c52b8e392f946889916757610d45d004e41140d81fb6cd5ddc"},
|
||||
{file = "ruff-0.14.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a497ec0c3d2c88561b6d90f9c29f5ae68221ac00d471f306fa21fa4264ce5fcd"},
|
||||
{file = "ruff-0.14.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e231e1be58fc568950a04fbe6887c8e4b85310e7889727e2b81db205c45059eb"},
|
||||
{file = "ruff-0.14.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:469e35872a09c0e45fecf48dd960bfbce056b5db2d5e6b50eca329b4f853ae20"},
|
||||
{file = "ruff-0.14.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d6bc90307c469cb9d28b7cfad90aaa600b10d67c6e22026869f585e1e8a2db0"},
|
||||
{file = "ruff-0.14.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2f8a0bbcffcfd895df39c9a4ecd59bb80dca03dc43f7fb63e647ed176b741e"},
|
||||
{file = "ruff-0.14.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:678fdd7c7d2d94851597c23ee6336d25f9930b460b55f8598e011b57c74fd8c5"},
|
||||
{file = "ruff-0.14.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1ec1ac071e7e37e0221d2f2dbaf90897a988c531a8592a6a5959f0603a1ecf5e"},
|
||||
{file = "ruff-0.14.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afcdc4b5335ef440d19e7df9e8ae2ad9f749352190e96d481dc501b753f0733e"},
|
||||
{file = "ruff-0.14.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7bfc42f81862749a7136267a343990f865e71fe2f99cf8d2958f684d23ce3dfa"},
|
||||
{file = "ruff-0.14.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a65e448cfd7e9c59fae8cf37f9221585d3354febaad9a07f29158af1528e165f"},
|
||||
{file = "ruff-0.14.3-py3-none-win32.whl", hash = "sha256:f3d91857d023ba93e14ed2d462ab62c3428f9bbf2b4fbac50a03ca66d31991f7"},
|
||||
{file = "ruff-0.14.3-py3-none-win_amd64.whl", hash = "sha256:d7b7006ac0756306db212fd37116cce2bd307e1e109375e1c6c106002df0ae5f"},
|
||||
{file = "ruff-0.14.3-py3-none-win_arm64.whl", hash = "sha256:26eb477ede6d399d898791d01961e16b86f02bc2486d0d1a7a9bb2379d055dc1"},
|
||||
{file = "ruff-0.14.3.tar.gz", hash = "sha256:4ff876d2ab2b161b6de0aa1f5bd714e8e9b4033dc122ee006925fbacc4f62153"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -686,7 +693,20 @@ files = [
|
||||
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
description = "Backported and Experimental Type Hints for Python 3.9+"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
|
||||
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
||||
]
|
||||
markers = {main = "platform_system != \"Pyodide\" and python_version < \"3.13\"", dev = "python_version < \"3.13\""}
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.13"
|
||||
content-hash = "1164f4cb57e282bd41d46df9cdbb43e0756ee0269442235c80aa96df57f740dc"
|
||||
python-versions = ">=3.11"
|
||||
content-hash = "8f33d13d6a2aea7ef3e91f7d058cf14c1ab3ec935de8dec09dd979e1f22e48ba"
|
||||
|
||||
@@ -1,39 +1,56 @@
|
||||
[project]
|
||||
name = "ren-browser"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
description = "A browser for the Reticulum Network."
|
||||
authors = [
|
||||
{name = "Sudo-Ivan"}
|
||||
]
|
||||
module = "ren_browser.app"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"flet (>=0.28.3,<0.29.0)",
|
||||
"rns (>=1.0.0,<1.5.0)"
|
||||
"rns (>=1.0.2,<1.5.0)"
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["ren_browser"]
|
||||
|
||||
[project.scripts]
|
||||
ren-browser = "ren_browser.app:run"
|
||||
ren-browser-web = "ren_browser.app:web"
|
||||
ren-browser-android = "ren_browser.app:android"
|
||||
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]
|
||||
ruff = "^0.11.11"
|
||||
pytest = "^8.4.2"
|
||||
pytest-cov = "^7.0.0"
|
||||
pytest-mock = "^3.15.1"
|
||||
pytest-asyncio = "^1.2.0"
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"ruff>=0.11.11,<1.0.0",
|
||||
"pytest>=8.4.2,<9.0.0",
|
||||
"pytest-cov>=7.0.0,<8.0.0",
|
||||
"pytest-mock>=3.15.1,<4.0.0",
|
||||
"pytest-asyncio>=1.2.0,<2.0.0"
|
||||
]
|
||||
|
||||
[tool.flet]
|
||||
exclude = ["watchdog"]
|
||||
|
||||
[tool.flet.flutter.pubspec.dependency_overrides]
|
||||
webview_flutter_android = "4.10.1"
|
||||
|
||||
[tool.flet.android]
|
||||
min_sdk_version = 21
|
||||
target_sdk_version = 34
|
||||
|
||||
[tool.flet.android.permission]
|
||||
"android.permission.INTERNET" = true
|
||||
"android.permission.ACCESS_NETWORK_STATE" = true
|
||||
"android.permission.ACCESS_WIFI_STATE" = true
|
||||
"android.permission.WAKE_LOCK" = true
|
||||
"android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" = true
|
||||
"android.permission.FOREGROUND_SERVICE" = true
|
||||
"android.permission.FOREGROUND_SERVICE_DATA_SYNC" = true
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
This module provides services for listening to and collecting network
|
||||
announces from the Reticulum network.
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import RNS
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class Announce:
|
||||
"""Represents a Reticulum network announce.
|
||||
@@ -21,6 +21,7 @@ class Announce:
|
||||
display_name: str | None
|
||||
timestamp: int
|
||||
|
||||
|
||||
class AnnounceService:
|
||||
"""Service to listen for Reticulum announces and collect them.
|
||||
|
||||
@@ -60,7 +61,11 @@ class AnnounceService:
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
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)
|
||||
if self.update_callback:
|
||||
self.update_callback(self.announces)
|
||||
|
||||
@@ -3,17 +3,25 @@
|
||||
This module provides the entry point and platform-specific launchers for the
|
||||
Ren Browser, a browser for the Reticulum Network built with Flet.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import flet as ft
|
||||
import RNS
|
||||
from flet import AppView, Page
|
||||
|
||||
from ren_browser import rns
|
||||
from ren_browser.storage.storage import initialize_storage
|
||||
from ren_browser.ui.ui import build_ui
|
||||
|
||||
RENDERER = "plaintext"
|
||||
RNS_CONFIG_DIR = None
|
||||
RNS_INSTANCE = None
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def main(page: Page):
|
||||
"""Initialize and launch the Ren Browser application.
|
||||
@@ -21,48 +29,143 @@ async def main(page: Page):
|
||||
Sets up the loading screen, initializes Reticulum network,
|
||||
and builds the main UI.
|
||||
"""
|
||||
page.title = "Ren Browser"
|
||||
page.theme_mode = ft.ThemeMode.DARK
|
||||
|
||||
loader = ft.Container(
|
||||
expand=True,
|
||||
alignment=ft.alignment.center,
|
||||
bgcolor=ft.Colors.SURFACE,
|
||||
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,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
spacing=10,
|
||||
),
|
||||
)
|
||||
page.add(loader)
|
||||
page.update()
|
||||
|
||||
def init_ret():
|
||||
# Initialize storage system
|
||||
storage = initialize_storage(page)
|
||||
initialize_storage(page)
|
||||
|
||||
# Get Reticulum config directory
|
||||
if RNS_CONFIG_DIR:
|
||||
config_dir = RNS_CONFIG_DIR
|
||||
else:
|
||||
config_dir = storage.get_reticulum_config_path()
|
||||
config_override = RNS_CONFIG_DIR
|
||||
|
||||
print("Initializing Reticulum Network...")
|
||||
try:
|
||||
# Set up logging capture first, before RNS init
|
||||
import ren_browser.logs
|
||||
|
||||
ren_browser.logs.setup_rns_logging()
|
||||
RNS.Reticulum(str(config_dir))
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("Unable to configure RNS logging")
|
||||
|
||||
success = rns.initialize_reticulum(config_override)
|
||||
if not success:
|
||||
error_text = rns.get_last_error() or "Unknown error"
|
||||
print(f"Error initializing Reticulum: {error_text}")
|
||||
else:
|
||||
global RNS_INSTANCE
|
||||
RNS_INSTANCE = rns.get_reticulum_instance()
|
||||
config_dir = rns.get_config_path()
|
||||
if config_dir:
|
||||
config_path = Path(config_dir)
|
||||
print(f"RNS config directory: {config_path}")
|
||||
print(f"Config directory exists: {config_path.exists()}")
|
||||
print(
|
||||
"Config directory is writable: "
|
||||
f"{config_path.is_dir() and os.access(config_path, os.W_OK)}",
|
||||
)
|
||||
print("RNS initialized successfully")
|
||||
|
||||
page.controls.clear()
|
||||
build_ui(page)
|
||||
page.update()
|
||||
|
||||
page.run_thread(init_ret)
|
||||
|
||||
async def reload_reticulum(page: Page, on_complete=None):
|
||||
"""Hot reload Reticulum with updated configuration.
|
||||
|
||||
Args:
|
||||
page: Flet page instance
|
||||
on_complete: Optional callback to run when reload is complete
|
||||
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
global RNS_INSTANCE
|
||||
|
||||
if RNS_INSTANCE:
|
||||
try:
|
||||
RNS_INSTANCE.exit_handler()
|
||||
print("RNS exit handler completed")
|
||||
except Exception as e:
|
||||
print(f"Warning during RNS shutdown: {e}")
|
||||
|
||||
rns.shutdown_reticulum()
|
||||
RNS.Reticulum._Reticulum__instance = None
|
||||
RNS.Transport.destinations = []
|
||||
RNS_INSTANCE = None
|
||||
print("RNS instance cleared")
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
success = rns.initialize_reticulum(RNS_CONFIG_DIR)
|
||||
if success:
|
||||
RNS_INSTANCE = rns.get_reticulum_instance()
|
||||
if on_complete:
|
||||
on_complete(True, None)
|
||||
else:
|
||||
error_text = rns.get_last_error() or "Unknown error"
|
||||
print(f"Error reinitializing Reticulum: {error_text}")
|
||||
if on_complete:
|
||||
on_complete(False, error_text)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during reload: {e}")
|
||||
if on_complete:
|
||||
on_complete(False, str(e))
|
||||
|
||||
|
||||
def run():
|
||||
"""Run Ren Browser with command line argument parsing."""
|
||||
global RENDERER, RNS_CONFIG_DIR
|
||||
parser = argparse.ArgumentParser(description="Ren Browser")
|
||||
parser.add_argument("-r", "--renderer", choices=["plaintext", "micron"], default=RENDERER, help="Select renderer (plaintext or micron)")
|
||||
parser.add_argument("-w", "--web", action="store_true", help="Launch in web browser mode")
|
||||
parser.add_argument("-p", "--port", type=int, default=None, help="Port for web server")
|
||||
parser.add_argument("-c", "--config-dir", type=str, default=None, help="RNS config directory (default: ~/.reticulum/)")
|
||||
parser.add_argument(
|
||||
"-r",
|
||||
"--renderer",
|
||||
choices=["plaintext", "micron"],
|
||||
default=RENDERER,
|
||||
help="Select renderer (plaintext or micron)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-w",
|
||||
"--web",
|
||||
action="store_true",
|
||||
help="Launch in web browser mode",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--port",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Port for web server",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--config-dir",
|
||||
type=str,
|
||||
default=None,
|
||||
help="RNS config directory (default: ~/.reticulum/)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
RENDERER = args.renderer
|
||||
|
||||
@@ -70,8 +173,7 @@ def run():
|
||||
if args.config_dir:
|
||||
RNS_CONFIG_DIR = args.config_dir
|
||||
else:
|
||||
import pathlib
|
||||
RNS_CONFIG_DIR = str(pathlib.Path.home() / ".reticulum")
|
||||
RNS_CONFIG_DIR = None
|
||||
|
||||
if args.web:
|
||||
if args.port is not None:
|
||||
@@ -81,33 +183,41 @@ def run():
|
||||
else:
|
||||
ft.app(main)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
|
||||
|
||||
def web():
|
||||
"""Launch Ren Browser in web mode."""
|
||||
ft.app(main, view=AppView.WEB_BROWSER)
|
||||
|
||||
|
||||
def android():
|
||||
"""Launch Ren Browser in Android mode."""
|
||||
ft.app(main, view=AppView.FLET_APP_WEB)
|
||||
|
||||
|
||||
def ios():
|
||||
"""Launch Ren Browser in iOS mode."""
|
||||
ft.app(main, view=AppView.FLET_APP_WEB)
|
||||
|
||||
|
||||
def run_dev():
|
||||
"""Launch Ren Browser in desktop mode."""
|
||||
ft.app(main)
|
||||
|
||||
|
||||
def web_dev():
|
||||
"""Launch Ren Browser in web mode."""
|
||||
ft.app(main, view=AppView.WEB_BROWSER)
|
||||
|
||||
|
||||
def android_dev():
|
||||
"""Launch Ren Browser in Android mode."""
|
||||
ft.app(main, view=AppView.FLET_APP_WEB)
|
||||
|
||||
|
||||
def ios_dev():
|
||||
"""Launch Ren Browser in iOS mode."""
|
||||
ft.app(main, view=AppView.FLET_APP_WEB)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Provides keyboard event handling and delegation to tab manager
|
||||
and UI components.
|
||||
"""
|
||||
|
||||
import flet as ft
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Provides centralized logging for application events, errors, and
|
||||
Reticulum network activities.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
import RNS
|
||||
@@ -12,6 +13,7 @@ ERROR_LOGS: list[str] = []
|
||||
RET_LOGS: list[str] = []
|
||||
_original_rns_log = RNS.log
|
||||
|
||||
|
||||
def log_ret(msg, *args, **kwargs):
|
||||
"""Log Reticulum messages with timestamp.
|
||||
|
||||
@@ -25,14 +27,16 @@ def log_ret(msg, *args, **kwargs):
|
||||
RET_LOGS.append(f"[{timestamp}] {msg}")
|
||||
return _original_rns_log(msg, *args, **kwargs)
|
||||
|
||||
|
||||
def setup_rns_logging():
|
||||
"""Set up RNS log replacement. Call this after RNS.Reticulum initialization."""
|
||||
global _original_rns_log
|
||||
# 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
|
||||
RNS.log = log_ret
|
||||
|
||||
|
||||
def log_error(msg: str):
|
||||
"""Log error messages to both error and application logs.
|
||||
|
||||
@@ -44,6 +48,7 @@ def log_error(msg: str):
|
||||
ERROR_LOGS.append(f"[{timestamp}] {msg}")
|
||||
APP_LOGS.append(f"[{timestamp}] ERROR: {msg}")
|
||||
|
||||
|
||||
def log_app(msg: str):
|
||||
"""Log application messages.
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Handles downloading pages from the Reticulum network using
|
||||
the nomadnetwork protocol.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
@@ -10,7 +11,6 @@ from dataclasses import dataclass
|
||||
import RNS
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class PageRequest:
|
||||
"""Represents a request for a page from the Reticulum network.
|
||||
@@ -22,6 +22,7 @@ class PageRequest:
|
||||
page_path: str
|
||||
field_data: dict | None = None
|
||||
|
||||
|
||||
class PageFetcher:
|
||||
"""Fetcher to download pages from the Reticulum network."""
|
||||
|
||||
@@ -43,7 +44,9 @@ class PageFetcher:
|
||||
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)
|
||||
if not RNS.Transport.has_path(dest_bytes):
|
||||
RNS.Transport.request_path(dest_bytes)
|
||||
@@ -79,9 +82,16 @@ class PageFetcher:
|
||||
ev.set()
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
@@ -1,26 +1,295 @@
|
||||
"""Micron markup renderer for Ren Browser.
|
||||
|
||||
Provides rendering capabilities for micron markup content,
|
||||
currently implemented as a placeholder.
|
||||
Provides rendering capabilities for micron markup content.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ren_browser.renderer.plaintext import render_plaintext
|
||||
|
||||
def render_micron(content: str) -> ft.Control:
|
||||
"""Render micron markup content to a Flet control placeholder.
|
||||
|
||||
Currently displays raw content.
|
||||
def hex_to_rgb(hex_color: str) -> str:
|
||||
"""Convert 3-char hex color to RGB string."""
|
||||
if len(hex_color) != 3:
|
||||
return "255,255,255"
|
||||
r = int(hex_color[0], 16) * 17
|
||||
g = int(hex_color[1], 16) * 17
|
||||
b = int(hex_color[2], 16) * 17
|
||||
return f"{r},{g},{b}"
|
||||
|
||||
|
||||
def parse_micron_line(line: str) -> list:
|
||||
"""Parse a single line of micron markup into styled text spans.
|
||||
|
||||
Returns list of dicts with 'text', 'bold', 'italic', 'underline', 'color', 'bgcolor'.
|
||||
"""
|
||||
spans = []
|
||||
current_text = ""
|
||||
bold = False
|
||||
italic = False
|
||||
underline = False
|
||||
color = None
|
||||
bgcolor = None
|
||||
|
||||
i = 0
|
||||
while i < len(line):
|
||||
if line[i] == "`" and i + 1 < len(line):
|
||||
if current_text:
|
||||
spans.append(
|
||||
{
|
||||
"text": current_text,
|
||||
"bold": bold,
|
||||
"italic": italic,
|
||||
"underline": underline,
|
||||
"color": color,
|
||||
"bgcolor": bgcolor,
|
||||
},
|
||||
)
|
||||
current_text = ""
|
||||
|
||||
tag = line[i + 1]
|
||||
|
||||
if tag == "!":
|
||||
bold = not bold
|
||||
i += 2
|
||||
elif tag == "*":
|
||||
italic = not italic
|
||||
i += 2
|
||||
elif tag == "_":
|
||||
underline = not underline
|
||||
i += 2
|
||||
elif tag == "F" and i + 5 <= len(line):
|
||||
color = hex_to_rgb(line[i + 2 : i + 5])
|
||||
i += 5
|
||||
elif tag == "f":
|
||||
color = None
|
||||
i += 2
|
||||
elif tag == "B" and i + 5 <= len(line):
|
||||
bgcolor = hex_to_rgb(line[i + 2 : i + 5])
|
||||
i += 5
|
||||
elif tag == "b":
|
||||
bgcolor = None
|
||||
i += 2
|
||||
elif tag == "`":
|
||||
bold = False
|
||||
italic = False
|
||||
underline = False
|
||||
color = None
|
||||
bgcolor = None
|
||||
i += 2
|
||||
else:
|
||||
current_text += line[i]
|
||||
i += 1
|
||||
else:
|
||||
current_text += line[i]
|
||||
i += 1
|
||||
|
||||
if current_text:
|
||||
spans.append(
|
||||
{
|
||||
"text": current_text,
|
||||
"bold": bold,
|
||||
"italic": italic,
|
||||
"underline": underline,
|
||||
"color": color,
|
||||
"bgcolor": bgcolor,
|
||||
},
|
||||
)
|
||||
|
||||
return spans
|
||||
|
||||
|
||||
def render_micron(content: str, on_link_click=None) -> ft.Control:
|
||||
"""Render micron markup content to a Flet control.
|
||||
|
||||
Falls back to plaintext renderer if parsing fails.
|
||||
|
||||
Args:
|
||||
content: Micron markup content to render.
|
||||
on_link_click: Optional callback function(url) called when a link is clicked.
|
||||
|
||||
Returns:
|
||||
ft.Control: Rendered content as a Flet control.
|
||||
|
||||
"""
|
||||
return ft.Text(
|
||||
content,
|
||||
selectable=True,
|
||||
font_family="monospace",
|
||||
try:
|
||||
return _render_micron_internal(content, on_link_click)
|
||||
except Exception as e:
|
||||
print(f"Micron rendering failed: {e}, falling back to plaintext")
|
||||
return render_plaintext(content)
|
||||
|
||||
|
||||
def _render_micron_internal(content: str, on_link_click=None) -> ft.Control:
|
||||
"""Internal micron rendering implementation.
|
||||
|
||||
Args:
|
||||
content: Micron markup content to render.
|
||||
on_link_click: Optional callback function(url) called when a link is clicked.
|
||||
|
||||
Returns:
|
||||
ft.Control: Rendered content as a Flet control.
|
||||
|
||||
"""
|
||||
lines = content.split("\n")
|
||||
controls = []
|
||||
section_level = 0
|
||||
alignment = ft.TextAlign.LEFT
|
||||
|
||||
for line in lines:
|
||||
if not line:
|
||||
controls.append(ft.Container(height=10))
|
||||
continue
|
||||
|
||||
if line.startswith("#"):
|
||||
continue
|
||||
|
||||
if line.startswith("`c"):
|
||||
alignment = ft.TextAlign.CENTER
|
||||
line = line[2:]
|
||||
elif line.startswith("`l"):
|
||||
alignment = ft.TextAlign.LEFT
|
||||
line = line[2:]
|
||||
elif line.startswith("`r"):
|
||||
alignment = ft.TextAlign.RIGHT
|
||||
line = line[2:]
|
||||
elif line.startswith("`a"):
|
||||
alignment = ft.TextAlign.LEFT
|
||||
line = line[2:]
|
||||
|
||||
if line.startswith(">"):
|
||||
level = 0
|
||||
while level < len(line) and line[level] == ">":
|
||||
level += 1
|
||||
section_level = level
|
||||
heading_text = line[level:].strip()
|
||||
|
||||
if heading_text:
|
||||
controls.append(
|
||||
ft.Container(
|
||||
content=ft.Text(
|
||||
heading_text,
|
||||
size=20 - (level * 2),
|
||||
weight=ft.FontWeight.BOLD,
|
||||
color=ft.Colors.BLUE_400,
|
||||
),
|
||||
padding=ft.padding.only(left=level * 20, top=10, bottom=5),
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
if line.strip() == "-":
|
||||
controls.append(
|
||||
ft.Container(
|
||||
content=ft.Divider(color=ft.Colors.GREY_700),
|
||||
padding=ft.padding.only(left=section_level * 20),
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
if "`[" in line:
|
||||
row_controls = []
|
||||
last_end = 0
|
||||
|
||||
for link_match in re.finditer(r"`\[([^`]*)`([^\]]*)\]", line):
|
||||
before = line[last_end : link_match.start()]
|
||||
if before:
|
||||
before_spans = parse_micron_line(before)
|
||||
row_controls.extend(
|
||||
create_text_span(span) for span in before_spans
|
||||
)
|
||||
|
||||
label = link_match.group(1)
|
||||
url = link_match.group(2)
|
||||
|
||||
def make_link_handler(link_url):
|
||||
def handler(e):
|
||||
if on_link_click:
|
||||
on_link_click(link_url)
|
||||
|
||||
return handler
|
||||
|
||||
row_controls.append(
|
||||
ft.TextButton(
|
||||
text=label if label else url,
|
||||
style=ft.ButtonStyle(
|
||||
color=ft.Colors.BLUE_400,
|
||||
overlay_color=ft.Colors.BLUE_900,
|
||||
),
|
||||
on_click=make_link_handler(url),
|
||||
),
|
||||
)
|
||||
|
||||
last_end = link_match.end()
|
||||
|
||||
after = line[last_end:]
|
||||
if after:
|
||||
after_spans = parse_micron_line(after)
|
||||
row_controls.extend(
|
||||
create_text_span(span) for span in after_spans
|
||||
)
|
||||
|
||||
if row_controls:
|
||||
controls.append(
|
||||
ft.Container(
|
||||
content=ft.Row(
|
||||
controls=row_controls,
|
||||
spacing=0,
|
||||
wrap=True,
|
||||
),
|
||||
padding=ft.padding.only(left=section_level * 20),
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
spans = parse_micron_line(line)
|
||||
if spans:
|
||||
text_controls = [create_text_span(span) for span in spans]
|
||||
|
||||
controls.append(
|
||||
ft.Container(
|
||||
content=ft.Row(
|
||||
controls=text_controls,
|
||||
spacing=0,
|
||||
wrap=True,
|
||||
alignment=alignment,
|
||||
),
|
||||
padding=ft.padding.only(left=section_level * 20),
|
||||
),
|
||||
)
|
||||
|
||||
return ft.Column(
|
||||
controls=controls,
|
||||
spacing=5,
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
expand=True,
|
||||
)
|
||||
|
||||
|
||||
def create_text_span(span: dict) -> ft.Text:
|
||||
"""Create a Text control from a span dict."""
|
||||
styles = []
|
||||
if span["bold"]:
|
||||
styles.append(ft.TextStyle(weight=ft.FontWeight.BOLD))
|
||||
if span["italic"]:
|
||||
styles.append(ft.TextStyle(italic=True))
|
||||
|
||||
text_decoration = ft.TextDecoration.UNDERLINE if span["underline"] else None
|
||||
color = span["color"]
|
||||
bgcolor = span["bgcolor"]
|
||||
|
||||
text_style = ft.TextStyle(
|
||||
weight=ft.FontWeight.BOLD if span["bold"] else None,
|
||||
italic=span["italic"] if span["italic"] else None,
|
||||
decoration=text_decoration,
|
||||
)
|
||||
|
||||
return ft.Text(
|
||||
span["text"],
|
||||
style=text_style,
|
||||
color=f"rgb({color})" if color else None,
|
||||
bgcolor=f"rgb({bgcolor})" if bgcolor else None,
|
||||
selectable=True,
|
||||
no_wrap=False,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
Provides fallback rendering for plaintext content and source viewing.
|
||||
"""
|
||||
|
||||
import flet as ft
|
||||
|
||||
|
||||
|
||||
289
ren_browser/rns.py
Normal file
289
ren_browser/rns.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""Reticulum helper utilities for Ren Browser."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import RNS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RNSManager:
|
||||
"""Manage Reticulum lifecycle and configuration."""
|
||||
|
||||
def __init__(self):
|
||||
self.reticulum = None
|
||||
self.config_path: str | None = None
|
||||
self.last_error: str | None = None
|
||||
|
||||
def _is_android(self) -> bool:
|
||||
vendor = getattr(RNS, "vendor", None)
|
||||
platformutils = getattr(vendor, "platformutils", None)
|
||||
if platformutils and hasattr(platformutils, "is_android"):
|
||||
try:
|
||||
return bool(platformutils.is_android())
|
||||
except Exception:
|
||||
return False
|
||||
return "ANDROID_ROOT" in os.environ
|
||||
|
||||
def _android_storage_root(self) -> Path:
|
||||
candidates = [
|
||||
os.environ.get("ANDROID_APP_PATH"),
|
||||
os.environ.get("ANDROID_PRIVATE"),
|
||||
os.environ.get("ANDROID_ARGUMENT"),
|
||||
]
|
||||
for raw_path in candidates:
|
||||
if not raw_path:
|
||||
continue
|
||||
path = Path(raw_path).expanduser()
|
||||
if path.name == "app":
|
||||
path = path.parent
|
||||
if path.is_file():
|
||||
path = path.parent
|
||||
if path.is_dir():
|
||||
return path
|
||||
return Path(tempfile.gettempdir())
|
||||
|
||||
def _default_config_root(self) -> Path:
|
||||
override = os.environ.get("REN_BROWSER_RNS_DIR") or os.environ.get(
|
||||
"REN_RETICULUM_CONFIG_DIR",
|
||||
)
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
if self._is_android():
|
||||
return self._android_storage_root() / "ren_browser" / "reticulum"
|
||||
return Path.home() / ".reticulum"
|
||||
|
||||
def _resolve_config_dir(self, preferred: str | Path | None) -> Path:
|
||||
target = (
|
||||
Path(preferred).expanduser() if preferred else self._default_config_root()
|
||||
)
|
||||
allow_fallback = preferred is None
|
||||
|
||||
try:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
if not allow_fallback:
|
||||
raise
|
||||
fallback = Path(tempfile.gettempdir()) / "ren_browser" / "reticulum"
|
||||
fallback.mkdir(parents=True, exist_ok=True)
|
||||
target = fallback
|
||||
|
||||
self._seed_config_if_missing(target)
|
||||
return target
|
||||
|
||||
def _default_tcp_interfaces_snippet(self) -> str:
|
||||
return """
|
||||
[[Quad4 Node 1]]
|
||||
type = TCPClientInterface
|
||||
interface_enabled = true
|
||||
target_host = rns.quad4.io
|
||||
target_port = 4242
|
||||
name = Quad4 Node 1
|
||||
selected_interface_mode = 1
|
||||
|
||||
[[Quad4 Node 2]]
|
||||
type = TCPClientInterface
|
||||
interface_enabled = true
|
||||
target_host = rns2.quad4.io
|
||||
target_port = 4242
|
||||
name = Quad4 Node 2
|
||||
selected_interface_mode = 1
|
||||
""".strip(
|
||||
"\n",
|
||||
)
|
||||
|
||||
def _seed_config_if_missing(self, target: Path) -> None:
|
||||
config_file = target / "config"
|
||||
if config_file.exists():
|
||||
return
|
||||
|
||||
base_content = None
|
||||
try:
|
||||
default_lines = getattr(RNS.Reticulum, "__default_rns_config__", None)
|
||||
if default_lines:
|
||||
if isinstance(default_lines, list):
|
||||
base_content = "\n".join(default_lines)
|
||||
else:
|
||||
base_content = str(default_lines)
|
||||
except Exception:
|
||||
base_content = None
|
||||
|
||||
if not base_content:
|
||||
base_content = (
|
||||
"[reticulum]\n"
|
||||
"share_instance = Yes\n\n"
|
||||
"[interfaces]\n\n"
|
||||
" [[Default Interface]]\n"
|
||||
" type = AutoInterface\n"
|
||||
" enabled = Yes\n"
|
||||
)
|
||||
|
||||
snippet = self._default_tcp_interfaces_snippet()
|
||||
if snippet and snippet not in base_content:
|
||||
base_content = base_content.rstrip() + "\n\n" + snippet + "\n"
|
||||
|
||||
try:
|
||||
config_file.write_text(base_content, encoding="utf-8")
|
||||
os.chmod(config_file, 0o600)
|
||||
except Exception:
|
||||
logger.exception("Failed to seed default config at %s", config_file)
|
||||
|
||||
def _ensure_default_tcp_interfaces(self) -> None:
|
||||
if not self.config_path:
|
||||
return
|
||||
config_file = Path(self.config_path) / "config"
|
||||
if not config_file.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
content = config_file.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
return
|
||||
|
||||
snippet = self._default_tcp_interfaces_snippet()
|
||||
if "target_host = rns.quad4.io" in content or "Quad4 Node 1" in content:
|
||||
return
|
||||
|
||||
try:
|
||||
with open(config_file, "a", encoding="utf-8") as cfg:
|
||||
if not content.endswith("\n"):
|
||||
cfg.write("\n")
|
||||
cfg.write("\n" + snippet + "\n")
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to append default TCP interfaces to %s",
|
||||
config_file,
|
||||
)
|
||||
|
||||
def _get_or_create_config_dir(self) -> Path:
|
||||
if self.config_path:
|
||||
return Path(self.config_path)
|
||||
|
||||
resolved = self._resolve_config_dir(None)
|
||||
self.config_path = str(resolved)
|
||||
return resolved
|
||||
|
||||
def initialize(self, config_dir: str | None = None) -> bool:
|
||||
"""Initialize the Reticulum instance."""
|
||||
self.last_error = None
|
||||
try:
|
||||
use_custom_dir = bool(config_dir or self._is_android())
|
||||
if use_custom_dir:
|
||||
resolved = self._resolve_config_dir(config_dir)
|
||||
self.config_path = str(resolved)
|
||||
self.reticulum = RNS.Reticulum(configdir=self.config_path)
|
||||
else:
|
||||
self.reticulum = RNS.Reticulum()
|
||||
self.config_path = getattr(
|
||||
RNS.Reticulum,
|
||||
"configdir",
|
||||
str(Path.home() / ".reticulum"),
|
||||
)
|
||||
|
||||
self._ensure_default_tcp_interfaces()
|
||||
return True
|
||||
except Exception as exc:
|
||||
self.last_error = str(exc)
|
||||
return False
|
||||
|
||||
def shutdown(self) -> bool:
|
||||
"""Shut down the active Reticulum instance."""
|
||||
try:
|
||||
if self.reticulum and hasattr(self.reticulum, "exit_handler"):
|
||||
self.reticulum.exit_handler()
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
self.reticulum = None
|
||||
return True
|
||||
|
||||
def read_config_file(self) -> str:
|
||||
"""Return the current configuration file contents."""
|
||||
config_dir = self._get_or_create_config_dir()
|
||||
config_file = config_dir / "config"
|
||||
|
||||
try:
|
||||
return config_file.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
self._seed_config_if_missing(config_dir)
|
||||
try:
|
||||
return config_file.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
return ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def write_config_file(self, content: str) -> bool:
|
||||
"""Persist configuration text to disk."""
|
||||
config_dir = self._get_or_create_config_dir()
|
||||
config_file = config_dir / "config"
|
||||
try:
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
config_file.write_text(content, encoding="utf-8")
|
||||
os.chmod(config_file, 0o600)
|
||||
return True
|
||||
except Exception as exc:
|
||||
self.last_error = str(exc)
|
||||
return False
|
||||
|
||||
def get_config_path(self) -> str | None:
|
||||
"""Return the directory holding the active Reticulum config."""
|
||||
if self.config_path:
|
||||
return self.config_path
|
||||
try:
|
||||
default_path = self._resolve_config_dir(None)
|
||||
self.config_path = str(default_path)
|
||||
return self.config_path
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_reticulum_instance(self):
|
||||
"""Return the current Reticulum instance, if any."""
|
||||
return self.reticulum
|
||||
|
||||
def get_last_error(self) -> str | None:
|
||||
"""Return the last recorded error string."""
|
||||
return self.last_error
|
||||
|
||||
|
||||
rns_manager = RNSManager()
|
||||
|
||||
|
||||
def initialize_reticulum(config_dir: str | None = None) -> bool:
|
||||
"""Initialize Reticulum using the shared manager."""
|
||||
return rns_manager.initialize(config_dir)
|
||||
|
||||
|
||||
def shutdown_reticulum() -> bool:
|
||||
"""Shut down the shared Reticulum instance."""
|
||||
return rns_manager.shutdown()
|
||||
|
||||
|
||||
def get_reticulum_instance():
|
||||
"""Expose the active Reticulum instance."""
|
||||
return rns_manager.get_reticulum_instance()
|
||||
|
||||
|
||||
def get_config_path() -> str | None:
|
||||
"""Expose the active configuration directory."""
|
||||
return rns_manager.get_config_path()
|
||||
|
||||
|
||||
def read_config_file() -> str:
|
||||
"""Read the Reticulum configuration file."""
|
||||
return rns_manager.read_config_file()
|
||||
|
||||
|
||||
def write_config_file(content: str) -> bool:
|
||||
"""Write the Reticulum configuration file."""
|
||||
return rns_manager.write_config_file(content)
|
||||
|
||||
|
||||
def get_last_error() -> str | None:
|
||||
"""Return the last recorded Reticulum error."""
|
||||
return rns_manager.get_last_error()
|
||||
@@ -3,10 +3,11 @@
|
||||
Provides persistent storage for configuration, bookmarks, history,
|
||||
and other application data across different platforms.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any
|
||||
|
||||
import flet as ft
|
||||
|
||||
@@ -18,7 +19,7 @@ class StorageManager:
|
||||
with platform-specific storage locations.
|
||||
"""
|
||||
|
||||
def __init__(self, page: Optional[ft.Page] = None):
|
||||
def __init__(self, page: ft.Page | None = None):
|
||||
"""Initialize storage manager.
|
||||
|
||||
Args:
|
||||
@@ -36,14 +37,18 @@ class StorageManager:
|
||||
pass
|
||||
|
||||
if os.name == "posix" and "ANDROID_ROOT" in os.environ:
|
||||
# Android - use app's private files directory
|
||||
storage_dir = pathlib.Path("/data/data/com.ren_browser/files")
|
||||
elif hasattr(os, "uname") and "iOS" in str(getattr(os, "uname", lambda: "")()).replace("iPhone", "iOS"):
|
||||
# iOS - use app's documents directory
|
||||
storage_dir = pathlib.Path.home() / "Documents" / "ren_browser"
|
||||
if "ANDROID_DATA" in os.environ:
|
||||
storage_dir = pathlib.Path(os.environ["ANDROID_DATA"]) / "ren_browser"
|
||||
elif "EXTERNAL_STORAGE" in os.environ:
|
||||
ext_storage = pathlib.Path(os.environ["EXTERNAL_STORAGE"])
|
||||
storage_dir = ext_storage / "ren_browser"
|
||||
else:
|
||||
# Desktop (Linux, Windows, macOS) - use home directory
|
||||
if "APPDATA" in os.environ: # Windows
|
||||
storage_dir = pathlib.Path("/data/local/tmp/ren_browser")
|
||||
elif hasattr(os, "uname") and "iOS" in str(
|
||||
getattr(os, "uname", lambda: "")(),
|
||||
).replace("iPhone", "iOS"):
|
||||
storage_dir = pathlib.Path.home() / "Documents" / "ren_browser"
|
||||
elif "APPDATA" in os.environ: # Windows
|
||||
storage_dir = pathlib.Path(os.environ["APPDATA"]) / "ren_browser"
|
||||
elif "XDG_CONFIG_HOME" in os.environ: # Linux XDG standard
|
||||
storage_dir = pathlib.Path(os.environ["XDG_CONFIG_HOME"]) / "ren_browser"
|
||||
@@ -58,6 +63,7 @@ class StorageManager:
|
||||
self._storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
except (OSError, PermissionError):
|
||||
import tempfile
|
||||
|
||||
self._storage_dir = pathlib.Path(tempfile.gettempdir()) / "ren_browser"
|
||||
self._storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -65,17 +71,21 @@ class StorageManager:
|
||||
"""Get the path to the main configuration file."""
|
||||
return self._storage_dir / "config"
|
||||
|
||||
@staticmethod
|
||||
def get_reticulum_config_path() -> pathlib.Path:
|
||||
def get_reticulum_config_path(self) -> pathlib.Path:
|
||||
"""Get the path to the Reticulum configuration directory."""
|
||||
# Check for global override from app
|
||||
try:
|
||||
from ren_browser.app import RNS_CONFIG_DIR
|
||||
|
||||
if RNS_CONFIG_DIR:
|
||||
return pathlib.Path(RNS_CONFIG_DIR)
|
||||
except ImportError:
|
||||
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
|
||||
return pathlib.Path.home() / ".reticulum"
|
||||
|
||||
@@ -90,6 +100,7 @@ class StorageManager:
|
||||
|
||||
"""
|
||||
try:
|
||||
# Always save to client storage first (most reliable on mobile)
|
||||
if self.page and hasattr(self.page, "client_storage"):
|
||||
self.page.client_storage.set("ren_browser_config", config_content)
|
||||
|
||||
@@ -100,6 +111,7 @@ class StorageManager:
|
||||
|
||||
# Also save to local config path as backup
|
||||
config_path = self.get_config_path()
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text(config_content, encoding="utf-8")
|
||||
return True
|
||||
|
||||
@@ -111,7 +123,10 @@ class StorageManager:
|
||||
try:
|
||||
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_error", f"File save failed: {error}")
|
||||
self.page.client_storage.set(
|
||||
"ren_browser_config_error",
|
||||
f"File save failed: {error}",
|
||||
)
|
||||
return True
|
||||
|
||||
try:
|
||||
@@ -122,6 +137,7 @@ class StorageManager:
|
||||
pass
|
||||
|
||||
import tempfile
|
||||
|
||||
temp_path = pathlib.Path(tempfile.gettempdir()) / "ren_browser_config.txt"
|
||||
temp_path.write_text(config_content, encoding="utf-8")
|
||||
return True
|
||||
@@ -136,6 +152,13 @@ class StorageManager:
|
||||
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:
|
||||
reticulum_config_path = self.get_reticulum_config_path() / "config"
|
||||
if reticulum_config_path.exists():
|
||||
@@ -145,13 +168,18 @@ class StorageManager:
|
||||
if config_path.exists():
|
||||
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"):
|
||||
stored_config = self.page.client_storage.get("ren_browser_config")
|
||||
if stored_config:
|
||||
return stored_config
|
||||
|
||||
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 ""
|
||||
|
||||
@@ -163,7 +191,10 @@ class StorageManager:
|
||||
json.dump(bookmarks, f, indent=2)
|
||||
|
||||
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
|
||||
except Exception:
|
||||
@@ -174,7 +205,7 @@ class StorageManager:
|
||||
try:
|
||||
bookmarks_path = self._storage_dir / "bookmarks.json"
|
||||
if bookmarks_path.exists():
|
||||
with open(bookmarks_path, "r", encoding="utf-8") as f:
|
||||
with open(bookmarks_path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
if self.page and hasattr(self.page, "client_storage"):
|
||||
@@ -206,7 +237,7 @@ class StorageManager:
|
||||
try:
|
||||
history_path = self._storage_dir / "history.json"
|
||||
if history_path.exists():
|
||||
with open(history_path, "r", encoding="utf-8") as f:
|
||||
with open(history_path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
if self.page and hasattr(self.page, "client_storage"):
|
||||
@@ -219,7 +250,49 @@ class StorageManager:
|
||||
|
||||
return []
|
||||
|
||||
def get_storage_info(self) -> Dict[str, Any]:
|
||||
def save_app_settings(self, settings: dict) -> bool:
|
||||
"""Save application settings to storage."""
|
||||
try:
|
||||
settings_path = self._storage_dir / "settings.json"
|
||||
with open(settings_path, "w", encoding="utf-8") as f:
|
||||
json.dump(settings, f, indent=2)
|
||||
|
||||
if self.page and hasattr(self.page, "client_storage"):
|
||||
self.page.client_storage.set(
|
||||
"ren_browser_settings",
|
||||
json.dumps(settings),
|
||||
)
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def load_app_settings(self) -> dict:
|
||||
"""Load application settings from storage."""
|
||||
default_settings = {
|
||||
"horizontal_scroll": False,
|
||||
"page_bgcolor": "#000000",
|
||||
}
|
||||
|
||||
try:
|
||||
settings_path = self._storage_dir / "settings.json"
|
||||
if settings_path.exists():
|
||||
with open(settings_path, encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
return {**default_settings, **loaded}
|
||||
|
||||
if self.page and hasattr(self.page, "client_storage"):
|
||||
stored_settings = self.page.client_storage.get("ren_browser_settings")
|
||||
if stored_settings and isinstance(stored_settings, str):
|
||||
loaded = json.loads(stored_settings)
|
||||
return {**default_settings, **loaded}
|
||||
|
||||
except (OSError, json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return default_settings
|
||||
|
||||
def get_storage_info(self) -> dict[str, Any]:
|
||||
"""Get information about the storage system."""
|
||||
return {
|
||||
"storage_dir": str(self._storage_dir),
|
||||
@@ -243,10 +316,10 @@ class StorageManager:
|
||||
|
||||
|
||||
# Global storage instance
|
||||
_storage_manager: Optional[StorageManager] = None
|
||||
_storage_manager: StorageManager | None = None
|
||||
|
||||
|
||||
def get_storage_manager(page: Optional[ft.Page] = None) -> StorageManager:
|
||||
def get_storage_manager(page: ft.Page | None = None) -> StorageManager:
|
||||
"""Get the global storage manager instance."""
|
||||
global _storage_manager
|
||||
if _storage_manager is None:
|
||||
@@ -267,6 +340,7 @@ def get_rns_config_directory() -> str:
|
||||
"""Get the RNS config directory, checking for global override."""
|
||||
try:
|
||||
from ren_browser.app import RNS_CONFIG_DIR
|
||||
|
||||
if RNS_CONFIG_DIR:
|
||||
return RNS_CONFIG_DIR
|
||||
except ImportError:
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
Provides tab creation, switching, and content management functionality
|
||||
for the browser interface.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ren_browser.pages.page_request import PageFetcher, PageRequest
|
||||
from ren_browser.renderer.micron import render_micron
|
||||
from ren_browser.renderer.plaintext import render_plaintext
|
||||
from ren_browser.storage.storage import get_storage_manager
|
||||
|
||||
|
||||
class TabsManager:
|
||||
@@ -17,7 +20,7 @@ class TabsManager:
|
||||
Handles tab creation, switching, closing, and content rendering.
|
||||
"""
|
||||
|
||||
def __init__(self, page: ft.Page):
|
||||
def __init__(self, page: ft.Page) -> None:
|
||||
"""Initialize the tab manager.
|
||||
|
||||
Args:
|
||||
@@ -25,73 +28,257 @@ class TabsManager:
|
||||
|
||||
"""
|
||||
import ren_browser.app as app_module
|
||||
|
||||
self.page = page
|
||||
self.page.on_resize = self._on_resize
|
||||
self.manager = SimpleNamespace(tabs=[], index=0)
|
||||
self.tab_bar = ft.Row(spacing=4)
|
||||
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")
|
||||
storage = get_storage_manager(page)
|
||||
self.settings = storage.load_app_settings()
|
||||
|
||||
self.tab_bar = ft.Container(
|
||||
content=ft.Row(
|
||||
spacing=6,
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
),
|
||||
padding=ft.padding.symmetric(horizontal=8, vertical=8),
|
||||
)
|
||||
self.overflow_menu = None
|
||||
self.content_container = ft.Container(
|
||||
expand=True,
|
||||
bgcolor=self.settings.get("page_bgcolor", ft.Colors.BLACK),
|
||||
padding=ft.padding.all(16),
|
||||
)
|
||||
|
||||
def handle_link_click_home(link_url):
|
||||
if len(self.manager.tabs) > 0:
|
||||
tab = self.manager.tabs[0]
|
||||
full_url = link_url
|
||||
if ":" not in link_url:
|
||||
full_url = f"{link_url}:/page/index.mu"
|
||||
tab["url_field"].value = full_url
|
||||
self._on_tab_go(None, 0)
|
||||
|
||||
default_content = (
|
||||
render_micron(
|
||||
"Welcome to Ren Browser",
|
||||
on_link_click=handle_link_click_home,
|
||||
)
|
||||
if app_module.RENDERER == "micron"
|
||||
else render_plaintext("Welcome to Ren Browser")
|
||||
)
|
||||
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.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.add_btn = ft.IconButton(
|
||||
ft.Icons.ADD,
|
||||
tooltip="New Tab",
|
||||
on_click=self._on_add_click,
|
||||
icon_color=ft.Colors.WHITE,
|
||||
)
|
||||
self.close_btn = ft.IconButton(
|
||||
ft.Icons.CLOSE,
|
||||
tooltip="Close Tab",
|
||||
on_click=self._on_close_click,
|
||||
icon_color=ft.Colors.WHITE,
|
||||
)
|
||||
self.tab_bar.content.controls.append(self.add_btn)
|
||||
self.tab_bar.content.controls.append(self.close_btn)
|
||||
self.select_tab(0)
|
||||
self._update_tab_visibility()
|
||||
|
||||
def _add_tab_internal(self, title: str, content: ft.Control):
|
||||
def _on_resize(self, e) -> None: # type: ignore
|
||||
"""Handle page resize event and update tab visibility."""
|
||||
self._update_tab_visibility()
|
||||
|
||||
def apply_settings(self, settings: dict) -> None:
|
||||
"""Apply appearance settings to the tab manager.
|
||||
|
||||
Args:
|
||||
settings: Dictionary containing appearance settings.
|
||||
|
||||
"""
|
||||
self.settings = settings
|
||||
bgcolor = settings.get("page_bgcolor", "#000000")
|
||||
self.content_container.bgcolor = bgcolor
|
||||
|
||||
horizontal_scroll = settings.get("horizontal_scroll", False)
|
||||
scroll_mode = ft.ScrollMode.ALWAYS if horizontal_scroll else ft.ScrollMode.AUTO
|
||||
|
||||
for tab in self.manager.tabs:
|
||||
if "content" in tab and hasattr(tab["content"], "scroll"):
|
||||
tab["content"].scroll = scroll_mode
|
||||
if "content_control" in tab and hasattr(tab["content_control"], "scroll"):
|
||||
tab["content_control"].scroll = scroll_mode
|
||||
|
||||
if self.content_container.content:
|
||||
self.content_container.content.update()
|
||||
self.page.update()
|
||||
|
||||
def _update_tab_visibility(self) -> None:
|
||||
"""Dynamically adjust tab visibility based on page width.
|
||||
|
||||
Hides tabs that do not fit and moves them to an overflow menu.
|
||||
"""
|
||||
if not self.page.width or self.page.width == 0:
|
||||
return
|
||||
|
||||
if self.overflow_menu and self.overflow_menu in self.tab_bar.content.controls:
|
||||
self.tab_bar.content.controls.remove(self.overflow_menu)
|
||||
self.overflow_menu = None
|
||||
|
||||
available_width = self.page.width - 100
|
||||
|
||||
cumulative_width = 0
|
||||
visible_tabs_count = 0
|
||||
|
||||
tab_containers = [
|
||||
c for c in self.tab_bar.content.controls if isinstance(c, ft.Container)
|
||||
]
|
||||
|
||||
for i, tab in enumerate(self.manager.tabs):
|
||||
estimated_width = len(tab["title"]) * 10 + 32 + self.tab_bar.content.spacing
|
||||
|
||||
if cumulative_width + estimated_width <= available_width or i == 0:
|
||||
cumulative_width += estimated_width
|
||||
if i < len(tab_containers):
|
||||
tab_containers[i].visible = True
|
||||
visible_tabs_count += 1
|
||||
elif i < len(tab_containers):
|
||||
tab_containers[i].visible = False
|
||||
|
||||
if len(self.manager.tabs) > visible_tabs_count:
|
||||
overflow_items = []
|
||||
for i in range(visible_tabs_count, len(self.manager.tabs)):
|
||||
tab_data = self.manager.tabs[i]
|
||||
overflow_items.append(
|
||||
ft.PopupMenuItem(
|
||||
text=tab_data["title"],
|
||||
on_click=lambda e, idx=i: self.select_tab(idx), # type: ignore
|
||||
),
|
||||
)
|
||||
|
||||
self.overflow_menu = ft.PopupMenuButton(
|
||||
icon=ft.Icons.MORE_HORIZ,
|
||||
tooltip=f"{len(self.manager.tabs) - visible_tabs_count} more tabs",
|
||||
items=overflow_items,
|
||||
)
|
||||
|
||||
self.tab_bar.content.controls.insert(visible_tabs_count, self.overflow_menu)
|
||||
|
||||
def _add_tab_internal(self, title: str, content: ft.Control) -> None:
|
||||
"""Add a new tab to the manager with the given title and content."""
|
||||
idx = len(self.manager.tabs)
|
||||
url_field = ft.TextField(
|
||||
value=title,
|
||||
expand=True,
|
||||
text_style=ft.TextStyle(size=12),
|
||||
content_padding=ft.padding.only(top=8, bottom=8, left=8, right=8)
|
||||
text_style=ft.TextStyle(size=14),
|
||||
content_padding=ft.padding.symmetric(horizontal=16, vertical=12),
|
||||
border_radius=24,
|
||||
border_color=ft.Colors.GREY_700,
|
||||
focused_border_color=ft.Colors.BLUE_400,
|
||||
bgcolor=ft.Colors.GREY_800,
|
||||
prefix_icon=ft.Icons.SEARCH,
|
||||
)
|
||||
go_btn = ft.IconButton(
|
||||
ft.Icons.ARROW_FORWARD,
|
||||
tooltip="Go",
|
||||
on_click=lambda e, i=idx: self._on_tab_go(e, i),
|
||||
icon_color=ft.Colors.BLUE_400,
|
||||
bgcolor=ft.Colors.BLUE_900,
|
||||
)
|
||||
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
|
||||
horizontal_scroll = self.settings.get("horizontal_scroll", False)
|
||||
scroll_mode = ft.ScrollMode.ALWAYS if horizontal_scroll else ft.ScrollMode.AUTO
|
||||
|
||||
tab_content = ft.Column(
|
||||
expand=True,
|
||||
scroll=scroll_mode,
|
||||
controls=[
|
||||
content_control,
|
||||
],
|
||||
)
|
||||
self.manager.tabs.append({
|
||||
self.manager.tabs.append(
|
||||
{
|
||||
"title": title,
|
||||
"url_field": url_field,
|
||||
"go_btn": go_btn,
|
||||
"content_control": content_control,
|
||||
"content": tab_content,
|
||||
})
|
||||
btn = ft.Container(
|
||||
content=ft.Text(title),
|
||||
on_click=lambda e, i=idx: self.select_tab(i),
|
||||
padding=ft.padding.symmetric(horizontal=12, vertical=6),
|
||||
border_radius=5,
|
||||
bgcolor=ft.Colors.SURFACE_CONTAINER_HIGHEST,
|
||||
},
|
||||
)
|
||||
insert_pos = max(0, len(self.tab_bar.controls) - 2)
|
||||
self.tab_bar.controls.insert(insert_pos, btn)
|
||||
tab_container = ft.Container(
|
||||
content=ft.Row(
|
||||
controls=[
|
||||
ft.Text(
|
||||
title,
|
||||
size=13,
|
||||
weight=ft.FontWeight.W_500,
|
||||
overflow=ft.TextOverflow.ELLIPSIS,
|
||||
),
|
||||
],
|
||||
spacing=8,
|
||||
),
|
||||
on_click=lambda e, i=idx: self.select_tab(i), # type: ignore
|
||||
padding=ft.padding.symmetric(horizontal=16, vertical=10),
|
||||
border_radius=8,
|
||||
bgcolor=ft.Colors.GREY_800,
|
||||
ink=True,
|
||||
width=150,
|
||||
)
|
||||
insert_pos = max(0, len(self.tab_bar.content.controls) - 2)
|
||||
self.tab_bar.content.controls.insert(insert_pos, tab_container)
|
||||
self._update_tab_visibility()
|
||||
|
||||
def _on_add_click(self, e):
|
||||
def _on_add_click(self, e) -> None: # type: ignore
|
||||
"""Handle the add tab button click event."""
|
||||
title = f"Tab {len(self.manager.tabs) + 1}"
|
||||
content_text = f"Content for {title}"
|
||||
import ren_browser.app as app_module
|
||||
content = render_micron(content_text) if app_module.RENDERER == "micron" else render_plaintext(content_text)
|
||||
|
||||
new_idx = len(self.manager.tabs)
|
||||
|
||||
def handle_link_click_new(link_url):
|
||||
tab = self.manager.tabs[new_idx]
|
||||
full_url = link_url
|
||||
if ":" not in link_url:
|
||||
full_url = f"{link_url}:/page/index.mu"
|
||||
tab["url_field"].value = full_url
|
||||
self._on_tab_go(None, new_idx)
|
||||
|
||||
content = (
|
||||
render_micron(content_text, on_link_click=handle_link_click_new)
|
||||
if app_module.RENDERER == "micron"
|
||||
else render_plaintext(content_text)
|
||||
)
|
||||
self._add_tab_internal(title, content)
|
||||
self.select_tab(len(self.manager.tabs) - 1)
|
||||
self.page.update()
|
||||
|
||||
def _on_close_click(self, e):
|
||||
def _on_close_click(self, e) -> None: # type: ignore
|
||||
"""Handle the close tab button click event."""
|
||||
if len(self.manager.tabs) <= 1:
|
||||
return
|
||||
idx = self.manager.index
|
||||
|
||||
tab_containers = [
|
||||
c for c in self.tab_bar.content.controls if isinstance(c, ft.Container)
|
||||
]
|
||||
control_to_remove = tab_containers[idx]
|
||||
|
||||
self.manager.tabs.pop(idx)
|
||||
self.tab_bar.controls.pop(idx)
|
||||
for i, control in enumerate(self.tab_bar.controls[:-2]):
|
||||
control.on_click = lambda e, i=i: self.select_tab(i)
|
||||
self.tab_bar.content.controls.remove(control_to_remove)
|
||||
|
||||
updated_tab_containers = [
|
||||
c for c in self.tab_bar.content.controls if isinstance(c, ft.Container)
|
||||
]
|
||||
for i, control in enumerate(updated_tab_containers):
|
||||
control.on_click = lambda e, i=i: self.select_tab(i) # type: ignore
|
||||
|
||||
new_idx = min(idx, len(self.manager.tabs) - 1)
|
||||
self.select_tab(new_idx)
|
||||
self._update_tab_visibility()
|
||||
self.page.update()
|
||||
|
||||
def select_tab(self, idx: int):
|
||||
def select_tab(self, idx: int) -> None:
|
||||
"""Select and display the tab at the given index.
|
||||
|
||||
Args:
|
||||
@@ -99,24 +286,89 @@ class TabsManager:
|
||||
|
||||
"""
|
||||
self.manager.index = idx
|
||||
for i, control in enumerate(self.tab_bar.controls[:-2]):
|
||||
|
||||
tab_containers = [
|
||||
c for c in self.tab_bar.content.controls if isinstance(c, ft.Container)
|
||||
]
|
||||
for i, control in enumerate(tab_containers):
|
||||
if i == idx:
|
||||
control.bgcolor = ft.Colors.PRIMARY_CONTAINER
|
||||
control.bgcolor = ft.Colors.BLUE_900
|
||||
control.border = ft.border.all(2, ft.Colors.BLUE_400)
|
||||
else:
|
||||
control.bgcolor = ft.Colors.SURFACE_CONTAINER_HIGHEST
|
||||
control.bgcolor = ft.Colors.GREY_800
|
||||
control.border = None
|
||||
|
||||
self.content_container.content = self.manager.tabs[idx]["content"]
|
||||
self.page.update()
|
||||
|
||||
def _on_tab_go(self, e, idx: int):
|
||||
def _on_tab_go(self, e, idx: int) -> None: # type: ignore
|
||||
"""Handle the go button click event for a tab, loading new content."""
|
||||
tab = self.manager.tabs[idx]
|
||||
url = tab["url_field"].value.strip()
|
||||
if not url:
|
||||
return
|
||||
placeholder_text = f"Loading content for {url}"
|
||||
|
||||
placeholder_text = f"Loading content for {url}..."
|
||||
import ren_browser.app as app_module
|
||||
new_control = render_micron(placeholder_text) if app_module.RENDERER == "micron" else render_plaintext(placeholder_text)
|
||||
|
||||
current_node_hash = None
|
||||
if ":" in url:
|
||||
current_node_hash = url.split(":")[0]
|
||||
|
||||
def handle_link_click(link_url):
|
||||
full_url = link_url
|
||||
if ":" not in link_url:
|
||||
full_url = f"{link_url}:/page/index.mu"
|
||||
elif link_url.startswith(":/"):
|
||||
if current_node_hash:
|
||||
full_url = f"{current_node_hash}{link_url}"
|
||||
else:
|
||||
full_url = link_url
|
||||
tab["url_field"].value = full_url
|
||||
self._on_tab_go(None, idx)
|
||||
|
||||
placeholder_control = (
|
||||
render_micron(placeholder_text, on_link_click=handle_link_click)
|
||||
if app_module.RENDERER == "micron"
|
||||
else render_plaintext(placeholder_text)
|
||||
)
|
||||
tab["content_control"] = placeholder_control
|
||||
tab["content"].controls[0] = placeholder_control
|
||||
if self.manager.index == idx:
|
||||
self.content_container.content = tab["content"]
|
||||
self.page.update()
|
||||
|
||||
def fetch_and_update():
|
||||
parts = url.split(":", 1)
|
||||
if len(parts) != 2:
|
||||
result = "Error: Invalid URL format. Expected format: hash:/page/path"
|
||||
page_path = ""
|
||||
else:
|
||||
dest_hash = parts[0]
|
||||
page_path = parts[1] if parts[1].startswith("/") else f"/{parts[1]}"
|
||||
|
||||
req = PageRequest(destination_hash=dest_hash, page_path=page_path)
|
||||
page_fetcher = PageFetcher()
|
||||
try:
|
||||
result = page_fetcher.fetch_page(req)
|
||||
except Exception as ex:
|
||||
app_module.log_error(str(ex))
|
||||
result = f"Error: {ex}"
|
||||
|
||||
try:
|
||||
tab = self.manager.tabs[idx]
|
||||
except IndexError:
|
||||
return
|
||||
|
||||
if page_path and page_path.endswith(".mu"):
|
||||
new_control = render_micron(result, on_link_click=handle_link_click)
|
||||
else:
|
||||
new_control = render_plaintext(result)
|
||||
|
||||
tab["content_control"] = new_control
|
||||
tab["content"].controls[0] = new_control
|
||||
if self.manager.index == idx:
|
||||
self.content_container.content = tab["content"]
|
||||
self.page.update()
|
||||
|
||||
self.page.run_thread(fetch_and_update)
|
||||
|
||||
@@ -1,115 +1,493 @@
|
||||
"""Settings interface for Ren Browser.
|
||||
"""Settings interface for Ren Browser."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
Provides configuration management, log viewing, and storage
|
||||
information display.
|
||||
"""
|
||||
import flet as ft
|
||||
import RNS
|
||||
|
||||
from ren_browser.logs import ERROR_LOGS, RET_LOGS
|
||||
from ren_browser import rns
|
||||
from ren_browser.storage.storage import get_storage_manager
|
||||
|
||||
BUTTON_BG = "#0B3D91"
|
||||
BUTTON_BG_HOVER = "#082C6C"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _blue_button_style() -> ft.ButtonStyle:
|
||||
return ft.ButtonStyle(
|
||||
bgcolor=BUTTON_BG,
|
||||
color=ft.Colors.WHITE,
|
||||
overlay_color=BUTTON_BG_HOVER,
|
||||
)
|
||||
|
||||
|
||||
def _get_config_file_path() -> Path:
|
||||
config_dir = rns.get_config_path()
|
||||
if config_dir:
|
||||
return Path(config_dir) / "config"
|
||||
return Path.home() / ".reticulum" / "config"
|
||||
|
||||
|
||||
def _read_config_text(config_path: Path) -> str:
|
||||
try:
|
||||
return config_path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text("", encoding="utf-8")
|
||||
return ""
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return f"# Error loading config: {exc}"
|
||||
|
||||
|
||||
def _write_config_text(config_path: Path, content: str) -> None:
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def _get_interface_statuses():
|
||||
statuses = []
|
||||
interfaces = getattr(RNS.Transport, "interfaces", []) or []
|
||||
for interface in interfaces:
|
||||
if interface is None:
|
||||
continue
|
||||
if interface.__class__.__name__ == "LocalClientInterface" and getattr(
|
||||
interface, "is_connected_to_shared_instance", False,
|
||||
):
|
||||
continue
|
||||
statuses.append(
|
||||
{
|
||||
"name": getattr(interface, "name", None)
|
||||
or interface.__class__.__name__,
|
||||
"online": bool(getattr(interface, "online", False)),
|
||||
"type": interface.__class__.__name__,
|
||||
"bitrate": getattr(interface, "bitrate", None),
|
||||
},
|
||||
)
|
||||
return statuses
|
||||
|
||||
|
||||
def _format_bitrate(bitrate: int | None) -> str | None:
|
||||
if not bitrate:
|
||||
return None
|
||||
if bitrate >= 1_000_000:
|
||||
return f"{bitrate / 1_000_000:.1f} Mbps"
|
||||
if bitrate >= 1_000:
|
||||
return f"{bitrate / 1_000:.0f} kbps"
|
||||
return f"{bitrate} bps"
|
||||
|
||||
|
||||
def _build_interface_chip_controls(statuses):
|
||||
if not statuses:
|
||||
return [
|
||||
ft.Text(
|
||||
"No interfaces detected",
|
||||
size=11,
|
||||
color=ft.Colors.ON_SURFACE_VARIANT,
|
||||
),
|
||||
]
|
||||
|
||||
chips = []
|
||||
for status in statuses:
|
||||
indicator_color = ft.Colors.GREEN if status["online"] else ft.Colors.ERROR
|
||||
tooltip = status["type"]
|
||||
bitrate_label = _format_bitrate(status.get("bitrate"))
|
||||
if bitrate_label:
|
||||
tooltip = f"{tooltip} • {bitrate_label}"
|
||||
|
||||
chips.append(
|
||||
ft.Container(
|
||||
content=ft.Row(
|
||||
[
|
||||
ft.Icon(ft.Icons.CIRCLE, size=10, color=indicator_color),
|
||||
ft.Text(status["name"], size=11),
|
||||
],
|
||||
spacing=4,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
),
|
||||
bgcolor="#1C1F2B",
|
||||
border_radius=999,
|
||||
padding=ft.padding.symmetric(horizontal=10, vertical=4),
|
||||
tooltip=tooltip,
|
||||
),
|
||||
)
|
||||
return chips
|
||||
|
||||
|
||||
def _refresh_interface_status(summary_text, chip_wrap, updated_text):
|
||||
statuses = _get_interface_statuses()
|
||||
total = len(statuses)
|
||||
online = sum(1 for entry in statuses if entry["online"])
|
||||
|
||||
if total == 0:
|
||||
summary_text.value = "No active interfaces"
|
||||
summary_text.color = ft.Colors.ERROR
|
||||
else:
|
||||
summary_text.value = f"{online}/{total} interfaces online"
|
||||
summary_text.color = ft.Colors.GREEN if online else ft.Colors.ERROR
|
||||
|
||||
chip_wrap.controls = _build_interface_chip_controls(statuses)
|
||||
updated_text.value = f"Updated {datetime.now().strftime('%H:%M:%S')}"
|
||||
|
||||
|
||||
def _build_status_section(page: ft.Page):
|
||||
summary_text = ft.Text("", size=16, weight=ft.FontWeight.BOLD)
|
||||
updated_text = ft.Text("", size=12, color=ft.Colors.ON_SURFACE_VARIANT)
|
||||
chip_wrap = ft.Row(
|
||||
spacing=6,
|
||||
run_spacing=6,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
)
|
||||
|
||||
def refresh(_=None):
|
||||
_refresh_interface_status(summary_text, chip_wrap, updated_text)
|
||||
page.update()
|
||||
|
||||
refresh()
|
||||
|
||||
refresh_button = ft.IconButton(
|
||||
icon=ft.Icons.REFRESH,
|
||||
tooltip="Refresh status",
|
||||
on_click=refresh,
|
||||
icon_color=ft.Colors.BLUE_200,
|
||||
)
|
||||
|
||||
section = ft.Column(
|
||||
spacing=12,
|
||||
controls=[
|
||||
ft.Row(
|
||||
controls=[
|
||||
ft.Row(
|
||||
controls=[
|
||||
ft.Icon(ft.Icons.LAN, size=18, color=ft.Colors.BLUE_200),
|
||||
summary_text,
|
||||
],
|
||||
spacing=6,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
),
|
||||
refresh_button,
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
),
|
||||
chip_wrap,
|
||||
updated_text,
|
||||
],
|
||||
)
|
||||
|
||||
return section, refresh
|
||||
|
||||
|
||||
def _build_storage_field(storage):
|
||||
storage_field = ft.TextField(
|
||||
label="Storage Information",
|
||||
value="",
|
||||
expand=True,
|
||||
multiline=True,
|
||||
read_only=True,
|
||||
min_lines=10,
|
||||
max_lines=15,
|
||||
border_color=ft.Colors.GREY_700,
|
||||
text_style=ft.TextStyle(font_family="monospace", size=12),
|
||||
)
|
||||
|
||||
def refresh():
|
||||
info = storage.get_storage_info()
|
||||
storage_field.value = "\n".join(
|
||||
f"{key}: {value}" for key, value in info.items()
|
||||
)
|
||||
|
||||
refresh()
|
||||
return storage_field, refresh
|
||||
|
||||
|
||||
def open_settings_tab(page: ft.Page, tab_manager):
|
||||
"""Open a settings tab with configuration and debugging options.
|
||||
|
||||
Args:
|
||||
page: Flet page instance for UI updates.
|
||||
tab_manager: Tab manager to add the settings tab to.
|
||||
|
||||
"""
|
||||
"""Open a settings tab with configuration, status, and storage info."""
|
||||
storage = get_storage_manager(page)
|
||||
|
||||
try:
|
||||
config_text = storage.load_config()
|
||||
except Exception as ex:
|
||||
config_text = f"Error reading config: {ex}"
|
||||
config_path = _get_config_file_path()
|
||||
config_text = _read_config_text(config_path)
|
||||
app_settings = storage.load_app_settings()
|
||||
|
||||
config_field = ft.TextField(
|
||||
label="Reticulum config",
|
||||
label="Reticulum Configuration",
|
||||
value=config_text,
|
||||
expand=True,
|
||||
multiline=True,
|
||||
min_lines=15,
|
||||
max_lines=20,
|
||||
border_color=ft.Colors.GREY_700,
|
||||
focused_border_color=ft.Colors.BLUE_400,
|
||||
text_style=ft.TextStyle(font_family="monospace", size=12),
|
||||
)
|
||||
|
||||
def on_save_config(ev):
|
||||
horizontal_scroll_switch = ft.Switch(
|
||||
label="Enable Horizontal Scroll (preserve ASCII art)",
|
||||
value=app_settings.get("horizontal_scroll", False),
|
||||
)
|
||||
|
||||
page_bgcolor_field = ft.TextField(
|
||||
label="Page Background Color (hex)",
|
||||
value=app_settings.get("page_bgcolor", "#000000"),
|
||||
hint_text="#000000",
|
||||
width=200,
|
||||
border_color=ft.Colors.GREY_700,
|
||||
focused_border_color=ft.Colors.BLUE_400,
|
||||
)
|
||||
|
||||
color_preview = ft.Container(
|
||||
width=40,
|
||||
height=40,
|
||||
bgcolor=app_settings.get("page_bgcolor", "#000000"),
|
||||
border_radius=8,
|
||||
border=ft.border.all(1, ft.Colors.GREY_700),
|
||||
)
|
||||
|
||||
def on_bgcolor_change(_):
|
||||
try:
|
||||
success = storage.save_config(config_field.value)
|
||||
if success:
|
||||
page.snack_bar = ft.SnackBar(ft.Text("Config saved successfully. Please restart the app."), open=True)
|
||||
else:
|
||||
page.snack_bar = ft.SnackBar(ft.Text("Error saving config: Storage operation failed"), open=True)
|
||||
except Exception as ex:
|
||||
page.snack_bar = ft.SnackBar(ft.Text(f"Error saving config: {ex}"), open=True)
|
||||
color_preview.bgcolor = page_bgcolor_field.value
|
||||
page.update()
|
||||
save_btn = ft.ElevatedButton("Save and Restart", on_click=on_save_config)
|
||||
error_field = ft.TextField(
|
||||
label="Error Logs",
|
||||
value="",
|
||||
expand=True,
|
||||
multiline=True,
|
||||
read_only=True,
|
||||
)
|
||||
ret_field = ft.TextField(
|
||||
label="Reticulum logs",
|
||||
value="",
|
||||
expand=True,
|
||||
multiline=True,
|
||||
read_only=True,
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Ignoring invalid background color '%s': %s",
|
||||
page_bgcolor_field.value,
|
||||
exc,
|
||||
)
|
||||
|
||||
# Storage information for debugging
|
||||
storage_info = storage.get_storage_info()
|
||||
storage_text = "\n".join([f"{key}: {value}" for key, value in storage_info.items()])
|
||||
storage_field = ft.TextField(
|
||||
label="Storage Information",
|
||||
value=storage_text,
|
||||
expand=True,
|
||||
multiline=True,
|
||||
read_only=True,
|
||||
)
|
||||
page_bgcolor_field.on_change = on_bgcolor_change
|
||||
|
||||
content_placeholder = ft.Container(expand=True)
|
||||
def show_config(ev):
|
||||
content_placeholder.content = config_field
|
||||
page.update()
|
||||
def show_errors(ev):
|
||||
error_field.value = "\n".join(ERROR_LOGS) or "No errors logged."
|
||||
content_placeholder.content = error_field
|
||||
page.update()
|
||||
def show_ret_logs(ev):
|
||||
ret_field.value = "\n".join(RET_LOGS) or "No Reticulum logs."
|
||||
content_placeholder.content = ret_field
|
||||
page.update()
|
||||
def show_storage_info(ev):
|
||||
storage_info = storage.get_storage_info()
|
||||
storage_field.value = "\n".join([f"{key}: {value}" for key, value in storage_info.items()])
|
||||
content_placeholder.content = storage_field
|
||||
page.update()
|
||||
def refresh_current_view(ev):
|
||||
# Refresh the currently displayed content
|
||||
if content_placeholder.content == error_field:
|
||||
show_errors(ev)
|
||||
elif content_placeholder.content == ret_field:
|
||||
show_ret_logs(ev)
|
||||
elif content_placeholder.content == storage_field:
|
||||
show_storage_info(ev)
|
||||
elif content_placeholder.content == config_field:
|
||||
show_config(ev)
|
||||
btn_config = ft.ElevatedButton("Config", on_click=show_config)
|
||||
btn_errors = ft.ElevatedButton("Errors", on_click=show_errors)
|
||||
btn_ret = ft.ElevatedButton("Ret Logs", on_click=show_ret_logs)
|
||||
btn_storage = ft.ElevatedButton("Storage", on_click=show_storage_info)
|
||||
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])
|
||||
content_placeholder.content = config_field
|
||||
settings_content = ft.Column(
|
||||
expand=True,
|
||||
def show_snack(message, *, success=True):
|
||||
snack = ft.SnackBar(
|
||||
content=ft.Row(
|
||||
controls=[
|
||||
button_row,
|
||||
content_placeholder,
|
||||
ft.Row([save_btn]),
|
||||
ft.Icon(
|
||||
ft.Icons.CHECK_CIRCLE if success else ft.Icons.ERROR,
|
||||
color=ft.Colors.GREEN_400 if success else ft.Colors.RED_400,
|
||||
size=20,
|
||||
),
|
||||
ft.Text(message, color=ft.Colors.WHITE),
|
||||
],
|
||||
tight=True,
|
||||
),
|
||||
bgcolor=ft.Colors.GREEN_900 if success else ft.Colors.RED_900,
|
||||
duration=3000 if success else 4000,
|
||||
)
|
||||
page.overlay.append(snack)
|
||||
snack.open = True
|
||||
page.update()
|
||||
|
||||
def on_save_config(_):
|
||||
try:
|
||||
_write_config_text(config_path, config_field.value)
|
||||
show_snack(f"Configuration saved to {config_path}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_snack(f"Failed to save configuration: {exc}", success=False)
|
||||
|
||||
def on_save_and_reload_config(_):
|
||||
try:
|
||||
_write_config_text(config_path, config_field.value)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_snack(f"Failed to save configuration: {exc}", success=False)
|
||||
return
|
||||
|
||||
loading_snack = ft.SnackBar(
|
||||
content=ft.Row(
|
||||
controls=[
|
||||
ft.ProgressRing(
|
||||
width=16,
|
||||
height=16,
|
||||
stroke_width=2,
|
||||
color=ft.Colors.BLUE_400,
|
||||
),
|
||||
ft.Text("Reloading Reticulum...", color=ft.Colors.WHITE),
|
||||
],
|
||||
tight=True,
|
||||
),
|
||||
bgcolor=ft.Colors.BLUE_900,
|
||||
duration=10000,
|
||||
)
|
||||
page.overlay.append(loading_snack)
|
||||
loading_snack.open = True
|
||||
page.update()
|
||||
|
||||
async def do_reload():
|
||||
import ren_browser.app as app_module
|
||||
|
||||
try:
|
||||
await app_module.reload_reticulum(page, on_reload_complete)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
loading_snack.open = False
|
||||
page.update()
|
||||
show_snack(f"Reload failed: {exc}", success=False)
|
||||
|
||||
def on_reload_complete(success, error):
|
||||
loading_snack.open = False
|
||||
page.update()
|
||||
if success:
|
||||
show_snack("Reticulum reloaded successfully!")
|
||||
else:
|
||||
show_snack(f"Reload failed: {error}", success=False)
|
||||
|
||||
page.run_task(do_reload)
|
||||
|
||||
def on_save_app_settings(_):
|
||||
try:
|
||||
new_settings = {
|
||||
"horizontal_scroll": horizontal_scroll_switch.value,
|
||||
"page_bgcolor": page_bgcolor_field.value,
|
||||
}
|
||||
success = storage.save_app_settings(new_settings)
|
||||
if success:
|
||||
if hasattr(tab_manager, "apply_settings"):
|
||||
tab_manager.apply_settings(new_settings)
|
||||
show_snack("Appearance settings saved and applied!")
|
||||
else:
|
||||
show_snack("Failed to save appearance settings", success=False)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_snack(f"Error saving appearance: {exc}", success=False)
|
||||
|
||||
save_btn = ft.ElevatedButton(
|
||||
"Save Configuration",
|
||||
icon=ft.Icons.SAVE,
|
||||
on_click=on_save_config,
|
||||
style=_blue_button_style(),
|
||||
)
|
||||
save_reload_btn = ft.ElevatedButton(
|
||||
"Save & Hot Reload",
|
||||
icon=ft.Icons.REFRESH,
|
||||
on_click=on_save_and_reload_config,
|
||||
style=_blue_button_style(),
|
||||
)
|
||||
save_appearance_btn = ft.ElevatedButton(
|
||||
"Save Appearance",
|
||||
icon=ft.Icons.PALETTE,
|
||||
on_click=on_save_app_settings,
|
||||
style=_blue_button_style(),
|
||||
)
|
||||
|
||||
status_content, refresh_status_section = _build_status_section(page)
|
||||
storage_field, refresh_storage_info = _build_storage_field(storage)
|
||||
|
||||
appearance_content = ft.Column(
|
||||
spacing=16,
|
||||
controls=[
|
||||
ft.Text("Appearance Settings", size=18, weight=ft.FontWeight.BOLD),
|
||||
horizontal_scroll_switch,
|
||||
ft.Row(
|
||||
controls=[page_bgcolor_field, color_preview],
|
||||
alignment=ft.MainAxisAlignment.START,
|
||||
spacing=16,
|
||||
),
|
||||
save_appearance_btn,
|
||||
],
|
||||
)
|
||||
|
||||
content_placeholder = ft.Container(expand=True, content=config_field)
|
||||
|
||||
def show_config(_):
|
||||
content_placeholder.content = config_field
|
||||
page.update()
|
||||
|
||||
def show_appearance(_):
|
||||
content_placeholder.content = appearance_content
|
||||
page.update()
|
||||
|
||||
def show_status(_):
|
||||
content_placeholder.content = status_content
|
||||
refresh_status_section()
|
||||
|
||||
def show_storage_info(_):
|
||||
refresh_storage_info()
|
||||
content_placeholder.content = storage_field
|
||||
page.update()
|
||||
|
||||
def refresh_current_view(_):
|
||||
if content_placeholder.content == status_content:
|
||||
refresh_status_section()
|
||||
elif content_placeholder.content == storage_field:
|
||||
refresh_storage_info()
|
||||
page.update()
|
||||
|
||||
btn_config = ft.FilledButton(
|
||||
"Configuration",
|
||||
icon=ft.Icons.SETTINGS,
|
||||
on_click=show_config,
|
||||
style=_blue_button_style(),
|
||||
)
|
||||
btn_appearance = ft.FilledButton(
|
||||
"Appearance",
|
||||
icon=ft.Icons.PALETTE,
|
||||
on_click=show_appearance,
|
||||
style=_blue_button_style(),
|
||||
)
|
||||
btn_status = ft.FilledButton(
|
||||
"Status",
|
||||
icon=ft.Icons.LAN,
|
||||
on_click=show_status,
|
||||
style=_blue_button_style(),
|
||||
)
|
||||
btn_storage = ft.FilledButton(
|
||||
"Storage",
|
||||
icon=ft.Icons.STORAGE,
|
||||
on_click=show_storage_info,
|
||||
style=_blue_button_style(),
|
||||
)
|
||||
btn_refresh = ft.IconButton(
|
||||
icon=ft.Icons.REFRESH,
|
||||
tooltip="Refresh",
|
||||
on_click=refresh_current_view,
|
||||
icon_color=ft.Colors.BLUE_400,
|
||||
)
|
||||
|
||||
nav_card = ft.Container(
|
||||
content=ft.Row(
|
||||
controls=[btn_config, btn_appearance, btn_status, btn_storage, btn_refresh],
|
||||
spacing=8,
|
||||
wrap=True,
|
||||
),
|
||||
padding=ft.padding.all(16),
|
||||
border_radius=12,
|
||||
bgcolor=ft.Colors.GREY_900,
|
||||
)
|
||||
|
||||
content_card = ft.Container(
|
||||
content=content_placeholder,
|
||||
expand=True,
|
||||
padding=ft.padding.all(16),
|
||||
border_radius=12,
|
||||
bgcolor=ft.Colors.GREY_900,
|
||||
)
|
||||
|
||||
action_row = ft.Container(
|
||||
content=ft.Row(
|
||||
controls=[save_btn, save_reload_btn],
|
||||
alignment=ft.MainAxisAlignment.END,
|
||||
spacing=8,
|
||||
),
|
||||
padding=ft.padding.symmetric(horizontal=16, vertical=8),
|
||||
)
|
||||
|
||||
settings_content = ft.Column(
|
||||
expand=True,
|
||||
spacing=16,
|
||||
controls=[
|
||||
ft.Container(
|
||||
content=ft.Text(
|
||||
"Settings",
|
||||
size=24,
|
||||
weight=ft.FontWeight.BOLD,
|
||||
color=ft.Colors.BLUE_400,
|
||||
),
|
||||
padding=ft.padding.only(left=16, top=16),
|
||||
),
|
||||
nav_card,
|
||||
content_card,
|
||||
action_row,
|
||||
],
|
||||
)
|
||||
|
||||
tab_manager._add_tab_internal("Settings", settings_content)
|
||||
idx = len(tab_manager.manager.tabs) - 1
|
||||
tab_manager.select_tab(idx)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Builds the complete browser interface including tabs, navigation,
|
||||
announce handling, and content rendering.
|
||||
"""
|
||||
|
||||
import flet as ft
|
||||
from flet import Page
|
||||
|
||||
@@ -22,15 +23,32 @@ def build_ui(page: Page):
|
||||
|
||||
"""
|
||||
page.theme_mode = ft.ThemeMode.DARK
|
||||
page.appbar = ft.AppBar()
|
||||
page.theme = ft.Theme(
|
||||
color_scheme=ft.ColorScheme(
|
||||
primary=ft.Colors.BLUE_400,
|
||||
on_primary=ft.Colors.WHITE,
|
||||
surface=ft.Colors.BLACK,
|
||||
on_surface=ft.Colors.WHITE,
|
||||
background=ft.Colors.BLACK,
|
||||
on_background=ft.Colors.WHITE,
|
||||
),
|
||||
)
|
||||
page.bgcolor = ft.Colors.BLACK
|
||||
page.appbar = ft.AppBar(
|
||||
bgcolor=ft.Colors.GREY_900,
|
||||
elevation=2,
|
||||
)
|
||||
page.window.maximized = True
|
||||
page.padding = 0
|
||||
|
||||
page_fetcher = PageFetcher()
|
||||
announce_list = ft.ListView(expand=True, spacing=1)
|
||||
announce_list = ft.ListView(expand=True, spacing=8, padding=ft.padding.all(8))
|
||||
|
||||
def update_announces(ann_list):
|
||||
announce_list.controls.clear()
|
||||
for ann in ann_list:
|
||||
label = ann.display_name or ann.destination_hash
|
||||
|
||||
def on_click_ann(e, dest=ann.destination_hash, disp=ann.display_name):
|
||||
title = disp or "Anonymous"
|
||||
full_url = f"{dest}:/page/index.mu"
|
||||
@@ -41,20 +59,35 @@ def build_ui(page: Page):
|
||||
tab["url_field"].value = full_url
|
||||
tab_manager.select_tab(idx)
|
||||
page.update()
|
||||
|
||||
def fetch_and_update():
|
||||
req = PageRequest(destination_hash=dest, page_path="/page/index.mu")
|
||||
try:
|
||||
result = page_fetcher.fetch_page(req)
|
||||
except Exception as ex:
|
||||
import ren_browser.app as app_module
|
||||
|
||||
app_module.log_error(str(ex))
|
||||
result = f"Error: {ex}"
|
||||
try:
|
||||
tab = tab_manager.manager.tabs[idx]
|
||||
except IndexError:
|
||||
return
|
||||
|
||||
def handle_link_click(url):
|
||||
full_url = url
|
||||
if ":" not in url:
|
||||
full_url = f"{url}:/page/index.mu"
|
||||
elif url.startswith(":/"):
|
||||
full_url = f"{dest}{url}"
|
||||
tab["url_field"].value = full_url
|
||||
tab_manager._on_tab_go(None, idx)
|
||||
|
||||
if req.page_path.endswith(".mu"):
|
||||
new_control = render_micron(result)
|
||||
new_control = render_micron(
|
||||
result,
|
||||
on_link_click=handle_link_click,
|
||||
)
|
||||
else:
|
||||
new_control = render_plaintext(result)
|
||||
tab["content_control"] = new_control
|
||||
@@ -62,44 +95,97 @@ def build_ui(page: Page):
|
||||
if tab_manager.manager.index == idx:
|
||||
tab_manager.content_container.content = tab["content"]
|
||||
page.update()
|
||||
|
||||
page.run_thread(fetch_and_update)
|
||||
announce_list.controls.append(ft.TextButton(label, on_click=on_click_ann))
|
||||
|
||||
announce_card = ft.Container(
|
||||
content=ft.Row(
|
||||
controls=[
|
||||
ft.Icon(ft.Icons.LANGUAGE, size=20, color=ft.Colors.BLUE_400),
|
||||
ft.Text(
|
||||
label,
|
||||
size=14,
|
||||
weight=ft.FontWeight.W_500,
|
||||
overflow=ft.TextOverflow.ELLIPSIS,
|
||||
),
|
||||
],
|
||||
spacing=12,
|
||||
),
|
||||
padding=ft.padding.all(12),
|
||||
border_radius=8,
|
||||
bgcolor=ft.Colors.GREY_800,
|
||||
ink=True,
|
||||
on_click=on_click_ann,
|
||||
)
|
||||
announce_list.controls.append(announce_card)
|
||||
page.update()
|
||||
|
||||
AnnounceService(update_callback=update_announces)
|
||||
page.drawer = ft.NavigationDrawer(
|
||||
bgcolor=ft.Colors.GREY_900,
|
||||
elevation=8,
|
||||
controls=[
|
||||
ft.Text("Announcements", weight=ft.FontWeight.BOLD, text_align=ft.TextAlign.CENTER, expand=True),
|
||||
ft.Divider(),
|
||||
ft.Container(
|
||||
content=ft.Text(
|
||||
"Announcements",
|
||||
size=20,
|
||||
weight=ft.FontWeight.BOLD,
|
||||
color=ft.Colors.BLUE_400,
|
||||
),
|
||||
padding=ft.padding.symmetric(horizontal=16, vertical=20),
|
||||
),
|
||||
ft.Divider(height=1, color=ft.Colors.GREY_700),
|
||||
announce_list,
|
||||
],
|
||||
)
|
||||
page.appbar.leading = ft.IconButton(
|
||||
ft.Icons.MENU,
|
||||
tooltip="Toggle sidebar",
|
||||
on_click=lambda e: (setattr(page.drawer, "open", not page.drawer.open), page.update()),
|
||||
tooltip="Announcements",
|
||||
icon_color=ft.Colors.WHITE,
|
||||
on_click=lambda e: (
|
||||
setattr(page.drawer, "open", not page.drawer.open),
|
||||
page.update(),
|
||||
),
|
||||
)
|
||||
|
||||
tab_manager = TabsManager(page)
|
||||
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",
|
||||
icon_color=ft.Colors.WHITE,
|
||||
on_click=lambda e: open_settings_tab(page, tab_manager),
|
||||
),
|
||||
]
|
||||
Shortcuts(page, tab_manager)
|
||||
url_bar = ft.Row(
|
||||
url_bar = ft.Container(
|
||||
content=ft.Row(
|
||||
controls=[
|
||||
tab_manager.manager.tabs[tab_manager.manager.index]["url_field"],
|
||||
tab_manager.manager.tabs[tab_manager.manager.index]["go_btn"],
|
||||
],
|
||||
spacing=8,
|
||||
),
|
||||
expand=True,
|
||||
padding=ft.padding.symmetric(horizontal=8),
|
||||
)
|
||||
page.appbar.title = url_bar
|
||||
orig_select_tab = tab_manager.select_tab
|
||||
|
||||
def _select_tab_and_update_url(i):
|
||||
orig_select_tab(i)
|
||||
tab = tab_manager.manager.tabs[i]
|
||||
url_bar.controls.clear()
|
||||
url_bar.controls.extend([tab["url_field"], tab["go_btn"]])
|
||||
url_bar.content.controls.clear()
|
||||
url_bar.content.controls.extend([tab["url_field"], tab["go_btn"]])
|
||||
page.update()
|
||||
|
||||
tab_manager.select_tab = _select_tab_and_update_url
|
||||
|
||||
def _update_content_width(e=None):
|
||||
tab_manager.content_container.width = page.width
|
||||
|
||||
_update_content_width()
|
||||
page.on_resized = lambda e: (_update_content_width(), page.update())
|
||||
main_area = ft.Column(
|
||||
|
||||
@@ -36,6 +36,7 @@ def mock_rns():
|
||||
|
||||
# Mock at the module level for all imports
|
||||
import sys
|
||||
|
||||
sys.modules["RNS"] = mock_rns
|
||||
|
||||
yield mock_rns
|
||||
@@ -51,7 +52,7 @@ def sample_announce_data():
|
||||
return {
|
||||
"destination_hash": "1234567890abcdef",
|
||||
"display_name": "Test Node",
|
||||
"timestamp": 1234567890
|
||||
"timestamp": 1234567890,
|
||||
}
|
||||
|
||||
|
||||
@@ -59,10 +60,11 @@ def sample_announce_data():
|
||||
def sample_page_request():
|
||||
"""Sample page request for testing."""
|
||||
from ren_browser.pages.page_request import PageRequest
|
||||
|
||||
return PageRequest(
|
||||
destination_hash="1234567890abcdef",
|
||||
page_path="/page/index.mu",
|
||||
field_data=None
|
||||
field_data=None,
|
||||
)
|
||||
|
||||
|
||||
@@ -74,12 +76,17 @@ def mock_storage_manager():
|
||||
mock_storage.save_config.return_value = True
|
||||
mock_storage.get_config_path.return_value = Mock()
|
||||
mock_storage.get_reticulum_config_path.return_value = Mock()
|
||||
mock_storage.load_app_settings.return_value = {
|
||||
"horizontal_scroll": False,
|
||||
"page_bgcolor": "#000000",
|
||||
}
|
||||
mock_storage.save_app_settings.return_value = True
|
||||
mock_storage.get_storage_info.return_value = {
|
||||
'storage_dir': '/mock/storage',
|
||||
'config_path': '/mock/storage/config.txt',
|
||||
'reticulum_config_path': '/mock/storage/reticulum',
|
||||
'storage_dir_exists': True,
|
||||
'storage_dir_writable': True,
|
||||
'has_client_storage': True,
|
||||
"storage_dir": "/mock/storage",
|
||||
"config_path": "/mock/storage/config.txt",
|
||||
"reticulum_config_path": "/mock/storage/reticulum",
|
||||
"storage_dir_exists": True,
|
||||
"storage_dir_writable": True,
|
||||
"has_client_storage": True,
|
||||
}
|
||||
return mock_storage
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
import flet as ft
|
||||
import pytest
|
||||
|
||||
from ren_browser import app
|
||||
@@ -14,22 +15,33 @@ class TestAppIntegration:
|
||||
mock_page = Mock()
|
||||
mock_page.add = Mock()
|
||||
mock_page.update = Mock()
|
||||
mock_page.run_thread = Mock()
|
||||
mock_page.controls = Mock()
|
||||
mock_page.controls.clear = Mock()
|
||||
mock_page.width = 1024
|
||||
mock_page.window = Mock()
|
||||
mock_page.window.maximized = False
|
||||
mock_page.appbar = Mock()
|
||||
mock_page.drawer = Mock()
|
||||
mock_page.theme_mode = ft.ThemeMode.DARK
|
||||
|
||||
await app.main(mock_page)
|
||||
|
||||
# Verify that the main function sets up the loading screen
|
||||
mock_page.add.assert_called_once()
|
||||
assert mock_page.add.call_count >= 1
|
||||
loader_call = mock_page.add.call_args_list[0][0][0]
|
||||
assert isinstance(loader_call, ft.Container)
|
||||
mock_page.update.assert_called()
|
||||
mock_page.run_thread.assert_called_once()
|
||||
|
||||
def test_entry_points_exist(self):
|
||||
"""Test that all expected entry points exist and are callable."""
|
||||
entry_points = [
|
||||
"run", "web", "android", "ios",
|
||||
"run_dev", "web_dev", "android_dev", "ios_dev"
|
||||
"run",
|
||||
"web",
|
||||
"android",
|
||||
"ios",
|
||||
"run_dev",
|
||||
"web_dev",
|
||||
"android_dev",
|
||||
"ios_dev",
|
||||
]
|
||||
|
||||
for entry_point in entry_points:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
from ren_browser.announces.announces import Announce
|
||||
|
||||
|
||||
@@ -10,7 +9,7 @@ class TestAnnounce:
|
||||
announce = Announce(
|
||||
destination_hash="1234567890abcdef",
|
||||
display_name="Test Node",
|
||||
timestamp=1234567890
|
||||
timestamp=1234567890,
|
||||
)
|
||||
|
||||
assert announce.destination_hash == "1234567890abcdef"
|
||||
@@ -22,13 +21,14 @@ class TestAnnounce:
|
||||
announce = Announce(
|
||||
destination_hash="1234567890abcdef",
|
||||
display_name=None,
|
||||
timestamp=1234567890
|
||||
timestamp=1234567890,
|
||||
)
|
||||
|
||||
assert announce.destination_hash == "1234567890abcdef"
|
||||
assert announce.display_name is None
|
||||
assert announce.timestamp == 1234567890
|
||||
|
||||
|
||||
class TestAnnounceService:
|
||||
"""Test cases for the AnnounceService class.
|
||||
|
||||
|
||||
@@ -12,32 +12,38 @@ class TestApp:
|
||||
@pytest.mark.asyncio
|
||||
async def test_main_initializes_loader(self, mock_page, mock_rns):
|
||||
"""Test that main function initializes with loading screen."""
|
||||
with patch("ren_browser.ui.ui.build_ui"):
|
||||
with (
|
||||
patch("ren_browser.rns.initialize_reticulum", return_value=True),
|
||||
patch("ren_browser.rns.get_reticulum_instance"),
|
||||
patch("ren_browser.rns.get_config_path", return_value="/tmp/.reticulum"),
|
||||
patch("ren_browser.app.build_ui"),
|
||||
):
|
||||
await app.main(mock_page)
|
||||
|
||||
mock_page.add.assert_called_once()
|
||||
assert mock_page.add.call_count >= 1
|
||||
loader_call = mock_page.add.call_args_list[0][0][0]
|
||||
assert isinstance(loader_call, ft.Container)
|
||||
mock_page.update.assert_called()
|
||||
mock_page.run_thread.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_main_function_structure(self, mock_page, mock_rns):
|
||||
"""Test that main function sets up the expected structure."""
|
||||
with (
|
||||
patch("ren_browser.rns.initialize_reticulum", return_value=True),
|
||||
patch("ren_browser.rns.get_reticulum_instance"),
|
||||
patch("ren_browser.rns.get_config_path"),
|
||||
patch("ren_browser.app.build_ui"),
|
||||
):
|
||||
await app.main(mock_page)
|
||||
|
||||
# Verify that main function adds content and sets up threading
|
||||
mock_page.add.assert_called_once()
|
||||
assert mock_page.add.call_count >= 1
|
||||
loader_call = mock_page.add.call_args_list[0][0][0]
|
||||
assert isinstance(loader_call, ft.Container)
|
||||
mock_page.update.assert_called()
|
||||
mock_page.run_thread.assert_called_once()
|
||||
|
||||
# Verify that a function was passed to run_thread
|
||||
init_function = mock_page.run_thread.call_args[0][0]
|
||||
assert callable(init_function)
|
||||
|
||||
def test_run_with_default_args(self, mock_rns):
|
||||
"""Test run function with default arguments."""
|
||||
with patch("sys.argv", ["ren-browser"]), \
|
||||
patch("flet.app") as mock_ft_app:
|
||||
|
||||
with patch("sys.argv", ["ren-browser"]), patch("flet.app") as mock_ft_app:
|
||||
app.run()
|
||||
|
||||
mock_ft_app.assert_called_once()
|
||||
@@ -46,9 +52,10 @@ class TestApp:
|
||||
|
||||
def test_run_with_web_flag(self, mock_rns):
|
||||
"""Test run function with web flag."""
|
||||
with patch("sys.argv", ["ren-browser", "--web"]), \
|
||||
patch("flet.app") as mock_ft_app:
|
||||
|
||||
with (
|
||||
patch("sys.argv", ["ren-browser", "--web"]),
|
||||
patch("flet.app") as mock_ft_app,
|
||||
):
|
||||
app.run()
|
||||
|
||||
mock_ft_app.assert_called_once()
|
||||
@@ -58,9 +65,10 @@ class TestApp:
|
||||
|
||||
def test_run_with_web_and_port(self, mock_rns):
|
||||
"""Test run function with web flag and custom port."""
|
||||
with patch("sys.argv", ["ren-browser", "--web", "--port", "8080"]), \
|
||||
patch("flet.app") as mock_ft_app:
|
||||
|
||||
with (
|
||||
patch("sys.argv", ["ren-browser", "--web", "--port", "8080"]),
|
||||
patch("flet.app") as mock_ft_app,
|
||||
):
|
||||
app.run()
|
||||
|
||||
mock_ft_app.assert_called_once()
|
||||
@@ -71,9 +79,10 @@ class TestApp:
|
||||
|
||||
def test_run_with_renderer_flag(self, mock_rns):
|
||||
"""Test run function with renderer selection."""
|
||||
with patch("sys.argv", ["ren-browser", "--renderer", "micron"]), \
|
||||
patch("flet.app"):
|
||||
|
||||
with (
|
||||
patch("sys.argv", ["ren-browser", "--renderer", "micron"]),
|
||||
patch("flet.app"),
|
||||
):
|
||||
app.run()
|
||||
|
||||
assert app.RENDERER == "micron"
|
||||
@@ -131,8 +140,10 @@ class TestApp:
|
||||
"""Test that RENDERER global is properly updated."""
|
||||
original_renderer = app.RENDERER
|
||||
|
||||
with patch("sys.argv", ["ren-browser", "--renderer", "micron"]), \
|
||||
patch("flet.app"):
|
||||
with (
|
||||
patch("sys.argv", ["ren-browser", "--renderer", "micron"]),
|
||||
patch("flet.app"),
|
||||
):
|
||||
app.run()
|
||||
assert app.RENDERER == "micron"
|
||||
|
||||
|
||||
@@ -58,7 +58,11 @@ class TestLogsModule:
|
||||
|
||||
assert len(logs.RET_LOGS) == 1
|
||||
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"
|
||||
|
||||
def test_multiple_log_calls(self):
|
||||
|
||||
@@ -8,7 +8,7 @@ class TestPageRequest:
|
||||
"""Test basic PageRequest creation."""
|
||||
request = PageRequest(
|
||||
destination_hash="1234567890abcdef",
|
||||
page_path="/page/index.mu"
|
||||
page_path="/page/index.mu",
|
||||
)
|
||||
|
||||
assert request.destination_hash == "1234567890abcdef"
|
||||
@@ -21,7 +21,7 @@ class TestPageRequest:
|
||||
request = PageRequest(
|
||||
destination_hash="1234567890abcdef",
|
||||
page_path="/page/form.mu",
|
||||
field_data=field_data
|
||||
field_data=field_data,
|
||||
)
|
||||
|
||||
assert request.destination_hash == "1234567890abcdef"
|
||||
@@ -59,7 +59,7 @@ class TestPageFetcher:
|
||||
requests = [
|
||||
PageRequest("hash1", "/index.mu"),
|
||||
PageRequest("hash2", "/about.mu", {"form": "data"}),
|
||||
PageRequest("hash3", "/contact.mu")
|
||||
PageRequest("hash3", "/contact.mu"),
|
||||
]
|
||||
|
||||
# Test that requests have the expected structure
|
||||
|
||||
@@ -63,66 +63,58 @@ class TestMicronRenderer:
|
||||
"""
|
||||
|
||||
def test_render_micron_basic(self):
|
||||
"""Test basic micron rendering (currently displays raw content)."""
|
||||
"""Test basic micron rendering."""
|
||||
content = "# Heading\n\nSome content"
|
||||
result = render_micron(content)
|
||||
|
||||
assert isinstance(result, ft.Text)
|
||||
assert result.value == "# Heading\n\nSome content"
|
||||
assert result.selectable is True
|
||||
assert result.font_family == "monospace"
|
||||
assert isinstance(result, ft.Column)
|
||||
assert result.expand is True
|
||||
assert result.scroll == ft.ScrollMode.AUTO
|
||||
|
||||
def test_render_micron_empty(self):
|
||||
"""Test micron rendering with empty content."""
|
||||
content = ""
|
||||
result = render_micron(content)
|
||||
|
||||
assert isinstance(result, ft.Text)
|
||||
assert result.value == ""
|
||||
assert result.selectable is True
|
||||
assert isinstance(result, ft.Column)
|
||||
assert len(result.controls) >= 0
|
||||
|
||||
def test_render_micron_unicode(self):
|
||||
"""Test micron rendering with Unicode characters."""
|
||||
content = "Unicode content: ä˝ ĺĄ˝ 🌍 αβγ"
|
||||
result = render_micron(content)
|
||||
|
||||
assert isinstance(result, ft.Text)
|
||||
assert result.value == content
|
||||
assert result.selectable is True
|
||||
assert isinstance(result, ft.Column)
|
||||
assert len(result.controls) > 0
|
||||
|
||||
|
||||
class TestRendererComparison:
|
||||
"""Test cases comparing both renderers."""
|
||||
|
||||
def test_renderers_return_same_type(self):
|
||||
"""Test that both renderers return the same control type."""
|
||||
"""Test that both renderers return Flet controls."""
|
||||
content = "Test content"
|
||||
|
||||
plaintext_result = render_plaintext(content)
|
||||
micron_result = render_micron(content)
|
||||
|
||||
assert type(plaintext_result) is type(micron_result)
|
||||
assert isinstance(plaintext_result, ft.Text)
|
||||
assert isinstance(micron_result, ft.Text)
|
||||
assert isinstance(micron_result, ft.Column)
|
||||
|
||||
def test_renderers_preserve_content(self):
|
||||
"""Test that both renderers preserve the original content."""
|
||||
"""Test that plaintext renderer preserves content."""
|
||||
content = "Test content with\nmultiple lines"
|
||||
|
||||
plaintext_result = render_plaintext(content)
|
||||
micron_result = render_micron(content)
|
||||
|
||||
assert plaintext_result.value == content
|
||||
assert micron_result.value == content
|
||||
|
||||
def test_renderers_same_properties(self):
|
||||
"""Test that both renderers set the same basic properties."""
|
||||
"""Test that both renderers have expand property."""
|
||||
content = "Test content"
|
||||
|
||||
plaintext_result = render_plaintext(content)
|
||||
micron_result = render_micron(content)
|
||||
|
||||
assert plaintext_result.selectable == micron_result.selectable
|
||||
assert plaintext_result.font_family == micron_result.font_family
|
||||
assert plaintext_result.expand == micron_result.expand
|
||||
assert plaintext_result.expand is True
|
||||
assert micron_result.expand is True
|
||||
|
||||
@@ -215,7 +215,7 @@ class TestShortcuts:
|
||||
url_field2 = Mock()
|
||||
mock_tab_manager.manager.tabs = [
|
||||
{"url_field": url_field1},
|
||||
{"url_field": url_field2}
|
||||
{"url_field": url_field2},
|
||||
]
|
||||
mock_tab_manager.manager.index = 1 # Second tab
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@ from unittest.mock import Mock, patch
|
||||
|
||||
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:
|
||||
@@ -13,11 +17,13 @@ class TestStorageManager:
|
||||
|
||||
def test_storage_manager_init_without_page(self):
|
||||
"""Test StorageManager initialization without a page."""
|
||||
with patch('ren_browser.storage.storage.StorageManager._get_storage_directory') as mock_get_dir:
|
||||
mock_dir = Path('/mock/storage')
|
||||
with patch(
|
||||
"ren_browser.storage.storage.StorageManager._get_storage_directory",
|
||||
) as mock_get_dir:
|
||||
mock_dir = Path("/mock/storage")
|
||||
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()
|
||||
|
||||
assert storage.page is None
|
||||
@@ -28,11 +34,13 @@ class TestStorageManager:
|
||||
"""Test StorageManager initialization with a page."""
|
||||
mock_page = Mock()
|
||||
|
||||
with patch('ren_browser.storage.storage.StorageManager._get_storage_directory') as mock_get_dir:
|
||||
mock_dir = Path('/mock/storage')
|
||||
with patch(
|
||||
"ren_browser.storage.storage.StorageManager._get_storage_directory",
|
||||
) as mock_get_dir:
|
||||
mock_dir = Path("/mock/storage")
|
||||
mock_get_dir.return_value = mock_dir
|
||||
|
||||
with patch('pathlib.Path.mkdir'):
|
||||
with patch("pathlib.Path.mkdir"):
|
||||
storage = StorageManager(mock_page)
|
||||
|
||||
assert storage.page == mock_page
|
||||
@@ -40,14 +48,21 @@ class TestStorageManager:
|
||||
|
||||
def test_get_storage_directory_desktop(self):
|
||||
"""Test storage directory detection for desktop platforms."""
|
||||
with patch('os.name', 'posix'), \
|
||||
patch.dict('os.environ', {'XDG_CONFIG_HOME': '/home/user/.config'}, clear=True), \
|
||||
patch('pathlib.Path.mkdir'):
|
||||
|
||||
with patch('ren_browser.storage.storage.StorageManager._ensure_storage_directory'):
|
||||
with (
|
||||
patch("os.name", "posix"),
|
||||
patch.dict(
|
||||
"os.environ",
|
||||
{"XDG_CONFIG_HOME": "/home/user/.config"},
|
||||
clear=True,
|
||||
),
|
||||
patch("pathlib.Path.mkdir"),
|
||||
patch(
|
||||
"ren_browser.storage.storage.StorageManager._ensure_storage_directory",
|
||||
),
|
||||
):
|
||||
storage = StorageManager()
|
||||
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
|
||||
|
||||
def test_get_storage_directory_windows(self):
|
||||
@@ -55,16 +70,57 @@ class TestStorageManager:
|
||||
# Skip this test on non-Windows systems to avoid path issues
|
||||
pytest.skip("Windows path test skipped on non-Windows system")
|
||||
|
||||
def test_get_storage_directory_android(self):
|
||||
"""Test storage directory detection for Android."""
|
||||
with patch('os.name', 'posix'), \
|
||||
patch.dict('os.environ', {'ANDROID_ROOT': '/system'}, clear=True), \
|
||||
patch('pathlib.Path.mkdir'):
|
||||
|
||||
with patch('ren_browser.storage.storage.StorageManager._ensure_storage_directory'):
|
||||
def test_get_storage_directory_android_with_android_data(self):
|
||||
"""Test storage directory detection for Android with ANDROID_DATA."""
|
||||
with (
|
||||
patch("os.name", "posix"),
|
||||
patch.dict(
|
||||
"os.environ",
|
||||
{"ANDROID_ROOT": "/system", "ANDROID_DATA": "/data"},
|
||||
clear=True,
|
||||
),
|
||||
patch("pathlib.Path.mkdir"),
|
||||
patch(
|
||||
"ren_browser.storage.storage.StorageManager._ensure_storage_directory",
|
||||
),
|
||||
):
|
||||
storage = StorageManager()
|
||||
storage._storage_dir = storage._get_storage_directory()
|
||||
expected_dir = Path('/data/data/com.ren_browser/files')
|
||||
expected_dir = Path("/data/ren_browser")
|
||||
assert storage._storage_dir == expected_dir
|
||||
|
||||
def test_get_storage_directory_android_with_external_storage(self):
|
||||
"""Test storage directory detection for Android with EXTERNAL_STORAGE."""
|
||||
with (
|
||||
patch("os.name", "posix"),
|
||||
patch.dict(
|
||||
"os.environ",
|
||||
{"ANDROID_ROOT": "/system", "EXTERNAL_STORAGE": "/storage/emulated/0"},
|
||||
clear=True,
|
||||
),
|
||||
patch("pathlib.Path.mkdir"),
|
||||
patch(
|
||||
"ren_browser.storage.storage.StorageManager._ensure_storage_directory",
|
||||
),
|
||||
):
|
||||
storage = StorageManager()
|
||||
storage._storage_dir = storage._get_storage_directory()
|
||||
expected_dir = Path("/storage/emulated/0/ren_browser")
|
||||
assert storage._storage_dir == expected_dir
|
||||
|
||||
def test_get_storage_directory_android_fallback(self):
|
||||
"""Test storage directory detection for Android with fallback."""
|
||||
with (
|
||||
patch("os.name", "posix"),
|
||||
patch.dict("os.environ", {"ANDROID_ROOT": "/system"}, clear=True),
|
||||
patch("pathlib.Path.mkdir"),
|
||||
patch(
|
||||
"ren_browser.storage.storage.StorageManager._ensure_storage_directory",
|
||||
),
|
||||
):
|
||||
storage = StorageManager()
|
||||
storage._storage_dir = storage._get_storage_directory()
|
||||
expected_dir = Path("/data/local/tmp/ren_browser")
|
||||
assert storage._storage_dir == expected_dir
|
||||
|
||||
def test_get_config_path(self):
|
||||
@@ -74,7 +130,7 @@ class TestStorageManager:
|
||||
storage._storage_dir = Path(temp_dir)
|
||||
|
||||
config_path = storage.get_config_path()
|
||||
expected_path = Path(temp_dir) / 'config'
|
||||
expected_path = Path(temp_dir) / "config"
|
||||
assert config_path == expected_path
|
||||
|
||||
def test_get_reticulum_config_path(self):
|
||||
@@ -82,7 +138,7 @@ class TestStorageManager:
|
||||
storage = StorageManager()
|
||||
|
||||
config_path = storage.get_reticulum_config_path()
|
||||
expected_path = Path.home() / '.reticulum'
|
||||
expected_path = Path.home() / ".reticulum"
|
||||
assert config_path == expected_path
|
||||
|
||||
def test_save_config_success(self):
|
||||
@@ -92,14 +148,18 @@ class TestStorageManager:
|
||||
storage._storage_dir = Path(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"
|
||||
result = storage.save_config(config_content)
|
||||
|
||||
assert result is True
|
||||
config_path = storage.get_config_path()
|
||||
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):
|
||||
"""Test config saving with client storage."""
|
||||
@@ -111,12 +171,19 @@ class TestStorageManager:
|
||||
storage._storage_dir = Path(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"
|
||||
result = storage.save_config(config_content)
|
||||
|
||||
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):
|
||||
"""Test config saving fallback when file system fails."""
|
||||
@@ -128,14 +195,26 @@ class TestStorageManager:
|
||||
storage._storage_dir = Path(temp_dir)
|
||||
|
||||
# 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('pathlib.Path.write_text', side_effect=PermissionError("Access denied")):
|
||||
with (
|
||||
patch.object(
|
||||
storage,
|
||||
"get_reticulum_config_path",
|
||||
return_value=Path(temp_dir) / "reticulum",
|
||||
),
|
||||
patch(
|
||||
"pathlib.Path.write_text",
|
||||
side_effect=PermissionError("Access denied"),
|
||||
),
|
||||
):
|
||||
config_content = "test config content"
|
||||
result = storage.save_config(config_content)
|
||||
|
||||
assert result is True
|
||||
# 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
|
||||
assert mock_page.client_storage.set.call_count >= 1
|
||||
|
||||
@@ -146,10 +225,14 @@ class TestStorageManager:
|
||||
storage._storage_dir = Path(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_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()
|
||||
assert loaded_config == config_content
|
||||
@@ -164,10 +247,14 @@ class TestStorageManager:
|
||||
storage._storage_dir = Path(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()
|
||||
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):
|
||||
"""Test loading default config when no config exists."""
|
||||
@@ -176,7 +263,11 @@ class TestStorageManager:
|
||||
storage._storage_dir = Path(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()
|
||||
assert loaded_config == ""
|
||||
|
||||
@@ -190,10 +281,10 @@ class TestStorageManager:
|
||||
result = storage.save_bookmarks(bookmarks)
|
||||
|
||||
assert result is True
|
||||
bookmarks_path = storage._storage_dir / 'bookmarks.json'
|
||||
bookmarks_path = storage._storage_dir / "bookmarks.json"
|
||||
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)
|
||||
assert loaded_bookmarks == bookmarks
|
||||
|
||||
@@ -204,9 +295,9 @@ class TestStorageManager:
|
||||
storage._storage_dir = Path(temp_dir)
|
||||
|
||||
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)
|
||||
|
||||
loaded_bookmarks = storage.load_bookmarks()
|
||||
@@ -231,10 +322,10 @@ class TestStorageManager:
|
||||
result = storage.save_history(history)
|
||||
|
||||
assert result is True
|
||||
history_path = storage._storage_dir / 'history.json'
|
||||
history_path = storage._storage_dir / "history.json"
|
||||
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)
|
||||
assert loaded_history == history
|
||||
|
||||
@@ -245,9 +336,9 @@ class TestStorageManager:
|
||||
storage._storage_dir = Path(temp_dir)
|
||||
|
||||
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)
|
||||
|
||||
loaded_history = storage.load_history()
|
||||
@@ -264,27 +355,32 @@ class TestStorageManager:
|
||||
|
||||
info = storage.get_storage_info()
|
||||
|
||||
assert 'storage_dir' in info
|
||||
assert 'config_path' in info
|
||||
assert 'reticulum_config_path' in info
|
||||
assert 'storage_dir_exists' in info
|
||||
assert 'storage_dir_writable' in info
|
||||
assert 'has_client_storage' in info
|
||||
assert "storage_dir" in info
|
||||
assert "config_path" in info
|
||||
assert "reticulum_config_path" in info
|
||||
assert "storage_dir_exists" in info
|
||||
assert "storage_dir_writable" in info
|
||||
assert "has_client_storage" in info
|
||||
|
||||
assert info['storage_dir'] == str(Path(temp_dir))
|
||||
assert info['storage_dir_exists'] is True
|
||||
assert info['has_client_storage'] is True
|
||||
assert info["storage_dir"] == str(Path(temp_dir))
|
||||
assert info["storage_dir_exists"] is True
|
||||
assert info["has_client_storage"] is True
|
||||
|
||||
def test_storage_directory_fallback(self):
|
||||
"""Test fallback to temp directory when storage creation fails."""
|
||||
with patch.object(StorageManager, '_get_storage_directory') as mock_get_dir:
|
||||
mock_get_dir.return_value = Path('/nonexistent/path')
|
||||
with patch.object(StorageManager, "_get_storage_directory") as mock_get_dir:
|
||||
mock_get_dir.return_value = Path("/nonexistent/path")
|
||||
|
||||
with patch('pathlib.Path.mkdir', side_effect=[PermissionError("Access denied"), None]):
|
||||
with patch('tempfile.gettempdir', return_value='/tmp'):
|
||||
with (
|
||||
patch(
|
||||
"pathlib.Path.mkdir",
|
||||
side_effect=[PermissionError("Access denied"), None],
|
||||
),
|
||||
patch("tempfile.gettempdir", return_value="/tmp"),
|
||||
):
|
||||
storage = StorageManager()
|
||||
|
||||
expected_fallback = Path('/tmp') / 'ren_browser'
|
||||
expected_fallback = Path("/tmp") / "ren_browser"
|
||||
assert storage._storage_dir == expected_fallback
|
||||
|
||||
|
||||
@@ -293,7 +389,7 @@ class TestStorageGlobalFunctions:
|
||||
|
||||
def test_get_storage_manager_singleton(self):
|
||||
"""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()
|
||||
storage2 = get_storage_manager()
|
||||
|
||||
@@ -303,7 +399,7 @@ class TestStorageGlobalFunctions:
|
||||
"""Test get_storage_manager with page parameter."""
|
||||
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)
|
||||
|
||||
assert storage.page == mock_page
|
||||
@@ -312,7 +408,7 @@ class TestStorageGlobalFunctions:
|
||||
"""Test initialize_storage function."""
|
||||
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)
|
||||
|
||||
assert storage.page == mock_page
|
||||
@@ -329,9 +425,16 @@ class TestStorageManagerEdgeCases:
|
||||
storage._storage_dir = Path(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
|
||||
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")
|
||||
# Should still succeed due to fallback
|
||||
assert result is False
|
||||
@@ -344,10 +447,14 @@ class TestStorageManagerEdgeCases:
|
||||
|
||||
# Create a config file with invalid encoding
|
||||
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
|
||||
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
|
||||
config = storage.load_config()
|
||||
assert config == ""
|
||||
@@ -356,8 +463,11 @@ class TestStorageManagerEdgeCases:
|
||||
"""Test _is_writable when permission is denied."""
|
||||
storage = StorageManager()
|
||||
|
||||
with patch('pathlib.Path.write_text', side_effect=PermissionError("Access denied")):
|
||||
test_path = Path('/mock/path')
|
||||
with patch(
|
||||
"pathlib.Path.write_text",
|
||||
side_effect=PermissionError("Access denied"),
|
||||
):
|
||||
test_path = Path("/mock/path")
|
||||
result = storage._is_writable(test_path)
|
||||
assert result is False
|
||||
|
||||
|
||||
@@ -13,17 +13,20 @@ class TestTabsManager:
|
||||
@pytest.fixture
|
||||
def tabs_manager(self, mock_page):
|
||||
"""Create a TabsManager instance for testing."""
|
||||
with patch("ren_browser.app.RENDERER", "plaintext"), \
|
||||
patch("ren_browser.renderer.plaintext.render_plaintext") as mock_render:
|
||||
|
||||
mock_page.width = 800 # Simulate page width for adaptive logic
|
||||
with (
|
||||
patch("ren_browser.app.RENDERER", "plaintext"),
|
||||
patch("ren_browser.renderer.plaintext.render_plaintext") as mock_render,
|
||||
):
|
||||
mock_render.return_value = Mock(spec=ft.Text)
|
||||
return TabsManager(mock_page)
|
||||
|
||||
def test_tabs_manager_init(self, mock_page):
|
||||
"""Test TabsManager initialization."""
|
||||
with patch("ren_browser.app.RENDERER", "plaintext"), \
|
||||
patch("ren_browser.renderer.plaintext.render_plaintext") as mock_render:
|
||||
|
||||
with (
|
||||
patch("ren_browser.app.RENDERER", "plaintext"),
|
||||
patch("ren_browser.renderer.plaintext.render_plaintext") as mock_render,
|
||||
):
|
||||
mock_render.return_value = Mock(spec=ft.Text)
|
||||
manager = TabsManager(mock_page)
|
||||
|
||||
@@ -31,7 +34,9 @@ class TestTabsManager:
|
||||
assert isinstance(manager.manager, SimpleNamespace)
|
||||
assert len(manager.manager.tabs) == 1
|
||||
assert manager.manager.index == 0
|
||||
assert isinstance(manager.tab_bar, ft.Row)
|
||||
assert isinstance(manager.tab_bar, ft.Container)
|
||||
assert isinstance(manager.tab_bar.content, ft.Row)
|
||||
assert manager.overflow_menu is None
|
||||
assert isinstance(manager.content_container, ft.Container)
|
||||
|
||||
def test_tabs_manager_init_micron_renderer(self, mock_page):
|
||||
@@ -55,9 +60,10 @@ class TestTabsManager:
|
||||
|
||||
def test_on_add_click(self, tabs_manager):
|
||||
"""Test adding a new tab via button click."""
|
||||
with patch("ren_browser.app.RENDERER", "plaintext"), \
|
||||
patch("ren_browser.renderer.plaintext.render_plaintext") as mock_render:
|
||||
|
||||
with (
|
||||
patch("ren_browser.app.RENDERER", "plaintext"),
|
||||
patch("ren_browser.renderer.plaintext.render_plaintext") as mock_render,
|
||||
):
|
||||
mock_render.return_value = Mock(spec=ft.Text)
|
||||
initial_count = len(tabs_manager.manager.tabs)
|
||||
|
||||
@@ -99,12 +105,14 @@ class TestTabsManager:
|
||||
"""Test that selecting a tab updates background colors correctly."""
|
||||
tabs_manager._add_tab_internal("Tab 2", Mock())
|
||||
|
||||
tab_controls = tabs_manager.tab_bar.controls[:-2] # Exclude add/close buttons
|
||||
tab_controls = tabs_manager.tab_bar.content.controls[
|
||||
:-2
|
||||
] # Exclude add/close buttons
|
||||
|
||||
tabs_manager.select_tab(1)
|
||||
|
||||
assert tab_controls[0].bgcolor == ft.Colors.SURFACE_CONTAINER_HIGHEST
|
||||
assert tab_controls[1].bgcolor == ft.Colors.PRIMARY_CONTAINER
|
||||
assert tab_controls[0].bgcolor == ft.Colors.GREY_800
|
||||
assert tab_controls[1].bgcolor == ft.Colors.BLUE_900
|
||||
|
||||
def test_on_tab_go_empty_url(self, tabs_manager):
|
||||
"""Test tab go with empty URL."""
|
||||
@@ -140,14 +148,14 @@ class TestTabsManager:
|
||||
def test_tab_container_properties(self, tabs_manager):
|
||||
"""Test that tab container has correct properties."""
|
||||
assert tabs_manager.content_container.expand is True
|
||||
assert tabs_manager.content_container.bgcolor == ft.Colors.BLACK
|
||||
assert tabs_manager.content_container.padding == ft.padding.all(5)
|
||||
assert tabs_manager.content_container.bgcolor in (ft.Colors.BLACK, "#000000")
|
||||
assert tabs_manager.content_container.padding == ft.padding.all(16)
|
||||
|
||||
def test_tab_bar_controls(self, tabs_manager):
|
||||
"""Test that tab bar has correct controls."""
|
||||
controls = tabs_manager.tab_bar.controls
|
||||
controls = tabs_manager.tab_bar.content.controls
|
||||
|
||||
# Should have: home tab, add button, close button
|
||||
# Should have: home tab, add button, close button (and potentially overflow menu)
|
||||
assert len(controls) >= 3
|
||||
assert isinstance(controls[-2], ft.IconButton) # Add button
|
||||
assert isinstance(controls[-1], ft.IconButton) # Close button
|
||||
@@ -174,7 +182,7 @@ class TestTabsManager:
|
||||
url_field = tab["url_field"]
|
||||
|
||||
assert url_field.expand is True
|
||||
assert url_field.text_style.size == 12
|
||||
assert url_field.text_style.size == 14
|
||||
assert url_field.content_padding is not None
|
||||
|
||||
def test_go_button_properties(self, tabs_manager):
|
||||
@@ -182,14 +190,16 @@ class TestTabsManager:
|
||||
tab = tabs_manager.manager.tabs[0]
|
||||
go_btn = tab["go_btn"]
|
||||
|
||||
assert go_btn.icon == ft.Icons.OPEN_IN_BROWSER
|
||||
assert go_btn.tooltip == "Load URL"
|
||||
assert go_btn.icon == ft.Icons.ARROW_FORWARD
|
||||
assert go_btn.tooltip == "Go"
|
||||
|
||||
def test_tab_click_handlers(self, tabs_manager):
|
||||
"""Test that tab click handlers are properly set."""
|
||||
tabs_manager._add_tab_internal("Tab 2", Mock())
|
||||
|
||||
tab_controls = tabs_manager.tab_bar.controls[:-2] # Exclude add/close buttons
|
||||
tab_controls = tabs_manager.tab_bar.content.controls[
|
||||
:-2
|
||||
] # Exclude add/close buttons
|
||||
|
||||
for i, control in enumerate(tab_controls):
|
||||
assert control.on_click is not None
|
||||
@@ -220,7 +230,44 @@ class TestTabsManager:
|
||||
tabs_manager._add_tab_internal("Tab 3", content2)
|
||||
|
||||
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)
|
||||
assert tabs_manager.content_container.content == tabs_manager.manager.tabs[2]["content"]
|
||||
assert (
|
||||
tabs_manager.content_container.content
|
||||
== tabs_manager.manager.tabs[2]["content"]
|
||||
)
|
||||
|
||||
def test_adaptive_overflow_behavior(self, tabs_manager):
|
||||
"""Test that the overflow menu adapts to tab changes."""
|
||||
# With page width at 800, add enough tabs that some should overflow.
|
||||
for i in range(10): # Total 11 tabs
|
||||
tabs_manager._add_tab_internal(f"Tab {i + 2}", Mock())
|
||||
|
||||
# Check that an overflow menu exists
|
||||
assert tabs_manager.overflow_menu is not None
|
||||
|
||||
# Simulate a smaller screen, expecting more tabs to overflow
|
||||
tabs_manager.page.width = 400
|
||||
tabs_manager._update_tab_visibility()
|
||||
visible_tabs_small = sum(
|
||||
1
|
||||
for c in tabs_manager.tab_bar.content.controls
|
||||
if isinstance(c, ft.Container) and c.visible
|
||||
)
|
||||
assert visible_tabs_small < 11
|
||||
|
||||
# Simulate a larger screen, expecting all tabs to be visible
|
||||
tabs_manager.page.width = 1600
|
||||
tabs_manager._update_tab_visibility()
|
||||
visible_tabs_large = sum(
|
||||
1
|
||||
for c in tabs_manager.tab_bar.content.controls
|
||||
if isinstance(c, ft.Container) and c.visible
|
||||
)
|
||||
|
||||
assert visible_tabs_large == 11
|
||||
assert tabs_manager.overflow_menu is None
|
||||
|
||||
@@ -28,7 +28,14 @@ class TestBuildUI:
|
||||
@patch("ren_browser.pages.page_request.PageFetcher")
|
||||
@patch("ren_browser.tabs.tabs.TabsManager")
|
||||
@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."""
|
||||
mock_tab_manager = Mock()
|
||||
mock_tabs.return_value = mock_tab_manager
|
||||
@@ -48,7 +55,14 @@ class TestBuildUI:
|
||||
@patch("ren_browser.pages.page_request.PageFetcher")
|
||||
@patch("ren_browser.tabs.tabs.TabsManager")
|
||||
@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."""
|
||||
mock_tab_manager = Mock()
|
||||
mock_tabs.return_value = mock_tab_manager
|
||||
@@ -79,28 +93,50 @@ class TestBuildUI:
|
||||
class TestOpenSettingsTab:
|
||||
"""Test cases for the open_settings_tab function."""
|
||||
|
||||
def test_open_settings_tab_basic(self, mock_page):
|
||||
def test_open_settings_tab_basic(self, mock_page, mock_storage_manager):
|
||||
"""Test opening settings tab with basic functionality."""
|
||||
mock_tab_manager = Mock()
|
||||
mock_tab_manager.manager.tabs = []
|
||||
mock_tab_manager._add_tab_internal = Mock()
|
||||
mock_tab_manager.select_tab = Mock()
|
||||
|
||||
with patch("pathlib.Path.read_text", return_value="config content"):
|
||||
mock_page.overlay = []
|
||||
|
||||
with (
|
||||
patch(
|
||||
"ren_browser.ui.settings.get_storage_manager",
|
||||
return_value=mock_storage_manager,
|
||||
),
|
||||
patch(
|
||||
"ren_browser.ui.settings.rns.get_config_path", return_value="/tmp/rns",
|
||||
),
|
||||
patch("pathlib.Path.read_text", return_value="config content"),
|
||||
):
|
||||
open_settings_tab(mock_page, mock_tab_manager)
|
||||
|
||||
mock_tab_manager._add_tab_internal.assert_called_once()
|
||||
mock_tab_manager.select_tab.assert_called_once()
|
||||
mock_page.update.assert_called()
|
||||
|
||||
def test_open_settings_tab_config_error(self, mock_page):
|
||||
def test_open_settings_tab_config_error(self, mock_page, mock_storage_manager):
|
||||
"""Test opening settings tab when config file cannot be read."""
|
||||
mock_tab_manager = Mock()
|
||||
mock_tab_manager.manager.tabs = []
|
||||
mock_tab_manager._add_tab_internal = Mock()
|
||||
mock_tab_manager.select_tab = Mock()
|
||||
|
||||
with patch("pathlib.Path.read_text", side_effect=Exception("File not found")):
|
||||
mock_page.overlay = []
|
||||
|
||||
with (
|
||||
patch(
|
||||
"ren_browser.ui.settings.get_storage_manager",
|
||||
return_value=mock_storage_manager,
|
||||
),
|
||||
patch(
|
||||
"ren_browser.ui.settings.rns.get_config_path", return_value="/tmp/rns",
|
||||
),
|
||||
patch("pathlib.Path.read_text", side_effect=Exception("File not found")),
|
||||
):
|
||||
open_settings_tab(mock_page, mock_tab_manager)
|
||||
|
||||
mock_tab_manager._add_tab_internal.assert_called_once()
|
||||
@@ -109,58 +145,110 @@ class TestOpenSettingsTab:
|
||||
args = mock_tab_manager._add_tab_internal.call_args
|
||||
assert args[0][0] == "Settings"
|
||||
|
||||
def test_settings_save_config_success(self, mock_page):
|
||||
def test_settings_save_config_success(self, mock_page, mock_storage_manager):
|
||||
"""Test saving config successfully in settings."""
|
||||
mock_tab_manager = Mock()
|
||||
mock_tab_manager.manager.tabs = []
|
||||
mock_tab_manager._add_tab_internal = Mock()
|
||||
mock_tab_manager.select_tab = Mock()
|
||||
|
||||
with patch("pathlib.Path.read_text", return_value="config"), \
|
||||
patch("pathlib.Path.write_text"):
|
||||
mock_page.overlay = []
|
||||
|
||||
with (
|
||||
patch(
|
||||
"ren_browser.ui.settings.get_storage_manager",
|
||||
return_value=mock_storage_manager,
|
||||
),
|
||||
patch(
|
||||
"ren_browser.ui.settings.rns.get_config_path", return_value="/tmp/rns",
|
||||
),
|
||||
patch("pathlib.Path.read_text", return_value="config"),
|
||||
patch("pathlib.Path.write_text") as mock_write,
|
||||
):
|
||||
open_settings_tab(mock_page, mock_tab_manager)
|
||||
|
||||
# Get the settings content that was added
|
||||
settings_content = mock_tab_manager._add_tab_internal.call_args[0][1]
|
||||
|
||||
# Find the save button and simulate click
|
||||
# Find the save button - now nested in action_row container
|
||||
save_btn = None
|
||||
for control in settings_content.controls:
|
||||
if hasattr(control, "controls"):
|
||||
for sub_control in control.controls:
|
||||
if hasattr(sub_control, "text") and sub_control.text == "Save and Restart":
|
||||
if hasattr(control, "content") and hasattr(control.content, "controls"):
|
||||
for sub_control in control.content.controls:
|
||||
if (
|
||||
hasattr(sub_control, "text")
|
||||
and sub_control.text == "Save Configuration"
|
||||
):
|
||||
save_btn = sub_control
|
||||
break
|
||||
|
||||
assert save_btn is not None
|
||||
save_btn.on_click(None)
|
||||
assert mock_write.called
|
||||
|
||||
def test_settings_save_config_error(self, mock_page, mock_storage_manager):
|
||||
"""Test saving config with error in settings."""
|
||||
"""Test saving config error path does not crash."""
|
||||
mock_tab_manager = Mock()
|
||||
mock_tab_manager.manager.tabs = []
|
||||
mock_tab_manager._add_tab_internal = Mock()
|
||||
mock_tab_manager.select_tab = Mock()
|
||||
|
||||
with patch('ren_browser.ui.settings.get_storage_manager', return_value=mock_storage_manager):
|
||||
mock_page.overlay = []
|
||||
|
||||
with (
|
||||
patch(
|
||||
"ren_browser.ui.settings.get_storage_manager",
|
||||
return_value=mock_storage_manager,
|
||||
),
|
||||
patch(
|
||||
"ren_browser.ui.settings.rns.get_config_path", return_value="/tmp/rns",
|
||||
),
|
||||
patch("pathlib.Path.read_text", return_value="config"),
|
||||
patch("pathlib.Path.write_text", side_effect=Exception("disk full")),
|
||||
):
|
||||
open_settings_tab(mock_page, mock_tab_manager)
|
||||
|
||||
settings_content = mock_tab_manager._add_tab_internal.call_args[0][1]
|
||||
assert settings_content is not None
|
||||
save_btn = None
|
||||
for control in settings_content.controls:
|
||||
if hasattr(control, "content") and hasattr(control.content, "controls"):
|
||||
for sub_control in control.content.controls:
|
||||
if (
|
||||
hasattr(sub_control, "text")
|
||||
and sub_control.text == "Save Configuration"
|
||||
):
|
||||
save_btn = sub_control
|
||||
break
|
||||
assert save_btn is not None
|
||||
# Should not raise despite write failure
|
||||
save_btn.on_click(None)
|
||||
|
||||
def test_settings_log_sections(self, mock_page, mock_storage_manager):
|
||||
"""Test that settings includes error logs and RNS logs sections."""
|
||||
def test_settings_status_section_present(self, mock_page, mock_storage_manager):
|
||||
"""Ensure the status navigation button is present."""
|
||||
mock_tab_manager = Mock()
|
||||
mock_tab_manager.manager.tabs = []
|
||||
mock_tab_manager._add_tab_internal = Mock()
|
||||
mock_tab_manager.select_tab = Mock()
|
||||
|
||||
with patch('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"]):
|
||||
mock_page.overlay = []
|
||||
|
||||
with (
|
||||
patch(
|
||||
"ren_browser.ui.settings.get_storage_manager",
|
||||
return_value=mock_storage_manager,
|
||||
),
|
||||
patch(
|
||||
"ren_browser.ui.settings.rns.get_config_path", return_value="/tmp/rns",
|
||||
),
|
||||
patch("pathlib.Path.read_text", return_value="config"),
|
||||
):
|
||||
open_settings_tab(mock_page, mock_tab_manager)
|
||||
|
||||
mock_tab_manager._add_tab_internal.assert_called_once()
|
||||
args = mock_tab_manager._add_tab_internal.call_args
|
||||
assert args[0][0] == "Settings"
|
||||
settings_content = mock_tab_manager._add_tab_internal.call_args[0][1]
|
||||
nav_container = settings_content.controls[1]
|
||||
button_labels = [
|
||||
ctrl.text
|
||||
for ctrl in nav_container.content.controls
|
||||
if hasattr(ctrl, "text")
|
||||
]
|
||||
assert "Status" in button_labels
|
||||
|
||||
Reference in New Issue
Block a user