Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
859c15653c
|
|||
|
b063c0cbec
|
|||
|
3bb64d0533
|
|||
|
64fa46f60f
|
|||
|
e05a94e152
|
|||
|
b5ad57ad8f
|
|||
|
3c149cdd0c
|
|||
|
a1480a5c1b
|
|||
|
1e39fe277e
|
|||
|
d8de2b1150
|
|||
|
d1536aa05a
|
|||
|
1882325224
|
|||
|
cce6471534
|
|||
|
8bd46f50f3
|
|||
|
1e9b53934f
|
|||
|
c2921876f7
|
|||
|
8d723a8944
|
|||
|
f3d0da08a2
|
|||
|
5571b810ae
|
|||
|
df72547bde
|
|||
|
5ec677437e
|
|||
|
3cddaeb2b9
|
|||
|
e36bfec4a0
|
|||
|
0aaa7938e6
|
|||
|
379f85c792
|
|||
|
bfbfb22312
|
|||
|
5f9d7784a8
|
|||
|
9c0564d253
|
|||
|
5da3be18cb
|
|||
|
9eb9aafd35
|
|||
| 263e5a92bf | |||
| 6b5d476d74 | |||
| 5d640032ee | |||
| 0d2d595867 | |||
| 0b531bba54 | |||
| 3809ac8274 | |||
| a32a542c54 | |||
| e77faa5105 | |||
| c0f60d52db | |||
| 57c6b8ce3d | |||
| 9fc912fba4 | |||
| 0d878e8491 | |||
| 2796059aef | |||
| a5ae444b6c | |||
| d354b96334 | |||
| f511b60361 | |||
| b8386a60c6 | |||
| e20d6fe214 | |||
| 8b45e5d72b | |||
| 047169f3af | |||
| e0939e70f8 | |||
| 63e93d0cff | |||
| 926b3a198d | |||
| 8db441612f | |||
| b34b8f23ff | |||
| 13ad0bcef6 | |||
| 64b9ac3df4 | |||
| ee521a9f60 | |||
| fd4e0c8a14 | |||
| b056271da7 | |||
| 189256edd7 | |||
| 62d3502f99 | |||
| cb218f2b29 | |||
| 871f626555 | |||
| 9a20152a70 | |||
| 52163c4d6d | |||
| 2ce356e750 | |||
| ab3ea64ecf | |||
| 5b5c2a3d2c | |||
| aabbd510ed | |||
| 93530387a4 | |||
| ea09d520aa | |||
| e9ecef79e5 | |||
| be40fc9eac | |||
| fc5396f91d | |||
| 4754fed238 | |||
| 9eb85e45b9 | |||
| ce8ece45a3 | |||
| 7c8e8e41cb | |||
| 66bcf0d25c | |||
| ed9b487d62 | |||
| d30456096e | |||
| 03e2ac9c89 | |||
| 6c0c89969f | |||
| 408a5a3423 | |||
| 7e9775c358 | |||
| 1d507cff19 | |||
| 2aa9afeb15 | |||
| 069967cb51 | |||
| 6baf6e1807 | |||
| 1aead1935b | |||
| c01d86c25d | |||
| 8ac3364420 | |||
| bb4c9aef78 | |||
| 7571b6b13d | |||
| 70a4675092 | |||
| aac9a1a107 | |||
| 0532dfdd55 | |||
| 272eeac62c | |||
| 34f47dc678 | |||
| d56a6934f9 | |||
| 73e11b1083 | |||
|
|
cd2f70641f | ||
|
|
d19a6165e3 | ||
|
|
57a8af5557 | ||
|
|
91a7148afe |
11
.deepsource.toml
Normal file
11
.deepsource.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
version = 1
|
||||
|
||||
exclude_patterns = [
|
||||
"tests/**"
|
||||
]
|
||||
|
||||
[[analyzers]]
|
||||
name = "python"
|
||||
|
||||
[analyzers.meta]
|
||||
runtime_version = "3.x.x"
|
||||
@@ -1,7 +0,0 @@
|
||||
config/
|
||||
__pycache__/
|
||||
build/
|
||||
dist/
|
||||
.ruff_cache/
|
||||
.env
|
||||
To-Do.md
|
||||
178
.gitea/workflows/build.yml
Normal file
178
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,178 @@
|
||||
name: Build Packages
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platform:
|
||||
description: 'Platform to build'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- linux
|
||||
- windows
|
||||
- android
|
||||
default: 'all'
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref_type == 'tag' || github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux'
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
|
||||
- name: Install Linux dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev cmake ninja-build clang pkg-config libgtk-3-dev liblzma-dev
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install Poetry and dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install poetry
|
||||
poetry config virtualenvs.create false
|
||||
poetry install --without dev
|
||||
|
||||
- name: Build Linux package
|
||||
run: poetry run flet build linux --no-rich-output
|
||||
|
||||
- name: Upload Linux artifact
|
||||
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
|
||||
with:
|
||||
name: ren-browser-linux
|
||||
path: build/linux
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
if: github.ref_type == 'tag' || github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows'
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install Poetry and dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install poetry
|
||||
poetry config virtualenvs.create false
|
||||
poetry install --without dev
|
||||
|
||||
- name: Build Windows package
|
||||
run: poetry run flet build windows --no-rich-output
|
||||
env:
|
||||
PYTHONIOENCODING: utf-8
|
||||
PYTHONUTF8: 1
|
||||
|
||||
- name: Upload Windows artifact
|
||||
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
|
||||
with:
|
||||
name: ren-browser-windows
|
||||
path: build/windows
|
||||
|
||||
build-android:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref_type == 'tag' || github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android'
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: Install Android dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cmake ninja-build clang pkg-config
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install Poetry and dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install poetry
|
||||
poetry config virtualenvs.create false
|
||||
poetry install --without dev
|
||||
|
||||
- name: Build Android APK
|
||||
run: poetry run flet build apk --no-rich-output --exclude watchdog
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
|
||||
with:
|
||||
name: ren-browser-apk
|
||||
path: build/apk
|
||||
|
||||
create-release:
|
||||
needs: [build-linux, build-windows, build-android]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref_type == 'tag' && !cancelled()
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
|
||||
- name: Download Linux artifact
|
||||
if: needs.build-linux.result == 'success'
|
||||
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a
|
||||
with:
|
||||
name: ren-browser-linux
|
||||
path: ./artifacts/linux
|
||||
|
||||
- name: Download Windows artifact
|
||||
if: needs.build-windows.result == 'success'
|
||||
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a
|
||||
with:
|
||||
name: ren-browser-windows
|
||||
path: ./artifacts/windows
|
||||
|
||||
- name: Download APK artifact
|
||||
if: needs.build-android.result == 'success'
|
||||
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a
|
||||
with:
|
||||
name: ren-browser-apk
|
||||
path: ./artifacts/apk
|
||||
|
||||
- name: Create draft release
|
||||
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836
|
||||
if: github.ref_type == 'tag'
|
||||
with:
|
||||
draft: true
|
||||
files: |
|
||||
./artifacts/linux/*
|
||||
./artifacts/windows/*
|
||||
./artifacts/apk/*
|
||||
name: Release ${{ github.ref_name }}
|
||||
body: |
|
||||
## Release ${{ github.ref_name }}
|
||||
|
||||
This release contains:
|
||||
- Linux binary package
|
||||
- Windows binary package
|
||||
- Android APK package
|
||||
17
.gitea/workflows/safety.yml
Normal file
17
.gitea/workflows/safety.yml
Normal 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
62
.gitea/workflows/test.yml
Normal 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
|
||||
81
.github/workflows/build.yml
vendored
81
.github/workflows/build.yml
vendored
@@ -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
|
||||
57
.github/workflows/docker.yml
vendored
57
.github/workflows/docker.yml
vendored
@@ -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
30
CONTRIBUTING.md
Normal 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
|
||||
```
|
||||
18
Dockerfile
18
Dockerfile
@@ -1,18 +0,0 @@
|
||||
FROM python:3.13-alpine
|
||||
|
||||
# Install build dependencies for cryptography
|
||||
RUN apk add --no-cache gcc musl-dev libffi-dev openssl-dev
|
||||
|
||||
# Upgrade pip and install application dependencies
|
||||
RUN pip install --upgrade pip \
|
||||
&& pip install --no-cache-dir "flet>=0.28.3,<0.29.0" "rns>=0.9.6,<0.10.0"
|
||||
|
||||
# Copy application source
|
||||
WORKDIR /app
|
||||
COPY . /app
|
||||
|
||||
# Expose the web port
|
||||
EXPOSE 8550
|
||||
|
||||
# Run the web version of Ren Browser
|
||||
CMD ["python3", "-u", "-m", "ren_browser.app", "--web", "--port", "8550"]
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Sudo-Ivan
|
||||
Copyright (c) 2025 Sudo-Ivan / Quad4.io
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
63
Makefile
Normal file
63
Makefile
Normal file
@@ -0,0 +1,63 @@
|
||||
# Ren Browser Makefile
|
||||
.PHONY: help build poetry-build linux apk clean test lint format run
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "Ren Browser Build System"
|
||||
@echo ""
|
||||
@echo "Available targets:"
|
||||
@echo " build - Build the project (alias for poetry-build)"
|
||||
@echo " poetry-build - Build project with Poetry"
|
||||
@echo " run - Launch Ren Browser via Poetry"
|
||||
@echo " linux - Build Linux package"
|
||||
@echo " apk - Build Android APK"
|
||||
@echo " test - Run tests"
|
||||
@echo " lint - Run linter"
|
||||
@echo " format - Format code"
|
||||
@echo " clean - Clean build artifacts"
|
||||
@echo " help - Show this help"
|
||||
|
||||
# Main build target
|
||||
build: poetry-build
|
||||
|
||||
# Poetry build
|
||||
poetry-build:
|
||||
@echo "Building project with Poetry..."
|
||||
poetry build
|
||||
|
||||
# Linux package build
|
||||
linux:
|
||||
@echo "Building Linux package..."
|
||||
poetry run flet build linux
|
||||
|
||||
# Android APK build
|
||||
apk:
|
||||
@echo "Building Android APK..."
|
||||
poetry run flet build apk --cleanup-packages --exclude watchdog
|
||||
|
||||
# Development targets
|
||||
test:
|
||||
@echo "Running tests..."
|
||||
poetry run pytest
|
||||
|
||||
lint:
|
||||
@echo "Running linter..."
|
||||
poetry run ruff check .
|
||||
|
||||
format:
|
||||
@echo "Formatting code..."
|
||||
poetry run ruff format .
|
||||
|
||||
# Run application
|
||||
run:
|
||||
@echo "Starting Ren Browser..."
|
||||
poetry run ren-browser
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
@echo "Cleaning build artifacts..."
|
||||
rm -rf build/
|
||||
rm -rf dist/
|
||||
rm -rf *.egg-info/
|
||||
find . -type d -name __pycache__ -exec rm -rf {} +
|
||||
find . -type f -name "*.pyc" -delete
|
||||
79
README.md
79
README.md
@@ -1,8 +1,11 @@
|
||||
# Ren Browser
|
||||
|
||||
A browser for the [Reticulum Network](https://reticulum.network/). Work-in-progress.
|
||||
A browser for the [Reticulum Network](https://reticulum.network/).
|
||||
|
||||
Target platforms: Web, Linux, Windows, MacOS, Android, iOS.
|
||||
> [!WARNING]
|
||||
> This is still a work-in-progress. Please be patient while I work on it.
|
||||
|
||||
Due to runner limitations for the time being, I can only build: Linux and Android. Windows and MacOS are coming eventually.
|
||||
|
||||
Built using [Flet](https://flet.dev/).
|
||||
|
||||
@@ -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
12
SECURITY.md
Normal 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`
|
||||
8
To-Do.md
8
To-Do.md
@@ -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
623
poetry.lock
generated
@@ -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"
|
||||
|
||||
@@ -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
17
pytest.ini
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
initialize_storage(page)
|
||||
|
||||
config_override = RNS_CONFIG_DIR
|
||||
|
||||
print("Initializing Reticulum Network...")
|
||||
try:
|
||||
RNS.Reticulum(str(config_dir))
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
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()
|
||||
|
||||
page.run_thread(init_ret)
|
||||
|
||||
async def reload_reticulum(page: Page, on_complete=None):
|
||||
"""Hot reload Reticulum with updated configuration.
|
||||
|
||||
Args:
|
||||
page: Flet page instance
|
||||
on_complete: Optional callback to run when reload is complete
|
||||
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
global RNS_INSTANCE
|
||||
|
||||
if RNS_INSTANCE:
|
||||
try:
|
||||
RNS_INSTANCE.exit_handler()
|
||||
print("RNS exit handler completed")
|
||||
except Exception as e:
|
||||
print(f"Warning during RNS shutdown: {e}")
|
||||
|
||||
rns.shutdown_reticulum()
|
||||
RNS.Reticulum._Reticulum__instance = None
|
||||
RNS.Transport.destinations = []
|
||||
RNS_INSTANCE = None
|
||||
print("RNS instance cleared")
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
success = rns.initialize_reticulum(RNS_CONFIG_DIR)
|
||||
if success:
|
||||
RNS_INSTANCE = rns.get_reticulum_instance()
|
||||
if on_complete:
|
||||
on_complete(True, None)
|
||||
else:
|
||||
error_text = rns.get_last_error() or "Unknown error"
|
||||
print(f"Error reinitializing Reticulum: {error_text}")
|
||||
if on_complete:
|
||||
on_complete(False, error_text)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during reload: {e}")
|
||||
if on_complete:
|
||||
on_complete(False, str(e))
|
||||
|
||||
|
||||
def run():
|
||||
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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
"""Performance profiler for Ren Browser.
|
||||
|
||||
Provides profiling capabilities for monitoring browser performance
|
||||
and resource usage.
|
||||
"""
|
||||
# Add a profiler to the browser.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Content rendering package for Ren Browser.
|
||||
|
||||
Provides rendering capabilities for different content types
|
||||
including micron markup and plaintext.
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
289
ren_browser/rns.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""Reticulum helper utilities for Ren Browser."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import RNS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RNSManager:
|
||||
"""Manage Reticulum lifecycle and configuration."""
|
||||
|
||||
def __init__(self):
|
||||
self.reticulum = None
|
||||
self.config_path: str | None = None
|
||||
self.last_error: str | None = None
|
||||
|
||||
def _is_android(self) -> bool:
|
||||
vendor = getattr(RNS, "vendor", None)
|
||||
platformutils = getattr(vendor, "platformutils", None)
|
||||
if platformutils and hasattr(platformutils, "is_android"):
|
||||
try:
|
||||
return bool(platformutils.is_android())
|
||||
except Exception:
|
||||
return False
|
||||
return "ANDROID_ROOT" in os.environ
|
||||
|
||||
def _android_storage_root(self) -> Path:
|
||||
candidates = [
|
||||
os.environ.get("ANDROID_APP_PATH"),
|
||||
os.environ.get("ANDROID_PRIVATE"),
|
||||
os.environ.get("ANDROID_ARGUMENT"),
|
||||
]
|
||||
for raw_path in candidates:
|
||||
if not raw_path:
|
||||
continue
|
||||
path = Path(raw_path).expanduser()
|
||||
if path.name == "app":
|
||||
path = path.parent
|
||||
if path.is_file():
|
||||
path = path.parent
|
||||
if path.is_dir():
|
||||
return path
|
||||
return Path(tempfile.gettempdir())
|
||||
|
||||
def _default_config_root(self) -> Path:
|
||||
override = os.environ.get("REN_BROWSER_RNS_DIR") or os.environ.get(
|
||||
"REN_RETICULUM_CONFIG_DIR",
|
||||
)
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
if self._is_android():
|
||||
return self._android_storage_root() / "ren_browser" / "reticulum"
|
||||
return Path.home() / ".reticulum"
|
||||
|
||||
def _resolve_config_dir(self, preferred: str | Path | None) -> Path:
|
||||
target = (
|
||||
Path(preferred).expanduser() if preferred else self._default_config_root()
|
||||
)
|
||||
allow_fallback = preferred is None
|
||||
|
||||
try:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
if not allow_fallback:
|
||||
raise
|
||||
fallback = Path(tempfile.gettempdir()) / "ren_browser" / "reticulum"
|
||||
fallback.mkdir(parents=True, exist_ok=True)
|
||||
target = fallback
|
||||
|
||||
self._seed_config_if_missing(target)
|
||||
return target
|
||||
|
||||
def _default_tcp_interfaces_snippet(self) -> str:
|
||||
return """
|
||||
[[Quad4 Node 1]]
|
||||
type = TCPClientInterface
|
||||
interface_enabled = true
|
||||
target_host = rns.quad4.io
|
||||
target_port = 4242
|
||||
name = Quad4 Node 1
|
||||
selected_interface_mode = 1
|
||||
|
||||
[[Quad4 Node 2]]
|
||||
type = TCPClientInterface
|
||||
interface_enabled = true
|
||||
target_host = rns2.quad4.io
|
||||
target_port = 4242
|
||||
name = Quad4 Node 2
|
||||
selected_interface_mode = 1
|
||||
""".strip(
|
||||
"\n",
|
||||
)
|
||||
|
||||
def _seed_config_if_missing(self, target: Path) -> None:
|
||||
config_file = target / "config"
|
||||
if config_file.exists():
|
||||
return
|
||||
|
||||
base_content = None
|
||||
try:
|
||||
default_lines = getattr(RNS.Reticulum, "__default_rns_config__", None)
|
||||
if default_lines:
|
||||
if isinstance(default_lines, list):
|
||||
base_content = "\n".join(default_lines)
|
||||
else:
|
||||
base_content = str(default_lines)
|
||||
except Exception:
|
||||
base_content = None
|
||||
|
||||
if not base_content:
|
||||
base_content = (
|
||||
"[reticulum]\n"
|
||||
"share_instance = Yes\n\n"
|
||||
"[interfaces]\n\n"
|
||||
" [[Default Interface]]\n"
|
||||
" type = AutoInterface\n"
|
||||
" enabled = Yes\n"
|
||||
)
|
||||
|
||||
snippet = self._default_tcp_interfaces_snippet()
|
||||
if snippet and snippet not in base_content:
|
||||
base_content = base_content.rstrip() + "\n\n" + snippet + "\n"
|
||||
|
||||
try:
|
||||
config_file.write_text(base_content, encoding="utf-8")
|
||||
os.chmod(config_file, 0o600)
|
||||
except Exception:
|
||||
logger.exception("Failed to seed default config at %s", config_file)
|
||||
|
||||
def _ensure_default_tcp_interfaces(self) -> None:
|
||||
if not self.config_path:
|
||||
return
|
||||
config_file = Path(self.config_path) / "config"
|
||||
if not config_file.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
content = config_file.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
return
|
||||
|
||||
snippet = self._default_tcp_interfaces_snippet()
|
||||
if "target_host = rns.quad4.io" in content or "Quad4 Node 1" in content:
|
||||
return
|
||||
|
||||
try:
|
||||
with open(config_file, "a", encoding="utf-8") as cfg:
|
||||
if not content.endswith("\n"):
|
||||
cfg.write("\n")
|
||||
cfg.write("\n" + snippet + "\n")
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to append default TCP interfaces to %s",
|
||||
config_file,
|
||||
)
|
||||
|
||||
def _get_or_create_config_dir(self) -> Path:
|
||||
if self.config_path:
|
||||
return Path(self.config_path)
|
||||
|
||||
resolved = self._resolve_config_dir(None)
|
||||
self.config_path = str(resolved)
|
||||
return resolved
|
||||
|
||||
def initialize(self, config_dir: str | None = None) -> bool:
|
||||
"""Initialize the Reticulum instance."""
|
||||
self.last_error = None
|
||||
try:
|
||||
use_custom_dir = bool(config_dir or self._is_android())
|
||||
if use_custom_dir:
|
||||
resolved = self._resolve_config_dir(config_dir)
|
||||
self.config_path = str(resolved)
|
||||
self.reticulum = RNS.Reticulum(configdir=self.config_path)
|
||||
else:
|
||||
self.reticulum = RNS.Reticulum()
|
||||
self.config_path = getattr(
|
||||
RNS.Reticulum,
|
||||
"configdir",
|
||||
str(Path.home() / ".reticulum"),
|
||||
)
|
||||
|
||||
self._ensure_default_tcp_interfaces()
|
||||
return True
|
||||
except Exception as exc:
|
||||
self.last_error = str(exc)
|
||||
return False
|
||||
|
||||
def shutdown(self) -> bool:
|
||||
"""Shut down the active Reticulum instance."""
|
||||
try:
|
||||
if self.reticulum and hasattr(self.reticulum, "exit_handler"):
|
||||
self.reticulum.exit_handler()
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
self.reticulum = None
|
||||
return True
|
||||
|
||||
def read_config_file(self) -> str:
|
||||
"""Return the current configuration file contents."""
|
||||
config_dir = self._get_or_create_config_dir()
|
||||
config_file = config_dir / "config"
|
||||
|
||||
try:
|
||||
return config_file.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
self._seed_config_if_missing(config_dir)
|
||||
try:
|
||||
return config_file.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
return ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def write_config_file(self, content: str) -> bool:
|
||||
"""Persist configuration text to disk."""
|
||||
config_dir = self._get_or_create_config_dir()
|
||||
config_file = config_dir / "config"
|
||||
try:
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
config_file.write_text(content, encoding="utf-8")
|
||||
os.chmod(config_file, 0o600)
|
||||
return True
|
||||
except Exception as exc:
|
||||
self.last_error = str(exc)
|
||||
return False
|
||||
|
||||
def get_config_path(self) -> str | None:
|
||||
"""Return the directory holding the active Reticulum config."""
|
||||
if self.config_path:
|
||||
return self.config_path
|
||||
try:
|
||||
default_path = self._resolve_config_dir(None)
|
||||
self.config_path = str(default_path)
|
||||
return self.config_path
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_reticulum_instance(self):
|
||||
"""Return the current Reticulum instance, if any."""
|
||||
return self.reticulum
|
||||
|
||||
def get_last_error(self) -> str | None:
|
||||
"""Return the last recorded error string."""
|
||||
return self.last_error
|
||||
|
||||
|
||||
rns_manager = RNSManager()
|
||||
|
||||
|
||||
def initialize_reticulum(config_dir: str | None = None) -> bool:
|
||||
"""Initialize Reticulum using the shared manager."""
|
||||
return rns_manager.initialize(config_dir)
|
||||
|
||||
|
||||
def shutdown_reticulum() -> bool:
|
||||
"""Shut down the shared Reticulum instance."""
|
||||
return rns_manager.shutdown()
|
||||
|
||||
|
||||
def get_reticulum_instance():
|
||||
"""Expose the active Reticulum instance."""
|
||||
return rns_manager.get_reticulum_instance()
|
||||
|
||||
|
||||
def get_config_path() -> str | None:
|
||||
"""Expose the active configuration directory."""
|
||||
return rns_manager.get_config_path()
|
||||
|
||||
|
||||
def read_config_file() -> str:
|
||||
"""Read the Reticulum configuration file."""
|
||||
return rns_manager.read_config_file()
|
||||
|
||||
|
||||
def write_config_file(content: str) -> bool:
|
||||
"""Write the Reticulum configuration file."""
|
||||
return rns_manager.write_config_file(content)
|
||||
|
||||
|
||||
def get_last_error() -> str | None:
|
||||
"""Return the last recorded Reticulum error."""
|
||||
return rns_manager.get_last_error()
|
||||
@@ -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")
|
||||
|
||||
@@ -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({
|
||||
self.manager.tabs.append(
|
||||
{
|
||||
"title": title,
|
||||
"url_field": url_field,
|
||||
"go_btn": go_btn,
|
||||
"content_control": content_control,
|
||||
"content": tab_content,
|
||||
})
|
||||
btn = ft.Container(
|
||||
content=ft.Text(title),
|
||||
on_click=lambda e, i=idx: self.select_tab(i),
|
||||
padding=ft.padding.symmetric(horizontal=12, vertical=6),
|
||||
border_radius=5,
|
||||
bgcolor=ft.Colors.SURFACE_CONTAINER_HIGHEST,
|
||||
},
|
||||
)
|
||||
insert_pos = max(0, len(self.tab_bar.controls) - 2)
|
||||
self.tab_bar.controls.insert(insert_pos, btn)
|
||||
tab_container = ft.Container(
|
||||
content=ft.Row(
|
||||
controls=[
|
||||
ft.Text(
|
||||
title,
|
||||
size=13,
|
||||
weight=ft.FontWeight.W_500,
|
||||
overflow=ft.TextOverflow.ELLIPSIS,
|
||||
),
|
||||
],
|
||||
spacing=8,
|
||||
),
|
||||
on_click=lambda e, i=idx: self.select_tab(i), # type: ignore
|
||||
padding=ft.padding.symmetric(horizontal=16, vertical=10),
|
||||
border_radius=8,
|
||||
bgcolor=ft.Colors.GREY_800,
|
||||
ink=True,
|
||||
width=150,
|
||||
)
|
||||
insert_pos = max(0, len(self.tab_bar.content.controls) - 2)
|
||||
self.tab_bar.content.controls.insert(insert_pos, tab_container)
|
||||
self._update_tab_visibility()
|
||||
|
||||
def _on_add_click(self, e):
|
||||
def _on_add_click(self, e) -> None: # type: ignore
|
||||
"""Handle the add tab button click event."""
|
||||
title = f"Tab {len(self.manager.tabs) + 1}"
|
||||
content_text = f"Content for {title}"
|
||||
import ren_browser.app as app_module
|
||||
content = render_micron(content_text) if app_module.RENDERER == "micron" else render_plaintext(content_text)
|
||||
|
||||
new_idx = len(self.manager.tabs)
|
||||
|
||||
def handle_link_click_new(link_url):
|
||||
tab = self.manager.tabs[new_idx]
|
||||
full_url = link_url
|
||||
if ":" not in link_url:
|
||||
full_url = f"{link_url}:/page/index.mu"
|
||||
tab["url_field"].value = full_url
|
||||
self._on_tab_go(None, new_idx)
|
||||
|
||||
content = (
|
||||
render_micron(content_text, on_link_click=handle_link_click_new)
|
||||
if app_module.RENDERER == "micron"
|
||||
else render_plaintext(content_text)
|
||||
)
|
||||
self._add_tab_internal(title, content)
|
||||
self.select_tab(len(self.manager.tabs) - 1)
|
||||
self.page.update()
|
||||
|
||||
def _on_close_click(self, e):
|
||||
def _on_close_click(self, e) -> None: # type: ignore
|
||||
"""Handle the close tab button click event."""
|
||||
if len(self.manager.tabs) <= 1:
|
||||
return
|
||||
idx = self.manager.index
|
||||
|
||||
tab_containers = [
|
||||
c for c in self.tab_bar.content.controls if isinstance(c, ft.Container)
|
||||
]
|
||||
control_to_remove = tab_containers[idx]
|
||||
|
||||
self.manager.tabs.pop(idx)
|
||||
self.tab_bar.controls.pop(idx)
|
||||
for i, control in enumerate(self.tab_bar.controls[:-2]):
|
||||
control.on_click = lambda e, i=i: self.select_tab(i)
|
||||
self.tab_bar.content.controls.remove(control_to_remove)
|
||||
|
||||
updated_tab_containers = [
|
||||
c for c in self.tab_bar.content.controls if isinstance(c, ft.Container)
|
||||
]
|
||||
for i, control in enumerate(updated_tab_containers):
|
||||
control.on_click = lambda e, i=i: self.select_tab(i) # type: ignore
|
||||
|
||||
new_idx = min(idx, len(self.manager.tabs) - 1)
|
||||
self.select_tab(new_idx)
|
||||
self._update_tab_visibility()
|
||||
self.page.update()
|
||||
|
||||
def select_tab(self, idx: int):
|
||||
def select_tab(self, idx: int) -> None:
|
||||
"""Select and display the tab at the given index.
|
||||
|
||||
Args:
|
||||
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)
|
||||
|
||||
current_node_hash = None
|
||||
if ":" in url:
|
||||
current_node_hash = url.split(":")[0]
|
||||
|
||||
def handle_link_click(link_url):
|
||||
full_url = link_url
|
||||
if ":" not in link_url:
|
||||
full_url = f"{link_url}:/page/index.mu"
|
||||
elif link_url.startswith(":/"):
|
||||
if current_node_hash:
|
||||
full_url = f"{current_node_hash}{link_url}"
|
||||
else:
|
||||
full_url = link_url
|
||||
tab["url_field"].value = full_url
|
||||
self._on_tab_go(None, idx)
|
||||
|
||||
placeholder_control = (
|
||||
render_micron(placeholder_text, on_link_click=handle_link_click)
|
||||
if app_module.RENDERER == "micron"
|
||||
else render_plaintext(placeholder_text)
|
||||
)
|
||||
tab["content_control"] = placeholder_control
|
||||
tab["content"].controls[0] = placeholder_control
|
||||
if self.manager.index == idx:
|
||||
self.content_container.content = tab["content"]
|
||||
self.page.update()
|
||||
|
||||
def fetch_and_update():
|
||||
parts = url.split(":", 1)
|
||||
if len(parts) != 2:
|
||||
result = "Error: Invalid URL format. Expected format: hash:/page/path"
|
||||
page_path = ""
|
||||
else:
|
||||
dest_hash = parts[0]
|
||||
page_path = parts[1] if parts[1].startswith("/") else f"/{parts[1]}"
|
||||
|
||||
req = PageRequest(destination_hash=dest_hash, page_path=page_path)
|
||||
page_fetcher = PageFetcher()
|
||||
try:
|
||||
result = page_fetcher.fetch_page(req)
|
||||
except Exception as ex:
|
||||
app_module.log_error(str(ex))
|
||||
result = f"Error: {ex}"
|
||||
|
||||
try:
|
||||
tab = self.manager.tabs[idx]
|
||||
except IndexError:
|
||||
return
|
||||
|
||||
if page_path and page_path.endswith(".mu"):
|
||||
new_control = render_micron(result, on_link_click=handle_link_click)
|
||||
else:
|
||||
new_control = render_plaintext(result)
|
||||
|
||||
tab["content_control"] = new_control
|
||||
tab["content"].controls[0] = new_control
|
||||
if self.manager.index == idx:
|
||||
self.content_container.content = tab["content"]
|
||||
self.page.update()
|
||||
|
||||
self.page.run_thread(fetch_and_update)
|
||||
|
||||
@@ -1,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()
|
||||
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,
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Ignoring invalid background color '%s': %s",
|
||||
page_bgcolor_field.value,
|
||||
exc,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
|
||||
page_bgcolor_field.on_change = on_bgcolor_change
|
||||
|
||||
def show_snack(message, *, success=True):
|
||||
snack = ft.SnackBar(
|
||||
content=ft.Row(
|
||||
controls=[
|
||||
button_row,
|
||||
content_placeholder,
|
||||
ft.Row([save_btn]),
|
||||
ft.Icon(
|
||||
ft.Icons.CHECK_CIRCLE if success else ft.Icons.ERROR,
|
||||
color=ft.Colors.GREEN_400 if success else ft.Colors.RED_400,
|
||||
size=20,
|
||||
),
|
||||
ft.Text(message, color=ft.Colors.WHITE),
|
||||
],
|
||||
tight=True,
|
||||
),
|
||||
bgcolor=ft.Colors.GREEN_900 if success else ft.Colors.RED_900,
|
||||
duration=3000 if success else 4000,
|
||||
)
|
||||
page.overlay.append(snack)
|
||||
snack.open = True
|
||||
page.update()
|
||||
|
||||
def on_save_config(_):
|
||||
try:
|
||||
_write_config_text(config_path, config_field.value)
|
||||
show_snack(f"Configuration saved to {config_path}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_snack(f"Failed to save configuration: {exc}", success=False)
|
||||
|
||||
def on_save_and_reload_config(_):
|
||||
try:
|
||||
_write_config_text(config_path, config_field.value)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_snack(f"Failed to save configuration: {exc}", success=False)
|
||||
return
|
||||
|
||||
loading_snack = ft.SnackBar(
|
||||
content=ft.Row(
|
||||
controls=[
|
||||
ft.ProgressRing(
|
||||
width=16,
|
||||
height=16,
|
||||
stroke_width=2,
|
||||
color=ft.Colors.BLUE_400,
|
||||
),
|
||||
ft.Text("Reloading Reticulum...", color=ft.Colors.WHITE),
|
||||
],
|
||||
tight=True,
|
||||
),
|
||||
bgcolor=ft.Colors.BLUE_900,
|
||||
duration=10000,
|
||||
)
|
||||
page.overlay.append(loading_snack)
|
||||
loading_snack.open = True
|
||||
page.update()
|
||||
|
||||
async def do_reload():
|
||||
import ren_browser.app as app_module
|
||||
|
||||
try:
|
||||
await app_module.reload_reticulum(page, on_reload_complete)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
loading_snack.open = False
|
||||
page.update()
|
||||
show_snack(f"Reload failed: {exc}", success=False)
|
||||
|
||||
def on_reload_complete(success, error):
|
||||
loading_snack.open = False
|
||||
page.update()
|
||||
if success:
|
||||
show_snack("Reticulum reloaded successfully!")
|
||||
else:
|
||||
show_snack(f"Reload failed: {error}", success=False)
|
||||
|
||||
page.run_task(do_reload)
|
||||
|
||||
def on_save_app_settings(_):
|
||||
try:
|
||||
new_settings = {
|
||||
"horizontal_scroll": horizontal_scroll_switch.value,
|
||||
"page_bgcolor": page_bgcolor_field.value,
|
||||
}
|
||||
success = storage.save_app_settings(new_settings)
|
||||
if success:
|
||||
if hasattr(tab_manager, "apply_settings"):
|
||||
tab_manager.apply_settings(new_settings)
|
||||
show_snack("Appearance settings saved and applied!")
|
||||
else:
|
||||
show_snack("Failed to save appearance settings", success=False)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_snack(f"Error saving appearance: {exc}", success=False)
|
||||
|
||||
save_btn = ft.ElevatedButton(
|
||||
"Save Configuration",
|
||||
icon=ft.Icons.SAVE,
|
||||
on_click=on_save_config,
|
||||
style=_blue_button_style(),
|
||||
)
|
||||
save_reload_btn = ft.ElevatedButton(
|
||||
"Save & Hot Reload",
|
||||
icon=ft.Icons.REFRESH,
|
||||
on_click=on_save_and_reload_config,
|
||||
style=_blue_button_style(),
|
||||
)
|
||||
save_appearance_btn = ft.ElevatedButton(
|
||||
"Save Appearance",
|
||||
icon=ft.Icons.PALETTE,
|
||||
on_click=on_save_app_settings,
|
||||
style=_blue_button_style(),
|
||||
)
|
||||
|
||||
status_content, refresh_status_section = _build_status_section(page)
|
||||
storage_field, refresh_storage_info = _build_storage_field(storage)
|
||||
|
||||
appearance_content = ft.Column(
|
||||
spacing=16,
|
||||
controls=[
|
||||
ft.Text("Appearance Settings", size=18, weight=ft.FontWeight.BOLD),
|
||||
horizontal_scroll_switch,
|
||||
ft.Row(
|
||||
controls=[page_bgcolor_field, color_preview],
|
||||
alignment=ft.MainAxisAlignment.START,
|
||||
spacing=16,
|
||||
),
|
||||
save_appearance_btn,
|
||||
],
|
||||
)
|
||||
|
||||
content_placeholder = ft.Container(expand=True, content=config_field)
|
||||
|
||||
def show_config(_):
|
||||
content_placeholder.content = config_field
|
||||
page.update()
|
||||
|
||||
def show_appearance(_):
|
||||
content_placeholder.content = appearance_content
|
||||
page.update()
|
||||
|
||||
def show_status(_):
|
||||
content_placeholder.content = status_content
|
||||
refresh_status_section()
|
||||
|
||||
def show_storage_info(_):
|
||||
refresh_storage_info()
|
||||
content_placeholder.content = storage_field
|
||||
page.update()
|
||||
|
||||
def refresh_current_view(_):
|
||||
if content_placeholder.content == status_content:
|
||||
refresh_status_section()
|
||||
elif content_placeholder.content == storage_field:
|
||||
refresh_storage_info()
|
||||
page.update()
|
||||
|
||||
btn_config = ft.FilledButton(
|
||||
"Configuration",
|
||||
icon=ft.Icons.SETTINGS,
|
||||
on_click=show_config,
|
||||
style=_blue_button_style(),
|
||||
)
|
||||
btn_appearance = ft.FilledButton(
|
||||
"Appearance",
|
||||
icon=ft.Icons.PALETTE,
|
||||
on_click=show_appearance,
|
||||
style=_blue_button_style(),
|
||||
)
|
||||
btn_status = ft.FilledButton(
|
||||
"Status",
|
||||
icon=ft.Icons.LAN,
|
||||
on_click=show_status,
|
||||
style=_blue_button_style(),
|
||||
)
|
||||
btn_storage = ft.FilledButton(
|
||||
"Storage",
|
||||
icon=ft.Icons.STORAGE,
|
||||
on_click=show_storage_info,
|
||||
style=_blue_button_style(),
|
||||
)
|
||||
btn_refresh = ft.IconButton(
|
||||
icon=ft.Icons.REFRESH,
|
||||
tooltip="Refresh",
|
||||
on_click=refresh_current_view,
|
||||
icon_color=ft.Colors.BLUE_400,
|
||||
)
|
||||
|
||||
nav_card = ft.Container(
|
||||
content=ft.Row(
|
||||
controls=[btn_config, btn_appearance, btn_status, btn_storage, btn_refresh],
|
||||
spacing=8,
|
||||
wrap=True,
|
||||
),
|
||||
padding=ft.padding.all(16),
|
||||
border_radius=12,
|
||||
bgcolor=ft.Colors.GREY_900,
|
||||
)
|
||||
|
||||
content_card = ft.Container(
|
||||
content=content_placeholder,
|
||||
expand=True,
|
||||
padding=ft.padding.all(16),
|
||||
border_radius=12,
|
||||
bgcolor=ft.Colors.GREY_900,
|
||||
)
|
||||
|
||||
action_row = ft.Container(
|
||||
content=ft.Row(
|
||||
controls=[save_btn, save_reload_btn],
|
||||
alignment=ft.MainAxisAlignment.END,
|
||||
spacing=8,
|
||||
),
|
||||
padding=ft.padding.symmetric(horizontal=16, vertical=8),
|
||||
)
|
||||
|
||||
settings_content = ft.Column(
|
||||
expand=True,
|
||||
spacing=16,
|
||||
controls=[
|
||||
ft.Container(
|
||||
content=ft.Text(
|
||||
"Settings",
|
||||
size=24,
|
||||
weight=ft.FontWeight.BOLD,
|
||||
color=ft.Colors.BLUE_400,
|
||||
),
|
||||
padding=ft.padding.only(left=16, top=16),
|
||||
),
|
||||
nav_card,
|
||||
content_card,
|
||||
action_row,
|
||||
],
|
||||
)
|
||||
|
||||
tab_manager._add_tab_internal("Settings", settings_content)
|
||||
idx = len(tab_manager.manager.tabs) - 1
|
||||
tab_manager.select_tab(idx)
|
||||
|
||||
@@ -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(
|
||||
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
43
tests/README.md
Normal 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
0
tests/__init__.py
Normal file
92
tests/conftest.py
Normal file
92
tests/conftest.py
Normal 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
|
||||
0
tests/integration/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
110
tests/integration/test_app_integration.py
Normal file
110
tests/integration/test_app_integration.py
Normal 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
0
tests/unit/__init__.py
Normal file
54
tests/unit/test_announces.py
Normal file
54
tests/unit/test_announces.py
Normal 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
150
tests/unit/test_app.py
Normal 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
143
tests/unit/test_logs.py
Normal 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}"
|
||||
76
tests/unit/test_page_request.py
Normal file
76
tests/unit/test_page_request.py
Normal 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
|
||||
120
tests/unit/test_renderers.py
Normal file
120
tests/unit/test_renderers.py
Normal 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
|
||||
240
tests/unit/test_shortcuts.py
Normal file
240
tests/unit/test_shortcuts.py
Normal 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
481
tests/unit/test_storage.py
Normal 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
273
tests/unit/test_tabs.py
Normal 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
254
tests/unit/test_ui.py
Normal 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
|
||||
Reference in New Issue
Block a user