106 Commits

Author SHA1 Message Date
859c15653c Update README to change repository URLs from GitHub to Gitea
Some checks failed
Safety / security (push) Failing after 17s
Run Tests / test (3.13) (push) Successful in 1m32s
2025-12-21 21:29:07 -06:00
b063c0cbec Update README 2025-12-21 21:26:16 -06:00
3bb64d0533 Update workflow to use older artifact upload and download actions for Gitea support 2025-12-21 21:22:55 -06:00
64fa46f60f Remove Codecov upload step from Gitea workflow for Python 3.13 2025-12-21 21:20:29 -06:00
e05a94e152 move from .github to .gitea 2025-12-21 21:18:51 -06:00
b5ad57ad8f Update LICENSE 2025-12-21 21:16:06 -06:00
3c149cdd0c Update CONTRIBUTING 2025-12-21 21:15:59 -06:00
a1480a5c1b Improve logging and error handling across modules
- Added logging functionality to app.py and rns.py for better error tracking.
- Improved exception handling in RNSManager methods to log specific failures.
- Refactored code in various modules to ensure consistent logging practices.
- Updated UI components to handle exceptions with user feedback.
- Cleaned up formatting in several files for better readability.
2025-11-30 15:55:30 -06:00
1e39fe277e Add 'run' target to Makefile for launching Ren Browser
- Introduced a new 'run' target to the Makefile to start the Ren Browser using Poetry.
- Updated help output to include the new 'run' command for user guidance.
2025-11-30 15:21:28 -06:00
d8de2b1150 Improve RNS management and settings interface in Ren Browser
- Introduced a new rns.py module to encapsulate Reticulum lifecycle management.
- Simplified RNS initialization and error handling in app.py.
- Enhanced settings.py to improve configuration management and user feedback.
- Updated UI components for better interaction and status display.
- Added tests for settings functionality and RNS integration.
2025-11-30 15:21:18 -06:00
d1536aa05a Fix RNS initialization and reload logic in app.py
- Introduced a dedicated storage initialization process for Reticulum.
- More error handling and logging during RNS initialization and reload.
- Updated the reload_reticulum function to support asynchronous execution.
- Modified settings.py to handle reload operations with improved user feedback.
2025-11-30 14:59:02 -06:00
1882325224 Add Android SDK configuration and permissions in pyproject.toml
- Set minimum SDK version to 21 and target SDK version to 34.
- Added necessary permissions for internet access and foreground services.
2025-11-29 10:23:31 -06:00
cce6471534 Update RNS initialization logging in app.py 2025-11-20 17:32:26 -06:00
8bd46f50f3 Update 2025-11-16 07:20:07 -06:00
1e9b53934f Fix windows build 2025-11-16 07:17:50 -06:00
c2921876f7 Update build configuration and dependencies
- Adjusted Python version requirement from 3.13 to 3.11 in pyproject.toml, poetry.lock, and uv.lock.
- Modified Android APK build command in Makefile and GitHub Actions workflow to include package cleanup and exclude the watchdog.
- Updated dependencies in poetry.lock to reflect changes in Python version compatibility.
2025-11-16 01:31:33 -06:00
8d723a8944 Update README 2025-11-16 01:15:08 -06:00
f3d0da08a2 Update GitHub Actions workflow for Android build 2025-11-16 01:04:25 -06:00
5571b810ae Add Windows build support 2025-11-16 00:59:58 -06:00
df72547bde drop docker support 2025-11-16 00:56:22 -06:00
5ec677437e ruff fixes and formatting 2025-11-16 00:46:42 -06:00
3cddaeb2b9 Update:
1. Add basic Micron parser and link support
2. Improve styling/layout
3. Add hot reloading for RNS
2025-11-16 00:34:51 -06:00
e36bfec4a0 Fix Android storage directory detection in StorageManager
- Updated logic to determine storage directory based on ANDROID_DATA and EXTERNAL_STORAGE environment variables.
- Added unit tests to cover new storage directory detection scenarios for Android, including fallback options.
2025-11-15 23:38:39 -06:00
0aaa7938e6 update 2025-11-13 10:04:57 -06:00
379f85c792 add compose 2025-11-12 19:21:02 -06:00
bfbfb22312 move dockerfile 2025-11-12 19:20:58 -06:00
5f9d7784a8 Add docker compose commands 2025-11-12 19:20:47 -06:00
9c0564d253 Update GitHub Actions workflows to use 'master' branch 2025-11-12 19:09:19 -06:00
5da3be18cb Update dependencies in poetry.lock and pyproject.toml
- Added new packages: annotated-doc (0.0.4), annotated-types (0.7.0), arrow (1.4.0), binaryornot (0.4.4), chardet (5.2.0), charset-normalizer (3.4.4), and websockets (15.0.1).
- Updated rns dependency version to (>=1.0.2,<1.5.0) in both pyproject.toml and uv.lock.
- Updated content hash in poetry.lock for consistency.
2025-11-12 19:07:22 -06:00
9eb9aafd35 Update README.md
- Add Poetry instructions back
2025-11-12 18:58:41 -06:00
263e5a92bf Merge pull request 'Tab-Overhaul' (#2) from Tab-Overhaul into master
Reviewed-on: Ivan/Ren-Browser#2
2025-11-03 17:40:41 +00:00
6b5d476d74 Merge branch 'master' into Tab-Overhaul 2025-11-03 17:40:22 +00:00
5d640032ee Update 2025-11-03 11:31:29 -06:00
0d2d595867 Merge pull request 'support-uv' (#1) from support-uv into master
Reviewed-on: Ivan/Ren-Browser#1
2025-11-03 17:30:59 +00:00
0b531bba54 Update 2025-11-03 11:27:05 -06:00
3809ac8274 Update dependencies and build system configuration 2025-11-03 11:22:33 -06:00
a32a542c54 Improve TabsManager for adaptive tab visibility and overflow handling
- Updated the constructor to set the resize event handler for dynamic tab visibility.
- Refactored tab visibility logic to adjust based on available page width, moving excess tabs to an overflow menu.
- Enhanced tab addition and removal methods to ensure proper overflow management.
- Updated unit tests to verify adaptive behavior of the overflow menu based on page width changes.
2025-09-28 20:26:03 -05:00
e77faa5105 Update TabsManager with overflow menu functionality
- Added support for an overflow menu in the tab bar when the number of tabs exceeds the maximum visible limit.
- Updated tab bar initialization to include scroll mode and maximum visible tabs.
- Refactored tab addition logic to ensure proper overflow handling.
2025-09-28 19:51:26 -05:00
c0f60d52db Update storage path in tests to reflect user-accessible external storage and change button label to "Save Config". 2025-09-28 15:51:05 -05:00
57c6b8ce3d Update CONTRIBUTING.md 2025-09-28 15:45:39 -05:00
9fc912fba4 Add config saving feedback in settings.py with print statements and update button label to "Save Config". 2025-09-28 15:38:25 -05:00
0d878e8491 Update Android storage path to use user-accessible external storage instead of the app's private files directory. 2025-09-28 15:38:05 -05:00
2796059aef Update 2025-09-28 15:37:50 -05:00
a5ae444b6c Update 2025-09-28 15:28:36 -05:00
d354b96334 Update 2025-09-25 12:15:35 -05:00
f511b60361 remove 2025-09-25 12:15:27 -05:00
b8386a60c6 Update GitHub workflows to use latest action versions for Python, Docker, Safety CLI, and caching 2025-09-22 19:02:28 -05:00
e20d6fe214 Update Dockerfile 2025-09-22 15:40:38 -05:00
8b45e5d72b add Makefile 2025-09-22 15:37:00 -05:00
047169f3af Remove development-specific entries from pyproject.toml to streamline configuration. 2025-09-22 15:31:16 -05:00
e0939e70f8 Add config file writing logic to ensure saved configurations are persisted before RNS initialization. Includes error handling for potential write failures. 2025-09-22 15:25:11 -05:00
63e93d0cff Add reticulum default config file 2025-09-22 15:20:44 -05:00
926b3a198d update hashes 2025-09-22 14:40:01 -05:00
8db441612f Bump version to 0.2.2
- Updated build workflow to fix fetching artifacts
- Ruff formatting and fixes
- Fix config path handling for android
- Codebase performance and bug risk fixes (Deepsource)
2025-09-22 14:13:30 -05:00
b34b8f23ff Fix log setup condition in setup_rns_logging to use 'is not' for comparison 2025-09-22 14:10:02 -05:00
13ad0bcef6 Fix config path handling in StorageManager for Android 2025-09-22 14:01:24 -05:00
64b9ac3df4 ruff formatting and fixes 2025-09-22 13:56:04 -05:00
ee521a9f60 ruff formatting and fixes 2025-09-22 13:55:52 -05:00
fd4e0c8a14 Update build workflow 2025-09-22 13:48:43 -05:00
b056271da7 Bump version to 0.2.1
- Fix broken APK and Linux builds due to webview_flutter_android compatibility issue
- Add manual workflow trigger for easier testing and deployment
- Add automated draft release creation with build artifacts
- Override webview_flutter_android to v4.10.1 for Dart 3.7 compatibility
2025-09-20 16:31:55 -05:00
189256edd7 Update README 2025-09-20 16:25:09 -05:00
62d3502f99 Add create-release job to GitHub Actions workflow for automated draft releases with Linux and APK artifacts 2025-09-20 16:24:59 -05:00
cb218f2b29 add manual trigger 2025-09-20 16:14:11 -05:00
871f626555 possible fix for broken apk/linux builds (flutter) 2025-09-20 16:13:59 -05:00
9a20152a70 update README 2025-09-20 16:08:22 -05:00
52163c4d6d Bump version to 0.2.0 2025-09-20 15:41:37 -05:00
2ce356e750 fix 2025-09-20 15:18:00 -05:00
ab3ea64ecf add full-length commit hashes for actions. 2025-09-20 15:11:53 -05:00
5b5c2a3d2c Add Security.md 2025-09-20 15:09:43 -05:00
aabbd510ed Add Safety workflow for vulnerability checks 2025-09-20 15:05:49 -05:00
93530387a4 Remove pull_request trigger from Docker workflow 2025-09-20 15:05:41 -05:00
ea09d520aa Update 2025-09-20 15:04:18 -05:00
e9ecef79e5 Add Contributing.md 2025-09-20 15:04:12 -05:00
be40fc9eac Add @staticmethod to improve performance. 2025-09-20 14:51:10 -05:00
fc5396f91d move exclude_patterns to top level 2025-09-20 14:28:55 -05:00
4754fed238 ignore tests 2025-09-20 14:26:55 -05:00
9eb85e45b9 Fix mocking reticulum config file 2025-09-20 14:25:58 -05:00
ce8ece45a3 add workflow timeout 2025-09-20 14:21:59 -05:00
7c8e8e41cb Fix RNS initialization and logging setup
- Moved RNS initialization and logging setup to the main application to ensure proper logging capture before RNS is used.
- Updated AnnounceService and PageFetcher to remove redundant RNS initialization, assuming it is handled in the main app.
- Enhanced settings interface with a refresh button for better user experience and updated error and Reticulum log displays.
2025-09-20 14:17:41 -05:00
66bcf0d25c add test workflow 2025-09-20 14:09:24 -05:00
ed9b487d62 Update README 2025-09-20 14:06:17 -05:00
d30456096e Refactor config path handling in TestStorageManager
- Updated expected config path to use 'config' instead of 'config.txt'.
- Simplified test for Reticulum config path to return the home directory path.
- Mocked Reticulum config path in multiple tests to ensure consistent behavior when loading configurations.
- Adjusted assertions to reflect changes in expected output for loading configurations.
2025-09-20 14:06:01 -05:00
03e2ac9c89 Update to not create default config as RNS does that (should), also update to config name. 2025-09-20 14:05:22 -05:00
6c0c89969f Add docstrings for the main UI construction in ui.py
- Enhanced documentation for the main UI build function and overall module.
- Improved clarity on function parameters and purpose, aiding future development and maintenance.
2025-09-20 13:57:04 -05:00
408a5a3423 Add storage management and enhance settings interface in settings.py
- Integrated storage management for loading and saving configuration.
- Added storage information display for debugging purposes.
- Improved docstrings for better clarity on function usage and parameters.
2025-09-20 13:56:54 -05:00
7e9775c358 Add detailed docstrings for the TabsManager class and its methods in tabs.py, enhancing code documentation and clarity. 2025-09-20 13:56:39 -05:00
1d507cff19 Add cross-platform storage management for Ren Browser with comprehensive docstrings.
Implements configuration, bookmarks, and history handling, ensuring persistent data storage across platforms.
2025-09-20 13:56:33 -05:00
2aa9afeb15 improve docstrings 2025-09-20 13:56:16 -05:00
069967cb51 docstring 2025-09-20 13:56:02 -05:00
6baf6e1807 add docstring 2025-09-20 13:55:50 -05:00
1aead1935b add logging and docstrings 2025-09-20 13:55:40 -05:00
c01d86c25d add docstrings 2025-09-20 13:55:26 -05:00
8ac3364420 Improve documentation and logging setup 2025-09-20 13:55:18 -05:00
bb4c9aef78 Add centralized logging system for Ren Browser with detailed function documentation 2025-09-20 13:55:00 -05:00
7571b6b13d Add initialization with storage setup and config directory handling 2025-09-20 13:54:52 -05:00
70a4675092 add pytest dev dependencies 2025-09-20 13:26:38 -05:00
aac9a1a107 Add basic test suite 2025-09-20 13:26:22 -05:00
0532dfdd55 add 2025-09-20 12:56:16 -05:00
272eeac62c Update dependencies 2025-09-20 12:55:16 -05:00
34f47dc678 Update order of imports and improve error handling in various modules 2025-09-20 12:53:04 -05:00
d56a6934f9 Remove subprocess call, update import order 2025-09-20 12:52:45 -05:00
73e11b1083 Update GitHub Actions workflows to use specific versions of actions using hashes (supply chain security) 2025-09-20 12:47:48 -05:00
Sudo-Ivan
cd2f70641f update link again 2025-05-29 18:02:03 -05:00
Sudo-Ivan
d19a6165e3 update 2025-05-29 18:00:06 -05:00
Sudo-Ivan
57a8af5557 update instructions 2025-05-29 17:59:30 -05:00
Sudo-Ivan
91a7148afe update 2025-05-29 17:50:24 -05:00
47 changed files with 6668 additions and 554 deletions

11
.deepsource.toml Normal file
View File

@@ -0,0 +1,11 @@
version = 1
exclude_patterns = [
"tests/**"
]
[[analyzers]]
name = "python"
[analyzers.meta]
runtime_version = "3.x.x"

View File

@@ -1,7 +0,0 @@
config/
__pycache__/
build/
dist/
.ruff_cache/
.env
To-Do.md

178
.gitea/workflows/build.yml Normal file
View 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

View File

@@ -0,0 +1,17 @@
name: Safety
on:
push:
branches: [ master ]
schedule:
- cron: '0 0 * * 0' # weekly
jobs:
security:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- name: Run Safety CLI to check for vulnerabilities
uses: pyupio/safety-action@2591cf2f3e67ba68b923f4c92f0d36e281c65023 # v1.0.1
with:
api-key: ${{ secrets.SAFETY_API_KEY }}

62
.gitea/workflows/test.yml Normal file
View File

@@ -0,0 +1,62 @@
name: Run Tests
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
strategy:
matrix:
python-version: ['3.13']
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: ${{ matrix.python-version }}
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev
- name: Install Poetry
run: |
python -m pip install --upgrade pip
pip install poetry
- name: Configure Poetry
run: |
poetry config virtualenvs.create true
poetry config virtualenvs.in-project true
- name: Cache Poetry dependencies
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: .venv
key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
restore-keys: |
venv-${{ runner.os }}-${{ matrix.python-version }}-
- name: Install dependencies
run: poetry install
- name: Run linting with ruff
run: |
poetry run ruff check ./ren_browser/
poetry run ruff check ./tests/
- name: Run tests with pytest
run: |
poetry run pytest -v --cov=ren_browser --cov-report=xml --cov-report=term

View File

@@ -1,81 +0,0 @@
name: Build APK and Linux
on:
push:
tags:
- 'v*.*.*'
jobs:
build-linux:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- 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@v4
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@v4
with:
name: ren-browser-linux
path: build/linux
build-android:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v3
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@v4
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@v4
with:
name: ren-browser-apk
path: build/apk

View File

@@ -1,57 +0,0 @@
name: Build and Publish Docker Image
on:
push:
branches: [ main ]
tags: [ 'v*' ]
pull_request:
branches: [ main ]
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@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
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@v5
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

30
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,30 @@
# Contributing to Ren Browser
I welcome all contributions to the project.
## Places to help out
- Styling/Design (I am bad at this)
- Documentation
- Micron Renderer/Parser
- Android and Flet (config/permissions/etc)
## Rules
1. Be nice to each other.
## 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
To run the tests, use the following command:
```bash
poetry run pytest
```

View File

@@ -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"]

View File

@@ -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
View 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

View File

@@ -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/).
@@ -17,58 +20,110 @@ Built using [Flet](https://flet.dev/).
- Python 3.13+
- Flet
- Reticulum 0.9.6+
- Poetry
- Reticulum 1.0.0+
- 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 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
```

12
SECURITY.md Normal file
View File

@@ -0,0 +1,12 @@
# Security Policy
## Tools/Services Used
- [Socket.dev](https://socket.dev/)
- [Deepsource](https://deepsource.io/)
- [Ruff](https://github.com/astral-sh/ruff)
- [Safety](https://github.com/pyupio/safety)
## Reporting a Vulnerability
Use GitHub reporting or email report to `rns@quad4.io`

View File

@@ -1,5 +1,11 @@
# To-Do
## Bugs
- [ ] 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
## UI
- [ ] Tab dragging/reordering.
@@ -33,10 +39,8 @@
- [ ] Interface status page.
- [ ] Plugins.
## Distribution
- [ ] Add Docker images to build Windows, Linux, MacOS, Android, iOS.
- [ ] Add/Update build workflow to build Windows, MacOS and iOS.
- [ ] Appimage
- [ ] Flatpak

623
poetry.lock generated
View File

@@ -1,179 +1,330 @@
# 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.9.0"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
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.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"},
{file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"},
{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]
doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"]
test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""]
trio = ["trio (>=0.26.1)"]
trio = ["trio (>=0.31.0)"]
[[package]]
name = "certifi"
version = "2025.4.26"
version = "2025.10.5"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.6"
python-versions = ">=3.7"
groups = ["main"]
markers = "platform_system != \"Pyodide\""
files = [
{file = "certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3"},
{file = "certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6"},
{file = "certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"},
{file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"},
]
[[package]]
name = "cffi"
version = "1.17.1"
version = "2.0.0"
description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["main"]
markers = "platform_python_implementation != \"PyPy\""
files = [
{file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"},
{file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"},
{file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"},
{file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"},
{file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"},
{file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"},
{file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"},
{file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"},
{file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"},
{file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"},
{file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"},
{file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"},
{file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"},
{file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"},
{file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"},
{file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"},
{file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"},
{file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"},
{file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"},
{file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"},
{file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"},
{file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"},
{file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"},
{file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"},
{file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"},
{file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"},
{file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"},
{file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"},
{file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"},
{file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"},
{file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"},
{file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"},
{file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"},
{file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"},
{file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"},
{file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"},
{file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"},
{file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
{file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"},
{file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"},
{file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"},
{file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"},
{file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"},
{file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"},
{file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"},
{file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"},
{file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"},
{file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"},
{file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"},
{file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"},
{file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"},
{file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"},
{file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"},
{file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"},
{file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"},
{file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"},
{file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"},
{file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"},
{file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"},
{file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"},
{file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"},
{file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"},
{file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"},
{file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"},
{file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"},
{file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"},
{file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"},
{file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"},
{file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"},
{file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"},
{file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"},
{file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"},
{file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"},
{file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"},
{file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"},
{file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"},
{file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"},
{file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"},
{file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"},
{file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"},
{file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"},
{file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"},
{file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"},
{file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"},
{file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"},
{file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"},
{file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"},
{file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"},
{file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"},
{file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"},
{file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"},
{file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"},
{file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"},
{file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"},
{file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"},
{file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"},
{file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"},
{file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"},
{file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"},
{file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"},
{file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"},
{file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"},
{file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"},
{file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"},
{file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"},
{file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"},
{file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"},
{file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"},
{file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"},
{file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"},
{file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"},
{file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"},
{file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"},
{file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"},
{file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"},
{file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"},
{file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"},
{file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"},
{file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"},
{file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"},
{file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"},
]
[package.dependencies]
pycparser = "*"
pycparser = {version = "*", markers = "implementation_name != \"PyPy\""}
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["dev"]
markers = "sys_platform == \"win32\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "coverage"
version = "7.11.0"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{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]
toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
[[package]]
name = "cryptography"
version = "45.0.3"
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.7"
python-versions = "!=3.9.0,!=3.9.1,>=3.8"
groups = ["main"]
files = [
{file = "cryptography-45.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:7573d9eebaeceeb55285205dbbb8753ac1e962af3d9640791d12b36864065e71"},
{file = "cryptography-45.0.3-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d377dde61c5d67eb4311eace661c3efda46c62113ff56bf05e2d679e02aebb5b"},
{file = "cryptography-45.0.3-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae1e637f527750811588e4582988932c222f8251f7b7ea93739acb624e1487f"},
{file = "cryptography-45.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ca932e11218bcc9ef812aa497cdf669484870ecbcf2d99b765d6c27a86000942"},
{file = "cryptography-45.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af3f92b1dc25621f5fad065288a44ac790c5798e986a34d393ab27d2b27fcff9"},
{file = "cryptography-45.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2f8f8f0b73b885ddd7f3d8c2b2234a7d3ba49002b0223f58cfde1bedd9563c56"},
{file = "cryptography-45.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9cc80ce69032ffa528b5e16d217fa4d8d4bb7d6ba8659c1b4d74a1b0f4235fca"},
{file = "cryptography-45.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c824c9281cb628015bfc3c59335163d4ca0540d49de4582d6c2637312907e4b1"},
{file = "cryptography-45.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5833bb4355cb377ebd880457663a972cd044e7f49585aee39245c0d592904578"},
{file = "cryptography-45.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bb5bf55dcb69f7067d80354d0a348368da907345a2c448b0babc4215ccd3497"},
{file = "cryptography-45.0.3-cp311-abi3-win32.whl", hash = "sha256:3ad69eeb92a9de9421e1f6685e85a10fbcfb75c833b42cc9bc2ba9fb00da4710"},
{file = "cryptography-45.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:97787952246a77d77934d41b62fb1b6f3581d83f71b44796a4158d93b8f5c490"},
{file = "cryptography-45.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:c92519d242703b675ccefd0f0562eb45e74d438e001f8ab52d628e885751fb06"},
{file = "cryptography-45.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5edcb90da1843df85292ef3a313513766a78fbbb83f584a5a58fb001a5a9d57"},
{file = "cryptography-45.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38deed72285c7ed699864f964a3f4cf11ab3fb38e8d39cfcd96710cd2b5bb716"},
{file = "cryptography-45.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5555365a50efe1f486eed6ac7062c33b97ccef409f5970a0b6f205a7cfab59c8"},
{file = "cryptography-45.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e4253ed8f5948a3589b3caee7ad9a5bf218ffd16869c516535325fece163dcc"},
{file = "cryptography-45.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cfd84777b4b6684955ce86156cfb5e08d75e80dc2585e10d69e47f014f0a5342"},
{file = "cryptography-45.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:a2b56de3417fd5f48773ad8e91abaa700b678dc7fe1e0c757e1ae340779acf7b"},
{file = "cryptography-45.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:57a6500d459e8035e813bd8b51b671977fb149a8c95ed814989da682314d0782"},
{file = "cryptography-45.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f22af3c78abfbc7cbcdf2c55d23c3e022e1a462ee2481011d518c7fb9c9f3d65"},
{file = "cryptography-45.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:232954730c362638544758a8160c4ee1b832dc011d2c41a306ad8f7cccc5bb0b"},
{file = "cryptography-45.0.3-cp37-abi3-win32.whl", hash = "sha256:cb6ab89421bc90e0422aca911c69044c2912fc3debb19bb3c1bfe28ee3dff6ab"},
{file = "cryptography-45.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:d54ae41e6bd70ea23707843021c778f151ca258081586f0cfa31d936ae43d1b2"},
{file = "cryptography-45.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ed43d396f42028c1f47b5fec012e9e12631266e3825e95c00e3cf94d472dac49"},
{file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fed5aaca1750e46db870874c9c273cd5182a9e9deb16f06f7bdffdb5c2bde4b9"},
{file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:00094838ecc7c6594171e8c8a9166124c1197b074cfca23645cee573910d76bc"},
{file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:92d5f428c1a0439b2040435a1d6bc1b26ebf0af88b093c3628913dd464d13fa1"},
{file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:ec64ee375b5aaa354b2b273c921144a660a511f9df8785e6d1c942967106438e"},
{file = "cryptography-45.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:71320fbefd05454ef2d457c481ba9a5b0e540f3753354fff6f780927c25d19b0"},
{file = "cryptography-45.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:edd6d51869beb7f0d472e902ef231a9b7689508e83880ea16ca3311a00bf5ce7"},
{file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:555e5e2d3a53b4fabeca32835878b2818b3f23966a4efb0d566689777c5a12c8"},
{file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:25286aacb947286620a31f78f2ed1a32cded7be5d8b729ba3fb2c988457639e4"},
{file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:050ce5209d5072472971e6efbfc8ec5a8f9a841de5a4db0ebd9c2e392cb81972"},
{file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:dc10ec1e9f21f33420cc05214989544727e776286c1c16697178978327b95c9c"},
{file = "cryptography-45.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9eda14f049d7f09c2e8fb411dda17dd6b16a3c76a1de5e249188a32aeb92de19"},
{file = "cryptography-45.0.3.tar.gz", hash = "sha256:ec21313dd335c51d7877baf2972569f40a4291b76a0ce51391523ae358d05899"},
{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]
cffi = {version = ">=1.14", markers = "platform_python_implementation != \"PyPy\""}
cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""}
[package.extras]
docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs ; python_full_version >= \"3.8.0\"", "sphinx-rtd-theme (>=3.0.0) ; python_full_version >= \"3.8.0\""]
docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"]
docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"]
nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_full_version >= \"3.8.0\""]
pep8test = ["check-sdist ; python_full_version >= \"3.8.0\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"]
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 (==45.0.3)", "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]]
@@ -262,31 +413,43 @@ 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]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "iniconfig"
version = "2.3.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
]
[[package]]
name = "oauthlib"
version = "3.2.2"
version = "3.3.1"
description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic"
optional = false
python-versions = ">=3.6"
python-versions = ">=3.8"
groups = ["main"]
markers = "platform_system != \"Pyodide\""
files = [
{file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"},
{file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"},
{file = "oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1"},
{file = "oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9"},
]
[package.extras]
@@ -294,19 +457,62 @@ rsa = ["cryptography (>=3.0.0)"]
signals = ["blinker (>=1.4.0)"]
signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"]
[[package]]
name = "packaging"
version = "25.0"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
]
[[package]]
name = "pluggy"
version = "1.6.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"]
[[package]]
name = "pycparser"
version = "2.22"
version = "2.23"
description = "C parser in Python"
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "platform_python_implementation != \"PyPy\""
markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""
files = [
{file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
{file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
{file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"},
{file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"},
]
[[package]]
name = "pygments"
version = "2.19.2"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
]
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyserial"
version = "3.5"
@@ -322,6 +528,86 @@ files = [
[package.extras]
cp2110 = ["hidapi"]
[[package]]
name = "pytest"
version = "8.4.2"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"},
{file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"},
]
[package.dependencies]
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
iniconfig = ">=1"
packaging = ">=20"
pluggy = ">=1.5,<2"
pygments = ">=2.7.2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-asyncio"
version = "1.2.0"
description = "Pytest support for asyncio"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99"},
{file = "pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57"},
]
[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)"]
testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
[[package]]
name = "pytest-cov"
version = "7.0.0"
description = "Pytest plugin for measuring coverage."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"},
{file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"},
]
[package.dependencies]
coverage = {version = ">=7.10.6", extras = ["toml"]}
pluggy = ">=1.2"
pytest = ">=7"
[package.extras]
testing = ["process-tests", "pytest-xdist", "virtualenv"]
[[package]]
name = "pytest-mock"
version = "3.15.1"
description = "Thin-wrapper around the mock package for easier use with pytest"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"},
{file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"},
]
[package.dependencies]
pytest = ">=6.2.5"
[package.extras]
dev = ["pre-commit", "pytest-asyncio", "tox"]
[[package]]
name = "repath"
version = "0.9.0"
@@ -339,13 +625,14 @@ six = ">=1.9.0"
[[package]]
name = "rns"
version = "0.9.6"
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-0.9.6-py3-none-any.whl", hash = "sha256:a23c64a04c1e83fd0ab449f564ac904da7fd4f61c0faf68a063f486cc48b44bd"},
{file = "rns-1.0.2-py3-none-any.whl", hash = "sha256:723bcf0a839025060ff680c4202b09fa766b35093a4a08506bb85485b8a1f154"},
{file = "rns-1.0.2.tar.gz", hash = "sha256:19c025dadc4a85fc37c751e0e892f446456800ca8c434e007c25d8fd6939687e"},
]
[package.dependencies]
@@ -354,30 +641,31 @@ pyserial = ">=3.5"
[[package]]
name = "ruff"
version = "0.11.11"
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.11-py3-none-linux_armv6l.whl", hash = "sha256:9924e5ae54125ed8958a4f7de320dab7380f6e9fa3195e3dc3b137c6842a0092"},
{file = "ruff-0.11.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c8a93276393d91e952f790148eb226658dd275cddfde96c6ca304873f11d2ae4"},
{file = "ruff-0.11.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d6e333dbe2e6ae84cdedefa943dfd6434753ad321764fd937eef9d6b62022bcd"},
{file = "ruff-0.11.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7885d9a5e4c77b24e8c88aba8c80be9255fa22ab326019dac2356cff42089fc6"},
{file = "ruff-0.11.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b5ab797fcc09121ed82e9b12b6f27e34859e4227080a42d090881be888755d4"},
{file = "ruff-0.11.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e231ff3132c1119ece836487a02785f099a43992b95c2f62847d29bace3c75ac"},
{file = "ruff-0.11.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a97c9babe1d4081037a90289986925726b802d180cca784ac8da2bbbc335f709"},
{file = "ruff-0.11.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8c4ddcbe8a19f59f57fd814b8b117d4fcea9bee7c0492e6cf5fdc22cfa563c8"},
{file = "ruff-0.11.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6224076c344a7694c6fbbb70d4f2a7b730f6d47d2a9dc1e7f9d9bb583faf390b"},
{file = "ruff-0.11.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:882821fcdf7ae8db7a951df1903d9cb032bbe838852e5fc3c2b6c3ab54e39875"},
{file = "ruff-0.11.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:dcec2d50756463d9df075a26a85a6affbc1b0148873da3997286caf1ce03cae1"},
{file = "ruff-0.11.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:99c28505ecbaeb6594701a74e395b187ee083ee26478c1a795d35084d53ebd81"},
{file = "ruff-0.11.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9263f9e5aa4ff1dec765e99810f1cc53f0c868c5329b69f13845f699fe74f639"},
{file = "ruff-0.11.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:64ac6f885e3ecb2fdbb71de2701d4e34526651f1e8503af8fb30d4915a3fe345"},
{file = "ruff-0.11.11-py3-none-win32.whl", hash = "sha256:1adcb9a18802268aaa891ffb67b1c94cd70578f126637118e8099b8e4adcf112"},
{file = "ruff-0.11.11-py3-none-win_amd64.whl", hash = "sha256:748b4bb245f11e91a04a4ff0f96e386711df0a30412b9fe0c74d5bdc0e4a531f"},
{file = "ruff-0.11.11-py3-none-win_arm64.whl", hash = "sha256:6c51f136c0364ab1b774767aa8b86331bd8e9d414e2d107db7a2189f35ea1f7b"},
{file = "ruff-0.11.11.tar.gz", hash = "sha256:7774173cc7c1980e6bf67569ebb7085989a78a103922fb83ef3dfe230cd0687d"},
{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]]
@@ -405,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 = "7c33d5fc8c448ce0080a3dd31c3e54ef6b559cad67354012ffb822867c21fbda"
python-versions = ">=3.11"
content-hash = "8f33d13d6a2aea7ef3e91f7d058cf14c1ab3ec935de8dec09dd979e1f22e48ba"

View File

@@ -1,32 +1,56 @@
[project]
name = "ren-browser"
version = "0.1.0"
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 (>=0.9.6,<0.10.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"
[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

17
pytest.ini Normal file
View File

@@ -0,0 +1,17 @@
[tool:pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
--verbose
--tb=short
--strict-markers
--disable-warnings
markers =
unit: Unit tests
integration: Integration tests
slow: Slow running tests
filterwarnings =
ignore::DeprecationWarning
ignore::PendingDeprecationWarning

View File

@@ -1,46 +1,71 @@
"""Reticulum network announce handling for Ren Browser.
This module provides services for listening to and collecting network
announces from the Reticulum network.
"""
import time
from dataclasses import dataclass
import pathlib
import RNS
@dataclass
class Announce:
"""Represents a Reticulum network announce.
Contains destination hash, display name, and timestamp.
"""
destination_hash: str
display_name: str | None
timestamp: int
class AnnounceService:
"""
Service to listen for Reticulum announces and collect them.
"""Service to listen for Reticulum announces and collect them.
Calls update_callback whenever a new announce is received.
"""
def __init__(self, update_callback):
"""Initialize the announce service.
Args:
update_callback: Function called when new announces are received.
"""
self.aspect_filter = "nomadnetwork.node"
self.receive_path_responses = True
self.announces: list[Announce] = []
self.update_callback = update_callback
config_dir = pathlib.Path(__file__).resolve().parents[2] / "config"
try:
RNS.Reticulum(str(config_dir))
except (OSError, ValueError):
pass
# RNS should already be initialized by main app
RNS.Transport.register_announce_handler(self)
RNS.log("AnnounceService: registered announce handler")
def received_announce(self, destination_hash, announced_identity, app_data):
"""Handle received announce from Reticulum network.
Args:
destination_hash: Hash of the announcing destination.
announced_identity: Identity of the announcer.
app_data: Optional application data from the announce.
"""
RNS.log(f"AnnounceService: received announce from {destination_hash.hex()}")
ts = int(time.time())
display_name = None
if app_data:
try:
display_name = app_data.decode("utf-8")
except:
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)

View File

@@ -1,50 +1,180 @@
"""Ren Browser main application module.
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 subprocess
import sys
import pathlib
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
import RNS
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.
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():
config_dir = pathlib.Path(__file__).resolve().parents[1] / "config"
try:
RNS.Reticulum(str(config_dir))
except (OSError, ValueError):
pass
page.controls.clear()
build_ui(page)
page.update()
initialize_storage(page)
config_override = RNS_CONFIG_DIR
print("Initializing Reticulum Network...")
try:
import ren_browser.logs
ren_browser.logs.setup_rns_logging()
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()
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))
page.run_thread(init_ret)
def run():
global RENDERER
"""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(
"-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
# Set RNS config directory
if args.config_dir:
RNS_CONFIG_DIR = args.config_dir
else:
RNS_CONFIG_DIR = None
if args.web:
if args.port is not None:
ft.app(main, view=AppView.WEB_BROWSER, port=args.port)
@@ -53,49 +183,41 @@ def run():
else:
ft.app(main)
if __name__ == "__main__":
run()
def web():
"""Launch Ren Browser in web mode via Flet CLI."""
script_path = pathlib.Path(__file__).resolve()
rc = subprocess.call(["flet", "run", str(script_path), "--web"])
sys.exit(rc)
"""Launch Ren Browser in web mode."""
ft.app(main, view=AppView.WEB_BROWSER)
def android():
"""Launch Ren Browser in Android mode via Flet CLI."""
script_path = pathlib.Path(__file__).resolve()
rc = subprocess.call(["flet", "run", str(script_path), "--android"])
sys.exit(rc)
"""Launch Ren Browser in Android mode."""
ft.app(main, view=AppView.FLET_APP_WEB)
def ios():
"""Launch Ren Browser in iOS mode via Flet CLI."""
script_path = pathlib.Path(__file__).resolve()
rc = subprocess.call(["flet", "run", str(script_path), "--ios"])
sys.exit(rc)
"""Launch Ren Browser in iOS mode."""
ft.app(main, view=AppView.FLET_APP_WEB)
# Hot reload (dev) mode entrypoints
def run_dev():
"""Launch Ren Browser in desktop mode via Flet CLI with hot reload."""
script_path = pathlib.Path(__file__).resolve()
rc = subprocess.call(["flet", "run", "-d", "-r", str(script_path)])
sys.exit(rc)
"""Launch Ren Browser in desktop mode."""
ft.app(main)
def web_dev():
"""Launch Ren Browser in web mode via Flet CLI with hot reload."""
script_path = pathlib.Path(__file__).resolve()
rc = subprocess.call(["flet", "run", "--web", "-d", "-r", str(script_path)])
sys.exit(rc)
"""Launch Ren Browser in web mode."""
ft.app(main, view=AppView.WEB_BROWSER)
def android_dev():
"""Launch Ren Browser in Android mode via Flet CLI with hot reload."""
script_path = pathlib.Path(__file__).resolve()
rc = subprocess.call(["flet", "run", "--android", "-d", "-r", str(script_path)])
sys.exit(rc)
"""Launch Ren Browser in Android mode."""
ft.app(main, view=AppView.FLET_APP_WEB)
def ios_dev():
"""Launch Ren Browser in iOS mode via Flet CLI with hot reload."""
script_path = pathlib.Path(__file__).resolve()
rc = subprocess.call(["flet", "run", "--ios", "-d", "-r", str(script_path)])
sys.exit(rc)
"""Launch Ren Browser in iOS mode."""
ft.app(main, view=AppView.FLET_APP_WEB)

View File

@@ -1,14 +1,37 @@
"""Keyboard shortcuts handling for Ren Browser.
Provides keyboard event handling and delegation to tab manager
and UI components.
"""
import flet as ft
class Shortcuts:
"""Handles keyboard shortcuts for the Ren Browser.
Provides shortcuts for tab management, navigation, and UI actions.
"""
def __init__(self, page: ft.Page, tab_manager):
"""Attach keyboard event handler to page and delegate actions to tab_manager and UI."""
"""Initialize shortcuts handler.
Args:
page: Flet page instance to attach keyboard events to.
tab_manager: Tab manager instance for tab-related actions.
"""
self.page = page
self.tab_manager = tab_manager
page.on_keyboard_event = self.on_keyboard
def on_keyboard(self, e: ft.KeyboardEvent):
"""Handle keyboard events and execute corresponding actions.
Args:
e: Keyboard event from Flet.
"""
# Support Ctrl (and Meta on macOS)
ctrl = e.ctrl or e.meta
if not ctrl:

View File

@@ -1,20 +1,60 @@
"""Logging system for Ren Browser.
Provides centralized logging for application events, errors, and
Reticulum network activities.
"""
import datetime
import RNS
APP_LOGS: list[str] = []
ERROR_LOGS: list[str] = []
RET_LOGS: list[str] = []
_original_RNS_log = RNS.log
_original_rns_log = RNS.log
def log_ret(msg, *args, **kwargs):
"""Log Reticulum messages with timestamp.
Args:
msg: Log message.
*args: Additional arguments passed to original RNS.log.
**kwargs: Additional keyword arguments passed to original RNS.log.
"""
timestamp = datetime.datetime.now().isoformat()
RET_LOGS.append(f"[{timestamp}] {msg}")
return _original_RNS_log(msg, *args, **kwargs)
RNS.log = log_ret
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 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.
Args:
msg: Error message to log.
"""
timestamp = datetime.datetime.now().isoformat()
ERROR_LOGS.append(f"[{timestamp}] {msg}")
APP_LOGS.append(f"[{timestamp}] ERROR: {msg}")
def log_app(msg: str):
"""Log application messages.
Args:
msg: Application message to log.
"""
timestamp = datetime.datetime.now().isoformat()
APP_LOGS.append(f"[{timestamp}] {msg}")

View File

@@ -1,34 +1,52 @@
"""Page fetching functionality for Ren Browser.
Handles downloading pages from the Reticulum network using
the nomadnetwork protocol.
"""
import threading
import time
import pathlib
from dataclasses import dataclass
import RNS
from dataclasses import dataclass
@dataclass
class PageRequest:
"""Represents a request for a page from the Reticulum network.
Contains the destination hash, page path, and optional field data.
"""
destination_hash: str
page_path: str
field_data: dict | None = None
class PageFetcher:
"""
Fetcher to download pages from the Reticulum network.
"""
"""Fetcher to download pages from the Reticulum network."""
def __init__(self):
config_dir = pathlib.Path(__file__).resolve().parents[2] / "config"
try:
RNS.Reticulum(str(config_dir))
except (OSError, ValueError):
pass
"""Initialize the page fetcher and Reticulum connection."""
# RNS should already be initialized by main app
@staticmethod
def fetch_page(req: PageRequest) -> str:
"""Download page content for the given PageRequest.
Args:
req: PageRequest containing destination and path information.
Returns:
str: The downloaded page content.
Raises:
Exception: If no path to destination or identity not found.
def fetch_page(self, req: PageRequest) -> str:
RNS.log(f"PageFetcher: starting fetch of {req.page_path} from {req.destination_hash}")
"""
Download page content for the given PageRequest.
"""
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)
@@ -39,34 +57,41 @@ class PageFetcher:
time.sleep(0.1)
identity = RNS.Identity.recall(dest_bytes)
if not identity:
raise Exception('Identity not found')
raise Exception("Identity not found")
destination = RNS.Destination(
identity,
RNS.Destination.OUT,
RNS.Destination.SINGLE,
'nomadnetwork',
'node',
"nomadnetwork",
"node",
)
link = RNS.Link(destination)
result = {'data': None}
result = {"data": None}
ev = threading.Event()
def on_response(receipt):
data = receipt.response
if isinstance(data, bytes):
result['data'] = data.decode('utf-8')
result["data"] = data.decode("utf-8")
else:
result['data'] = str(data)
result["data"] = str(data)
ev.set()
def on_failed(_):
ev.set()
link.set_link_established_callback(
lambda l: l.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}")
data_str = result["data"] or "No content received"
RNS.log(
f"PageFetcher: received data for {req.destination_hash}:{req.page_path}",
)
return data_str

View File

@@ -1 +1,6 @@
"""Performance profiler for Ren Browser.
Provides profiling capabilities for monitoring browser performance
and resource usage.
"""
# Add a profiler to the browser.

View File

@@ -0,0 +1,5 @@
"""Content rendering package for Ren Browser.
Provides rendering capabilities for different content types
including micron markup and plaintext.
"""

View File

@@ -1,13 +1,295 @@
"""Micron markup renderer for Ren Browser.
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'.
"""
return ft.Text(
content,
selectable=True,
font_family="monospace",
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.
"""
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,
)

View File

@@ -1,10 +1,13 @@
"""Plaintext renderer for Ren Browser.
Provides fallback rendering for plaintext content and source viewing.
"""
import flet as ft
def render_plaintext(content: str) -> ft.Control:
"""
Fallback plaintext renderer: displays raw text safely in a monospace, selectable control.
"""
"""Fallback plaintext renderer: displays raw text safely in a monospace, selectable control."""
return ft.Text(
content,
selectable=True,

289
ren_browser/rns.py Normal file
View 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()

View File

@@ -1 +1,350 @@
# Add storage system/management, eg handling downloading files, saving bookmarks, caching, tabs and history.
"""Cross-platform storage management for Ren Browser.
Provides persistent storage for configuration, bookmarks, history,
and other application data across different platforms.
"""
import json
import os
import pathlib
from typing import Any
import flet as ft
class StorageManager:
"""Cross-platform storage manager for Ren Browser.
Handles configuration, bookmarks, history, and other persistent data
with platform-specific storage locations.
"""
def __init__(self, page: ft.Page | None = None):
"""Initialize storage manager.
Args:
page: Optional Flet page instance for client storage access.
"""
self.page = page
self._storage_dir = self._get_storage_directory()
self._ensure_storage_directory()
def _get_storage_directory(self) -> pathlib.Path:
"""Get the appropriate storage directory for the current platform."""
# Try to use Flet's client storage if available (works on all platforms)
if self.page and hasattr(self.page, "client_storage"):
pass
if os.name == "posix" and "ANDROID_ROOT" in os.environ:
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:
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"
else:
storage_dir = pathlib.Path.home() / ".ren_browser"
return storage_dir
def _ensure_storage_directory(self):
"""Ensure the storage directory exists."""
try:
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)
def get_config_path(self) -> pathlib.Path:
"""Get the path to the main configuration file."""
return self._storage_dir / "config"
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"
def save_config(self, config_content: str) -> bool:
"""Save configuration content to file.
Args:
config_content: Configuration text to save
Returns:
True if successful, False otherwise
"""
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)
# Save to reticulum config directory for RNS to use
reticulum_config_path = self.get_reticulum_config_path() / "config"
reticulum_config_path.parent.mkdir(parents=True, exist_ok=True)
reticulum_config_path.write_text(config_content, encoding="utf-8")
# 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
except (OSError, PermissionError, UnicodeEncodeError) as e:
return self._save_config_fallback(config_content, str(e))
def _save_config_fallback(self, config_content: str, error: str) -> bool:
"""Fallback config saving for when primary method fails."""
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}",
)
return True
try:
reticulum_config_path = self.get_reticulum_config_path() / "config"
reticulum_config_path.write_text(config_content, encoding="utf-8")
return True
except (OSError, PermissionError):
pass
import tempfile
temp_path = pathlib.Path(tempfile.gettempdir()) / "ren_browser_config.txt"
temp_path.write_text(config_content, encoding="utf-8")
return True
except Exception:
return False
def load_config(self) -> str:
"""Load configuration content from storage.
Returns:
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():
return reticulum_config_path.read_text(encoding="utf-8")
config_path = self.get_config_path()
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):
# 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 ""
def save_bookmarks(self, bookmarks: list) -> bool:
"""Save bookmarks to storage."""
try:
bookmarks_path = self._storage_dir / "bookmarks.json"
with open(bookmarks_path, "w", encoding="utf-8") as f:
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),
)
return True
except Exception:
return False
def load_bookmarks(self) -> list:
"""Load bookmarks from storage."""
try:
bookmarks_path = self._storage_dir / "bookmarks.json"
if bookmarks_path.exists():
with open(bookmarks_path, encoding="utf-8") as f:
return json.load(f)
if self.page and hasattr(self.page, "client_storage"):
stored_bookmarks = self.page.client_storage.get("ren_browser_bookmarks")
if stored_bookmarks:
return json.loads(stored_bookmarks)
except (OSError, json.JSONDecodeError):
pass
return []
def save_history(self, history: list) -> bool:
"""Save browsing history to storage."""
try:
history_path = self._storage_dir / "history.json"
with open(history_path, "w", encoding="utf-8") as f:
json.dump(history, f, indent=2)
if self.page and hasattr(self.page, "client_storage"):
self.page.client_storage.set("ren_browser_history", json.dumps(history))
return True
except Exception:
return False
def load_history(self) -> list:
"""Load browsing history from storage."""
try:
history_path = self._storage_dir / "history.json"
if history_path.exists():
with open(history_path, encoding="utf-8") as f:
return json.load(f)
if self.page and hasattr(self.page, "client_storage"):
stored_history = self.page.client_storage.get("ren_browser_history")
if stored_history:
return json.loads(stored_history)
except (OSError, json.JSONDecodeError):
pass
return []
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),
"config_path": str(self.get_config_path()),
"reticulum_config_path": str(self.get_reticulum_config_path()),
"storage_dir_exists": self._storage_dir.exists(),
"storage_dir_writable": self._is_writable(self._storage_dir),
"has_client_storage": self.page and hasattr(self.page, "client_storage"),
}
@staticmethod
def _is_writable(path: pathlib.Path) -> bool:
"""Check if a directory is writable."""
try:
test_file = path / ".write_test"
test_file.write_text("test")
test_file.unlink()
return True
except (OSError, PermissionError):
return False
# Global storage instance
_storage_manager: StorageManager | None = None
def get_storage_manager(page: ft.Page | None = None) -> StorageManager:
"""Get the global storage manager instance."""
global _storage_manager
if _storage_manager is None:
_storage_manager = StorageManager(page)
elif page and _storage_manager.page is None:
_storage_manager.page = page
return _storage_manager
def initialize_storage(page: ft.Page) -> StorageManager:
"""Initialize the storage system with a Flet page."""
global _storage_manager
_storage_manager = StorageManager(page)
return _storage_manager
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:
pass
# Default to standard RNS config directory
return str(pathlib.Path.home() / ".reticulum")

View File

@@ -1,100 +1,374 @@
"""Tab management system for Ren Browser.
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:
def __init__(self, page: ft.Page):
"""Manages browser tabs and their content.
Handles tab creation, switching, closing, and content rendering.
"""
def __init__(self, page: ft.Page) -> None:
"""Initialize the tab manager.
Args:
page: Flet page instance for UI updates.
"""
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({
"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,
self.manager.tabs.append(
{
"title": title,
"url_field": url_field,
"go_btn": go_btn,
"content_control": content_control,
"content": tab_content,
},
)
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:
idx: Index of the tab to select.
"""
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)
tab["content_control"] = new_control
tab["content"].controls[0] = new_control
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)

View File

@@ -1,66 +1,493 @@
"""Settings interface for Ren Browser."""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
import logging
import flet as ft
import pathlib
from ren_browser.logs import ERROR_LOGS, RET_LOGS
import RNS
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):
config_path = pathlib.Path(__file__).resolve().parents[2] / "config" / "config"
try:
config_text = config_path.read_text()
except Exception as ex:
config_text = f"Error reading config: {ex}"
"""Open a settings tab with configuration, status, and storage info."""
storage = get_storage_manager(page)
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:
config_path.write_text(config_field.value)
page.snack_bar = ft.SnackBar(ft.Text("Config saved. Please restart the app."), 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()
except Exception as exc:
logger.warning(
"Ignoring invalid background color '%s': %s",
page_bgcolor_field.value,
exc,
)
page_bgcolor_field.on_change = on_bgcolor_change
def show_snack(message, *, success=True):
snack = ft.SnackBar(
content=ft.Row(
controls=[
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()
save_btn = ft.ElevatedButton("Save and Restart", on_click=on_save_config)
error_text = "\n".join(ERROR_LOGS) or "No errors logged."
error_field = ft.TextField(
label="Error Logs",
value=error_text,
expand=True,
multiline=True,
read_only=True,
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(),
)
ret_text = "\n".join(RET_LOGS) or "No Reticulum logs."
ret_field = ft.TextField(
label="Reticulum logs",
value=ret_text,
expand=True,
multiline=True,
read_only=True,
save_reload_btn = ft.ElevatedButton(
"Save & Hot Reload",
icon=ft.Icons.REFRESH,
on_click=on_save_and_reload_config,
style=_blue_button_style(),
)
content_placeholder = ft.Container(expand=True)
def show_config(ev):
content_placeholder.content = config_field
page.update()
def show_errors(ev):
content_placeholder.content = error_field
page.update()
def show_ret_logs(ev):
content_placeholder.content = ret_field
page.update()
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)
button_row = ft.Row(controls=[btn_config, btn_errors, btn_ret])
content_placeholder.content = config_field
settings_content = ft.Column(
expand=True,
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=[
button_row,
content_placeholder,
ft.Row([save_btn]),
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)

View File

@@ -1,3 +1,9 @@
"""Main UI construction for Ren Browser.
Builds the complete browser interface including tabs, navigation,
announce handling, and content rendering.
"""
import flet as ft
from flet import Page
@@ -10,17 +16,39 @@ from ren_browser.tabs.tabs import TabsManager
def build_ui(page: Page):
import ren_browser.app as app_module
"""Build and configure the main browser UI.
Args:
page: Flet page instance to build UI on.
"""
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"
@@ -31,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
@@ -52,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(
controls=[
tab_manager.manager.tabs[tab_manager.manager.index]["url_field"],
tab_manager.manager.tabs[tab_manager.manager.index]["go_btn"],
],
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(

43
tests/README.md Normal file
View File

@@ -0,0 +1,43 @@
# Ren Browser Basic Test Suite
## To-Do
- Security tests
- Performance tests
- Proper RNS support and testing
- Micron Renderer tests (when implemented)
This directory contains comprehensive tests for the Ren Browser application.
## Test Structure
- `unit/` - Unit tests for individual components
- `integration/` - Integration tests for component interactions
- `conftest.py` - Shared test fixtures and configuration
## Running Tests
### All Tests
```bash
poetry run pytest
```
### Unit Tests Only
```bash
poetry run pytest tests/unit/
```
### Integration Tests Only
```bash
poetry run pytest tests/integration/
```
### Specific Test File
```bash
poetry run pytest tests/unit/test_app.py
```
### With Coverage
```bash
poetry run pytest --cov=ren_browser --cov-report=html
```

0
tests/__init__.py Normal file
View File

92
tests/conftest.py Normal file
View File

@@ -0,0 +1,92 @@
from unittest.mock import MagicMock, Mock
import flet as ft
import pytest
@pytest.fixture
def mock_page():
"""Create a mock Flet page for testing."""
page = Mock(spec=ft.Page)
page.add = Mock()
page.update = Mock()
page.run_thread = Mock()
page.controls = []
page.theme_mode = ft.ThemeMode.DARK
page.appbar = Mock()
page.drawer = Mock()
page.window = Mock()
page.width = 1024
page.snack_bar = None
page.on_resized = None
page.on_keyboard_event = None
return page
@pytest.fixture
def mock_rns():
"""Mock RNS module to avoid network dependencies in tests."""
mock_rns = MagicMock()
mock_rns.Reticulum = Mock()
mock_rns.Transport = Mock()
mock_rns.Identity = Mock()
mock_rns.Destination = Mock()
mock_rns.Link = Mock()
mock_rns.log = Mock()
# Mock at the module level for all imports
import sys
sys.modules["RNS"] = mock_rns
yield mock_rns
# Cleanup
if "RNS" in sys.modules:
del sys.modules["RNS"]
@pytest.fixture
def sample_announce_data():
"""Sample announce data for testing."""
return {
"destination_hash": "1234567890abcdef",
"display_name": "Test Node",
"timestamp": 1234567890,
}
@pytest.fixture
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,
)
@pytest.fixture
def mock_storage_manager():
"""Mock storage manager for testing."""
mock_storage = Mock()
mock_storage.load_config.return_value = "test config content"
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,
}
return mock_storage

View File

View File

@@ -0,0 +1,110 @@
from unittest.mock import Mock
import flet as ft
import pytest
from ren_browser import app
class TestAppIntegration:
"""Integration tests for the main app functionality."""
@pytest.mark.asyncio
async def test_main_function_structure(self):
"""Test that the main function has the expected structure."""
mock_page = Mock()
mock_page.add = Mock()
mock_page.update = 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)
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()
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",
]
for entry_point in entry_points:
assert hasattr(app, entry_point)
assert callable(getattr(app, entry_point))
def test_renderer_global_exists(self):
"""Test that the RENDERER global variable exists."""
assert hasattr(app, "RENDERER")
assert app.RENDERER in ["plaintext", "micron"]
def test_app_module_imports(self):
"""Test that required modules can be imported."""
# Test that the app module imports work
import ren_browser.app
import ren_browser.ui.ui
# Verify key functions exist
assert hasattr(ren_browser.app, "main")
assert hasattr(ren_browser.app, "run")
assert hasattr(ren_browser.ui.ui, "build_ui")
class TestModuleIntegration:
"""Integration tests for module interactions."""
def test_renderer_modules_exist(self):
"""Test that renderer modules can be imported."""
from ren_browser.renderer import micron, plaintext
assert hasattr(plaintext, "render_plaintext")
assert hasattr(micron, "render_micron")
assert callable(plaintext.render_plaintext)
assert callable(micron.render_micron)
def test_data_classes_exist(self):
"""Test that data classes can be imported and used."""
from ren_browser.announces.announces import Announce
from ren_browser.pages.page_request import PageRequest
# Test Announce creation
announce = Announce("hash1", "name1", 1000)
assert announce.destination_hash == "hash1"
# Test PageRequest creation
request = PageRequest("hash2", "/path")
assert request.destination_hash == "hash2"
def test_logs_module_integration(self):
"""Test that logs module integrates correctly."""
from ren_browser import logs
# Test that log functions exist
assert hasattr(logs, "log_error")
assert hasattr(logs, "log_app")
assert hasattr(logs, "log_ret")
# Test that log storage exists
assert hasattr(logs, "APP_LOGS")
assert hasattr(logs, "ERROR_LOGS")
assert hasattr(logs, "RET_LOGS")
# Test that they are lists
assert isinstance(logs.APP_LOGS, list)
assert isinstance(logs.ERROR_LOGS, list)
assert isinstance(logs.RET_LOGS, list)

0
tests/unit/__init__.py Normal file
View File

View File

@@ -0,0 +1,54 @@
from ren_browser.announces.announces import Announce
class TestAnnounce:
"""Test cases for the Announce dataclass."""
def test_announce_creation(self):
"""Test basic Announce creation."""
announce = Announce(
destination_hash="1234567890abcdef",
display_name="Test Node",
timestamp=1234567890,
)
assert announce.destination_hash == "1234567890abcdef"
assert announce.display_name == "Test Node"
assert announce.timestamp == 1234567890
def test_announce_with_none_display_name(self):
"""Test Announce creation with None display name."""
announce = Announce(
destination_hash="1234567890abcdef",
display_name=None,
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.
Note: These tests are simplified due to complex RNS integration.
Full integration tests will be added in the future.
"""
def test_announce_dataclass_functionality(self):
"""Test that the Announce dataclass works correctly."""
# Test that we can create and use Announce objects
announce1 = Announce("hash1", "Node1", 1000)
announce2 = Announce("hash2", None, 2000)
# Test that announces can be stored in lists
announces = [announce1, announce2]
assert len(announces) == 2
assert announces[0].display_name == "Node1"
assert announces[1].display_name is None
# Test that we can filter announces by hash
filtered = [ann for ann in announces if ann.destination_hash == "hash1"]
assert len(filtered) == 1
assert filtered[0].display_name == "Node1"

150
tests/unit/test_app.py Normal file
View File

@@ -0,0 +1,150 @@
from unittest.mock import patch
import flet as ft
import pytest
from ren_browser import app
class TestApp:
"""Test cases for the main app module."""
@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.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)
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()
@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)
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()
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:
app.run()
mock_ft_app.assert_called_once()
args = mock_ft_app.call_args
assert args[0][0] == app.main
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,
):
app.run()
mock_ft_app.assert_called_once()
args, kwargs = mock_ft_app.call_args
assert args[0] == app.main
assert kwargs["view"] == ft.AppView.WEB_BROWSER
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,
):
app.run()
mock_ft_app.assert_called_once()
args, kwargs = mock_ft_app.call_args
assert args[0] == app.main
assert kwargs["view"] == ft.AppView.WEB_BROWSER
assert kwargs["port"] == 8080
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"),
):
app.run()
assert app.RENDERER == "micron"
def test_web_function(self, mock_rns):
"""Test web() entry point function."""
with patch("flet.app") as mock_ft_app:
app.web()
mock_ft_app.assert_called_once_with(app.main, view=ft.AppView.WEB_BROWSER)
def test_android_function(self, mock_rns):
"""Test android() entry point function."""
with patch("flet.app") as mock_ft_app:
app.android()
mock_ft_app.assert_called_once_with(app.main, view=ft.AppView.FLET_APP_WEB)
def test_ios_function(self, mock_rns):
"""Test ios() entry point function."""
with patch("flet.app") as mock_ft_app:
app.ios()
mock_ft_app.assert_called_once_with(app.main, view=ft.AppView.FLET_APP_WEB)
def test_run_dev_function(self, mock_rns):
"""Test run_dev() entry point function."""
with patch("flet.app") as mock_ft_app:
app.run_dev()
mock_ft_app.assert_called_once_with(app.main)
def test_web_dev_function(self, mock_rns):
"""Test web_dev() entry point function."""
with patch("flet.app") as mock_ft_app:
app.web_dev()
mock_ft_app.assert_called_once_with(app.main, view=ft.AppView.WEB_BROWSER)
def test_android_dev_function(self, mock_rns):
"""Test android_dev() entry point function."""
with patch("flet.app") as mock_ft_app:
app.android_dev()
mock_ft_app.assert_called_once_with(app.main, view=ft.AppView.FLET_APP_WEB)
def test_ios_dev_function(self, mock_rns):
"""Test ios_dev() entry point function."""
with patch("flet.app") as mock_ft_app:
app.ios_dev()
mock_ft_app.assert_called_once_with(app.main, view=ft.AppView.FLET_APP_WEB)
def test_global_renderer_setting(self):
"""Test that RENDERER global is properly updated."""
original_renderer = app.RENDERER
with (
patch("sys.argv", ["ren-browser", "--renderer", "micron"]),
patch("flet.app"),
):
app.run()
assert app.RENDERER == "micron"
app.RENDERER = original_renderer

143
tests/unit/test_logs.py Normal file
View File

@@ -0,0 +1,143 @@
import datetime
from unittest.mock import Mock, patch
from ren_browser import logs
class TestLogsModule:
"""Test cases for the logs module."""
def setup_method(self):
"""Reset logs before each test."""
logs.APP_LOGS.clear()
logs.ERROR_LOGS.clear()
logs.RET_LOGS.clear()
def test_initial_state(self):
"""Test that logs start empty."""
assert logs.APP_LOGS == []
assert logs.ERROR_LOGS == []
assert logs.RET_LOGS == []
def test_log_error(self):
"""Test log_error function."""
with patch("datetime.datetime") as mock_datetime:
mock_now = Mock()
mock_now.isoformat.return_value = "2023-01-01T12:00:00"
mock_datetime.now.return_value = mock_now
logs.log_error("Test error message")
assert len(logs.ERROR_LOGS) == 1
assert len(logs.APP_LOGS) == 1
assert logs.ERROR_LOGS[0] == "[2023-01-01T12:00:00] Test error message"
assert logs.APP_LOGS[0] == "[2023-01-01T12:00:00] ERROR: Test error message"
def test_log_app(self):
"""Test log_app function."""
with patch("datetime.datetime") as mock_datetime:
mock_now = Mock()
mock_now.isoformat.return_value = "2023-01-01T12:00:00"
mock_datetime.now.return_value = mock_now
logs.log_app("Test app message")
assert len(logs.APP_LOGS) == 1
assert logs.APP_LOGS[0] == "[2023-01-01T12:00:00] Test app message"
def test_log_ret_with_original_function(self, mock_rns):
"""Test log_ret function calls original RNS.log."""
with patch("datetime.datetime") as mock_datetime:
mock_now = Mock()
mock_now.isoformat.return_value = "2023-01-01T12:00:00"
mock_datetime.now.return_value = mock_now
logs._original_rns_log = Mock(return_value="original_result")
result = logs.log_ret("Test RNS message", "arg1", kwarg1="value1")
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",
)
assert result == "original_result"
def test_multiple_log_calls(self):
"""Test multiple log calls accumulate correctly."""
with patch("datetime.datetime") as mock_datetime:
mock_now = Mock()
mock_now.isoformat.return_value = "2023-01-01T12:00:00"
mock_datetime.now.return_value = mock_now
logs.log_error("Error 1")
logs.log_error("Error 2")
logs.log_app("App message")
assert len(logs.ERROR_LOGS) == 2
assert len(logs.APP_LOGS) == 3 # 2 errors + 1 app message
assert logs.ERROR_LOGS[0] == "[2023-01-01T12:00:00] Error 1"
assert logs.ERROR_LOGS[1] == "[2023-01-01T12:00:00] Error 2"
assert logs.APP_LOGS[2] == "[2023-01-01T12:00:00] App message"
def test_timestamp_format(self):
"""Test that timestamps are properly formatted."""
real_datetime = datetime.datetime(2023, 1, 1, 12, 30, 45, 123456)
with patch("datetime.datetime") as mock_datetime:
mock_datetime.now.return_value = real_datetime
logs.log_app("Test message")
expected_timestamp = real_datetime.isoformat()
assert logs.APP_LOGS[0] == f"[{expected_timestamp}] Test message"
def test_rns_log_replacement(self, mock_rns):
"""Test that RNS.log replacement concept works."""
import ren_browser.logs as logs_module
# Test that the log_ret function exists and is callable
assert hasattr(logs_module, "log_ret")
assert callable(logs_module.log_ret)
# Test that we can call the log function
logs_module.log_ret("test message")
# Verify that RET_LOGS was updated
assert len(logs_module.RET_LOGS) > 0
def test_original_rns_log_stored(self, mock_rns):
"""Test that original RNS.log function is stored."""
original_log = Mock()
with patch.object(logs, "_original_rns_log", original_log):
logs.log_ret("test message")
original_log.assert_called_once_with("test message")
def test_empty_message_handling(self):
"""Test handling of empty messages."""
with patch("datetime.datetime") as mock_datetime:
mock_now = Mock()
mock_now.isoformat.return_value = "2023-01-01T12:00:00"
mock_datetime.now.return_value = mock_now
logs.log_error("")
logs.log_app("")
assert logs.ERROR_LOGS[0] == "[2023-01-01T12:00:00] "
assert logs.APP_LOGS[0] == "[2023-01-01T12:00:00] ERROR: "
assert logs.APP_LOGS[1] == "[2023-01-01T12:00:00] "
def test_special_characters_in_messages(self):
"""Test handling of special characters in log messages."""
with patch("datetime.datetime") as mock_datetime:
mock_now = Mock()
mock_now.isoformat.return_value = "2023-01-01T12:00:00"
mock_datetime.now.return_value = mock_now
special_msg = "Message with\nnewlines\tand\ttabs and unicode: 🚀"
logs.log_app(special_msg)
assert logs.APP_LOGS[0] == f"[2023-01-01T12:00:00] {special_msg}"

View File

@@ -0,0 +1,76 @@
from ren_browser.pages.page_request import PageRequest
class TestPageRequest:
"""Test cases for the PageRequest dataclass."""
def test_page_request_creation(self):
"""Test basic PageRequest creation."""
request = PageRequest(
destination_hash="1234567890abcdef",
page_path="/page/index.mu",
)
assert request.destination_hash == "1234567890abcdef"
assert request.page_path == "/page/index.mu"
assert request.field_data is None
def test_page_request_with_field_data(self):
"""Test PageRequest creation with field data."""
field_data = {"key": "value", "form_field": "data"}
request = PageRequest(
destination_hash="1234567890abcdef",
page_path="/page/form.mu",
field_data=field_data,
)
assert request.destination_hash == "1234567890abcdef"
assert request.page_path == "/page/form.mu"
assert request.field_data == field_data
def test_page_request_validation(self):
"""Test PageRequest field validation."""
# Test with various path formats
request1 = PageRequest("hash1", "/")
request2 = PageRequest("hash2", "/page/test.mu")
request3 = PageRequest("hash3", "/deep/nested/path/file.mu")
assert request1.page_path == "/"
assert request2.page_path == "/page/test.mu"
assert request3.page_path == "/deep/nested/path/file.mu"
# Test with different hash formats
assert request1.destination_hash == "hash1"
assert len(request1.destination_hash) > 0
# NOTE: PageFetcher tests are complex due to RNS networking integration.
# These will be implemented when the networking layer is more stable.
class TestPageFetcher:
"""Test cases for the PageFetcher class.
Note: These tests are simplified due to complex RNS networking integration.
Full integration tests will be added when the networking layer is stable.
"""
def test_page_fetcher_concepts(self):
"""Test basic concepts that PageFetcher should handle."""
# Test that we can create PageRequest objects for the fetcher
requests = [
PageRequest("hash1", "/index.mu"),
PageRequest("hash2", "/about.mu", {"form": "data"}),
PageRequest("hash3", "/contact.mu"),
]
# Test that requests have the expected structure
assert all(hasattr(req, "destination_hash") for req in requests)
assert all(hasattr(req, "page_path") for req in requests)
assert all(hasattr(req, "field_data") for req in requests)
# Test request with form data
form_request = requests[1]
assert form_request.field_data == {"form": "data"}
# Test requests without form data
simple_requests = [req for req in requests if req.field_data is None]
assert len(simple_requests) == 2

View File

@@ -0,0 +1,120 @@
import flet as ft
from ren_browser.renderer.micron import render_micron
from ren_browser.renderer.plaintext import render_plaintext
class TestPlaintextRenderer:
"""Test cases for the plaintext renderer."""
def test_render_plaintext_basic(self):
"""Test basic plaintext rendering."""
content = "Hello, world!"
result = render_plaintext(content)
assert isinstance(result, ft.Text)
assert result.value == "Hello, world!"
assert result.selectable is True
assert result.font_family == "monospace"
assert result.expand is True
def test_render_plaintext_multiline(self):
"""Test plaintext rendering with multiline content."""
content = "Line 1\nLine 2\nLine 3"
result = render_plaintext(content)
assert isinstance(result, ft.Text)
assert result.value == "Line 1\nLine 2\nLine 3"
assert result.selectable is True
def test_render_plaintext_empty(self):
"""Test plaintext rendering with empty content."""
content = ""
result = render_plaintext(content)
assert isinstance(result, ft.Text)
assert result.value == ""
assert result.selectable is True
def test_render_plaintext_special_chars(self):
"""Test plaintext rendering with special characters."""
content = "Special chars: !@#$%^&*()_+{}|:<>?[]\\;'\",./"
result = render_plaintext(content)
assert isinstance(result, ft.Text)
assert result.value == content
assert result.selectable is True
def test_render_plaintext_unicode(self):
"""Test plaintext rendering with Unicode characters."""
content = "Unicode: 你好 🌍 αβγ"
result = render_plaintext(content)
assert isinstance(result, ft.Text)
assert result.value == content
assert result.selectable is True
class TestMicronRenderer:
"""Test cases for the micron renderer.
Note: The micron renderer is currently a placeholder implementation
that displays raw content without markup processing.
"""
def test_render_micron_basic(self):
"""Test basic micron rendering."""
content = "# Heading\n\nSome content"
result = render_micron(content)
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.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.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 Flet controls."""
content = "Test content"
plaintext_result = render_plaintext(content)
micron_result = render_micron(content)
assert isinstance(plaintext_result, ft.Text)
assert isinstance(micron_result, ft.Column)
def test_renderers_preserve_content(self):
"""Test that plaintext renderer preserves content."""
content = "Test content with\nmultiple lines"
plaintext_result = render_plaintext(content)
assert plaintext_result.value == content
def test_renderers_same_properties(self):
"""Test that both renderers have expand property."""
content = "Test content"
plaintext_result = render_plaintext(content)
micron_result = render_micron(content)
assert plaintext_result.expand is True
assert micron_result.expand is True

View File

@@ -0,0 +1,240 @@
from unittest.mock import Mock
import pytest
from ren_browser.controls.shortcuts import Shortcuts
class TestShortcuts:
"""Test cases for the Shortcuts class."""
@pytest.fixture
def mock_tab_manager(self):
"""Create a mock tab manager for testing."""
manager = Mock()
manager.manager.index = 0
manager.manager.tabs = [{"url_field": Mock()}]
manager._on_add_click = Mock()
manager._on_close_click = Mock()
manager.select_tab = Mock()
return manager
@pytest.fixture
def shortcuts(self, mock_page, mock_tab_manager):
"""Create a Shortcuts instance for testing."""
return Shortcuts(mock_page, mock_tab_manager)
def test_shortcuts_init(self, mock_page, mock_tab_manager):
"""Test Shortcuts initialization."""
shortcuts = Shortcuts(mock_page, mock_tab_manager)
assert shortcuts.page == mock_page
assert shortcuts.tab_manager == mock_tab_manager
assert mock_page.on_keyboard_event == shortcuts.on_keyboard
def test_new_tab_shortcut_ctrl_t(self, shortcuts, mock_tab_manager):
"""Test Ctrl+T shortcut for new tab."""
event = Mock()
event.ctrl = True
event.meta = False
event.key = "t"
event.shift = False
shortcuts.on_keyboard(event)
mock_tab_manager._on_add_click.assert_called_once_with(None)
shortcuts.page.update.assert_called_once()
def test_new_tab_shortcut_meta_t(self, shortcuts, mock_tab_manager):
"""Test Meta+T shortcut for new tab (macOS)."""
event = Mock()
event.ctrl = False
event.meta = True
event.key = "T"
event.shift = False
shortcuts.on_keyboard(event)
mock_tab_manager._on_add_click.assert_called_once_with(None)
def test_close_tab_shortcut_ctrl_w(self, shortcuts, mock_tab_manager):
"""Test Ctrl+W shortcut for close tab."""
event = Mock()
event.ctrl = True
event.meta = False
event.key = "w"
event.shift = False
shortcuts.on_keyboard(event)
mock_tab_manager._on_close_click.assert_called_once_with(None)
shortcuts.page.update.assert_called_once()
def test_focus_url_bar_shortcut_ctrl_l(self, shortcuts, mock_tab_manager):
"""Test Ctrl+L shortcut for focusing URL bar."""
event = Mock()
event.ctrl = True
event.meta = False
event.key = "l"
event.shift = False
url_field = Mock()
mock_tab_manager.manager.tabs = [{"url_field": url_field}]
mock_tab_manager.manager.index = 0
shortcuts.on_keyboard(event)
url_field.focus.assert_called_once()
shortcuts.page.update.assert_called_once()
def test_show_announces_drawer_ctrl_a(self, shortcuts):
"""Test Ctrl+A shortcut for showing announces drawer."""
event = Mock()
event.ctrl = True
event.meta = False
event.key = "a"
event.shift = False
shortcuts.page.drawer = Mock()
shortcuts.page.drawer.open = False
shortcuts.on_keyboard(event)
assert shortcuts.page.drawer.open is True
shortcuts.page.update.assert_called_once()
def test_cycle_tabs_forward_ctrl_tab(self, shortcuts, mock_tab_manager):
"""Test Ctrl+Tab for cycling tabs forward."""
event = Mock()
event.ctrl = True
event.meta = False
event.key = "Tab"
event.shift = False
mock_tab_manager.manager.index = 0
mock_tab_manager.manager.tabs = [Mock(), Mock(), Mock()] # 3 tabs
shortcuts.on_keyboard(event)
mock_tab_manager.select_tab.assert_called_once_with(1)
shortcuts.page.update.assert_called_once()
def test_cycle_tabs_backward_ctrl_shift_tab(self, shortcuts, mock_tab_manager):
"""Test Ctrl+Shift+Tab for cycling tabs backward."""
event = Mock()
event.ctrl = True
event.meta = False
event.key = "Tab"
event.shift = True
mock_tab_manager.manager.index = 1
mock_tab_manager.manager.tabs = [Mock(), Mock(), Mock()] # 3 tabs
shortcuts.on_keyboard(event)
mock_tab_manager.select_tab.assert_called_once_with(0)
shortcuts.page.update.assert_called_once()
def test_cycle_tabs_wrap_around_forward(self, shortcuts, mock_tab_manager):
"""Test tab cycling wraps around when going forward from last tab."""
event = Mock()
event.ctrl = True
event.meta = False
event.key = "Tab"
event.shift = False
mock_tab_manager.manager.index = 2 # Last tab
mock_tab_manager.manager.tabs = [Mock(), Mock(), Mock()] # 3 tabs
shortcuts.on_keyboard(event)
mock_tab_manager.select_tab.assert_called_once_with(0) # Wrap to first
def test_cycle_tabs_wrap_around_backward(self, shortcuts, mock_tab_manager):
"""Test tab cycling wraps around when going backward from first tab."""
event = Mock()
event.ctrl = True
event.meta = False
event.key = "Tab"
event.shift = True
mock_tab_manager.manager.index = 0 # First tab
mock_tab_manager.manager.tabs = [Mock(), Mock(), Mock()] # 3 tabs
shortcuts.on_keyboard(event)
mock_tab_manager.select_tab.assert_called_once_with(2) # Wrap to last
def test_no_ctrl_or_meta_key_returns_early(self, shortcuts, mock_tab_manager):
"""Test that shortcuts without Ctrl or Meta key don't trigger actions."""
event = Mock()
event.ctrl = False
event.meta = False
event.key = "t"
event.shift = False
shortcuts.on_keyboard(event)
mock_tab_manager._on_add_click.assert_not_called()
shortcuts.page.update.assert_not_called()
def test_unknown_key_returns_early(self, shortcuts, mock_tab_manager):
"""Test that unknown key combinations don't trigger actions."""
event = Mock()
event.ctrl = True
event.meta = False
event.key = "z" # Unknown shortcut
event.shift = False
shortcuts.on_keyboard(event)
mock_tab_manager._on_add_click.assert_not_called()
shortcuts.page.update.assert_not_called()
def test_case_insensitive_keys(self, shortcuts, mock_tab_manager):
"""Test that shortcuts work with uppercase keys."""
event = Mock()
event.ctrl = True
event.meta = False
event.key = "T" # Uppercase
event.shift = False
shortcuts.on_keyboard(event)
mock_tab_manager._on_add_click.assert_called_once_with(None)
def test_multiple_tabs_url_field_access(self, shortcuts, mock_tab_manager):
"""Test URL field access with multiple tabs."""
event = Mock()
event.ctrl = True
event.meta = False
event.key = "l"
event.shift = False
url_field1 = Mock()
url_field2 = Mock()
mock_tab_manager.manager.tabs = [
{"url_field": url_field1},
{"url_field": url_field2},
]
mock_tab_manager.manager.index = 1 # Second tab
shortcuts.on_keyboard(event)
url_field1.focus.assert_not_called()
url_field2.focus.assert_called_once()
def test_single_tab_cycling(self, shortcuts, mock_tab_manager):
"""Test tab cycling with only one tab."""
event = Mock()
event.ctrl = True
event.meta = False
event.key = "Tab"
event.shift = False
mock_tab_manager.manager.index = 0
mock_tab_manager.manager.tabs = [Mock()] # Only 1 tab
shortcuts.on_keyboard(event)
mock_tab_manager.select_tab.assert_called_once_with(0) # Stay on same tab

481
tests/unit/test_storage.py Normal file
View File

@@ -0,0 +1,481 @@
import json
import tempfile
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from ren_browser.storage.storage import (
StorageManager,
get_storage_manager,
initialize_storage,
)
class TestStorageManager:
"""Test cases for the StorageManager class."""
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")
mock_get_dir.return_value = mock_dir
with patch("pathlib.Path.mkdir") as mock_mkdir:
storage = StorageManager()
assert storage.page is None
assert storage._storage_dir == mock_dir
mock_mkdir.assert_called_once_with(parents=True, exist_ok=True)
def test_storage_manager_init_with_page(self):
"""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")
mock_get_dir.return_value = mock_dir
with patch("pathlib.Path.mkdir"):
storage = StorageManager(mock_page)
assert storage.page == mock_page
assert storage._storage_dir == mock_dir
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"),
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"
assert storage._storage_dir == expected_dir
def test_get_storage_directory_windows(self):
"""Test storage directory detection for Windows."""
# 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_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/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):
"""Test getting config file path."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager()
storage._storage_dir = Path(temp_dir)
config_path = storage.get_config_path()
expected_path = Path(temp_dir) / "config"
assert config_path == expected_path
def test_get_reticulum_config_path(self):
"""Test getting Reticulum config directory path."""
storage = StorageManager()
config_path = storage.get_reticulum_config_path()
expected_path = Path.home() / ".reticulum"
assert config_path == expected_path
def test_save_config_success(self):
"""Test successful config saving."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager()
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",
):
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
def test_save_config_with_client_storage(self):
"""Test config saving with client storage."""
mock_page = Mock()
mock_page.client_storage.set = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager(mock_page)
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",
):
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,
)
def test_save_config_fallback(self):
"""Test config saving fallback when file system fails."""
with tempfile.TemporaryDirectory() as temp_dir:
mock_page = Mock()
mock_page.client_storage.set = Mock()
storage = StorageManager(mock_page)
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",
),
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,
)
# Verify that client storage was called at least once
assert mock_page.client_storage.set.call_count >= 1
def test_load_config_from_file(self):
"""Test loading config from file."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager()
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",
):
config_content = "test config content"
config_path = storage.get_config_path()
config_path.write_text(config_content, encoding="utf-8")
loaded_config = storage.load_config()
assert loaded_config == config_content
def test_load_config_from_client_storage(self):
"""Test loading config from client storage when file doesn't exist."""
mock_page = Mock()
mock_page.client_storage.get = Mock(return_value="client storage config")
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager(mock_page)
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",
):
loaded_config = storage.load_config()
assert loaded_config == "client storage 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."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager()
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",
):
loaded_config = storage.load_config()
assert loaded_config == ""
def test_save_bookmarks(self):
"""Test saving bookmarks."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager()
storage._storage_dir = Path(temp_dir)
bookmarks = [{"name": "Test", "url": "test://example"}]
result = storage.save_bookmarks(bookmarks)
assert result is True
bookmarks_path = storage._storage_dir / "bookmarks.json"
assert bookmarks_path.exists()
with open(bookmarks_path, encoding="utf-8") as f:
loaded_bookmarks = json.load(f)
assert loaded_bookmarks == bookmarks
def test_load_bookmarks(self):
"""Test loading bookmarks."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager()
storage._storage_dir = Path(temp_dir)
bookmarks = [{"name": "Test", "url": "test://example"}]
bookmarks_path = storage._storage_dir / "bookmarks.json"
with open(bookmarks_path, "w", encoding="utf-8") as f:
json.dump(bookmarks, f)
loaded_bookmarks = storage.load_bookmarks()
assert loaded_bookmarks == bookmarks
def test_load_bookmarks_empty(self):
"""Test loading bookmarks when none exist."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager()
storage._storage_dir = Path(temp_dir)
loaded_bookmarks = storage.load_bookmarks()
assert loaded_bookmarks == []
def test_save_history(self):
"""Test saving history."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager()
storage._storage_dir = Path(temp_dir)
history = [{"url": "test://example", "timestamp": 1234567890}]
result = storage.save_history(history)
assert result is True
history_path = storage._storage_dir / "history.json"
assert history_path.exists()
with open(history_path, encoding="utf-8") as f:
loaded_history = json.load(f)
assert loaded_history == history
def test_load_history(self):
"""Test loading history."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager()
storage._storage_dir = Path(temp_dir)
history = [{"url": "test://example", "timestamp": 1234567890}]
history_path = storage._storage_dir / "history.json"
with open(history_path, "w", encoding="utf-8") as f:
json.dump(history, f)
loaded_history = storage.load_history()
assert loaded_history == history
def test_get_storage_info(self):
"""Test getting storage information."""
with tempfile.TemporaryDirectory() as temp_dir:
mock_page = Mock()
mock_page.client_storage = Mock()
storage = StorageManager(mock_page)
storage._storage_dir = Path(temp_dir)
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 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(
"pathlib.Path.mkdir",
side_effect=[PermissionError("Access denied"), None],
),
patch("tempfile.gettempdir", return_value="/tmp"),
):
storage = StorageManager()
expected_fallback = Path("/tmp") / "ren_browser"
assert storage._storage_dir == expected_fallback
class TestStorageGlobalFunctions:
"""Test cases for global storage functions."""
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):
storage1 = get_storage_manager()
storage2 = get_storage_manager()
assert storage1 is storage2
def test_get_storage_manager_with_page(self):
"""Test get_storage_manager with page parameter."""
mock_page = Mock()
with patch("ren_browser.storage.storage._storage_manager", None):
storage = get_storage_manager(mock_page)
assert storage.page == mock_page
def test_initialize_storage(self):
"""Test initialize_storage function."""
mock_page = Mock()
with patch("ren_browser.storage.storage._storage_manager", None):
storage = initialize_storage(mock_page)
assert storage.page == mock_page
assert get_storage_manager() is storage
class TestStorageManagerEdgeCases:
"""Test edge cases and error scenarios."""
def test_save_config_encoding_error(self):
"""Test config saving with encoding errors."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager()
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",
):
# Test with content that might cause encoding issues
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
def test_load_config_encoding_error(self):
"""Test config loading with encoding errors."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager()
storage._storage_dir = Path(temp_dir)
# Create a config file with invalid encoding
config_path = storage.get_config_path()
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",
):
# Should return empty string when encoding fails
config = storage.load_config()
assert config == ""
def test_is_writable_permission_denied(self):
"""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")
result = storage._is_writable(test_path)
assert result is False
def test_is_writable_success(self):
"""Test _is_writable when directory is writable."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager()
test_path = Path(temp_dir)
result = storage._is_writable(test_path)
assert result is True

273
tests/unit/test_tabs.py Normal file
View File

@@ -0,0 +1,273 @@
from types import SimpleNamespace
from unittest.mock import Mock, patch
import flet as ft
import pytest
from ren_browser.tabs.tabs import TabsManager
class TestTabsManager:
"""Test cases for the TabsManager class."""
@pytest.fixture
def tabs_manager(self, mock_page):
"""Create a TabsManager instance for testing."""
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,
):
mock_render.return_value = Mock(spec=ft.Text)
manager = TabsManager(mock_page)
assert manager.page == mock_page
assert isinstance(manager.manager, SimpleNamespace)
assert len(manager.manager.tabs) == 1
assert manager.manager.index == 0
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):
"""Test TabsManager initialization with micron renderer."""
with patch("ren_browser.app.RENDERER", "micron"):
manager = TabsManager(mock_page)
# Verify that micron renderer was selected and TabsManager was created
assert manager.page == mock_page
assert len(manager.manager.tabs) == 1
def test_add_tab_internal(self, tabs_manager):
"""Test adding a tab internally."""
content = Mock(spec=ft.Text)
tabs_manager._add_tab_internal("Test Tab", content)
assert len(tabs_manager.manager.tabs) == 2
new_tab = tabs_manager.manager.tabs[1]
assert new_tab["title"] == "Test Tab"
assert new_tab["content_control"] == content
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,
):
mock_render.return_value = Mock(spec=ft.Text)
initial_count = len(tabs_manager.manager.tabs)
tabs_manager._on_add_click(None)
assert len(tabs_manager.manager.tabs) == initial_count + 1
assert tabs_manager.manager.index == initial_count
tabs_manager.page.update.assert_called()
def test_on_close_click_multiple_tabs(self, tabs_manager):
"""Test closing a tab when multiple tabs exist."""
tabs_manager._add_tab_internal("Tab 2", Mock())
tabs_manager._add_tab_internal("Tab 3", Mock())
tabs_manager.select_tab(1)
initial_count = len(tabs_manager.manager.tabs)
tabs_manager._on_close_click(None)
assert len(tabs_manager.manager.tabs) == initial_count - 1
tabs_manager.page.update.assert_called()
def test_on_close_click_single_tab(self, tabs_manager):
"""Test closing a tab when only one tab exists (should not close)."""
initial_count = len(tabs_manager.manager.tabs)
tabs_manager._on_close_click(None)
assert len(tabs_manager.manager.tabs) == initial_count
def test_select_tab(self, tabs_manager):
"""Test selecting a tab."""
tabs_manager._add_tab_internal("Tab 2", Mock())
tabs_manager.select_tab(1)
assert tabs_manager.manager.index == 1
tabs_manager.page.update.assert_called()
def test_select_tab_updates_background_colors(self, tabs_manager):
"""Test that selecting a tab updates background colors correctly."""
tabs_manager._add_tab_internal("Tab 2", Mock())
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.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."""
tab = tabs_manager.manager.tabs[0]
tab["url_field"].value = ""
tabs_manager._on_tab_go(None, 0)
# Should not change anything for empty URL
assert len(tabs_manager.manager.tabs) == 1
def test_on_tab_go_with_url(self, tabs_manager):
"""Test tab go with valid URL."""
tab = tabs_manager.manager.tabs[0]
tab["url_field"].value = "test://example"
tabs_manager._on_tab_go(None, 0)
# Verify that the tab content was updated and page was refreshed
tabs_manager.page.update.assert_called()
def test_on_tab_go_micron_renderer(self, tabs_manager):
"""Test tab go with micron renderer."""
with patch("ren_browser.app.RENDERER", "micron"):
tab = tabs_manager.manager.tabs[0]
tab["url_field"].value = "test://example"
tabs_manager._on_tab_go(None, 0)
# Verify that the page was updated with micron renderer
tabs_manager.page.update.assert_called()
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 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.content.controls
# 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
assert controls[-2].icon == ft.Icons.ADD
assert controls[-1].icon == ft.Icons.CLOSE
def test_tab_content_structure(self, tabs_manager):
"""Test the structure of tab content."""
tab = tabs_manager.manager.tabs[0]
assert "title" in tab
assert "url_field" in tab
assert "go_btn" in tab
assert "content_control" in tab
assert "content" in tab
assert isinstance(tab["url_field"], ft.TextField)
assert isinstance(tab["go_btn"], ft.IconButton)
assert isinstance(tab["content"], ft.Column)
def test_url_field_properties(self, tabs_manager):
"""Test URL field properties."""
tab = tabs_manager.manager.tabs[0]
url_field = tab["url_field"]
assert url_field.expand is True
assert url_field.text_style.size == 14
assert url_field.content_padding is not None
def test_go_button_properties(self, tabs_manager):
"""Test go button properties."""
tab = tabs_manager.manager.tabs[0]
go_btn = tab["go_btn"]
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.content.controls[
:-2
] # Exclude add/close buttons
for i, control in enumerate(tab_controls):
assert control.on_click is not None
def test_multiple_tabs_management(self, tabs_manager):
"""Test management of multiple tabs."""
# Add several tabs
for i in range(3):
tabs_manager._add_tab_internal(f"Tab {i + 2}", Mock())
assert len(tabs_manager.manager.tabs) == 4
# Select different tabs
tabs_manager.select_tab(2)
assert tabs_manager.manager.index == 2
# Close current tab
tabs_manager._on_close_click(None)
assert len(tabs_manager.manager.tabs) == 3
assert tabs_manager.manager.index <= 2
def test_tab_content_update_on_select(self, tabs_manager):
"""Test that content container updates when selecting tabs."""
content1 = Mock()
content2 = Mock()
tabs_manager._add_tab_internal("Tab 2", content1)
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"]
)
tabs_manager.select_tab(2)
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

254
tests/unit/test_ui.py Normal file
View File

@@ -0,0 +1,254 @@
from unittest.mock import Mock, patch
import flet as ft
from ren_browser.ui.settings import open_settings_tab
from ren_browser.ui.ui import build_ui
class TestBuildUI:
"""Test cases for the build_ui function."""
def test_build_ui_basic_setup(self, mock_page):
"""Test that build_ui sets up basic page properties."""
# Mock the page properties we can test without complex dependencies
mock_page.theme_mode = None
mock_page.window = Mock()
mock_page.window.maximized = False
mock_page.appbar = Mock()
# Test basic setup that should always work
mock_page.theme_mode = ft.ThemeMode.DARK
mock_page.window.maximized = True
assert mock_page.theme_mode == ft.ThemeMode.DARK
assert mock_page.window.maximized is True
@patch("ren_browser.announces.announces.AnnounceService")
@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,
):
"""Test that build_ui sets up the app bar correctly."""
mock_tab_manager = Mock()
mock_tabs.return_value = mock_tab_manager
mock_tab_manager.manager.tabs = [{"url_field": Mock(), "go_btn": Mock()}]
mock_tab_manager.manager.index = 0
mock_tab_manager.tab_bar = Mock()
mock_tab_manager.content_container = Mock()
build_ui(mock_page)
assert mock_page.appbar is not None
assert mock_page.appbar.leading is not None
assert mock_page.appbar.actions is not None
assert mock_page.appbar.title is not None
@patch("ren_browser.announces.announces.AnnounceService")
@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,
):
"""Test that build_ui sets up the drawer correctly."""
mock_tab_manager = Mock()
mock_tabs.return_value = mock_tab_manager
mock_tab_manager.manager.tabs = [{"url_field": Mock(), "go_btn": Mock()}]
mock_tab_manager.manager.index = 0
mock_tab_manager.tab_bar = Mock()
mock_tab_manager.content_container = Mock()
build_ui(mock_page)
assert mock_page.drawer is not None
assert isinstance(mock_page.drawer, ft.NavigationDrawer)
def test_ui_basic_functionality(self, mock_page):
"""Test basic UI functionality without complex mocking."""
# Test that we can create basic UI components
mock_page.theme_mode = ft.ThemeMode.DARK
mock_page.window = Mock()
mock_page.window.maximized = True
mock_page.appbar = Mock()
mock_page.drawer = Mock()
# Verify basic properties can be set
assert mock_page.theme_mode == ft.ThemeMode.DARK
assert mock_page.window.maximized is True
class TestOpenSettingsTab:
"""Test cases for the open_settings_tab function."""
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()
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, 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()
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()
mock_tab_manager.select_tab.assert_called_once()
# Verify settings tab was opened
args = mock_tab_manager._add_tab_internal.call_args
assert args[0][0] == "Settings"
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()
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 - now nested in action_row container
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
save_btn.on_click(None)
assert mock_write.called
def test_settings_save_config_error(self, mock_page, mock_storage_manager):
"""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()
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]
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_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()
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)
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

1502
uv.lock generated Normal file
View File

File diff suppressed because it is too large Load Diff