Compare commits
No commits in common. "c9704efc8d40096631bbce03a41d6a4b815b135d" and "634eed89966c330b1e0ab0b00eea8f43b32d0263" have entirely different histories.
c9704efc8d
...
634eed8996
54 changed files with 246 additions and 7032 deletions
11
.env.example
11
.env.example
|
|
@ -2,7 +2,8 @@
|
||||||
|
|
||||||
# Application
|
# Application
|
||||||
APP_NAME=WebDrop Bridge
|
APP_NAME=WebDrop Bridge
|
||||||
APP_VERSION=0.1.0
|
APP_VERSION=1.0.0
|
||||||
|
APP_ENV=development
|
||||||
|
|
||||||
# Web App
|
# Web App
|
||||||
WEBAPP_URL=file:///./webapp/index.html
|
WEBAPP_URL=file:///./webapp/index.html
|
||||||
|
|
@ -11,13 +12,15 @@ WEBAPP_URL=file:///./webapp/index.html
|
||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL=DEBUG
|
LOG_LEVEL=DEBUG
|
||||||
LOG_FILE=logs/webdrop_bridge.log
|
LOG_FILE=logs/webdrop_bridge.log
|
||||||
ENABLE_LOGGING=true
|
|
||||||
|
|
||||||
# Security - Path Whitelist
|
# Security - Path Whitelist
|
||||||
ALLOWED_ROOTS=Z:/,C:/Users/Public
|
ALLOWED_ROOTS=Z:/,C:/Users/Public
|
||||||
ALLOWED_URLS=
|
|
||||||
|
|
||||||
# UI
|
# UI
|
||||||
WINDOW_WIDTH=1024
|
WINDOW_WIDTH=1024
|
||||||
WINDOW_HEIGHT=768
|
WINDOW_HEIGHT=768
|
||||||
# WINDOW_TITLE= (leave empty to use: "{APP_NAME} v{APP_VERSION}")
|
WINDOW_TITLE=WebDrop Bridge
|
||||||
|
|
||||||
|
# Feature Flags
|
||||||
|
ENABLE_DRAG_LOGGING=true
|
||||||
|
ENABLE_PROFILING=false
|
||||||
|
|
|
||||||
27
.github/copilot-instructions.md
vendored
27
.github/copilot-instructions.md
vendored
|
|
@ -19,7 +19,6 @@ WebDrop Bridge is a professional Qt-based desktop application that converts web-
|
||||||
| `src/webdrop_bridge/config.py` | Configuration management |
|
| `src/webdrop_bridge/config.py` | Configuration management |
|
||||||
| `src/webdrop_bridge/core/validator.py` | Path validation and security |
|
| `src/webdrop_bridge/core/validator.py` | Path validation and security |
|
||||||
| `src/webdrop_bridge/core/drag_interceptor.py` | Drag-and-drop handling |
|
| `src/webdrop_bridge/core/drag_interceptor.py` | Drag-and-drop handling |
|
||||||
| `src/webdrop_bridge/core/updater.py` | Update check and release management |
|
|
||||||
| `src/webdrop_bridge/ui/main_window.py` | Main Qt window |
|
| `src/webdrop_bridge/ui/main_window.py` | Main Qt window |
|
||||||
| `tests/` | Pytest-based test suite |
|
| `tests/` | Pytest-based test suite |
|
||||||
| `pyproject.toml` | Modern Python packaging |
|
| `pyproject.toml` | Modern Python packaging |
|
||||||
|
|
@ -37,11 +36,11 @@ WebDrop Bridge is a professional Qt-based desktop application that converts web-
|
||||||
```python
|
```python
|
||||||
def validate_path(path: Path, allowed_roots: List[Path]) -> bool:
|
def validate_path(path: Path, allowed_roots: List[Path]) -> bool:
|
||||||
"""Validate path against allowed roots.
|
"""Validate path against allowed roots.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: File path to validate
|
path: File path to validate
|
||||||
allowed_roots: List of allowed root directories
|
allowed_roots: List of allowed root directories
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if path is valid, False otherwise
|
True if path is valid, False otherwise
|
||||||
"""
|
"""
|
||||||
|
|
@ -65,23 +64,17 @@ def validate_path(path: Path, allowed_roots: List[Path]) -> bool:
|
||||||
6. **Run quality checks**: `tox -e lint,type`
|
6. **Run quality checks**: `tox -e lint,type`
|
||||||
7. **Update docs**: Add docstrings and update README if needed
|
7. **Update docs**: Add docstrings and update README if needed
|
||||||
|
|
||||||
## Development Environment
|
|
||||||
|
|
||||||
**Virtual Environment**: `.venv` (already created)
|
|
||||||
- Activate: `.venv\Scripts\activate` (Windows) or `source .venv/bin/activate` (macOS/Linux)
|
|
||||||
- All Python commands automatically use this environment through VS Code integration
|
|
||||||
|
|
||||||
## Common Commands
|
## Common Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Setup (one-time)
|
# Setup
|
||||||
pip install -r requirements-dev.txt
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
# Testing (uses .venv automatically)
|
# Testing
|
||||||
pytest tests -v
|
pytest tests -v
|
||||||
pytest tests --cov=src/webdrop_bridge --cov-report=html
|
pytest tests --cov=src/webdrop_bridge --cov-report=html
|
||||||
|
|
||||||
# Quality checks
|
# Quality
|
||||||
tox -e lint # Ruff + Black checks
|
tox -e lint # Ruff + Black checks
|
||||||
tox -e type # mypy type checking
|
tox -e type # mypy type checking
|
||||||
tox -e format # Auto-format code
|
tox -e format # Auto-format code
|
||||||
|
|
@ -103,12 +96,6 @@ bash build/scripts/build_macos.sh # macOS
|
||||||
- `LocalContentCanAccessFileUrls`: True (required for drag)
|
- `LocalContentCanAccessFileUrls`: True (required for drag)
|
||||||
- `LocalContentCanAccessRemoteUrls`: False (prevent phishing)
|
- `LocalContentCanAccessRemoteUrls`: False (prevent phishing)
|
||||||
|
|
||||||
### Update Flow
|
|
||||||
- UpdateManager checks for new releases via Forgejo API.
|
|
||||||
- Caching is used to avoid redundant network calls.
|
|
||||||
- Only newer versions trigger update signals.
|
|
||||||
- Release notes and assets are parsed and preserved.
|
|
||||||
|
|
||||||
### Cross-Platform
|
### Cross-Platform
|
||||||
- Use PySide6 APIs that work on both Windows and macOS
|
- Use PySide6 APIs that work on both Windows and macOS
|
||||||
- Test on both platforms when possible
|
- Test on both platforms when possible
|
||||||
|
|
@ -121,10 +108,9 @@ bash build/scripts/build_macos.sh # macOS
|
||||||
tests/unit/test_validator.py
|
tests/unit/test_validator.py
|
||||||
tests/unit/test_drag_interceptor.py
|
tests/unit/test_drag_interceptor.py
|
||||||
|
|
||||||
# Integration tests: Component interaction and update flow
|
# Integration tests: Component interaction
|
||||||
tests/integration/test_drag_workflow.py
|
tests/integration/test_drag_workflow.py
|
||||||
tests/integration/test_end_to_end.py
|
tests/integration/test_end_to_end.py
|
||||||
tests/integration/test_update_flow.py
|
|
||||||
|
|
||||||
# Fixtures: Reusable test data
|
# Fixtures: Reusable test data
|
||||||
tests/conftest.py
|
tests/conftest.py
|
||||||
|
|
@ -144,7 +130,6 @@ Target: 80%+ code coverage
|
||||||
- **Public APIs**: Docstrings required
|
- **Public APIs**: Docstrings required
|
||||||
- **Modules**: Add docstring at top of file
|
- **Modules**: Add docstring at top of file
|
||||||
- **Features**: Update README.md and docs/
|
- **Features**: Update README.md and docs/
|
||||||
- **Integration tests**: Reference and document in README.md and docs/ARCHITECTURE.md
|
|
||||||
- **Breaking changes**: Update DEVELOPMENT_PLAN.md
|
- **Breaking changes**: Update DEVELOPMENT_PLAN.md
|
||||||
|
|
||||||
## Git Workflow
|
## Git Workflow
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,3 @@
|
||||||
## [0.1.0] - 2026-01-30
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to WebDrop Bridge will be documented in this file.
|
All notable changes to WebDrop Bridge will be documented in this file.
|
||||||
|
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
# Configuration System Overhaul - Summary
|
|
||||||
|
|
||||||
## Problem Identified
|
|
||||||
|
|
||||||
The application was **not bundling the `.env` configuration file** into built executables. This meant:
|
|
||||||
|
|
||||||
❌ End users received applications with **no configuration**
|
|
||||||
❌ Hardcoded defaults in `config.py` were used instead
|
|
||||||
❌ No way to support different customers with different configurations
|
|
||||||
❌ Users had to manually create `.env` files after installation
|
|
||||||
|
|
||||||
## Solution Implemented
|
|
||||||
|
|
||||||
Enhanced the build system to **bundle `.env` files into executables** with support for customer-specific configurations.
|
|
||||||
|
|
||||||
### Key Changes
|
|
||||||
|
|
||||||
#### 1. **Windows Build Script** (`build/scripts/build_windows.py`)
|
|
||||||
- Added `--env-file` command-line parameter
|
|
||||||
- Validates `.env` file exists before building
|
|
||||||
- Passes `.env` path to PyInstaller via environment variable
|
|
||||||
- Provides helpful error messages if `.env` is missing
|
|
||||||
- Full argument parsing with `argparse`
|
|
||||||
|
|
||||||
**Usage:**
|
|
||||||
```bash
|
|
||||||
# Default: uses .env from project root
|
|
||||||
python build_windows.py --msi
|
|
||||||
|
|
||||||
# Custom config for a customer
|
|
||||||
python build_windows.py --msi --env-file customer_configs/acme.env
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. **macOS Build Script** (`build/scripts/build_macos.sh`)
|
|
||||||
- Added `--env-file` parameter (shell-based)
|
|
||||||
- Validates `.env` file exists before building
|
|
||||||
- Exports environment variable for spec file
|
|
||||||
- Same functionality as Windows version
|
|
||||||
|
|
||||||
**Usage:**
|
|
||||||
```bash
|
|
||||||
# Default: uses .env from project root
|
|
||||||
bash build_macos.sh
|
|
||||||
|
|
||||||
# Custom config
|
|
||||||
bash build_macos.sh --env-file customer_configs/acme.env
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. **PyInstaller Spec File** (`build/webdrop_bridge.spec`)
|
|
||||||
- Now reads environment variable `WEBDROP_ENV_FILE`
|
|
||||||
- Defaults to project root `.env` if not specified
|
|
||||||
- **Validates .env exists** before bundling
|
|
||||||
- Includes `.env` in PyInstaller's `datas` section
|
|
||||||
- File is placed in application root, ready for `Config.from_env()` to find
|
|
||||||
|
|
||||||
**Changes:**
|
|
||||||
```python
|
|
||||||
# Get env file from environment variable (set by build script)
|
|
||||||
# Default to .env in project root if not specified
|
|
||||||
env_file = os.getenv("WEBDROP_ENV_FILE", os.path.join(project_root, ".env"))
|
|
||||||
|
|
||||||
# Verify env file exists
|
|
||||||
if not os.path.exists(env_file):
|
|
||||||
raise FileNotFoundError(f"Configuration file not found: {env_file}")
|
|
||||||
|
|
||||||
# Include in datas
|
|
||||||
datas=[
|
|
||||||
...
|
|
||||||
(env_file, "."), # Include .env file in the root of bundled app
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. **Documentation** (`docs/CONFIGURATION_BUILD.md`)
|
|
||||||
- Complete guide on configuration management
|
|
||||||
- Examples for default and custom configurations
|
|
||||||
- Multi-customer setup examples
|
|
||||||
- Build command reference for Windows and macOS
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
### At Build Time
|
|
||||||
1. User specifies `.env` file (or uses default from project root)
|
|
||||||
2. Build script validates the file exists
|
|
||||||
3. PyInstaller bundles the `.env` into the application
|
|
||||||
4. Users receive a pre-configured executable
|
|
||||||
|
|
||||||
### At Runtime
|
|
||||||
1. Application starts and calls `Config.from_env()`
|
|
||||||
2. Looks for `.env` in the current working directory
|
|
||||||
3. Finds the bundled `.env` file
|
|
||||||
4. Loads all configuration (URLs, paths, logging, etc.)
|
|
||||||
5. Application starts with customer-specific settings
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
✅ **Multi-customer support** - Build different configs for different clients
|
|
||||||
✅ **No user setup** - Configuration is included in the installer
|
|
||||||
✅ **Safe builds** - Process fails if `.env` doesn't exist
|
|
||||||
✅ **Override capability** - Users can edit `.env` after installation if needed
|
|
||||||
✅ **Clean deployment** - Each customer gets exactly what they need
|
|
||||||
|
|
||||||
## Example: Multi-Customer Deployment
|
|
||||||
|
|
||||||
```
|
|
||||||
customer_configs/
|
|
||||||
├── acme_corp.env
|
|
||||||
│ WEBAPP_URL=https://acme.example.com
|
|
||||||
│ ALLOWED_ROOTS=Z:/acme_files/
|
|
||||||
├── globex.env
|
|
||||||
│ WEBAPP_URL=https://globex.example.com
|
|
||||||
│ ALLOWED_ROOTS=C:/globex_data/
|
|
||||||
└── initech.env
|
|
||||||
WEBAPP_URL=https://initech.example.com
|
|
||||||
ALLOWED_ROOTS=D:/initech/
|
|
||||||
```
|
|
||||||
|
|
||||||
Build for each:
|
|
||||||
```bash
|
|
||||||
python build_windows.py --msi --env-file customer_configs/acme_corp.env
|
|
||||||
python build_windows.py --msi --env-file customer_configs/globex.env
|
|
||||||
python build_windows.py --msi --env-file customer_configs/initech.env
|
|
||||||
```
|
|
||||||
|
|
||||||
Each MSI includes the customer's specific configuration.
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
1. ✅ `build/scripts/build_windows.py` - Enhanced with `.env` support
|
|
||||||
2. ✅ `build/scripts/build_macos.sh` - Enhanced with `.env` support
|
|
||||||
3. ✅ `build/webdrop_bridge.spec` - Now includes `.env` in bundle
|
|
||||||
4. ✅ `docs/CONFIGURATION_BUILD.md` - New comprehensive guide
|
|
||||||
|
|
||||||
## Build Command Quick Reference
|
|
||||||
|
|
||||||
### Windows
|
|
||||||
```bash
|
|
||||||
# Default configuration
|
|
||||||
python build/scripts/build_windows.py --msi
|
|
||||||
|
|
||||||
# Custom configuration
|
|
||||||
python build/scripts/build_windows.py --msi --env-file path/to/config.env
|
|
||||||
|
|
||||||
# Without MSI (just EXE)
|
|
||||||
python build/scripts/build_windows.py
|
|
||||||
|
|
||||||
# With code signing
|
|
||||||
python build/scripts/build_windows.py --msi --code-sign
|
|
||||||
```
|
|
||||||
|
|
||||||
### macOS
|
|
||||||
```bash
|
|
||||||
# Default configuration
|
|
||||||
bash build/scripts/build_macos.sh
|
|
||||||
|
|
||||||
# Custom configuration
|
|
||||||
bash build/scripts/build_macos.sh --env-file path/to/config.env
|
|
||||||
|
|
||||||
# With signing
|
|
||||||
bash build/scripts/build_macos.sh --sign
|
|
||||||
|
|
||||||
# With notarization
|
|
||||||
bash build/scripts/build_macos.sh --notarize
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
To test the new functionality:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Verify default build (uses project .env)
|
|
||||||
python build/scripts/build_windows.py --help
|
|
||||||
|
|
||||||
# 2. Create a test .env with custom values
|
|
||||||
# (or use existing .env)
|
|
||||||
|
|
||||||
# 3. Try building (will include .env)
|
|
||||||
# python build/scripts/build_windows.py --msi
|
|
||||||
```
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
- ✅ Configuration bundling implemented
|
|
||||||
- ✅ Multi-customer support enabled
|
|
||||||
- ✅ Documentation created
|
|
||||||
- 🔄 Test builds with different `.env` files (optional)
|
|
||||||
- 🔄 Document in DEVELOPMENT_PLAN.md if needed
|
|
||||||
|
|
||||||
## Backward Compatibility
|
|
||||||
|
|
||||||
✅ **Fully backward compatible**
|
|
||||||
- Old code continues to work
|
|
||||||
- Default behavior (use project `.env`) is the same
|
|
||||||
- No changes required for existing workflows
|
|
||||||
- New `--env-file` parameter is optional
|
|
||||||
166
CONTRIBUTING.md
166
CONTRIBUTING.md
|
|
@ -308,165 +308,33 @@ start docs\_build\html\index.html # Windows
|
||||||
- Add screenshots for UI features
|
- Add screenshots for UI features
|
||||||
- Keep language clear and concise
|
- Keep language clear and concise
|
||||||
|
|
||||||
## Writing Integration Tests
|
|
||||||
|
|
||||||
Integration tests should cover workflows across multiple components. See [tests/integration/test_update_flow.py](tests/integration/test_update_flow.py) for an example covering the update system.
|
|
||||||
|
|
||||||
## Release Process
|
## Release Process
|
||||||
|
|
||||||
### Versioning & Release Process
|
### Version Numbering
|
||||||
|
|
||||||
### Version Management
|
We follow [Semantic Versioning](https://semver.org/):
|
||||||
|
|
||||||
WebDrop Bridge uses **semantic versioning** (MAJOR.MINOR.PATCH). The version is centralized in one location:
|
- **MAJOR**: Breaking changes
|
||||||
|
- **MINOR**: New features (backward compatible)
|
||||||
|
- **PATCH**: Bug fixes
|
||||||
|
|
||||||
**Single Source of Truth**: `src/webdrop_bridge/__init__.py`
|
Example: `1.2.3` (Major.Minor.Patch)
|
||||||
|
|
||||||
```python
|
### Creating a Release
|
||||||
__version__ = "1.0.0"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Shared Version Utility**: `build/scripts/version_utils.py`
|
1. Update version in:
|
||||||
|
- `pyproject.toml`
|
||||||
|
- `src/webdrop_bridge/__init__.py`
|
||||||
|
|
||||||
All build scripts and version management tools use a shared utility to read the version from `__init__.py`, ensuring consistency across:
|
2. Update CHANGELOG.md
|
||||||
- `pyproject.toml` - Reads dynamically at build time
|
|
||||||
- `config.py` - Reads dynamically at startup
|
|
||||||
- `.env.example` - Updated by sync script (optional)
|
|
||||||
- `CHANGELOG.md` - Updated by sync script
|
|
||||||
|
|
||||||
### Releasing a New Version
|
3. Create git tag:
|
||||||
|
```bash
|
||||||
|
git tag -a v1.2.3 -m "Release version 1.2.3"
|
||||||
|
git push origin v1.2.3
|
||||||
|
```
|
||||||
|
|
||||||
#### Step 1: Update the Version (Only Place to Edit)
|
4. GitHub Actions will automatically build installers
|
||||||
|
|
||||||
Edit `src/webdrop_bridge/__init__.py` and change `__version__`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
__version__ = "1.2.0" # Change this to your new version
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Step 2: Sync Version to Changelog
|
|
||||||
|
|
||||||
Run the sync script to update the changelog:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python scripts/sync_version.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Or let the build script do it automatically:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Windows
|
|
||||||
python build/scripts/build_windows.py
|
|
||||||
|
|
||||||
# macOS
|
|
||||||
bash build/scripts/build_macos.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Both the build script and sync script use the shared `build/scripts/version_utils.py` utility.
|
|
||||||
|
|
||||||
#### Step 3: Update CHANGELOG.md Manually (Content Only)
|
|
||||||
|
|
||||||
The sync script adds the version header with the date. Now add your changes under each section:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## [1.2.0] - 2026-01-15
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- New feature description
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Breaking change description
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Bug fix description
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Step 4: Commit and Tag
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add -A
|
|
||||||
git commit -m "chore: release v1.2.0
|
|
||||||
|
|
||||||
- Feature 1 details
|
|
||||||
- Feature 2 details"
|
|
||||||
|
|
||||||
git tag -a v1.2.0 -m "Release version 1.2.0"
|
|
||||||
git push origin main --tags
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual Version Sync (If Needed)
|
|
||||||
|
|
||||||
If you need to sync versions without building:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python scripts/sync_version.py
|
|
||||||
```
|
|
||||||
|
|
||||||
To set a specific version:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python scripts/sync_version.py --version 1.2.0
|
|
||||||
```
|
|
||||||
|
|
||||||
### Querying Version in Code
|
|
||||||
|
|
||||||
Always import from the package:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from webdrop_bridge import __version__
|
|
||||||
|
|
||||||
print(__version__) # "1.2.0"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Override (Development Only)
|
|
||||||
|
|
||||||
If needed for testing, you can override with `.env`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# .env (development only)
|
|
||||||
APP_VERSION=1.2.0-dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Config loads it via lazy import (to avoid circular dependencies):
|
|
||||||
```python
|
|
||||||
if not os.getenv("APP_VERSION"):
|
|
||||||
from webdrop_bridge import __version__
|
|
||||||
app_version = __version__
|
|
||||||
else:
|
|
||||||
app_version = os.getenv("APP_VERSION")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Shared Version Utility
|
|
||||||
|
|
||||||
Both build scripts and the sync script use `build/scripts/version_utils.py` to read the version:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from version_utils import get_current_version, get_project_root
|
|
||||||
|
|
||||||
version = get_current_version() # Reads from __init__.py
|
|
||||||
root = get_project_root() # Gets project root
|
|
||||||
```
|
|
||||||
|
|
||||||
This ensures:
|
|
||||||
- **No duplication** - Single implementation used everywhere
|
|
||||||
- **Consistency** - All tools read from the same source
|
|
||||||
- **Maintainability** - Update once, affects all tools
|
|
||||||
|
|
||||||
If you create new build scripts or tools, import from this utility instead of implementing version reading again.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary of Version Management
|
|
||||||
|
|
||||||
| Task | How | Location |
|
|
||||||
|------|-----|----------|
|
|
||||||
| Define version | Edit `__version__` | `src/webdrop_bridge/__init__.py` |
|
|
||||||
| Read version in app | Lazy import `__init__.py` | `src/webdrop_bridge/config.py` |
|
|
||||||
| Read version in builds | Use shared utility | `build/scripts/version_utils.py` |
|
|
||||||
| Update changelog | Run sync script | `scripts/sync_version.py` |
|
|
||||||
| Release new version | Edit `__init__.py`, run sync, commit/tag | See "Releasing a New Version" above |
|
|
||||||
|
|
||||||
**Golden Rule**: Only edit `src/webdrop_bridge/__init__.py`. Everything else is automated or handled by scripts.
|
|
||||||
|
|
||||||
## Getting Help
|
## Getting Help
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -709,32 +709,6 @@ https://git.him-tools.de/HIM-public/webdrop-bridge/packages/
|
||||||
|
|
||||||
## Phase 4: Professional Features & Auto-Update (Weeks 9-12)
|
## Phase 4: Professional Features & Auto-Update (Weeks 9-12)
|
||||||
|
|
||||||
**Phase 4.1 Status**: ✅ **COMPLETE** (Jan 29, 2026)
|
|
||||||
- Priority 1 (Core): 27 tests passing (100%) - UpdateManager fully implemented
|
|
||||||
- Priority 2 (UI): 49 tests passing (100%) - Menu integration, dialogs, status bar
|
|
||||||
- Total Coverage: 76 tests passing, 48% coverage
|
|
||||||
- UpdateManager: 79% coverage
|
|
||||||
- MainWindow: 64% coverage
|
|
||||||
- Full workflow validated: startup check → dialog → download → install
|
|
||||||
|
|
||||||
**Phase 4.2 Status**: ✅ **COMPLETE** (Jan 29, 2026)
|
|
||||||
- Enhanced logging: 20 tests passing, 91% coverage
|
|
||||||
- JSONFormatter for structured logging
|
|
||||||
- PerformanceTracker for operation timing
|
|
||||||
- Log archival with 30-day retention
|
|
||||||
|
|
||||||
**Phase 4.3 Status**: ✅ **COMPLETE** (Jan 29, 2026)
|
|
||||||
- Configuration validation: ConfigValidator class with comprehensive schema
|
|
||||||
- Profile management: ConfigProfile for named profiles (work, personal, etc.)
|
|
||||||
- Settings UI: SettingsDialog with 5 organized tabs
|
|
||||||
- Import/Export: ConfigExporter for JSON serialization
|
|
||||||
- Total: 43 tests passing across config_manager and settings_dialog
|
|
||||||
|
|
||||||
**Phase 4 Overall**: ✅ **COMPLETE** - All 3 subphases complete
|
|
||||||
- **Total Tests**: 139 tests (76 Phase 4.1 + 20 Phase 4.2 + 43 Phase 4.3)
|
|
||||||
- **Coverage**: Professional-grade configuration, update, and logging systems
|
|
||||||
- **Next Phase**: 4.4 User Documentation and Phase 5 Post-Release
|
|
||||||
|
|
||||||
### 4.1 Auto-Update System with Forgejo Integration
|
### 4.1 Auto-Update System with Forgejo Integration
|
||||||
|
|
||||||
**Forgejo Configuration:**
|
**Forgejo Configuration:**
|
||||||
|
|
@ -810,191 +784,40 @@ AUTO_UPDATE_NOTIFY=true
|
||||||
- Security: HTTPS-only, checksum verification
|
- Security: HTTPS-only, checksum verification
|
||||||
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
- [x] `src/webdrop_bridge/core/updater.py` - Update manager (COMPLETE)
|
- [ ] `src/webdrop_bridge/core/updater.py` - Update manager
|
||||||
- [x] Unit tests for update checking and downloading (20 tests passing)
|
- [ ] Menu item for manual update check
|
||||||
- [x] Integration with Forgejo API (async queries working)
|
- [ ] Update notification dialog
|
||||||
- [x] Menu item for manual update check (COMPLETE - Priority 2)
|
- [ ] Unit tests for update checking and downloading
|
||||||
- [x] Update notification dialog (COMPLETE - Priority 2)
|
- [ ] Integration with Forgejo API
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
**Acceptance Criteria:**
|
||||||
- [x] Can query Forgejo releases API
|
- Can query Forgejo releases API
|
||||||
- [x] Detects new versions correctly
|
- Detects new versions correctly
|
||||||
- [x] Downloads and verifies checksums
|
- Downloads and verifies checksums
|
||||||
- [x] Gracefully handles network errors
|
- Prompts user for restart
|
||||||
- [x] Version comparison uses semantic versioning
|
- Manual check works from menu
|
||||||
- [x] Manual check works from menu (COMPLETE - Priority 2)
|
- Gracefully handles network errors
|
||||||
- [x] Prompts user for restart (COMPLETE - Priority 2)
|
- Version comparison uses semantic versioning
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 4.1.2 Update UI Components (`src/webdrop_bridge/ui/update_manager_ui.py`)
|
|
||||||
|
|
||||||
**Menu Integration:**
|
|
||||||
```
|
|
||||||
Help Menu
|
|
||||||
├─ Check for Updates... (manual trigger)
|
|
||||||
├─ ─────────────────────
|
|
||||||
└─ About WebDrop Bridge (show current version)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Dialogs:**
|
|
||||||
|
|
||||||
1. **"Checking for Updates..." Dialog**
|
|
||||||
- Animated spinner/progress
|
|
||||||
- "Cancel" button
|
|
||||||
- Message: "Checking for updates..."
|
|
||||||
- Timeout: 10 seconds
|
|
||||||
|
|
||||||
2. **"Update Available" Dialog**
|
|
||||||
- Current version: X.X.X
|
|
||||||
- New version: Y.Y.Y
|
|
||||||
- Changelog/release notes (scrollable)
|
|
||||||
- Buttons: "Update Now", "Later", "Skip This Version"
|
|
||||||
- Checkbox: "Show next update reminder"
|
|
||||||
|
|
||||||
3. **"Downloading Update..." Dialog**
|
|
||||||
- Progress bar (download %)
|
|
||||||
- File size info: "Downloading 195 MB..."
|
|
||||||
- "Cancel Download" button
|
|
||||||
- Cancel option reverts to "Later"
|
|
||||||
|
|
||||||
4. **"Install & Restart?" Dialog**
|
|
||||||
- Message: "Update downloaded and ready to install"
|
|
||||||
- Buttons: "Install Now", "Install on Next Restart"
|
|
||||||
- Checkbox: "Save my work before installing"
|
|
||||||
- Shows warning if unsaved changes exist
|
|
||||||
|
|
||||||
5. **"No Updates Available" Dialog**
|
|
||||||
- Message: "You're running the latest version (X.X.X)"
|
|
||||||
- Button: "OK"
|
|
||||||
- Optional: "Check again" button
|
|
||||||
|
|
||||||
6. **"Update Failed" Dialog**
|
|
||||||
- Error message with reason
|
|
||||||
- Buttons: "Retry", "Download Manually", "OK"
|
|
||||||
- Manual download link to Forgejo releases
|
|
||||||
|
|
||||||
**Status Bar Integration:**
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ Ready 🔄 Checking for updates... │ (during check)
|
|
||||||
│ Ready ✅ Update available (v1.1.0) │ (when found)
|
|
||||||
│ Ready ⬇️ Downloading update (45%) │ (during download)
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Background Behavior:**
|
|
||||||
- Startup: Check for updates automatically (no UI blocking)
|
|
||||||
- If newer version found: Show notification badge on Help menu
|
|
||||||
- Silent background download when user is idle
|
|
||||||
- Notification when download complete
|
|
||||||
- Prompt for restart when convenient
|
|
||||||
|
|
||||||
**Implementation:**
|
|
||||||
- Signal/slot architecture for async operations
|
|
||||||
- Non-blocking UI (all operations async)
|
|
||||||
- Graceful degradation if network unavailable
|
|
||||||
- Thread pool for download operations
|
|
||||||
- Cancel-safe download handling
|
|
||||||
|
|
||||||
**Deliverables:**
|
|
||||||
- [x] `src/webdrop_bridge/ui/update_manager_ui.py` - UI dialogs (COMPLETE)
|
|
||||||
- [x] Status bar update indicator (COMPLETE - emoji + status text)
|
|
||||||
- [x] Update menu item integration (COMPLETE - Priority 2)
|
|
||||||
- [x] All dialogs with signal hookups (COMPLETE - Priority 2)
|
|
||||||
- [x] Tests for UI interactions (COMPLETE - Priority 2)
|
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
|
||||||
- [x] Status bar updates in real-time (DONE)
|
|
||||||
- [x] No blocking operations on main thread (async/await)
|
|
||||||
- [x] Network errors handled gracefully (try/except with logging)
|
|
||||||
- [x] Menu item works and triggers check (COMPLETE - Priority 2)
|
|
||||||
- [x] All dialogs display correctly (COMPLETE - Priority 2)
|
|
||||||
- [x] Progress shown during download (COMPLETE - Priority 2)
|
|
||||||
- [x] Restart options work (COMPLETE - Priority 2)
|
|
||||||
- [x] Cancel operations work safely (COMPLETE - Priority 2)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 4.2 Enhanced Logging & Monitoring
|
### 4.2 Enhanced Logging & Monitoring
|
||||||
|
|
||||||
**Status**: ✅ **COMPLETE** (Jan 29, 2026)
|
|
||||||
- Structured JSON logging fully implemented
|
|
||||||
- Log rotation and archival with retention policies
|
|
||||||
- Performance metrics tracking with context managers
|
|
||||||
- 20 comprehensive tests, 91% coverage
|
|
||||||
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
- [x] Structured logging (JSON format option) - JSONFormatter class supports JSON output
|
- [ ] Structured logging (JSON format option)
|
||||||
- [x] Log rotation/archival - _archive_old_logs() manages old logs with 30-day retention
|
- [ ] Log rotation/archival
|
||||||
- [x] Performance metrics collection - PerformanceTracker context manager for timing operations
|
- [ ] Performance metrics collection
|
||||||
```python
|
- [ ] Crash reporting (optional)
|
||||||
with PerformanceTracker("database_query") as tracker:
|
|
||||||
# Your code
|
|
||||||
pass # Automatically logs elapsed time
|
|
||||||
```
|
|
||||||
- [x] Tests for enhanced logging - 20 tests covering all features
|
|
||||||
|
|
||||||
**Features Implemented:**
|
|
||||||
- `JSONFormatter` - Formats logs as JSON with timestamp, level, module, function, line number
|
|
||||||
- `setup_logging()` - Now supports `json_format=True` parameter for structured logging
|
|
||||||
- `_archive_old_logs()` - Automatically cleans up old log files based on retention period
|
|
||||||
- `PerformanceTracker` - Context manager for tracking operation duration and logging performance
|
|
||||||
```python
|
|
||||||
with PerformanceTracker("database_query") as tracker:
|
|
||||||
# Your code
|
|
||||||
pass # Automatically logs elapsed time
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 4.3 Advanced Configuration
|
### 4.3 Advanced Configuration
|
||||||
|
|
||||||
**Status**: ✅ **COMPLETE** (Jan 29, 2026)
|
|
||||||
- ConfigValidator with comprehensive schema validation (8 tests passing)
|
|
||||||
- ConfigProfile for named profile management (7 tests passing)
|
|
||||||
- ConfigExporter for JSON import/export (5 tests passing)
|
|
||||||
- SettingsDialog Qt UI with 5 tabs (23 tests passing)
|
|
||||||
- Total: 43 tests passing, 75% coverage on new modules
|
|
||||||
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
- [x] Configuration validation schema - ConfigValidator class with 8-test suite
|
- [ ] UI settings dialog
|
||||||
- Validates all config fields with detailed error messages
|
- [ ] Configuration validation schema
|
||||||
- Enforces type constraints, ranges, and allowed values
|
- [ ] Profile support (work, personal, etc.)
|
||||||
- Used throughout to ensure config consistency
|
- [ ] Export/import settings
|
||||||
|
|
||||||
- [x] UI settings dialog - SettingsDialog with 5 tabs (23 tests)
|
|
||||||
- **Paths Tab**: Manage allowed root directories with add/remove buttons
|
|
||||||
- **URLs Tab**: Manage allowed web URLs with wildcard support
|
|
||||||
- **Logging Tab**: Select log level and choose log file location
|
|
||||||
- **Window Tab**: Configure window width and height
|
|
||||||
- **Profiles Tab**: Save/load/delete named profiles, export/import configs
|
|
||||||
|
|
||||||
- [x] Profile support - ConfigProfile class (7 tests)
|
|
||||||
- Save current config as named profile (work, personal, etc.)
|
|
||||||
- Load saved profile to restore settings
|
|
||||||
- List all available profiles
|
|
||||||
- Delete profiles
|
|
||||||
- Profiles stored in ~/.webdrop-bridge/profiles/ as JSON
|
|
||||||
|
|
||||||
- [x] Export/import settings - ConfigExporter class (5 tests)
|
|
||||||
- `export_to_json()` - Save configuration to JSON file
|
|
||||||
- `import_from_json()` - Load and validate configuration from JSON
|
|
||||||
- All imports validated with ConfigValidator
|
|
||||||
- Handles file I/O errors gracefully
|
|
||||||
|
|
||||||
**Key Features:**
|
|
||||||
- Full configuration validation with helpful error messages
|
|
||||||
- Named profiles for different work contexts
|
|
||||||
- JSON export/import with validation
|
|
||||||
- Professional Qt dialog with organized tabs
|
|
||||||
- Profiles stored in standard ~/.webdrop-bridge/ directory
|
|
||||||
- 43 unit tests covering all functionality (87% coverage on config_manager)
|
|
||||||
|
|
||||||
**Test Results:**
|
|
||||||
- `test_config_manager.py` - 20 tests, 87% coverage
|
|
||||||
- `test_settings_dialog.py` - 23 tests, 75% coverage
|
|
||||||
- Total Phase 4.3 - 43 tests passing
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -1210,15 +1033,28 @@ February 2026
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current Phase
|
|
||||||
|
|
||||||
Pre-release development (Phase 1-2). Integration tests for update flow implemented.
|
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
- Finalize auto-update system
|
1. **Immediate** (This week):
|
||||||
- Expand integration test coverage (see `tests/integration/test_update_flow.py`)
|
- [ ] Set up project directories ✅
|
||||||
- Update documentation for new features
|
- [ ] Create configuration system
|
||||||
|
- [ ] Implement path validator
|
||||||
|
- [ ] Set up CI/CD
|
||||||
|
|
||||||
|
2. **Near term** (Next 2 weeks):
|
||||||
|
- [ ] Complete core components
|
||||||
|
- [ ] Write comprehensive tests
|
||||||
|
- [ ] Build installers
|
||||||
|
|
||||||
|
3. **Medium term** (Weeks 5-8):
|
||||||
|
- [ ] Code review & QA
|
||||||
|
- [ ] Performance optimization
|
||||||
|
- [ ] Documentation
|
||||||
|
|
||||||
|
4. **Long term** (Months 2-3):
|
||||||
|
- [ ] Advanced features
|
||||||
|
- [ ] Community engagement
|
||||||
|
- [ ] Auto-update system
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,21 +64,11 @@ src/webdrop_bridge/
|
||||||
└── __init__.py Utils module initialization
|
└── __init__.py Utils module initialization
|
||||||
```
|
```
|
||||||
|
|
||||||
## Source Files
|
|
||||||
|
|
||||||
- src/webdrop_bridge/main.py
|
|
||||||
- src/webdrop_bridge/config.py
|
|
||||||
- src/webdrop_bridge/core/validator.py
|
|
||||||
- src/webdrop_bridge/core/drag_interceptor.py
|
|
||||||
- src/webdrop_bridge/core/updater.py
|
|
||||||
- src/webdrop_bridge/ui/main_window.py
|
|
||||||
|
|
||||||
Structure ready for implementation:
|
Structure ready for implementation:
|
||||||
- `src/webdrop_bridge/main.py` (to implement)
|
- `src/webdrop_bridge/main.py` (to implement)
|
||||||
- `src/webdrop_bridge/config.py` (to implement)
|
- `src/webdrop_bridge/config.py` (to implement)
|
||||||
- `src/webdrop_bridge/core/validator.py` (to implement)
|
- `src/webdrop_bridge/core/validator.py` (to implement)
|
||||||
- `src/webdrop_bridge/core/drag_interceptor.py` (to implement)
|
- `src/webdrop_bridge/core/drag_interceptor.py` (to implement)
|
||||||
- `src/webdrop_bridge/core/updater.py` (to implement)
|
|
||||||
- `src/webdrop_bridge/ui/main_window.py` (to implement)
|
- `src/webdrop_bridge/ui/main_window.py` (to implement)
|
||||||
- `src/webdrop_bridge/utils/logging.py` (to implement)
|
- `src/webdrop_bridge/utils/logging.py` (to implement)
|
||||||
|
|
||||||
|
|
@ -99,14 +89,6 @@ tests/
|
||||||
└── (ready for test data)
|
└── (ready for test data)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
- tests/unit/test_validator.py
|
|
||||||
- tests/unit/test_drag_interceptor.py
|
|
||||||
- tests/integration/test_drag_workflow.py
|
|
||||||
- tests/integration/test_end_to_end.py
|
|
||||||
- tests/integration/test_update_flow.py
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Build & Automation Files (5)
|
## Build & Automation Files (5)
|
||||||
|
|
|
||||||
|
|
@ -213,29 +213,6 @@ def main():
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Task 1.7: Auto-update System
|
|
||||||
|
|
||||||
**File**: `src/webdrop_bridge/utils/update.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
def setup_auto_update():
|
|
||||||
# Configure auto-update
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
|
|
||||||
**Tests**: `tests/unit/test_update.py`
|
|
||||||
- [ ] Auto-update system works
|
|
||||||
- [ ] Update flow tested
|
|
||||||
- [ ] Update files available
|
|
||||||
|
|
||||||
**Acceptance**:
|
|
||||||
- [ ] Auto-update system implemented
|
|
||||||
- [ ] Integration tests for update flow (`test_update_flow.py`)
|
|
||||||
- [ ] Documentation updated for new features
|
|
||||||
- [ ] Documentation files verified and synced
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quality Gates
|
## Quality Gates
|
||||||
|
|
||||||
### Before Committing
|
### Before Committing
|
||||||
|
|
|
||||||
|
|
@ -1,193 +0,0 @@
|
||||||
"""Phase 4.3 Advanced Configuration - Summary Report
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Phase 4.3 (Advanced Configuration) has been successfully completed with comprehensive
|
|
||||||
configuration management, validation, profile support, and settings UI.
|
|
||||||
|
|
||||||
## Files Created
|
|
||||||
|
|
||||||
### Core Implementation
|
|
||||||
1. src/webdrop_bridge/core/config_manager.py (263 lines)
|
|
||||||
- ConfigValidator: Schema-based validation with helpful error messages
|
|
||||||
- ConfigProfile: Named profile management in ~/.webdrop-bridge/profiles/
|
|
||||||
- ConfigExporter: JSON import/export with validation
|
|
||||||
|
|
||||||
2. src/webdrop_bridge/ui/settings_dialog.py (437 lines)
|
|
||||||
- SettingsDialog: Professional Qt dialog with 5 tabs
|
|
||||||
- Paths Tab: Manage allowed root directories
|
|
||||||
- URLs Tab: Manage allowed web URLs
|
|
||||||
- Logging Tab: Configure log level and file
|
|
||||||
- Window Tab: Manage window dimensions
|
|
||||||
- Profiles Tab: Save/load/delete profiles, export/import
|
|
||||||
|
|
||||||
### Test Files
|
|
||||||
1. tests/unit/test_config_manager.py (264 lines)
|
|
||||||
- 20 comprehensive tests
|
|
||||||
- 87% coverage on config_manager module
|
|
||||||
- Tests for validation, profiles, export/import
|
|
||||||
|
|
||||||
2. tests/unit/test_settings_dialog.py (296 lines)
|
|
||||||
- 23 comprehensive tests
|
|
||||||
- 75% coverage on settings_dialog module
|
|
||||||
- Tests for UI initialization, data retrieval, config application
|
|
||||||
|
|
||||||
## Test Results
|
|
||||||
|
|
||||||
### Config Manager Tests (20/20 passing)
|
|
||||||
- TestConfigValidator: 8 tests
|
|
||||||
* Valid config validation
|
|
||||||
* Missing required fields
|
|
||||||
* Invalid types
|
|
||||||
* Invalid log levels
|
|
||||||
* Out of range values
|
|
||||||
* validate_or_raise functionality
|
|
||||||
|
|
||||||
- TestConfigProfile: 7 tests
|
|
||||||
* Save/load profiles
|
|
||||||
* List profiles
|
|
||||||
* Delete profiles
|
|
||||||
* Invalid profile names
|
|
||||||
* Nonexistent profiles
|
|
||||||
|
|
||||||
- TestConfigExporter: 5 tests
|
|
||||||
* Export to JSON
|
|
||||||
* Import from JSON
|
|
||||||
* Nonexistent files
|
|
||||||
* Invalid JSON
|
|
||||||
* Invalid config detection
|
|
||||||
|
|
||||||
### Settings Dialog Tests (23/23 passing)
|
|
||||||
- TestSettingsDialogInitialization: 7 tests
|
|
||||||
* Dialog creation
|
|
||||||
* Tab structure
|
|
||||||
* All 5 tabs present (Paths, URLs, Logging, Window, Profiles)
|
|
||||||
|
|
||||||
- TestPathsTab: 2 tests
|
|
||||||
* Paths loaded from config
|
|
||||||
* Add button exists
|
|
||||||
|
|
||||||
- TestURLsTab: 1 test
|
|
||||||
* URLs loaded from config
|
|
||||||
|
|
||||||
- TestLoggingTab: 2 tests
|
|
||||||
* Log level set from config
|
|
||||||
* All log levels available
|
|
||||||
|
|
||||||
- TestWindowTab: 4 tests
|
|
||||||
* Window dimensions set from config
|
|
||||||
* Min/max constraints
|
|
||||||
|
|
||||||
- TestProfilesTab: 1 test
|
|
||||||
* Profiles list initialized
|
|
||||||
|
|
||||||
- TestConfigDataRetrieval: 3 tests
|
|
||||||
* Get config data from dialog
|
|
||||||
* Config data validation
|
|
||||||
* Modified values preserved
|
|
||||||
|
|
||||||
- TestApplyConfigData: 3 tests
|
|
||||||
* Apply paths
|
|
||||||
* Apply URLs
|
|
||||||
* Apply window size
|
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
### ConfigValidator
|
|
||||||
- Comprehensive schema definition
|
|
||||||
- Type validation (str, int, bool, list, Path)
|
|
||||||
- Value constraints (min/max, allowed values, length)
|
|
||||||
- Detailed error messages
|
|
||||||
- Reusable for all configuration validation
|
|
||||||
|
|
||||||
### ConfigProfile
|
|
||||||
- Save configurations as named profiles
|
|
||||||
- Profile storage: ~/.webdrop-bridge/profiles/
|
|
||||||
- JSON serialization with validation
|
|
||||||
- List/load/delete profile operations
|
|
||||||
- Error handling for invalid names and I/O failures
|
|
||||||
|
|
||||||
### ConfigExporter
|
|
||||||
- Export current configuration to JSON file
|
|
||||||
- Import and validate JSON configurations
|
|
||||||
- Handles file I/O errors
|
|
||||||
- All imports validated before return
|
|
||||||
|
|
||||||
### SettingsDialog
|
|
||||||
- Professional Qt QDialog with tabbed interface
|
|
||||||
- Load config on initialization
|
|
||||||
- Save modifications as profiles or export
|
|
||||||
- Import configurations from files
|
|
||||||
- All settings integrated with validation
|
|
||||||
- User-friendly error dialogs
|
|
||||||
|
|
||||||
## Code Quality
|
|
||||||
|
|
||||||
### Validation
|
|
||||||
- All validation centralized in ConfigValidator
|
|
||||||
- Schema-driven approach enables consistency
|
|
||||||
- Detailed error messages guide users
|
|
||||||
- Type hints throughout
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- 43 comprehensive unit tests (100% passing)
|
|
||||||
- 87% coverage on config_manager
|
|
||||||
- 75% coverage on settings_dialog
|
|
||||||
- Tests cover normal operations and error conditions
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- Module docstrings for all classes
|
|
||||||
- Method docstrings with Args/Returns/Raises
|
|
||||||
- Schema definition documented in code
|
|
||||||
- Example usage in tests
|
|
||||||
|
|
||||||
## Integration Points
|
|
||||||
|
|
||||||
### With MainWindow
|
|
||||||
- Settings menu item can launch SettingsDialog
|
|
||||||
- Dialog returns validated configuration dict
|
|
||||||
- Changes can be applied on OK
|
|
||||||
|
|
||||||
### With Configuration System
|
|
||||||
- ConfigValidator used to ensure all configs valid
|
|
||||||
- ConfigProfile integrates with ~/.webdrop-bridge/
|
|
||||||
- Export/import uses standard JSON format
|
|
||||||
|
|
||||||
### With Logging
|
|
||||||
- Log level changes apply through SettingsDialog
|
|
||||||
- Profiles can include different logging configs
|
|
||||||
|
|
||||||
## Phase 4.3 Completion Summary
|
|
||||||
|
|
||||||
✅ All 4 Deliverables Implemented:
|
|
||||||
1. UI Settings Dialog - SettingsDialog with 5 organized tabs
|
|
||||||
2. Validation Schema - ConfigValidator with comprehensive checks
|
|
||||||
3. Profile Support - ConfigProfile for named configurations
|
|
||||||
4. Export/Import - ConfigExporter for JSON serialization
|
|
||||||
|
|
||||||
✅ Test Coverage: 43 tests passing (87-75% coverage)
|
|
||||||
|
|
||||||
✅ Code Quality:
|
|
||||||
- Type hints throughout
|
|
||||||
- Comprehensive docstrings
|
|
||||||
- Error handling
|
|
||||||
- Validation at all levels
|
|
||||||
|
|
||||||
✅ Ready for Phase 4.4 (User Documentation)
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. Phase 4.4: User Documentation
|
|
||||||
- User manual for configuration system
|
|
||||||
- Video tutorials for settings dialog
|
|
||||||
- Troubleshooting guide
|
|
||||||
|
|
||||||
2. Phase 5: Post-Release
|
|
||||||
- Analytics integration
|
|
||||||
- Enhanced monitoring
|
|
||||||
- Community support
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Report Generated: January 29, 2026
|
|
||||||
Phase 4.3 Status: ✅ COMPLETE
|
|
||||||
"""
|
|
||||||
|
|
@ -76,12 +76,6 @@ Build Scripts: Windows & macOS
|
||||||
CI/CD Workflows: Automated testing & building
|
CI/CD Workflows: Automated testing & building
|
||||||
```
|
```
|
||||||
|
|
||||||
## Statistics
|
|
||||||
|
|
||||||
- Source files: 6
|
|
||||||
- Test files: 5
|
|
||||||
- Documentation files: 9
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
@ -390,12 +384,6 @@ All dependencies are locked in:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
- Auto-update system: Implemented
|
|
||||||
- Integration tests: Implemented (`test_update_flow.py`)
|
|
||||||
- Documentation: Updated and verified
|
|
||||||
|
|
||||||
**Status**: ✅ Project Ready for Development
|
**Status**: ✅ Project Ready for Development
|
||||||
**Next Phase**: Implement Core Components (Phase 1)
|
**Next Phase**: Implement Core Components (Phase 1)
|
||||||
**Timeline**: 12 weeks to complete all phases
|
**Timeline**: 12 weeks to complete all phases
|
||||||
|
|
|
||||||
|
|
@ -110,12 +110,6 @@ pytest tests/unit/ -v # Unit tests
|
||||||
pytest tests/integration/ -v # Integration tests
|
pytest tests/integration/ -v # Integration tests
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running Integration Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest tests/integration/ -v
|
|
||||||
```
|
|
||||||
|
|
||||||
### Code Quality
|
### Code Quality
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
189
README.md
189
README.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> Professional Qt-based desktop application for intelligent drag-and-drop file handling between web applications and desktop clients (InDesign, Word, Notepad++, etc.)
|
> Professional Qt-based desktop application for intelligent drag-and-drop file handling between web applications and desktop clients (InDesign, Word, Notepad++, etc.)
|
||||||
|
|
||||||
  
|
  
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|
@ -23,20 +23,16 @@ WebDrop Bridge embeds a web application in a Qt container with full filesystem a
|
||||||
- ✅ **Embedded Web App** - QtWebEngine provides Chromium without browser limitations
|
- ✅ **Embedded Web App** - QtWebEngine provides Chromium without browser limitations
|
||||||
- ✅ **Drag Interception** - Converts text paths to native file operations
|
- ✅ **Drag Interception** - Converts text paths to native file operations
|
||||||
- ✅ **Path Whitelist** - Security-conscious file system access control
|
- ✅ **Path Whitelist** - Security-conscious file system access control
|
||||||
- ✅ **Configuration Management** - Profile-based settings with validation
|
|
||||||
- ✅ **Settings Dialog** - Professional UI for path, URL, logging, and window configuration
|
|
||||||
- ✅ **Auto-Update System** - Automatic release detection via Forgejo API
|
|
||||||
- ✅ **Professional Build Pipeline** - MSI for Windows, DMG for macOS
|
- ✅ **Professional Build Pipeline** - MSI for Windows, DMG for macOS
|
||||||
- ✅ **Comprehensive Testing** - Unit, integration, and end-to-end tests (80%+ coverage)
|
- ✅ **Comprehensive Testing** - Unit, integration, and end-to-end tests
|
||||||
- ✅ **CI/CD Ready** - GitHub Actions workflows included
|
- ✅ **CI/CD Ready** - GitHub Actions workflows included
|
||||||
- ✅ **Structured Logging** - File-based logging with configurable levels
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
- Python 3.10+
|
- Python 3.10+
|
||||||
- Windows 10/11 or macOS 12+
|
- Windows 10/11 or macOS 12+
|
||||||
- 200 MB disk space (includes Chromium from PyInstaller)
|
- 100 MB disk space
|
||||||
|
|
||||||
### Installation from Source
|
### Installation from Source
|
||||||
|
|
||||||
|
|
@ -45,11 +41,10 @@ WebDrop Bridge embeds a web application in a Qt container with full filesystem a
|
||||||
git clone https://github.com/yourusername/webdrop-bridge.git
|
git clone https://github.com/yourusername/webdrop-bridge.git
|
||||||
cd webdrop-bridge
|
cd webdrop-bridge
|
||||||
|
|
||||||
# Create and activate virtual environment
|
# Create virtual environment
|
||||||
python -m venv venv
|
python -m venv venv
|
||||||
source venv/bin/activate # macOS/Linux
|
source venv/bin/activate # macOS/Linux
|
||||||
# venv\Scripts\activate.ps1 # Windows (PowerShell)
|
# venv\Scripts\activate # Windows
|
||||||
# venv\Scripts\activate.bat # Windows (cmd.exe)
|
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
@ -65,14 +60,14 @@ python -m webdrop_bridge.main
|
||||||
pip install -r requirements-dev.txt
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
# Run tests
|
# Run tests
|
||||||
pytest tests -v
|
pytest
|
||||||
|
|
||||||
# Run all quality checks (lint, type, format)
|
# Run linting checks
|
||||||
tox
|
tox -e lint
|
||||||
|
|
||||||
# Build installers
|
# Build for your platform
|
||||||
python build/scripts/build_windows.py # Windows MSI
|
tox -e build-windows # Windows
|
||||||
bash build/scripts/build_macos.sh # macOS DMG
|
tox -e build-macos # macOS
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
@ -135,139 +130,55 @@ webdrop-bridge/
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
WebDrop Bridge supports two configuration methods:
|
Create `.env` file from `.env.example`:
|
||||||
|
|
||||||
### 1. Settings Dialog (Recommended)
|
|
||||||
Launch the application and access the Settings menu to configure:
|
|
||||||
- **Paths Tab** - Add/remove allowed root directories
|
|
||||||
- **URLs Tab** - Configure allowed web URLs (whitelist mode)
|
|
||||||
- **Logging Tab** - Set log level and file location
|
|
||||||
- **Window Tab** - Configure window dimensions
|
|
||||||
- **Profiles Tab** - Save/load/export-import configuration profiles
|
|
||||||
|
|
||||||
Profiles are saved in `~/.webdrop-bridge/profiles/`
|
|
||||||
|
|
||||||
### 2. Environment Variables
|
|
||||||
Create a `.env` file in the project root. Available settings:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Application
|
cp .env.example .env
|
||||||
APP_NAME=WebDrop Bridge
|
|
||||||
APP_VERSION=1.0.0
|
|
||||||
|
|
||||||
# Paths (comma-separated)
|
|
||||||
ALLOWED_ROOTS=Z:/,C:/Users/Public
|
|
||||||
|
|
||||||
# Web URLs (empty = no restriction, items = kiosk mode)
|
|
||||||
ALLOWED_URLS=
|
|
||||||
|
|
||||||
# Interface
|
|
||||||
WEBAPP_URL=file:///./webapp/index.html
|
|
||||||
WINDOW_WIDTH=1024
|
|
||||||
WINDOW_HEIGHT=768
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
LOG_LEVEL=INFO
|
|
||||||
ENABLE_LOGGING=true
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Key settings:
|
||||||
|
- `WEBAPP_URL` - Local or remote web app URL
|
||||||
|
- `ALLOWED_ROOTS` - Comma-separated whitelist of allowed directories
|
||||||
|
- `LOG_LEVEL` - DEBUG, INFO, WARNING, ERROR
|
||||||
|
- `WINDOW_WIDTH` / `WINDOW_HEIGHT` - Initial window size
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
WebDrop Bridge includes comprehensive test coverage with unit, integration, and end-to-end tests.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run all tests
|
# Run all tests
|
||||||
pytest tests -v
|
pytest
|
||||||
|
|
||||||
# Run with coverage report
|
# Run specific test type
|
||||||
pytest tests --cov=src/webdrop_bridge --cov-report=html
|
pytest tests/unit/ # Unit tests only
|
||||||
|
pytest tests/integration/ # Integration tests only
|
||||||
|
|
||||||
# Run specific test categories
|
# With coverage report
|
||||||
pytest tests/unit -v # Unit tests only
|
pytest --cov=src/webdrop_bridge --cov-report=html
|
||||||
pytest tests/integration -v # Integration tests only
|
|
||||||
|
|
||||||
# Run specific test
|
# Run on specific platform marker
|
||||||
pytest tests/unit/test_validator.py -v
|
pytest -m windows # Windows-specific tests
|
||||||
|
pytest -m macos # macOS-specific tests
|
||||||
# Run tests matching a pattern
|
|
||||||
pytest tests -k "config" -v
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Test Coverage**:
|
|
||||||
- Current target: 80%+
|
|
||||||
- Coverage report: `htmlcov/index.html`
|
|
||||||
|
|
||||||
Integration tests cover:
|
|
||||||
- Drag-and-drop workflow
|
|
||||||
- Update flow and release detection
|
|
||||||
- End-to-end application scenarios
|
|
||||||
|
|
||||||
## Auto-Update System
|
|
||||||
|
|
||||||
WebDrop Bridge includes an intelligent auto-update system that:
|
|
||||||
|
|
||||||
- **Automatic Detection**: Periodically checks Forgejo/GitHub releases API
|
|
||||||
- **Smart Caching**: Avoids redundant network calls with smart caching
|
|
||||||
- **User Notification**: Alerts users of available updates via UI
|
|
||||||
- **Release Notes**: Displays release notes and changes
|
|
||||||
- **Safe Deployment**: Only triggers on newer versions
|
|
||||||
|
|
||||||
The update system is fully integrated with the application and runs in the background without blocking the UI.
|
|
||||||
|
|
||||||
For technical details, see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#update-system).
|
|
||||||
|
|
||||||
## Changelog
|
|
||||||
|
|
||||||
See [CHANGELOG.md](CHANGELOG.md) for release notes.
|
|
||||||
|
|
||||||
## Building Installers
|
## Building Installers
|
||||||
|
|
||||||
### Windows MSI Installer
|
### Windows MSI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Simple build (creates standalone .exe)
|
pip install pyinstaller
|
||||||
python build/scripts/build_windows.py
|
python build/scripts/build_windows.py
|
||||||
|
|
||||||
# Build with MSI installer
|
|
||||||
python build/scripts/build_windows.py --msi
|
|
||||||
|
|
||||||
# Build and sign executable
|
|
||||||
python build/scripts/build_windows.py --sign
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Output:
|
Output: `build/dist/WebDropBridge.exe`
|
||||||
- Standalone executable: `build/dist/windows/WebDropBridge.exe` (~195 MB)
|
|
||||||
- Optional MSI installer: `build/dist/windows/WebDropBridge.msi`
|
|
||||||
- SHA256 checksum: `build/dist/windows/WebDropBridge.exe.sha256`
|
|
||||||
|
|
||||||
### macOS DMG Installer
|
### macOS DMG
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build DMG (requires macOS)
|
pip install pyinstaller
|
||||||
bash build/scripts/build_macos.sh
|
bash build/scripts/build_macos.sh
|
||||||
|
|
||||||
# Build with code signing
|
|
||||||
SIGN_APP=true bash build/scripts/build_macos.sh
|
|
||||||
|
|
||||||
# Build with notarization
|
|
||||||
NOTARIZE_APP=true bash build/scripts/build_macos.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Output:
|
Output: `build/dist/WebDropBridge.dmg`
|
||||||
- DMG installer: `build/dist/macos/WebDropBridge.dmg`
|
|
||||||
- App bundle: `build/dist/macos/WebDropBridge.app`
|
|
||||||
|
|
||||||
### Creating Releases
|
|
||||||
|
|
||||||
For Forgejo/GitHub releases:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Windows - Create release with executable
|
|
||||||
powershell -ExecutionPolicy Bypass -File build/scripts/create_release.ps1
|
|
||||||
|
|
||||||
# macOS - Create release with DMG
|
|
||||||
bash build/scripts/create_release.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
|
|
||||||
|
|
@ -340,35 +251,13 @@ MIT License - see [LICENSE](LICENSE) file for details
|
||||||
- Inspired by professional desktop integration practices
|
- Inspired by professional desktop integration practices
|
||||||
- Special thanks to the Qt community
|
- Special thanks to the Qt community
|
||||||
|
|
||||||
## Development Status
|
|
||||||
|
|
||||||
**Current Phase**: Phase 4.3 - Advanced Configuration & Testing
|
|
||||||
|
|
||||||
**Completed**:
|
|
||||||
- ✅ Phase 1: Core Components (Validator, Config, Drag Interceptor, Main Window)
|
|
||||||
- ✅ Phase 2: UI Implementation (Settings Dialog, Main Window UI Components)
|
|
||||||
- ✅ Phase 3: Build & Distribution (Windows MSI, macOS DMG, Release Scripts)
|
|
||||||
- ✅ Phase 4.1: Update System (Auto-update, Forgejo API integration)
|
|
||||||
- ✅ Phase 4.2: Web App Improvements (Modern UI, Drag-drop testing)
|
|
||||||
- ✅ Phase 4.3: Advanced Configuration (Profiles, Validation, Settings UI)
|
|
||||||
|
|
||||||
**In Progress/Planned**:
|
|
||||||
- Phase 4.4: Performance optimization & security hardening
|
|
||||||
- Phase 5: Release candidates & final testing
|
|
||||||
- v1.0: Stable Windows & macOS release
|
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- [x] Core drag-drop functionality
|
- [ ] v1.0 - Stable Windows & macOS release
|
||||||
- [x] Configuration management with profiles
|
- [ ] v1.1 - Advanced filtering and logging UI
|
||||||
- [x] Auto-update system
|
|
||||||
- [x] Professional build pipeline
|
|
||||||
- [x] Comprehensive test suite
|
|
||||||
- [ ] Performance benchmarking & optimization
|
|
||||||
- [ ] Security audit & hardening
|
|
||||||
- [ ] v1.1 - Advanced filtering and extended logging
|
|
||||||
- [ ] v1.2 - API for custom handlers
|
- [ ] v1.2 - API for custom handlers
|
||||||
- [ ] v2.0 - Plugin architecture
|
- [ ] v2.0 - Plugin architecture
|
||||||
|
- [ ] v2.1 - Cloud storage integration (OneDrive, Google Drive)
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
|
|
@ -378,4 +267,4 @@ MIT License - see [LICENSE](LICENSE) file for details
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Development Phase**: Pre-Release Phase 4.3 | **Last Updated**: February 2026 | **Python**: 3.10+ | **Qt**: PySide6 (Qt 6)
|
**Status**: Alpha Development | **Last Updated**: January 2026
|
||||||
|
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
# Update Feature Fixes - Final Summary
|
|
||||||
|
|
||||||
## Problem Identified
|
|
||||||
The update feature was causing the application to hang indefinitely when clicked. The issue had two components:
|
|
||||||
|
|
||||||
1. **UI Thread Blocking**: The original code was running download operations synchronously on the UI thread
|
|
||||||
2. **Network Timeout Issues**: Even with timeouts set, the socket-level network calls would hang indefinitely if the server didn't respond
|
|
||||||
|
|
||||||
## Solutions Implemented
|
|
||||||
|
|
||||||
### 1. Background Threading (First Fix)
|
|
||||||
- Created `UpdateDownloadWorker` class to run download operations in a background thread
|
|
||||||
- Moved blocking network calls off the UI thread
|
|
||||||
- This prevents the UI from freezing while waiting for network operations
|
|
||||||
|
|
||||||
### 2. Aggressive Timeout Strategy (Second Fix)
|
|
||||||
Applied timeouts at multiple levels to ensure the app never hangs:
|
|
||||||
|
|
||||||
#### A. Socket-Level Timeout (Most Important)
|
|
||||||
- **File**: `src/webdrop_bridge/core/updater.py`
|
|
||||||
- Reduced `urlopen()` timeout from 10 seconds to **5 seconds**
|
|
||||||
- This is the first line of defense against hanging socket connections
|
|
||||||
- Applied in `_fetch_release()` method
|
|
||||||
|
|
||||||
#### B. Asyncio-Level Timeout
|
|
||||||
- **File**: `src/webdrop_bridge/ui/main_window.py` and `src/webdrop_bridge/core/updater.py`
|
|
||||||
- `UpdateCheckWorker`: 10-second timeout on entire check operation
|
|
||||||
- `UpdateDownloadWorker`: 300-second timeout on download, 30-second on verification
|
|
||||||
- `check_for_updates()`: 8-second timeout on async executor
|
|
||||||
- These catch any remaining hangs in the asyncio operations
|
|
||||||
|
|
||||||
#### C. Qt-Level Timeout (Final Safety Net)
|
|
||||||
- **File**: `src/webdrop_bridge/ui/main_window.py`
|
|
||||||
- Update check: **30-second QTimer** safety timeout (`_run_async_check()`)
|
|
||||||
- Download: **10-minute QTimer** safety timeout (`_perform_update_async()`)
|
|
||||||
- If nothing else works, Qt's event loop will forcefully close the operation
|
|
||||||
|
|
||||||
### 3. Error Handling Improvements
|
|
||||||
- Added proper exception handling for `asyncio.TimeoutError`
|
|
||||||
- Better logging to identify where hangs occur
|
|
||||||
- User-friendly error messages like "no server response" or "Operation timed out"
|
|
||||||
- Graceful degradation: operations fail fast instead of hanging
|
|
||||||
|
|
||||||
## Timeout Hierarchy (in seconds)
|
|
||||||
```
|
|
||||||
Update Check Flow:
|
|
||||||
QTimer safety net: 30s ─┐
|
|
||||||
├─ Asyncio timeout: 10s ─┐
|
|
||||||
├─ Socket timeout: 5s (first to trigger)
|
|
||||||
Download Flow:
|
|
||||||
QTimer safety net: 600s ─┐
|
|
||||||
├─ Asyncio timeout: 300s ─┐
|
|
||||||
├─ Socket timeout: 5s (first to trigger)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
1. **src/webdrop_bridge/ui/main_window.py**
|
|
||||||
- Updated `UpdateCheckWorker.run()` with timeout handling
|
|
||||||
- Updated `UpdateDownloadWorker.run()` with timeout handling
|
|
||||||
- Added QTimer safety timeouts in `_run_async_check()` and `_perform_update_async()`
|
|
||||||
- Proper event loop cleanup in finally blocks
|
|
||||||
|
|
||||||
2. **src/webdrop_bridge/core/updater.py**
|
|
||||||
- Reduced socket timeout in `_fetch_release()` from 10s to 5s
|
|
||||||
- Added timeout to `check_for_updates()` async operation
|
|
||||||
- Added timeout to `download_update()` async operation
|
|
||||||
- Added timeout to `verify_checksum()` async operation
|
|
||||||
- Better error logging with exception types
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
- All 7 integration tests pass
|
|
||||||
- Timeout verification script confirms all timeout mechanisms are in place
|
|
||||||
- No syntax errors in modified code
|
|
||||||
|
|
||||||
## Result
|
|
||||||
The application will no longer hang indefinitely when checking for or downloading updates. Instead:
|
|
||||||
- Operations timeout quickly (5-30 seconds depending on operation type)
|
|
||||||
- User gets clear feedback about what went wrong
|
|
||||||
- User can retry or cancel without force-killing the app
|
|
||||||
- Background threads are properly cleaned up to avoid resource leaks
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
# Simplified Versioning System
|
|
||||||
|
|
||||||
## Problem Solved
|
|
||||||
|
|
||||||
Previously, the application version had to be manually updated in **multiple places**:
|
|
||||||
1. `src/webdrop_bridge/__init__.py` - source of truth
|
|
||||||
2. `pyproject.toml` - package version
|
|
||||||
3. `.env.example` - environment example
|
|
||||||
4. Run `scripts/sync_version.py` - manual sync step
|
|
||||||
|
|
||||||
This was error-prone and tedious.
|
|
||||||
|
|
||||||
## Solution: Single Source of Truth
|
|
||||||
|
|
||||||
The version is now defined **only in one place**:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# src/webdrop_bridge/__init__.py
|
|
||||||
__version__ = "1.0.0"
|
|
||||||
```
|
|
||||||
|
|
||||||
All other components automatically read from this single source.
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
### 1. **pyproject.toml** (Automatic)
|
|
||||||
```toml
|
|
||||||
[tool.setuptools.dynamic]
|
|
||||||
version = {attr = "webdrop_bridge.__version__"}
|
|
||||||
|
|
||||||
[project]
|
|
||||||
name = "webdrop-bridge"
|
|
||||||
dynamic = ["version"] # Reads from __init__.py
|
|
||||||
```
|
|
||||||
|
|
||||||
When you build the package, setuptools automatically extracts the version from `__init__.py`.
|
|
||||||
|
|
||||||
### 2. **config.py** (Automatic - with ENV override)
|
|
||||||
```python
|
|
||||||
# Lazy import to avoid circular imports
|
|
||||||
if not os.getenv("APP_VERSION"):
|
|
||||||
from webdrop_bridge import __version__
|
|
||||||
app_version = __version__
|
|
||||||
else:
|
|
||||||
app_version = os.getenv("APP_VERSION")
|
|
||||||
```
|
|
||||||
|
|
||||||
The config automatically reads from `__init__.py`, but can be overridden with the `APP_VERSION` environment variable if needed.
|
|
||||||
|
|
||||||
### 3. **sync_version.py** (Simplified)
|
|
||||||
The script now only handles:
|
|
||||||
- Updating `__init__.py` with a new version
|
|
||||||
- Updating `CHANGELOG.md` with a new version header
|
|
||||||
- Optional: updating `.env.example` if it explicitly sets `APP_VERSION`
|
|
||||||
|
|
||||||
It **no longer** needs to manually sync pyproject.toml or config defaults.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
### To Release a New Version
|
|
||||||
|
|
||||||
**Option 1: Simple (Recommended)**
|
|
||||||
```bash
|
|
||||||
# Edit only one file
|
|
||||||
# src/webdrop_bridge/__init__.py:
|
|
||||||
__version__ = "1.1.0" # Change this
|
|
||||||
|
|
||||||
# Then run sync script to update changelog
|
|
||||||
python scripts/sync_version.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option 2: Using the Sync Script**
|
|
||||||
```bash
|
|
||||||
python scripts/sync_version.py --version 1.1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
The script will:
|
|
||||||
- ✅ Update `__init__.py`
|
|
||||||
- ✅ Update `CHANGELOG.md`
|
|
||||||
- ✅ (Optional) Update `.env.example` if it has `APP_VERSION=`
|
|
||||||
|
|
||||||
### What Happens Automatically
|
|
||||||
|
|
||||||
When you run your application:
|
|
||||||
1. Config loads and checks environment for `APP_VERSION`
|
|
||||||
2. If not set, it imports `__version__` from `__init__.py`
|
|
||||||
3. The version is displayed in the UI
|
|
||||||
4. Update checks use the correct version
|
|
||||||
|
|
||||||
When you build with `pip install`:
|
|
||||||
1. setuptools reads `__version__` from `__init__.py`
|
|
||||||
2. Package metadata is set automatically
|
|
||||||
3. No manual sync needed
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
To verify the version is correctly propagated:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check __init__.py
|
|
||||||
python -c "from webdrop_bridge import __version__; print(__version__)"
|
|
||||||
|
|
||||||
# Check config loading
|
|
||||||
python -c "from webdrop_bridge.config import Config; c = Config.from_env(); print(c.app_version)"
|
|
||||||
|
|
||||||
# Check package metadata (after building)
|
|
||||||
pip show webdrop-bridge
|
|
||||||
```
|
|
||||||
|
|
||||||
All should show the same version.
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Always edit `__init__.py` first** - it's the single source of truth
|
|
||||||
2. **Run `sync_version.py` to update changelog** - keeps release notes organized
|
|
||||||
3. **Use environment variables only for testing** - don't hardcode overrides
|
|
||||||
4. **Run tests after version changes** - config tests verify version loading
|
|
||||||
|
|
||||||
## Migration Notes
|
|
||||||
|
|
||||||
If you had other places where version was defined:
|
|
||||||
- ❌ Remove version from `pyproject.toml` `[project]` section
|
|
||||||
- ✅ Add `dynamic = ["version"]` instead
|
|
||||||
- ❌ Don't manually edit `.env.example` for version
|
|
||||||
- ✅ Let `sync_version.py` handle it
|
|
||||||
- ❌ Don't hardcode version in config.py defaults
|
|
||||||
- ✅ Use lazy import from `__init__.py`
|
|
||||||
|
|
||||||
## Testing the System
|
|
||||||
|
|
||||||
Run the config tests to verify everything works:
|
|
||||||
```bash
|
|
||||||
pytest tests/unit/test_config.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
All tests should pass, confirming version loading works correctly.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Result**: One place to change, multiple places automatically updated. Simple, clean, professional.
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,48 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
|
|
||||||
<Product Id="*" Name="WebDrop Bridge" Language="1033" Version="0.1.0"
|
|
||||||
Manufacturer="HIM-Tools"
|
|
||||||
UpgradeCode="12345678-1234-1234-1234-123456789012">
|
|
||||||
|
|
||||||
<Package InstallerVersion="200" Compressed="yes" InstallScope="perMachine" />
|
|
||||||
<Media Id="1" Cabinet="WebDropBridge.cab" EmbedCab="yes" />
|
|
||||||
|
|
||||||
<Feature Id="ProductFeature" Title="WebDrop Bridge" Level="1">
|
|
||||||
<ComponentRef Id="MainExecutable" />
|
|
||||||
<ComponentRef Id="ProgramMenuShortcut" />
|
|
||||||
</Feature>
|
|
||||||
|
|
||||||
<Directory Id="TARGETDIR" Name="SourceDir">
|
|
||||||
<Directory Id="ProgramFilesFolder">
|
|
||||||
<Directory Id="INSTALLFOLDER" Name="WebDrop Bridge" />
|
|
||||||
</Directory>
|
|
||||||
<Directory Id="ProgramMenuFolder">
|
|
||||||
<Directory Id="ApplicationProgramsFolder" Name="WebDrop Bridge"/>
|
|
||||||
</Directory>
|
|
||||||
</Directory>
|
|
||||||
|
|
||||||
<DirectoryRef Id="INSTALLFOLDER">
|
|
||||||
<Component Id="MainExecutable" Guid="*">
|
|
||||||
<File Id="WebDropBridgeExe" Source="$(var.DistDir)\WebDropBridge.exe" KeyPath="yes"/>
|
|
||||||
</Component>
|
|
||||||
</DirectoryRef>
|
|
||||||
|
|
||||||
<DirectoryRef Id="ApplicationProgramsFolder">
|
|
||||||
<Component Id="ProgramMenuShortcut" Guid="*">
|
|
||||||
<Shortcut Id="ApplicationStartMenuShortcut"
|
|
||||||
Name="WebDrop Bridge"
|
|
||||||
Description="Web Drag-and-Drop Bridge"
|
|
||||||
Target="[INSTALLFOLDER]WebDropBridge.exe"
|
|
||||||
WorkingDirectory="INSTALLFOLDER" />
|
|
||||||
<RemoveFolder Id="ApplicationProgramsFolderRemove"
|
|
||||||
On="uninstall" />
|
|
||||||
<RegistryValue Root="HKCU"
|
|
||||||
Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\WebDropBridge"
|
|
||||||
Name="installed"
|
|
||||||
Type="integer"
|
|
||||||
Value="1"
|
|
||||||
KeyPath="yes" />
|
|
||||||
</Component>
|
|
||||||
</DirectoryRef>
|
|
||||||
</Product>
|
|
||||||
</Wix>
|
|
||||||
|
|
@ -11,13 +11,7 @@
|
||||||
# - create-dmg (optional, for custom DMG: brew install create-dmg)
|
# - create-dmg (optional, for custom DMG: brew install create-dmg)
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# bash build_macos.sh [--sign] [--notarize] [--env-file PATH]
|
# bash build_macos.sh [--sign] [--notarize]
|
||||||
#
|
|
||||||
# Options:
|
|
||||||
# --sign Sign app (requires Apple developer certificate)
|
|
||||||
# --notarize Notarize app (requires Apple ID)
|
|
||||||
# --env-file PATH Use custom .env file (default: project root .env)
|
|
||||||
# Build fails if .env doesn't exist
|
|
||||||
|
|
||||||
set -e # Exit on error
|
set -e # Exit on error
|
||||||
|
|
||||||
|
|
@ -33,9 +27,6 @@ APP_NAME="WebDropBridge"
|
||||||
DMG_VOLUME_NAME="WebDrop Bridge"
|
DMG_VOLUME_NAME="WebDrop Bridge"
|
||||||
VERSION="1.0.0"
|
VERSION="1.0.0"
|
||||||
|
|
||||||
# Default .env file
|
|
||||||
ENV_FILE="$PROJECT_ROOT/.env"
|
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
SIGN_APP=0
|
SIGN_APP=0
|
||||||
NOTARIZE_APP=0
|
NOTARIZE_APP=0
|
||||||
|
|
@ -50,10 +41,6 @@ while [[ $# -gt 0 ]]; do
|
||||||
NOTARIZE_APP=1
|
NOTARIZE_APP=1
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
--env-file)
|
|
||||||
ENV_FILE="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1"
|
echo "Unknown option: $1"
|
||||||
exit 1
|
exit 1
|
||||||
|
|
@ -61,15 +48,6 @@ while [[ $# -gt 0 ]]; do
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# Validate env file
|
|
||||||
if [ ! -f "$ENV_FILE" ]; then
|
|
||||||
echo "❌ Configuration file not found: $ENV_FILE"
|
|
||||||
echo "Please provide a valid .env file or use --env-file parameter"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "📋 Using configuration: $ENV_FILE"
|
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
|
|
@ -176,9 +154,6 @@ build_executable() {
|
||||||
log_info "Building macOS executable with PyInstaller..."
|
log_info "Building macOS executable with PyInstaller..."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Export env file for spec file to pick up
|
|
||||||
export WEBDROP_ENV_FILE="$ENV_FILE"
|
|
||||||
|
|
||||||
python3 -m PyInstaller \
|
python3 -m PyInstaller \
|
||||||
--distpath="$DIST_DIR" \
|
--distpath="$DIST_DIR" \
|
||||||
--buildpath="$TEMP_BUILD" \
|
--buildpath="$TEMP_BUILD" \
|
||||||
|
|
|
||||||
|
|
@ -9,27 +9,16 @@ Requirements:
|
||||||
- For MSI: WiX Toolset (optional, requires separate installation)
|
- For MSI: WiX Toolset (optional, requires separate installation)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python build_windows.py [--msi] [--code-sign] [--env-file PATH]
|
python build_windows.py [--msi] [--code-sign]
|
||||||
|
|
||||||
Options:
|
|
||||||
--msi Create MSI installer (requires WiX Toolset)
|
|
||||||
--code-sign Sign executable (requires certificate)
|
|
||||||
--env-file PATH Use custom .env file (default: project root .env)
|
|
||||||
If not provided, uses .env from project root
|
|
||||||
Build fails if .env doesn't exist
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import argparse
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
# Import shared version utilities
|
|
||||||
from version_utils import get_current_version
|
|
||||||
|
|
||||||
# Fix Unicode output on Windows
|
# Fix Unicode output on Windows
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
import io
|
import io
|
||||||
|
|
@ -41,43 +30,23 @@ if sys.platform == "win32":
|
||||||
class WindowsBuilder:
|
class WindowsBuilder:
|
||||||
"""Build Windows installer using PyInstaller."""
|
"""Build Windows installer using PyInstaller."""
|
||||||
|
|
||||||
def __init__(self, env_file: Path | None = None):
|
def __init__(self):
|
||||||
"""Initialize builder paths.
|
"""Initialize builder paths."""
|
||||||
|
|
||||||
Args:
|
|
||||||
env_file: Path to .env file to bundle. If None, uses project root .env.
|
|
||||||
If that doesn't exist, raises error.
|
|
||||||
"""
|
|
||||||
self.project_root = Path(__file__).parent.parent.parent
|
self.project_root = Path(__file__).parent.parent.parent
|
||||||
self.build_dir = self.project_root / "build"
|
self.build_dir = self.project_root / "build"
|
||||||
self.dist_dir = self.build_dir / "dist" / "windows"
|
self.dist_dir = self.build_dir / "dist" / "windows"
|
||||||
self.temp_dir = self.build_dir / "temp" / "windows"
|
self.temp_dir = self.build_dir / "temp" / "windows"
|
||||||
self.spec_file = self.build_dir / "webdrop_bridge.spec"
|
self.spec_file = self.build_dir / "webdrop_bridge.spec"
|
||||||
self.version = get_current_version()
|
self.version = self._get_version()
|
||||||
|
|
||||||
# Validate and set env file
|
|
||||||
if env_file is None:
|
|
||||||
env_file = self.project_root / ".env"
|
|
||||||
else:
|
|
||||||
env_file = Path(env_file).resolve()
|
|
||||||
|
|
||||||
if not env_file.exists():
|
|
||||||
raise FileNotFoundError(
|
|
||||||
f"Configuration file not found: {env_file}\n"
|
|
||||||
f"Please provide a .env file using --env-file parameter\n"
|
|
||||||
f"or ensure .env exists in project root"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.env_file = env_file
|
|
||||||
print(f"📋 Using configuration: {self.env_file}")
|
|
||||||
|
|
||||||
def _get_version(self) -> str:
|
def _get_version(self) -> str:
|
||||||
"""Get version from __init__.py.
|
"""Get version from config.py."""
|
||||||
|
config_file = self.project_root / "src" / "webdrop_bridge" / "config.py"
|
||||||
Note: This method is deprecated. Use get_current_version() from
|
for line in config_file.read_text().split("\n"):
|
||||||
version_utils.py instead.
|
if "app_version" in line and "1.0.0" in line:
|
||||||
"""
|
# Extract default version from config
|
||||||
return get_current_version()
|
return "1.0.0"
|
||||||
|
return "1.0.0"
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
"""Clean previous builds."""
|
"""Clean previous builds."""
|
||||||
|
|
@ -95,7 +64,6 @@ class WindowsBuilder:
|
||||||
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# PyInstaller command using spec file
|
# PyInstaller command using spec file
|
||||||
# Pass env_file path as environment variable for spec to pick up
|
|
||||||
cmd = [
|
cmd = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
"-m",
|
"-m",
|
||||||
|
|
@ -108,18 +76,7 @@ class WindowsBuilder:
|
||||||
]
|
]
|
||||||
|
|
||||||
print(f" Command: {' '.join(cmd)}")
|
print(f" Command: {' '.join(cmd)}")
|
||||||
|
result = subprocess.run(cmd, cwd=str(self.project_root))
|
||||||
# Set environment variable for spec file to use
|
|
||||||
env = os.environ.copy()
|
|
||||||
env["WEBDROP_ENV_FILE"] = str(self.env_file)
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
cwd=str(self.project_root),
|
|
||||||
encoding="utf-8",
|
|
||||||
errors="replace",
|
|
||||||
env=env
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print("❌ PyInstaller build failed")
|
print("❌ PyInstaller build failed")
|
||||||
|
|
@ -166,21 +123,13 @@ class WindowsBuilder:
|
||||||
"""
|
"""
|
||||||
print("\n📦 Creating MSI installer with WiX...")
|
print("\n📦 Creating MSI installer with WiX...")
|
||||||
|
|
||||||
# Check if WiX is installed (try PATH first, then default location)
|
# Check if WiX is installed
|
||||||
heat_exe = shutil.which("heat.exe")
|
heat_exe = shutil.which("heat.exe")
|
||||||
candle_exe = shutil.which("candle.exe")
|
candle_exe = shutil.which("candle.exe")
|
||||||
light_exe = shutil.which("light.exe")
|
light_exe = shutil.which("light.exe")
|
||||||
|
|
||||||
# Fallback to default WiX installation location
|
|
||||||
if not candle_exe:
|
|
||||||
default_wix = Path("C:\\Program Files (x86)\\WiX Toolset v3.14\\bin")
|
|
||||||
if default_wix.exists():
|
|
||||||
heat_exe = str(default_wix / "heat.exe")
|
|
||||||
candle_exe = str(default_wix / "candle.exe")
|
|
||||||
light_exe = str(default_wix / "light.exe")
|
|
||||||
|
|
||||||
if not all([heat_exe, candle_exe, light_exe]):
|
if not all([heat_exe, candle_exe, light_exe]):
|
||||||
print("⚠️ WiX Toolset not found in PATH or default location")
|
print("⚠️ WiX Toolset not found in PATH")
|
||||||
print(" Install from: https://wixtoolset.org/releases/")
|
print(" Install from: https://wixtoolset.org/releases/")
|
||||||
print(" Or use: choco install wixtoolset")
|
print(" Or use: choco install wixtoolset")
|
||||||
return False
|
return False
|
||||||
|
|
@ -193,21 +142,16 @@ class WindowsBuilder:
|
||||||
wix_obj = self.build_dir / "WebDropBridge.wixobj"
|
wix_obj = self.build_dir / "WebDropBridge.wixobj"
|
||||||
msi_output = self.dist_dir / f"WebDropBridge-{self.version}-Setup.msi"
|
msi_output = self.dist_dir / f"WebDropBridge-{self.version}-Setup.msi"
|
||||||
|
|
||||||
# Run candle (compiler) - pass preprocessor variables
|
# Run candle (compiler)
|
||||||
candle_cmd = [
|
candle_cmd = [
|
||||||
str(candle_exe),
|
str(candle_exe),
|
||||||
f"-dDistDir={self.dist_dir}",
|
|
||||||
"-o",
|
"-o",
|
||||||
str(wix_obj),
|
str(wix_obj),
|
||||||
str(self.build_dir / "WebDropBridge.wxs"),
|
str(self.build_dir / "WebDropBridge.wxs"),
|
||||||
]
|
]
|
||||||
|
|
||||||
print(f" Compiling WiX source...")
|
print(f" Compiling WiX source...")
|
||||||
result = subprocess.run(
|
result = subprocess.run(candle_cmd)
|
||||||
candle_cmd,
|
|
||||||
encoding="utf-8",
|
|
||||||
errors="replace"
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print("❌ WiX compilation failed")
|
print("❌ WiX compilation failed")
|
||||||
return False
|
return False
|
||||||
|
|
@ -221,11 +165,7 @@ class WindowsBuilder:
|
||||||
]
|
]
|
||||||
|
|
||||||
print(f" Linking MSI installer...")
|
print(f" Linking MSI installer...")
|
||||||
result = subprocess.run(
|
result = subprocess.run(light_cmd)
|
||||||
light_cmd,
|
|
||||||
encoding="utf-8",
|
|
||||||
errors="replace"
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print("❌ MSI linking failed")
|
print("❌ MSI linking failed")
|
||||||
return False
|
return False
|
||||||
|
|
@ -328,11 +268,7 @@ class WindowsBuilder:
|
||||||
str(exe_path),
|
str(exe_path),
|
||||||
]
|
]
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(cmd)
|
||||||
cmd,
|
|
||||||
encoding="utf-8",
|
|
||||||
errors="replace"
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print("❌ Code signing failed")
|
print("❌ Code signing failed")
|
||||||
return False
|
return False
|
||||||
|
|
@ -377,10 +313,12 @@ class WindowsBuilder:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main():
|
||||||
"""Build Windows MSI installer."""
|
"""Main entry point."""
|
||||||
|
import argparse
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Build WebDrop Bridge Windows installer"
|
description="Build WebDrop Bridge for Windows"
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--msi",
|
"--msi",
|
||||||
|
|
@ -388,29 +326,15 @@ def main() -> int:
|
||||||
help="Create MSI installer (requires WiX Toolset)",
|
help="Create MSI installer (requires WiX Toolset)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--code-sign",
|
"--sign",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Sign executable (requires certificate in CODE_SIGN_CERT env var)",
|
help="Sign executable (requires CODE_SIGN_CERT environment variable)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"--env-file",
|
|
||||||
type=str,
|
|
||||||
default=None,
|
|
||||||
help="Path to .env file to bundle (default: project root .env)",
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
print("🔄 Syncing version...")
|
|
||||||
sync_version()
|
|
||||||
|
|
||||||
try:
|
builder = WindowsBuilder()
|
||||||
builder = WindowsBuilder(env_file=args.env_file)
|
success = builder.build(create_msi=args.msi, sign=args.sign)
|
||||||
except FileNotFoundError as e:
|
|
||||||
print(f"❌ Build failed: {e}")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
success = builder.build(create_msi=args.msi, sign=args.code_sign)
|
|
||||||
|
|
||||||
return 0 if success else 1
|
return 0 if success else 1
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
# Create Forgejo Release with Binary Assets
|
# Create Forgejo Release with Binary Assets
|
||||||
# Usage: .\create_release.ps1 [-Version 1.0.0]
|
# Usage: .\create_release.ps1 -Version 1.0.0
|
||||||
# If -Version is not provided, it will be read from src/webdrop_bridge/__init__.py
|
|
||||||
# Uses your Forgejo credentials (same as git)
|
# Uses your Forgejo credentials (same as git)
|
||||||
# First run will prompt for credentials and save them to this session
|
# First run will prompt for credentials and save them to this session
|
||||||
|
|
||||||
|
|
@ -19,43 +18,11 @@ param(
|
||||||
[string]$ForgejoUrl = "https://git.him-tools.de",
|
[string]$ForgejoUrl = "https://git.him-tools.de",
|
||||||
[string]$Repo = "HIM-public/webdrop-bridge",
|
[string]$Repo = "HIM-public/webdrop-bridge",
|
||||||
[string]$ExePath = "build\dist\windows\WebDropBridge.exe",
|
[string]$ExePath = "build\dist\windows\WebDropBridge.exe",
|
||||||
[string]$ChecksumPath = "build\dist\windows\WebDropBridge.exe.sha256",
|
[string]$ChecksumPath = "build\dist\windows\WebDropBridge.exe.sha256"
|
||||||
[string]$MsiPath = "build\dist\windows\WebDropBridge-1.0.0-Setup.msi"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
# Function to read version from .env or .env.example
|
|
||||||
function Get-VersionFromEnv {
|
|
||||||
# PSScriptRoot is build/scripts, go up to project root with ../../
|
|
||||||
$projectRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..")
|
|
||||||
|
|
||||||
# Try .env first (runtime config), then .env.example (template)
|
|
||||||
$envFile = Join-Path $projectRoot ".env"
|
|
||||||
$envExampleFile = Join-Path $projectRoot ".env.example"
|
|
||||||
|
|
||||||
# Check .env first
|
|
||||||
if (Test-Path $envFile) {
|
|
||||||
$content = Get-Content $envFile -Raw
|
|
||||||
if ($content -match 'APP_VERSION=([^\r\n]+)') {
|
|
||||||
Write-Host "Version read from .env" -ForegroundColor Gray
|
|
||||||
return $matches[1].Trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Fall back to .env.example
|
|
||||||
if (Test-Path $envExampleFile) {
|
|
||||||
$content = Get-Content $envExampleFile -Raw
|
|
||||||
if ($content -match 'APP_VERSION=([^\r\n]+)') {
|
|
||||||
Write-Host "Version read from .env.example" -ForegroundColor Gray
|
|
||||||
return $matches[1].Trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "ERROR: Could not find APP_VERSION in .env or .env.example" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Handle --ClearCredentials flag
|
# Handle --ClearCredentials flag
|
||||||
if ($ClearCredentials) {
|
if ($ClearCredentials) {
|
||||||
Remove-Item env:FORGEJO_USER -ErrorAction SilentlyContinue
|
Remove-Item env:FORGEJO_USER -ErrorAction SilentlyContinue
|
||||||
|
|
@ -93,11 +60,11 @@ if (-not $ForgejoUser -or -not $ForgejoPW) {
|
||||||
Write-Host "Tip: Credentials will persist until you close PowerShell or run: .\create_release.ps1 -ClearCredentials" -ForegroundColor Gray
|
Write-Host "Tip: Credentials will persist until you close PowerShell or run: .\create_release.ps1 -ClearCredentials" -ForegroundColor Gray
|
||||||
}
|
}
|
||||||
|
|
||||||
# Verify Version parameter - if not provided, read from .env.example
|
# Verify Version parameter
|
||||||
if (-not $Version) {
|
if (-not $Version) {
|
||||||
Write-Host "Version not provided, reading from .env.example..." -ForegroundColor Cyan
|
Write-Host "ERROR: Version parameter required" -ForegroundColor Red
|
||||||
$Version = Get-VersionFromEnv
|
Write-Host "Usage: .\create_release.ps1 -Version 1.0.0" -ForegroundColor Yellow
|
||||||
Write-Host "Using version: $Version" -ForegroundColor Green
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Verify files exist
|
# Verify files exist
|
||||||
|
|
@ -111,9 +78,6 @@ if (-not (Test-Path $ChecksumPath)) {
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# MSI is optional (only available on Windows after build)
|
|
||||||
$hasMsi = Test-Path $MsiPath
|
|
||||||
|
|
||||||
Write-Host "Creating WebDropBridge $Version release on Forgejo..." -ForegroundColor Cyan
|
Write-Host "Creating WebDropBridge $Version release on Forgejo..." -ForegroundColor Cyan
|
||||||
|
|
||||||
# Get file info
|
# Get file info
|
||||||
|
|
@ -121,10 +85,6 @@ $exeSize = (Get-Item $ExePath).Length / 1MB
|
||||||
$checksum = Get-Content $ChecksumPath -Raw
|
$checksum = Get-Content $ChecksumPath -Raw
|
||||||
|
|
||||||
Write-Host "File: WebDropBridge.exe ($([math]::Round($exeSize, 2)) MB)"
|
Write-Host "File: WebDropBridge.exe ($([math]::Round($exeSize, 2)) MB)"
|
||||||
if ($hasMsi) {
|
|
||||||
$msiSize = (Get-Item $MsiPath).Length / 1MB
|
|
||||||
Write-Host "File: WebDropBridge-1.0.0-Setup.msi ($([math]::Round($msiSize, 2)) MB)"
|
|
||||||
}
|
|
||||||
Write-Host "Checksum: $($checksum.Substring(0, 16))..."
|
Write-Host "Checksum: $($checksum.Substring(0, 16))..."
|
||||||
|
|
||||||
# Create basic auth header
|
# Create basic auth header
|
||||||
|
|
@ -210,28 +170,5 @@ catch {
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Step 4: Upload MSI as asset (if available)
|
|
||||||
if ($hasMsi) {
|
|
||||||
Write-Host "Uploading MSI installer asset..." -ForegroundColor Yellow
|
|
||||||
|
|
||||||
try {
|
|
||||||
$response = curl.exe -s -X POST `
|
|
||||||
-u $curlAuth `
|
|
||||||
-F "attachment=@$MsiPath" `
|
|
||||||
$uploadUrl
|
|
||||||
|
|
||||||
if ($response -like "*error*" -or $response -like "*404*") {
|
|
||||||
Write-Host "ERROR uploading MSI: $response" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "[OK] MSI uploaded" -ForegroundColor Green
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Host "ERROR uploading MSI: $_" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "`n[OK] Release complete!" -ForegroundColor Green
|
Write-Host "`n[OK] Release complete!" -ForegroundColor Green
|
||||||
Write-Host "View at: $ForgejoUrl/$Repo/releases/tag/v$Version" -ForegroundColor Cyan
|
Write-Host "View at: $ForgejoUrl/$Repo/releases/tag/v$Version" -ForegroundColor Cyan
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
"""Shared version management utilities for build scripts.
|
|
||||||
|
|
||||||
This module provides a single source of truth for version reading
|
|
||||||
to avoid duplication between different build scripts.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def get_project_root() -> Path:
|
|
||||||
"""Get the project root directory.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to project root (parent of build/scripts)
|
|
||||||
"""
|
|
||||||
return Path(__file__).parent.parent.parent
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_version() -> str:
|
|
||||||
"""Read version from __init__.py.
|
|
||||||
|
|
||||||
This is the single source of truth for version information.
|
|
||||||
All build scripts and version management tools use this function.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Current version string from __init__.py
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If __version__ cannot be found in __init__.py
|
|
||||||
"""
|
|
||||||
project_root = get_project_root()
|
|
||||||
init_file = project_root / "src" / "webdrop_bridge" / "__init__.py"
|
|
||||||
|
|
||||||
if not init_file.exists():
|
|
||||||
raise FileNotFoundError(
|
|
||||||
f"Cannot find __init__.py at {init_file}"
|
|
||||||
)
|
|
||||||
|
|
||||||
content = init_file.read_text(encoding="utf-8")
|
|
||||||
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
|
|
||||||
|
|
||||||
if not match:
|
|
||||||
raise ValueError(
|
|
||||||
f"Could not find __version__ in {init_file}. "
|
|
||||||
"Expected: __version__ = \"X.Y.Z\""
|
|
||||||
)
|
|
||||||
|
|
||||||
return match.group(1)
|
|
||||||
|
|
@ -258,19 +258,33 @@ Startup: <1 second
|
||||||
- **Paths**: Forward slash `/` (native)
|
- **Paths**: Forward slash `/` (native)
|
||||||
- **Permissions**: May require accessibility permissions
|
- **Permissions**: May require accessibility permissions
|
||||||
|
|
||||||
## Update Manager
|
## Monitoring & Debugging
|
||||||
|
|
||||||
The `UpdateManager` class checks for new releases using the Forgejo API. It caches results and only signals updates for newer versions. See `src/webdrop_bridge/core/updater.py` for implementation.
|
### Debug Logging
|
||||||
|
|
||||||
## Release Flow
|
```python
|
||||||
|
# Enable debug logging
|
||||||
|
LOG_LEVEL=DEBUG
|
||||||
|
|
||||||
- Checks for new releases on startup or user request
|
# Output
|
||||||
- Parses release notes and assets
|
2026-01-28 14:32:15 - webdrop_bridge - DEBUG - DragInterceptor: dragEnterEvent triggered
|
||||||
- Notifies UI if update is available
|
2026-01-28 14:32:15 - webdrop_bridge - DEBUG - PathValidator: Checking Z:\file.psd
|
||||||
|
2026-01-28 14:32:15 - webdrop_bridge - INFO - File dragged: Z:\file.psd
|
||||||
|
```
|
||||||
|
|
||||||
## Integration Test Strategy
|
### Performance Profiling
|
||||||
|
|
||||||
Integration tests verify workflows across modules. The update workflow is covered in [tests/integration/test_update_flow.py](../tests/integration/test_update_flow.py).
|
```python
|
||||||
|
import cProfile
|
||||||
|
import pstats
|
||||||
|
|
||||||
|
profiler = cProfile.Profile()
|
||||||
|
profiler.enable()
|
||||||
|
# ... drag operation ...
|
||||||
|
profiler.disable()
|
||||||
|
stats = pstats.Stats(profiler)
|
||||||
|
stats.print_stats()
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
# Configuration Management for Builds
|
|
||||||
|
|
||||||
This document explains how configuration is handled when building executables and installers for WebDrop Bridge.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
WebDrop Bridge uses `.env` files for runtime configuration. When building distributable packages (exe, MSI, or DMG), the `.env` file is **bundled into the application** so that users receive pre-configured settings.
|
|
||||||
|
|
||||||
## Configuration File
|
|
||||||
|
|
||||||
The configuration file must be named `.env` and contains settings like:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
APP_NAME=WebDrop Bridge
|
|
||||||
APP_VERSION=0.1.0
|
|
||||||
WEBAPP_URL=https://example.com
|
|
||||||
ALLOWED_ROOTS=Z:/,C:/Users/Public
|
|
||||||
ALLOWED_URLS=
|
|
||||||
LOG_LEVEL=INFO
|
|
||||||
LOG_FILE=logs/webdrop_bridge.log
|
|
||||||
ENABLE_LOGGING=true
|
|
||||||
WINDOW_WIDTH=1024
|
|
||||||
WINDOW_HEIGHT=768
|
|
||||||
```
|
|
||||||
|
|
||||||
See `.env.example` for a template with all available options.
|
|
||||||
|
|
||||||
## Building with Default Configuration
|
|
||||||
|
|
||||||
If you want to use the project's `.env` file (in the project root), simply run:
|
|
||||||
|
|
||||||
### Windows
|
|
||||||
```bash
|
|
||||||
python build/scripts/build_windows.py --msi
|
|
||||||
```
|
|
||||||
|
|
||||||
### macOS
|
|
||||||
```bash
|
|
||||||
bash build/scripts/build_macos.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important:** The build will **fail** if `.env` doesn't exist. This prevents accidentally shipping without configuration.
|
|
||||||
|
|
||||||
## Building with Custom Configuration
|
|
||||||
|
|
||||||
For different customers or deployments, you can specify a custom `.env` file:
|
|
||||||
|
|
||||||
### Windows
|
|
||||||
```bash
|
|
||||||
python build/scripts/build_windows.py --msi --env-file path/to/customer1.env
|
|
||||||
```
|
|
||||||
|
|
||||||
### macOS
|
|
||||||
```bash
|
|
||||||
bash build/scripts/build_macos.sh --env-file path/to/customer1.env
|
|
||||||
```
|
|
||||||
|
|
||||||
The custom `.env` file will be bundled into the executable and users will receive those pre-configured settings.
|
|
||||||
|
|
||||||
## Example: Multi-Customer Setup
|
|
||||||
|
|
||||||
If you have different customer configurations:
|
|
||||||
|
|
||||||
```
|
|
||||||
webdrop_bridge/
|
|
||||||
├── .env # Default project configuration
|
|
||||||
├── .env.example # Template
|
|
||||||
├── build/
|
|
||||||
│ └── scripts/
|
|
||||||
│ ├── build_windows.py
|
|
||||||
│ └── build_macos.sh
|
|
||||||
├── customer_configs/ # Create this for customer-specific settings
|
|
||||||
│ ├── acme_corp.env
|
|
||||||
│ ├── globex_corporation.env
|
|
||||||
│ └── initech.env
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
|
|
||||||
Then build for each customer:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# ACME Corp
|
|
||||||
python build/scripts/build_windows.py --msi --env-file customer_configs/acme_corp.env
|
|
||||||
|
|
||||||
# Globex Corporation
|
|
||||||
python build/scripts/build_windows.py --msi --env-file customer_configs/globex_corporation.env
|
|
||||||
|
|
||||||
# Initech
|
|
||||||
python build/scripts/build_windows.py --msi --env-file customer_configs/initech.env
|
|
||||||
```
|
|
||||||
|
|
||||||
Each MSI will include that customer's specific configuration (URLs, allowed paths, etc.).
|
|
||||||
|
|
||||||
## What Gets Bundled
|
|
||||||
|
|
||||||
When building, the `.env` file is:
|
|
||||||
1. ✅ Copied into the PyInstaller bundle
|
|
||||||
2. ✅ Extracted to the application's working directory when the app starts
|
|
||||||
3. ✅ Automatically loaded by `Config.from_env()` at startup
|
|
||||||
|
|
||||||
Users **do not** need to create their own `.env` files.
|
|
||||||
|
|
||||||
## After Installation
|
|
||||||
|
|
||||||
When users run the installed application:
|
|
||||||
1. The embedded `.env` is automatically available
|
|
||||||
2. Settings are loaded and applied
|
|
||||||
3. Users can optionally create a custom `.env` in the installation directory to override settings
|
|
||||||
|
|
||||||
This allows:
|
|
||||||
- **Pre-configured deployments** for your customers
|
|
||||||
- **Easy customization** by users (just edit the `.env` file)
|
|
||||||
- **No manual setup** required after installation
|
|
||||||
|
|
||||||
## Build Command Reference
|
|
||||||
|
|
||||||
### Windows
|
|
||||||
```bash
|
|
||||||
# Default (.env from project root)
|
|
||||||
python build/scripts/build_windows.py --msi
|
|
||||||
|
|
||||||
# Custom .env file
|
|
||||||
python build/scripts/build_windows.py --msi --env-file customer_configs/acme.env
|
|
||||||
|
|
||||||
# Without MSI (just EXE)
|
|
||||||
python build/scripts/build_windows.py
|
|
||||||
|
|
||||||
# Sign executable (requires CODE_SIGN_CERT env var)
|
|
||||||
python build/scripts/build_windows.py --msi --code-sign
|
|
||||||
```
|
|
||||||
|
|
||||||
### macOS
|
|
||||||
```bash
|
|
||||||
# Default (.env from project root)
|
|
||||||
bash build/scripts/build_macos.sh
|
|
||||||
|
|
||||||
# Custom .env file
|
|
||||||
bash build/scripts/build_macos.sh --env-file customer_configs/acme.env
|
|
||||||
|
|
||||||
# Sign app (requires Apple developer certificate)
|
|
||||||
bash build/scripts/build_macos.sh --sign
|
|
||||||
|
|
||||||
# Notarize app (requires Apple ID)
|
|
||||||
bash build/scripts/build_macos.sh --notarize
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration Validation
|
|
||||||
|
|
||||||
The build process validates that:
|
|
||||||
1. ✅ The specified `.env` file exists
|
|
||||||
2. ✅ All required environment variables are present
|
|
||||||
3. ✅ Values are valid (LOG_LEVEL is valid, paths exist for ALLOWED_ROOTS, etc.)
|
|
||||||
|
|
||||||
If validation fails, the build stops with a clear error message.
|
|
||||||
|
|
||||||
## Version Management
|
|
||||||
|
|
||||||
The `APP_VERSION` is read from two places (in order):
|
|
||||||
1. `.env` file (if specified)
|
|
||||||
2. `src/webdrop_bridge/__init__.py` (as fallback)
|
|
||||||
|
|
||||||
This allows you to override the version per customer if needed.
|
|
||||||
|
|
@ -1,299 +0,0 @@
|
||||||
# Customer-Specific Build Examples
|
|
||||||
|
|
||||||
This document shows practical examples of how to build WebDrop Bridge for different customers or deployment scenarios.
|
|
||||||
|
|
||||||
## Scenario 1: Single Build with Default Configuration
|
|
||||||
|
|
||||||
**Situation:** You have one main configuration for your primary customer or general use.
|
|
||||||
|
|
||||||
**Setup:**
|
|
||||||
```
|
|
||||||
webdrop_bridge/
|
|
||||||
├── .env # Your main configuration
|
|
||||||
└── build/
|
|
||||||
└── scripts/
|
|
||||||
└── build_windows.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**Build Command:**
|
|
||||||
```bash
|
|
||||||
python build/scripts/build_windows.py --msi
|
|
||||||
```
|
|
||||||
|
|
||||||
**Result:** `WebDropBridge-x.x.x-Setup.msi` with your `.env` configuration bundled.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scenario 2: Multi-Customer Builds
|
|
||||||
|
|
||||||
**Situation:** You support multiple customers, each with different URLs, allowed paths, etc.
|
|
||||||
|
|
||||||
**Setup:**
|
|
||||||
```
|
|
||||||
webdrop_bridge/
|
|
||||||
├── .env # Default project config
|
|
||||||
├── build/
|
|
||||||
│ └── scripts/
|
|
||||||
│ └── build_windows.py
|
|
||||||
└── deploy/ # Create this directory
|
|
||||||
└── customer_configs/
|
|
||||||
├── README.md
|
|
||||||
├── acme_corp.env
|
|
||||||
├── globex_corporation.env
|
|
||||||
├── initech.env
|
|
||||||
└── wayne_enterprises.env
|
|
||||||
```
|
|
||||||
|
|
||||||
**Customer Config Example:** `deploy/customer_configs/acme_corp.env`
|
|
||||||
```dotenv
|
|
||||||
APP_NAME=WebDrop Bridge - ACME Corp Edition
|
|
||||||
APP_VERSION=1.0.0
|
|
||||||
WEBAPP_URL=https://acme-drop.example.com/drop
|
|
||||||
ALLOWED_ROOTS=Z:/acme_files/,C:/Users/Public/ACME
|
|
||||||
LOG_LEVEL=INFO
|
|
||||||
LOG_FILE=logs/webdrop_bridge.log
|
|
||||||
ENABLE_LOGGING=true
|
|
||||||
WINDOW_WIDTH=1024
|
|
||||||
WINDOW_HEIGHT=768
|
|
||||||
```
|
|
||||||
|
|
||||||
**Build Commands:**
|
|
||||||
```bash
|
|
||||||
# Build for ACME Corp
|
|
||||||
python build/scripts/build_windows.py --msi --env-file deploy/customer_configs/acme_corp.env
|
|
||||||
|
|
||||||
# Build for Globex
|
|
||||||
python build/scripts/build_windows.py --msi --env-file deploy/customer_configs/globex_corporation.env
|
|
||||||
|
|
||||||
# Build for Initech
|
|
||||||
python build/scripts/build_windows.py --msi --env-file deploy/customer_configs/initech.env
|
|
||||||
|
|
||||||
# Build for Wayne Enterprises
|
|
||||||
python build/scripts/build_windows.py --msi --env-file deploy/customer_configs/wayne_enterprises.env
|
|
||||||
```
|
|
||||||
|
|
||||||
**Result:** Four separate MSI files:
|
|
||||||
- `WebDropBridge-1.0.0-Setup.msi` (ACME - says "ACME Corp Edition")
|
|
||||||
- `WebDropBridge-1.0.0-Setup.msi` (Globex - say "Globex Edition")
|
|
||||||
- etc.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scenario 3: Development vs. Production Builds
|
|
||||||
|
|
||||||
**Situation:** You want different settings for internal testing vs. customer releases.
|
|
||||||
|
|
||||||
**Setup:**
|
|
||||||
```
|
|
||||||
webdrop_bridge/
|
|
||||||
├── .env # Production config (primary)
|
|
||||||
├── build/
|
|
||||||
│ └── scripts/
|
|
||||||
│ └── build_windows.py
|
|
||||||
└── build_configs/
|
|
||||||
├── development.env # For internal testing
|
|
||||||
├── staging.env # Pre-production testing
|
|
||||||
└── production.env # For customers (same as project .env)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Development Config:** `build_configs/development.env`
|
|
||||||
```dotenv
|
|
||||||
APP_NAME=WebDrop Bridge DEV
|
|
||||||
WEBAPP_URL=http://localhost:3000
|
|
||||||
LOG_LEVEL=DEBUG
|
|
||||||
LOG_FILE=logs/webdrop_bridge.log
|
|
||||||
ENABLE_LOGGING=true
|
|
||||||
WINDOW_WIDTH=1024
|
|
||||||
WINDOW_HEIGHT=768
|
|
||||||
```
|
|
||||||
|
|
||||||
**Build Commands:**
|
|
||||||
```bash
|
|
||||||
# Development build (for testing)
|
|
||||||
python build/scripts/build_windows.py --env-file build_configs/development.env
|
|
||||||
|
|
||||||
# Staging build (pre-release testing)
|
|
||||||
python build/scripts/build_windows.py --env-file build_configs/staging.env
|
|
||||||
|
|
||||||
# Production build (for customers)
|
|
||||||
python build/scripts/build_windows.py --msi
|
|
||||||
# OR explicitly:
|
|
||||||
python build/scripts/build_windows.py --msi --env-file build_configs/production.env
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scenario 4: Building with Code Signing
|
|
||||||
|
|
||||||
**Situation:** You have a code signing certificate and want to sign releases.
|
|
||||||
|
|
||||||
**Prerequisites:**
|
|
||||||
- Set environment variable: `CODE_SIGN_CERT=path/to/certificate.pfx`
|
|
||||||
- Set environment variable: `CODE_SIGN_PASSWORD=your_password`
|
|
||||||
|
|
||||||
**Build Command:**
|
|
||||||
```bash
|
|
||||||
python build/scripts/build_windows.py --msi --code-sign --env-file deploy/customer_configs/acme_corp.env
|
|
||||||
```
|
|
||||||
|
|
||||||
**Result:** Signed MSI installer ready for enterprise deployment.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scenario 5: Automated Build Pipeline
|
|
||||||
|
|
||||||
**Situation:** You have multiple customers and want to automate builds.
|
|
||||||
|
|
||||||
**Script:** `build_all_customers.ps1`
|
|
||||||
```powershell
|
|
||||||
# Build WebDrop Bridge for all customers
|
|
||||||
|
|
||||||
$PROJECT_ROOT = "C:\Development\VS Code Projects\webdrop_bridge"
|
|
||||||
$CONFIG_DIR = "$PROJECT_ROOT\deploy\customer_configs"
|
|
||||||
$BUILD_SCRIPT = "$PROJECT_ROOT\build\scripts\build_windows.py"
|
|
||||||
|
|
||||||
# Get all .env files for customers
|
|
||||||
$customerConfigs = @(
|
|
||||||
"acme_corp.env",
|
|
||||||
"globex_corporation.env",
|
|
||||||
"initech.env",
|
|
||||||
"wayne_enterprises.env"
|
|
||||||
)
|
|
||||||
|
|
||||||
$timestamp = Get-Date -Format "yyyy-MM-dd_HH-mm-ss"
|
|
||||||
$output_dir = "$PROJECT_ROOT\build\releases\$timestamp"
|
|
||||||
New-Item -ItemType Directory -Path $output_dir -Force | Out-Null
|
|
||||||
|
|
||||||
Write-Host "🚀 Building WebDrop Bridge for all customers..." -ForegroundColor Cyan
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
foreach ($config in $customerConfigs) {
|
|
||||||
$customer_name = $config -replace '\.env$', ''
|
|
||||||
$config_path = "$CONFIG_DIR\$config"
|
|
||||||
|
|
||||||
Write-Host "Building for $customer_name..." -ForegroundColor Yellow
|
|
||||||
|
|
||||||
# Build
|
|
||||||
python $BUILD_SCRIPT --msi --env-file "$config_path"
|
|
||||||
|
|
||||||
# Copy to output directory
|
|
||||||
$msi_file = Get-ChildItem "$PROJECT_ROOT\build\dist\windows\*.msi" | Sort-Object LastWriteTime | Select-Object -Last 1
|
|
||||||
if ($msi_file) {
|
|
||||||
Copy-Item $msi_file.FullName "$output_dir\WebDropBridge-${customer_name}.msi"
|
|
||||||
Write-Host "✅ Built: WebDropBridge-${customer_name}.msi" -ForegroundColor Green
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "✅ All builds complete!" -ForegroundColor Green
|
|
||||||
Write-Host "📦 Outputs in: $output_dir"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Run:**
|
|
||||||
```bash
|
|
||||||
.\build_all_customers.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
**Result:** All customer builds in a timestamped directory:
|
|
||||||
```
|
|
||||||
build/releases/2024-01-30_14-30-00/
|
|
||||||
├── WebDropBridge-acme_corp.msi
|
|
||||||
├── WebDropBridge-globex_corporation.msi
|
|
||||||
├── WebDropBridge-initech.msi
|
|
||||||
└── WebDropBridge-wayne_enterprises.msi
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration Best Practices
|
|
||||||
|
|
||||||
### 1. **Version Numbers**
|
|
||||||
Keep APP_VERSION in sync across all builds. Options:
|
|
||||||
- Use project `.env` with single source of truth
|
|
||||||
- Or explicitly set in each customer config
|
|
||||||
|
|
||||||
### 2. **Naming Convention**
|
|
||||||
Customer configs:
|
|
||||||
```
|
|
||||||
deploy/customer_configs/
|
|
||||||
├── {customer_name_lowercase}.env
|
|
||||||
├── {customer_name_lowercase}-staging.env
|
|
||||||
└── {customer_name_lowercase}-dev.env
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **Security**
|
|
||||||
- Don't commit customer configs to git (if they contain sensitive URLs)
|
|
||||||
- Use `.gitignore`: `deploy/customer_configs/*.env` (but keep template)
|
|
||||||
- Store customer configs in secure location (separate backup/version control)
|
|
||||||
|
|
||||||
### 4. **Documentation**
|
|
||||||
In each customer config, add comments:
|
|
||||||
```dotenv
|
|
||||||
# WebDropBridge Configuration - ACME Corp
|
|
||||||
# Last updated: 2024-01-30
|
|
||||||
# Contact: support@acmecorp.com
|
|
||||||
|
|
||||||
# The web application they'll connect to
|
|
||||||
WEBAPP_URL=https://acme-drop.example.com/drop
|
|
||||||
|
|
||||||
# Directories they can access
|
|
||||||
ALLOWED_ROOTS=Z:/acme_files/,C:/Users/Public/ACME
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. **Testing**
|
|
||||||
Before building for a customer:
|
|
||||||
1. Copy their config to `.env` in project root
|
|
||||||
2. Run the app: `python src/webdrop_bridge/main.py`
|
|
||||||
3. Test the configuration loads correctly
|
|
||||||
4. Then build: `python build/scripts/build_windows.py --msi`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "Configuration file not found"
|
|
||||||
**Problem:** `.env` file specified with `--env-file` doesn't exist.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
```bash
|
|
||||||
# Check the file exists
|
|
||||||
ls deploy/customer_configs/acme_corp.env
|
|
||||||
|
|
||||||
# Use full path if relative path doesn't work
|
|
||||||
python build/scripts/build_windows.py --msi --env-file C:\full\path\to\acme_corp.env
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build fails with no --env-file specified
|
|
||||||
**Problem:** Project root `.env` doesn't exist, but no `--env-file` provided.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
```bash
|
|
||||||
# Option 1: Create .env in project root
|
|
||||||
copy .env.example .env
|
|
||||||
# Edit .env as needed
|
|
||||||
|
|
||||||
# Option 2: Specify custom location
|
|
||||||
python build/scripts/build_windows.py --msi --env-file deploy/customer_configs/your_config.env
|
|
||||||
```
|
|
||||||
|
|
||||||
### App shows wrong configuration
|
|
||||||
**Problem:** Built app has old configuration.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Delete previous build: `rmdir /s build\dist`
|
|
||||||
2. Verify you're using correct `.env`:
|
|
||||||
- Check with `python build/scripts/build_windows.py --help`
|
|
||||||
- Look at the console output during build: "📋 Using configuration: ..."
|
|
||||||
3. Rebuild
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
With the new configuration bundling system, you can:
|
|
||||||
- ✅ Build once, configure for different customers
|
|
||||||
- ✅ Maintain centralized customer configurations
|
|
||||||
- ✅ Automate multi-customer builds
|
|
||||||
- ✅ Deploy to different environments (dev/staging/prod)
|
|
||||||
- ✅ No manual customer setup required after installation
|
|
||||||
BIN
full_test.txt
BIN
full_test.txt
Binary file not shown.
|
|
@ -2,12 +2,9 @@
|
||||||
requires = ["setuptools>=65.0", "wheel"]
|
requires = ["setuptools>=65.0", "wheel"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[tool.setuptools.dynamic]
|
|
||||||
version = {attr = "webdrop_bridge.__version__"}
|
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "webdrop-bridge"
|
name = "webdrop-bridge"
|
||||||
dynamic = ["version"]
|
version = "1.0.0"
|
||||||
description = "Professional Qt-based desktop bridge application converting web drag-and-drop to native file operations for InDesign, Word, and other desktop applications"
|
description = "Professional Qt-based desktop bridge application converting web drag-and-drop to native file operations for InDesign, Word, and other desktop applications"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
pytest>=7.4.0
|
pytest>=7.4.0
|
||||||
pytest-cov>=4.1.0
|
pytest-cov>=4.1.0
|
||||||
pytest-qt>=4.2.0
|
pytest-qt>=4.2.0
|
||||||
pytest-asyncio>=0.21.0
|
|
||||||
|
|
||||||
# Code Quality
|
# Code Quality
|
||||||
black>=23.0.0
|
black>=23.0.0
|
||||||
|
|
|
||||||
|
|
@ -1,179 +0,0 @@
|
||||||
"""Sync version from __init__.py to changelog.
|
|
||||||
|
|
||||||
This script reads the version from src/webdrop_bridge/__init__.py and
|
|
||||||
updates the CHANGELOG.md. Config and pyproject.toml automatically read
|
|
||||||
from __init__.py, so no manual sync needed for those files.
|
|
||||||
|
|
||||||
This script uses shared version utilities (build/scripts/version_utils.py)
|
|
||||||
to ensure consistent version reading across all build scripts.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python scripts/sync_version.py [--version VERSION]
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
python scripts/sync_version.py # Use version from __init__.py
|
|
||||||
python scripts/sync_version.py --version 2.0.0 # Override with new version
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Enable UTF-8 output on Windows
|
|
||||||
if sys.platform == "win32":
|
|
||||||
import io
|
|
||||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
|
|
||||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8")
|
|
||||||
|
|
||||||
# Import shared version utilities
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "build" / "scripts"))
|
|
||||||
from version_utils import get_current_version, get_project_root
|
|
||||||
|
|
||||||
PROJECT_ROOT = get_project_root()
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_version_from_init() -> str:
|
|
||||||
"""Get version from __init__.py using shared utility.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Current version string from __init__.py
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If __version__ cannot be found
|
|
||||||
"""
|
|
||||||
return get_current_version()
|
|
||||||
|
|
||||||
|
|
||||||
def update_init_version(version: str) -> None:
|
|
||||||
"""Update version in __init__.py.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
version: New version string to set
|
|
||||||
"""
|
|
||||||
init_file = PROJECT_ROOT / "src/webdrop_bridge/__init__.py"
|
|
||||||
content = init_file.read_text()
|
|
||||||
new_content = re.sub(
|
|
||||||
r'__version__\s*=\s*["\'][^"\']+["\']',
|
|
||||||
f'__version__ = "{version}"',
|
|
||||||
content,
|
|
||||||
)
|
|
||||||
init_file.write_text(new_content)
|
|
||||||
print(f"✓ Updated src/webdrop_bridge/__init__.py to {version}")
|
|
||||||
|
|
||||||
|
|
||||||
def update_env_example(version: str) -> None:
|
|
||||||
"""Update APP_VERSION in .env.example (optional).
|
|
||||||
|
|
||||||
Note: config.py now reads from __init__.py by default.
|
|
||||||
Only update if .env.example explicitly sets APP_VERSION for testing.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
version: New version string to set
|
|
||||||
"""
|
|
||||||
env_file = PROJECT_ROOT / ".env.example"
|
|
||||||
if env_file.exists():
|
|
||||||
content = env_file.read_text()
|
|
||||||
# Only update if APP_VERSION is explicitly set
|
|
||||||
if 'APP_VERSION=' in content:
|
|
||||||
new_content = re.sub(
|
|
||||||
r'APP_VERSION=[^\n]+',
|
|
||||||
f'APP_VERSION={version}',
|
|
||||||
content,
|
|
||||||
)
|
|
||||||
env_file.write_text(new_content)
|
|
||||||
print(f"✓ Updated .env.example to {version}")
|
|
||||||
else:
|
|
||||||
print(
|
|
||||||
f"ℹ️ .env.example does not override APP_VERSION "
|
|
||||||
f"(uses __init__.py)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def update_env_file(version: str) -> None:
|
|
||||||
"""Update APP_VERSION in .env if it exists.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
version: New version string to set
|
|
||||||
"""
|
|
||||||
env_file = PROJECT_ROOT / ".env"
|
|
||||||
if env_file.exists():
|
|
||||||
content = env_file.read_text()
|
|
||||||
# Update if APP_VERSION is present
|
|
||||||
if 'APP_VERSION=' in content:
|
|
||||||
new_content = re.sub(
|
|
||||||
r'APP_VERSION=[^\n]+',
|
|
||||||
f'APP_VERSION={version}',
|
|
||||||
content,
|
|
||||||
)
|
|
||||||
env_file.write_text(new_content)
|
|
||||||
print(f"✓ Updated .env to {version}")
|
|
||||||
|
|
||||||
|
|
||||||
def update_changelog(version: str) -> None:
|
|
||||||
"""Add version header to CHANGELOG.md if not present.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
version: New version string to add
|
|
||||||
"""
|
|
||||||
changelog = PROJECT_ROOT / "CHANGELOG.md"
|
|
||||||
if changelog.exists():
|
|
||||||
content = changelog.read_text()
|
|
||||||
if f"## [{version}]" not in content and f"## {version}" not in content:
|
|
||||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
|
||||||
header = (
|
|
||||||
f"## [{version}] - {date_str}\n\n"
|
|
||||||
"### Added\n\n### Changed\n\n### Fixed\n\n"
|
|
||||||
)
|
|
||||||
new_content = header + content
|
|
||||||
changelog.write_text(new_content)
|
|
||||||
print(f"✓ Added version header to CHANGELOG.md for {version}")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
"""Sync version across project.
|
|
||||||
|
|
||||||
Updates __init__.py (source of truth) and changelog.
|
|
||||||
Config and pyproject.toml automatically read from __init__.py.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
0 on success, 1 on error
|
|
||||||
"""
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Sync version from __init__.py to dependent files"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--version",
|
|
||||||
type=str,
|
|
||||||
help="Version to set (if not provided, reads from __init__.py)",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
try:
|
|
||||||
if args.version:
|
|
||||||
if not re.match(r"^\d+\.\d+\.\d+", args.version):
|
|
||||||
print(
|
|
||||||
"❌ Invalid version format. Use semantic versioning"
|
|
||||||
" (e.g., 1.2.3)"
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
version = args.version
|
|
||||||
update_init_version(version)
|
|
||||||
else:
|
|
||||||
version = get_current_version_from_init()
|
|
||||||
print(f"📍 Current version from __init__.py: {version}")
|
|
||||||
|
|
||||||
update_env_example(version)
|
|
||||||
update_env_file(version)
|
|
||||||
update_changelog(version)
|
|
||||||
print(f"\n✅ Version sync complete: {version}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Error: {e}")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""WebDrop Bridge - Qt-based desktop application for intelligent drag-and-drop file handling."""
|
"""WebDrop Bridge - Qt-based desktop application for intelligent drag-and-drop file handling."""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "1.0.0"
|
||||||
__author__ = "WebDrop Team"
|
__author__ = "WebDrop Team"
|
||||||
__license__ = "MIT"
|
__license__ = "MIT"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ class Config:
|
||||||
webapp_url: URL to load in embedded web application
|
webapp_url: URL to load in embedded web application
|
||||||
window_width: Initial window width in pixels
|
window_width: Initial window width in pixels
|
||||||
window_height: Initial window height in pixels
|
window_height: Initial window height in pixels
|
||||||
window_title: Main window title (default: "{app_name} v{app_version}")
|
|
||||||
enable_logging: Whether to write logs to file
|
enable_logging: Whether to write logs to file
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
|
|
@ -47,7 +46,6 @@ class Config:
|
||||||
webapp_url: str
|
webapp_url: str
|
||||||
window_width: int
|
window_width: int
|
||||||
window_height: int
|
window_height: int
|
||||||
window_title: str
|
|
||||||
enable_logging: bool
|
enable_logging: bool
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -71,12 +69,7 @@ class Config:
|
||||||
|
|
||||||
# Extract and validate configuration values
|
# Extract and validate configuration values
|
||||||
app_name = os.getenv("APP_NAME", "WebDrop Bridge")
|
app_name = os.getenv("APP_NAME", "WebDrop Bridge")
|
||||||
# Version comes from __init__.py (lazy import to avoid circular imports)
|
app_version = os.getenv("APP_VERSION", "1.0.0")
|
||||||
if not os.getenv("APP_VERSION"):
|
|
||||||
from webdrop_bridge import __version__
|
|
||||||
app_version = __version__
|
|
||||||
else:
|
|
||||||
app_version = os.getenv("APP_VERSION")
|
|
||||||
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
log_file_str = os.getenv("LOG_FILE", "logs/webdrop_bridge.log")
|
log_file_str = os.getenv("LOG_FILE", "logs/webdrop_bridge.log")
|
||||||
allowed_roots_str = os.getenv("ALLOWED_ROOTS", "Z:/,C:/Users/Public")
|
allowed_roots_str = os.getenv("ALLOWED_ROOTS", "Z:/,C:/Users/Public")
|
||||||
|
|
@ -84,9 +77,6 @@ class Config:
|
||||||
webapp_url = os.getenv("WEBAPP_URL", "file:///./webapp/index.html")
|
webapp_url = os.getenv("WEBAPP_URL", "file:///./webapp/index.html")
|
||||||
window_width = int(os.getenv("WINDOW_WIDTH", "1024"))
|
window_width = int(os.getenv("WINDOW_WIDTH", "1024"))
|
||||||
window_height = int(os.getenv("WINDOW_HEIGHT", "768"))
|
window_height = int(os.getenv("WINDOW_HEIGHT", "768"))
|
||||||
# Window title defaults to app_name + version if not specified
|
|
||||||
default_title = f"{app_name} v{app_version}"
|
|
||||||
window_title = os.getenv("WINDOW_TITLE", default_title)
|
|
||||||
enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true"
|
enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true"
|
||||||
|
|
||||||
# Validate log level
|
# Validate log level
|
||||||
|
|
@ -150,7 +140,6 @@ class Config:
|
||||||
webapp_url=webapp_url,
|
webapp_url=webapp_url,
|
||||||
window_width=window_width,
|
window_width=window_width,
|
||||||
window_height=window_height,
|
window_height=window_height,
|
||||||
window_title=window_title,
|
|
||||||
enable_logging=enable_logging,
|
enable_logging=enable_logging,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,263 +0,0 @@
|
||||||
"""Configuration management with validation, profiles, and import/export."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from webdrop_bridge.config import Config, ConfigurationError
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigValidator:
|
|
||||||
"""Validates configuration values against schema.
|
|
||||||
|
|
||||||
Provides detailed error messages for invalid configurations.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Schema definition for configuration
|
|
||||||
SCHEMA = {
|
|
||||||
"app_name": {"type": str, "min_length": 1, "max_length": 100},
|
|
||||||
"app_version": {"type": str, "pattern": r"^\d+\.\d+\.\d+$"},
|
|
||||||
"log_level": {"type": str, "allowed": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]},
|
|
||||||
"log_file": {"type": (str, type(None)), "optional": True},
|
|
||||||
"allowed_roots": {"type": list, "item_type": (str, Path), "min_items": 0},
|
|
||||||
"allowed_urls": {"type": list, "item_type": str, "min_items": 0},
|
|
||||||
"webapp_url": {"type": str, "min_length": 1},
|
|
||||||
"window_width": {"type": int, "min_value": 400, "max_value": 5000},
|
|
||||||
"window_height": {"type": int, "min_value": 300, "max_value": 5000},
|
|
||||||
"enable_logging": {"type": bool},
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def validate(config_dict: Dict[str, Any]) -> List[str]:
|
|
||||||
"""Validate configuration dictionary.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config_dict: Configuration dictionary to validate
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of validation error messages (empty if valid)
|
|
||||||
"""
|
|
||||||
errors = []
|
|
||||||
|
|
||||||
for field, rules in ConfigValidator.SCHEMA.items():
|
|
||||||
if field not in config_dict:
|
|
||||||
if not rules.get("optional", False):
|
|
||||||
errors.append(f"Missing required field: {field}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
value = config_dict[field]
|
|
||||||
|
|
||||||
# Check type
|
|
||||||
expected_type = rules.get("type")
|
|
||||||
if expected_type and not isinstance(value, expected_type):
|
|
||||||
errors.append(f"{field}: expected {expected_type.__name__}, got {type(value).__name__}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check allowed values
|
|
||||||
if "allowed" in rules and value not in rules["allowed"]:
|
|
||||||
errors.append(f"{field}: must be one of {rules['allowed']}, got {value}")
|
|
||||||
|
|
||||||
# Check string length
|
|
||||||
if isinstance(value, str):
|
|
||||||
if "min_length" in rules and len(value) < rules["min_length"]:
|
|
||||||
errors.append(f"{field}: minimum length is {rules['min_length']}")
|
|
||||||
if "max_length" in rules and len(value) > rules["max_length"]:
|
|
||||||
errors.append(f"{field}: maximum length is {rules['max_length']}")
|
|
||||||
|
|
||||||
# Check numeric range
|
|
||||||
if isinstance(value, int):
|
|
||||||
if "min_value" in rules and value < rules["min_value"]:
|
|
||||||
errors.append(f"{field}: minimum value is {rules['min_value']}")
|
|
||||||
if "max_value" in rules and value > rules["max_value"]:
|
|
||||||
errors.append(f"{field}: maximum value is {rules['max_value']}")
|
|
||||||
|
|
||||||
# Check list items
|
|
||||||
if isinstance(value, list):
|
|
||||||
if "min_items" in rules and len(value) < rules["min_items"]:
|
|
||||||
errors.append(f"{field}: minimum {rules['min_items']} items required")
|
|
||||||
|
|
||||||
return errors
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def validate_or_raise(config_dict: Dict[str, Any]) -> None:
|
|
||||||
"""Validate configuration and raise error if invalid.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config_dict: Configuration dictionary to validate
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ConfigurationError: If configuration is invalid
|
|
||||||
"""
|
|
||||||
errors = ConfigValidator.validate(config_dict)
|
|
||||||
if errors:
|
|
||||||
raise ConfigurationError(f"Configuration validation failed:\n" + "\n".join(errors))
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigProfile:
|
|
||||||
"""Manages named configuration profiles.
|
|
||||||
|
|
||||||
Profiles are stored in ~/.webdrop-bridge/profiles/ directory as JSON files.
|
|
||||||
"""
|
|
||||||
|
|
||||||
PROFILES_DIR = Path.home() / ".webdrop-bridge" / "profiles"
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Initialize profile manager."""
|
|
||||||
self.PROFILES_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
def save_profile(self, profile_name: str, config: Config) -> Path:
|
|
||||||
"""Save configuration as a named profile.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_name: Name of the profile (e.g., "work", "personal")
|
|
||||||
config: Config object to save
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to the saved profile file
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ConfigurationError: If profile name is invalid
|
|
||||||
"""
|
|
||||||
if not profile_name or "/" in profile_name or "\\" in profile_name:
|
|
||||||
raise ConfigurationError(f"Invalid profile name: {profile_name}")
|
|
||||||
|
|
||||||
profile_path = self.PROFILES_DIR / f"{profile_name}.json"
|
|
||||||
|
|
||||||
config_data = {
|
|
||||||
"app_name": config.app_name,
|
|
||||||
"app_version": config.app_version,
|
|
||||||
"log_level": config.log_level,
|
|
||||||
"log_file": str(config.log_file) if config.log_file else None,
|
|
||||||
"allowed_roots": [str(p) for p in config.allowed_roots],
|
|
||||||
"allowed_urls": config.allowed_urls,
|
|
||||||
"webapp_url": config.webapp_url,
|
|
||||||
"window_width": config.window_width,
|
|
||||||
"window_height": config.window_height,
|
|
||||||
"enable_logging": config.enable_logging,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
profile_path.write_text(json.dumps(config_data, indent=2))
|
|
||||||
logger.info(f"Profile saved: {profile_name}")
|
|
||||||
return profile_path
|
|
||||||
except (OSError, IOError) as e:
|
|
||||||
raise ConfigurationError(f"Failed to save profile {profile_name}: {e}")
|
|
||||||
|
|
||||||
def load_profile(self, profile_name: str) -> Dict[str, Any]:
|
|
||||||
"""Load configuration from a named profile.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_name: Name of the profile to load
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Configuration dictionary
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ConfigurationError: If profile not found or invalid
|
|
||||||
"""
|
|
||||||
profile_path = self.PROFILES_DIR / f"{profile_name}.json"
|
|
||||||
|
|
||||||
if not profile_path.exists():
|
|
||||||
raise ConfigurationError(f"Profile not found: {profile_name}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
config_data = json.loads(profile_path.read_text())
|
|
||||||
# Validate before returning
|
|
||||||
ConfigValidator.validate_or_raise(config_data)
|
|
||||||
return config_data
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
raise ConfigurationError(f"Invalid JSON in profile {profile_name}: {e}")
|
|
||||||
|
|
||||||
def list_profiles(self) -> List[str]:
|
|
||||||
"""List all available profiles.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of profile names (without .json extension)
|
|
||||||
"""
|
|
||||||
if not self.PROFILES_DIR.exists():
|
|
||||||
return []
|
|
||||||
|
|
||||||
return sorted([p.stem for p in self.PROFILES_DIR.glob("*.json")])
|
|
||||||
|
|
||||||
def delete_profile(self, profile_name: str) -> None:
|
|
||||||
"""Delete a profile.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_name: Name of the profile to delete
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ConfigurationError: If profile not found
|
|
||||||
"""
|
|
||||||
profile_path = self.PROFILES_DIR / f"{profile_name}.json"
|
|
||||||
|
|
||||||
if not profile_path.exists():
|
|
||||||
raise ConfigurationError(f"Profile not found: {profile_name}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
profile_path.unlink()
|
|
||||||
logger.info(f"Profile deleted: {profile_name}")
|
|
||||||
except OSError as e:
|
|
||||||
raise ConfigurationError(f"Failed to delete profile {profile_name}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigExporter:
|
|
||||||
"""Handle configuration import and export operations."""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def export_to_json(config: Config, output_path: Path) -> None:
|
|
||||||
"""Export configuration to JSON file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config: Config object to export
|
|
||||||
output_path: Path to write JSON file
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ConfigurationError: If export fails
|
|
||||||
"""
|
|
||||||
config_data = {
|
|
||||||
"app_name": config.app_name,
|
|
||||||
"app_version": config.app_version,
|
|
||||||
"log_level": config.log_level,
|
|
||||||
"log_file": str(config.log_file) if config.log_file else None,
|
|
||||||
"allowed_roots": [str(p) for p in config.allowed_roots],
|
|
||||||
"allowed_urls": config.allowed_urls,
|
|
||||||
"webapp_url": config.webapp_url,
|
|
||||||
"window_width": config.window_width,
|
|
||||||
"window_height": config.window_height,
|
|
||||||
"enable_logging": config.enable_logging,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
output_path.write_text(json.dumps(config_data, indent=2))
|
|
||||||
logger.info(f"Configuration exported to: {output_path}")
|
|
||||||
except (OSError, IOError) as e:
|
|
||||||
raise ConfigurationError(f"Failed to export configuration: {e}")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def import_from_json(input_path: Path) -> Dict[str, Any]:
|
|
||||||
"""Import configuration from JSON file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
input_path: Path to JSON file to import
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Configuration dictionary
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ConfigurationError: If import fails or validation fails
|
|
||||||
"""
|
|
||||||
if not input_path.exists():
|
|
||||||
raise ConfigurationError(f"File not found: {input_path}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
config_data = json.loads(input_path.read_text())
|
|
||||||
# Validate before returning
|
|
||||||
ConfigValidator.validate_or_raise(config_data)
|
|
||||||
logger.info(f"Configuration imported from: {input_path}")
|
|
||||||
return config_data
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
raise ConfigurationError(f"Invalid JSON file: {e}")
|
|
||||||
|
|
@ -1,444 +0,0 @@
|
||||||
"""Auto-update system for WebDrop Bridge using Forgejo releases.
|
|
||||||
|
|
||||||
This module manages checking for updates, downloading installers, and
|
|
||||||
verifying checksums from Forgejo releases.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import socket
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
from urllib.error import URLError
|
|
||||||
from urllib.request import urlopen
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Release:
|
|
||||||
"""Represents a Forgejo release."""
|
|
||||||
|
|
||||||
tag_name: str
|
|
||||||
name: str
|
|
||||||
version: str # Semantic version (e.g., "1.0.0")
|
|
||||||
body: str # Release notes/changelog
|
|
||||||
assets: list[dict] # List of {name, browser_download_url}
|
|
||||||
published_at: str # ISO format datetime
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateManager:
|
|
||||||
"""Manages auto-updates via Forgejo releases API."""
|
|
||||||
|
|
||||||
def __init__(self, current_version: str, config_dir: Optional[Path] = None):
|
|
||||||
"""Initialize update manager.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
current_version: Current app version (e.g., "0.0.1")
|
|
||||||
config_dir: Directory for storing update cache. Defaults to temp.
|
|
||||||
"""
|
|
||||||
self.current_version = current_version
|
|
||||||
self.forgejo_url = "https://git.him-tools.de"
|
|
||||||
self.repo = "HIM-public/webdrop-bridge"
|
|
||||||
self.api_endpoint = (
|
|
||||||
f"{self.forgejo_url}/api/v1/repos/{self.repo}/releases/latest"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Cache management
|
|
||||||
self.cache_dir = config_dir or Path.home() / ".webdrop-bridge"
|
|
||||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
self.cache_file = self.cache_dir / "update_check.json"
|
|
||||||
self.cache_ttl = timedelta(hours=24)
|
|
||||||
|
|
||||||
def _parse_version(self, version_str: str) -> tuple[int, int, int]:
|
|
||||||
"""Parse semantic version string to tuple.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
version_str: Version string (e.g., "1.0.0" or "v1.0.0")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (major, minor, patch)
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If version format is invalid
|
|
||||||
"""
|
|
||||||
# Remove 'v' prefix if present
|
|
||||||
version_str = version_str.lstrip("v")
|
|
||||||
|
|
||||||
try:
|
|
||||||
parts = version_str.split(".")
|
|
||||||
if len(parts) != 3:
|
|
||||||
raise ValueError(f"Invalid version format: {version_str}")
|
|
||||||
return tuple(int(p) for p in parts) # type: ignore
|
|
||||||
except ValueError as e:
|
|
||||||
logger.error(f"Failed to parse version '{version_str}': {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _is_newer_version(self, latest_version: str) -> bool:
|
|
||||||
"""Check if latest version is newer than current.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
latest_version: Latest version string
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if latest_version > current_version
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
current = self._parse_version(self.current_version)
|
|
||||||
latest = self._parse_version(latest_version)
|
|
||||||
return latest > current
|
|
||||||
except ValueError:
|
|
||||||
logger.error("Failed to compare versions")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _load_cache(self) -> Optional[dict]:
|
|
||||||
"""Load cached release info if valid.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Cached release dict if cache exists and is fresh, None otherwise
|
|
||||||
"""
|
|
||||||
if not self.cache_file.exists():
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(self.cache_file) as f:
|
|
||||||
cached = json.load(f)
|
|
||||||
|
|
||||||
# Check if cache is still valid
|
|
||||||
timestamp = datetime.fromisoformat(cached.get("timestamp", ""))
|
|
||||||
if datetime.now() - timestamp < self.cache_ttl:
|
|
||||||
logger.debug("Using cached release info")
|
|
||||||
return cached
|
|
||||||
else:
|
|
||||||
logger.debug("Cache expired")
|
|
||||||
self.cache_file.unlink()
|
|
||||||
return None
|
|
||||||
except (json.JSONDecodeError, ValueError) as e:
|
|
||||||
logger.warning(f"Failed to load cache: {e}")
|
|
||||||
self.cache_file.unlink()
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _save_cache(self, release_info: dict) -> None:
|
|
||||||
"""Save release info to cache.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
release_info: Release information to cache
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
cache_data = {
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
"release": release_info,
|
|
||||||
}
|
|
||||||
with open(self.cache_file, "w") as f:
|
|
||||||
json.dump(cache_data, f)
|
|
||||||
logger.debug("Cached release info")
|
|
||||||
except OSError as e:
|
|
||||||
logger.warning(f"Failed to save cache: {e}")
|
|
||||||
|
|
||||||
async def check_for_updates(self) -> Optional[Release]:
|
|
||||||
"""Check Forgejo API for latest release.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Release object if newer version available, None otherwise
|
|
||||||
"""
|
|
||||||
logger.debug(f"check_for_updates() called, current version: {self.current_version}")
|
|
||||||
|
|
||||||
# Try cache first
|
|
||||||
logger.debug("Checking cache...")
|
|
||||||
cached = self._load_cache()
|
|
||||||
if cached:
|
|
||||||
logger.debug("Found cached release")
|
|
||||||
release_data = cached.get("release")
|
|
||||||
if release_data:
|
|
||||||
version = release_data["tag_name"].lstrip("v")
|
|
||||||
if not self._is_newer_version(version):
|
|
||||||
logger.info("No newer version available (cached)")
|
|
||||||
return None
|
|
||||||
return Release(**release_data)
|
|
||||||
|
|
||||||
# Fetch from API
|
|
||||||
logger.debug("Fetching from API...")
|
|
||||||
try:
|
|
||||||
logger.info(f"Checking for updates from {self.api_endpoint}")
|
|
||||||
|
|
||||||
# Run in thread pool with aggressive timeout
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
response = await asyncio.wait_for(
|
|
||||||
loop.run_in_executor(
|
|
||||||
None, self._fetch_release
|
|
||||||
),
|
|
||||||
timeout=8 # Timeout after network call also has timeout
|
|
||||||
)
|
|
||||||
|
|
||||||
if not response:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Check if newer version
|
|
||||||
version = response["tag_name"].lstrip("v")
|
|
||||||
if not self._is_newer_version(version):
|
|
||||||
logger.info(f"Latest version {version} is not newer than {self.current_version}")
|
|
||||||
self._save_cache(response)
|
|
||||||
return None
|
|
||||||
|
|
||||||
logger.info(f"New version available: {version}")
|
|
||||||
release = Release(**response)
|
|
||||||
self._save_cache(response)
|
|
||||||
return release
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning("Update check timed out - API server not responding")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error checking for updates: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _fetch_release(self) -> Optional[dict]:
|
|
||||||
"""Fetch latest release from Forgejo API (blocking).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Release data dict or None on error
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
logger.debug(f"Fetching release from {self.api_endpoint}")
|
|
||||||
|
|
||||||
# Set socket timeout to prevent hanging
|
|
||||||
old_timeout = socket.getdefaulttimeout()
|
|
||||||
socket.setdefaulttimeout(5)
|
|
||||||
|
|
||||||
try:
|
|
||||||
logger.debug("Opening URL connection...")
|
|
||||||
with urlopen(self.api_endpoint, timeout=5) as response:
|
|
||||||
logger.debug(f"Response status: {response.status}, reading data...")
|
|
||||||
response_data = response.read()
|
|
||||||
logger.debug(f"Read {len(response_data)} bytes, parsing JSON...")
|
|
||||||
data = json.loads(response_data)
|
|
||||||
logger.info(f"Successfully fetched release: {data.get('tag_name', 'unknown')}")
|
|
||||||
return {
|
|
||||||
"tag_name": data["tag_name"],
|
|
||||||
"name": data["name"],
|
|
||||||
"version": data["tag_name"].lstrip("v"),
|
|
||||||
"body": data["body"],
|
|
||||||
"assets": data.get("assets", []),
|
|
||||||
"published_at": data.get("published_at", ""),
|
|
||||||
}
|
|
||||||
finally:
|
|
||||||
socket.setdefaulttimeout(old_timeout)
|
|
||||||
|
|
||||||
except socket.timeout as e:
|
|
||||||
logger.error(f"Socket timeout (5s) connecting to {self.api_endpoint}")
|
|
||||||
return None
|
|
||||||
except TimeoutError as e:
|
|
||||||
logger.error(f"Timeout error: {e}")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to fetch release: {type(e).__name__}: {e}")
|
|
||||||
import traceback
|
|
||||||
logger.debug(traceback.format_exc())
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def download_update(
|
|
||||||
self, release: Release, output_dir: Optional[Path] = None
|
|
||||||
) -> Optional[Path]:
|
|
||||||
"""Download installer from release assets.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
release: Release information
|
|
||||||
output_dir: Directory to save installer. Defaults to cache_dir.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to downloaded file or None on error
|
|
||||||
"""
|
|
||||||
if not release.assets:
|
|
||||||
logger.error("No assets found in release")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Find .msi or .dmg file
|
|
||||||
installer_asset = None
|
|
||||||
for asset in release.assets:
|
|
||||||
if asset["name"].endswith((".msi", ".dmg")):
|
|
||||||
installer_asset = asset
|
|
||||||
break
|
|
||||||
|
|
||||||
if not installer_asset:
|
|
||||||
logger.error("No installer found in release assets")
|
|
||||||
return None
|
|
||||||
|
|
||||||
output_dir = output_dir or self.cache_dir
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
output_file = output_dir / installer_asset["name"]
|
|
||||||
|
|
||||||
try:
|
|
||||||
logger.info(f"Downloading {installer_asset['name']}")
|
|
||||||
|
|
||||||
# Run in thread pool with 5-minute timeout for large files
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
success = await asyncio.wait_for(
|
|
||||||
loop.run_in_executor(
|
|
||||||
None,
|
|
||||||
self._download_file,
|
|
||||||
installer_asset["browser_download_url"],
|
|
||||||
output_file,
|
|
||||||
),
|
|
||||||
timeout=300
|
|
||||||
)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
logger.info(f"Downloaded to {output_file}")
|
|
||||||
return output_file
|
|
||||||
return None
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.error(f"Download timed out: {installer_asset['name']}")
|
|
||||||
if output_file.exists():
|
|
||||||
output_file.unlink()
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error downloading update: {e}")
|
|
||||||
if output_file.exists():
|
|
||||||
output_file.unlink()
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _download_file(self, url: str, output_path: Path) -> bool:
|
|
||||||
"""Download file from URL (blocking).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: URL to download from
|
|
||||||
output_path: Path to save file
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if successful, False otherwise
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
logger.debug(f"Downloading from {url}")
|
|
||||||
with urlopen(url, timeout=300) as response: # 5 min timeout
|
|
||||||
with open(output_path, "wb") as f:
|
|
||||||
f.write(response.read())
|
|
||||||
logger.debug(f"Downloaded {output_path.stat().st_size} bytes")
|
|
||||||
return True
|
|
||||||
except URLError as e:
|
|
||||||
logger.error(f"Download failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def verify_checksum(
|
|
||||||
self, file_path: Path, release: Release
|
|
||||||
) -> bool:
|
|
||||||
"""Verify file checksum against release checksum file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_path: Path to downloaded installer
|
|
||||||
release: Release information
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if checksum matches, False otherwise
|
|
||||||
"""
|
|
||||||
# Find .sha256 file in release assets
|
|
||||||
checksum_asset = None
|
|
||||||
for asset in release.assets:
|
|
||||||
if asset["name"].endswith(".sha256"):
|
|
||||||
checksum_asset = asset
|
|
||||||
break
|
|
||||||
|
|
||||||
if not checksum_asset:
|
|
||||||
logger.warning("No checksum file found in release")
|
|
||||||
return True # Continue anyway
|
|
||||||
|
|
||||||
try:
|
|
||||||
logger.info("Verifying checksum...")
|
|
||||||
|
|
||||||
# Download checksum file with 30 second timeout
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
checksum_content = await asyncio.wait_for(
|
|
||||||
loop.run_in_executor(
|
|
||||||
None,
|
|
||||||
self._download_checksum,
|
|
||||||
checksum_asset["browser_download_url"],
|
|
||||||
),
|
|
||||||
timeout=30
|
|
||||||
)
|
|
||||||
|
|
||||||
if not checksum_content:
|
|
||||||
logger.warning("Failed to download checksum")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Calculate file checksum
|
|
||||||
sha256_hash = hashlib.sha256()
|
|
||||||
with open(file_path, "rb") as f:
|
|
||||||
for chunk in iter(lambda: f.read(8192), b""):
|
|
||||||
sha256_hash.update(chunk)
|
|
||||||
|
|
||||||
file_checksum = sha256_hash.hexdigest()
|
|
||||||
expected_checksum = checksum_content.strip()
|
|
||||||
|
|
||||||
if file_checksum == expected_checksum:
|
|
||||||
logger.info("Checksum verification passed")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logger.error(
|
|
||||||
f"Checksum mismatch: {file_checksum} != {expected_checksum}"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.error("Checksum verification timed out")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error verifying checksum: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _download_checksum(self, url: str) -> Optional[str]:
|
|
||||||
"""Download checksum file (blocking).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: URL to checksum file
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Checksum content or None on error
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
with urlopen(url, timeout=10) as response:
|
|
||||||
return response.read().decode().strip()
|
|
||||||
except URLError as e:
|
|
||||||
logger.error(f"Failed to download checksum: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def install_update(self, installer_path: Path) -> bool:
|
|
||||||
"""Launch installer for update.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
installer_path: Path to installer executable
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if installer launched, False otherwise
|
|
||||||
|
|
||||||
Note:
|
|
||||||
The actual installation and restart are handled by the installer.
|
|
||||||
"""
|
|
||||||
if not installer_path.exists():
|
|
||||||
logger.error(f"Installer not found: {installer_path}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
import platform
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
if platform.system() == "Windows":
|
|
||||||
# Windows: Run MSI installer
|
|
||||||
logger.info(f"Launching installer: {installer_path}")
|
|
||||||
subprocess.Popen([str(installer_path)])
|
|
||||||
return True
|
|
||||||
elif platform.system() == "Darwin":
|
|
||||||
# macOS: Mount DMG and run installer
|
|
||||||
logger.info(f"Launching DMG: {installer_path}")
|
|
||||||
subprocess.Popen(["open", str(installer_path)])
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logger.error(f"Unsupported platform: {platform.system()}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to launch installer: {e}")
|
|
||||||
return False
|
|
||||||
|
|
@ -53,9 +53,6 @@ def main() -> int:
|
||||||
window.show()
|
window.show()
|
||||||
|
|
||||||
logger.info("Main window opened successfully")
|
logger.info("Main window opened successfully")
|
||||||
|
|
||||||
# Check for updates on startup (non-blocking, async)
|
|
||||||
window.check_for_updates_startup()
|
|
||||||
|
|
||||||
# Run event loop
|
# Run event loop
|
||||||
return app.exec()
|
return app.exec()
|
||||||
|
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
// WebDrop Bridge - Injected Script
|
|
||||||
// Automatically converts Z:\ path drags to native file drags via QWebChannel bridge
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
if (window.__webdrop_bridge_injected) return;
|
|
||||||
window.__webdrop_bridge_injected = true;
|
|
||||||
|
|
||||||
function ensureChannel(cb) {
|
|
||||||
if (window.bridge) { cb(); return; }
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
if (window.QWebChannel && window.qt && window.qt.webChannelTransport) {
|
|
||||||
new QWebChannel(window.qt.webChannelTransport, function(channel) {
|
|
||||||
window.bridge = channel.objects.bridge;
|
|
||||||
cb();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.QWebChannel) {
|
|
||||||
init();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var s = document.createElement('script');
|
|
||||||
s.src = 'qrc:///qtwebchannel/qwebchannel.js';
|
|
||||||
s.onload = init;
|
|
||||||
document.documentElement.appendChild(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hook() {
|
|
||||||
document.addEventListener('dragstart', function(e) {
|
|
||||||
var dt = e.dataTransfer;
|
|
||||||
if (!dt) return;
|
|
||||||
|
|
||||||
// Get path from existing payload or from the card markup.
|
|
||||||
var path = dt.getData('text/plain');
|
|
||||||
if (!path) {
|
|
||||||
var card = e.target.closest && e.target.closest('.drag-item');
|
|
||||||
if (card) {
|
|
||||||
var pathEl = card.querySelector('p');
|
|
||||||
if (pathEl) {
|
|
||||||
path = (pathEl.textContent || '').trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!path) return;
|
|
||||||
|
|
||||||
// Ensure text payload exists for non-file drags and downstream targets.
|
|
||||||
if (!dt.getData('text/plain')) {
|
|
||||||
dt.setData('text/plain', path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if path is Z:\ — if yes, trigger native file drag. Otherwise, stay as text.
|
|
||||||
var isZDrive = /^z:/i.test(path);
|
|
||||||
if (!isZDrive) return;
|
|
||||||
|
|
||||||
// Z:\ detected — prevent default browser drag and convert to native file drag
|
|
||||||
e.preventDefault();
|
|
||||||
ensureChannel(function() {
|
|
||||||
if (window.bridge && typeof window.bridge.start_file_drag === 'function') {
|
|
||||||
window.bridge.start_file_drag(path);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', hook);
|
|
||||||
} else {
|
|
||||||
hook();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
@ -1,34 +1,16 @@
|
||||||
"""Main application window with web engine integration."""
|
"""Main application window with web engine integration."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, QPoint, QSize, Qt, QThread, QTimer, QUrl, Signal, Slot
|
from PySide6.QtCore import QSize, Qt, QUrl
|
||||||
from PySide6.QtGui import QIcon
|
from PySide6.QtWidgets import QMainWindow, QToolBar, QVBoxLayout, QWidget
|
||||||
from PySide6.QtWebChannel import QWebChannel
|
|
||||||
from PySide6.QtWebEngineCore import QWebEngineScript
|
|
||||||
from PySide6.QtWidgets import (
|
|
||||||
QLabel,
|
|
||||||
QMainWindow,
|
|
||||||
QSizePolicy,
|
|
||||||
QSpacerItem,
|
|
||||||
QStatusBar,
|
|
||||||
QToolBar,
|
|
||||||
QVBoxLayout,
|
|
||||||
QWidget,
|
|
||||||
QWidgetAction,
|
|
||||||
)
|
|
||||||
|
|
||||||
from webdrop_bridge.config import Config
|
from webdrop_bridge.config import Config
|
||||||
from webdrop_bridge.core.drag_interceptor import DragInterceptor
|
from webdrop_bridge.core.drag_interceptor import DragInterceptor
|
||||||
from webdrop_bridge.core.validator import PathValidator
|
from webdrop_bridge.core.validator import PathValidator
|
||||||
from webdrop_bridge.ui.restricted_web_view import RestrictedWebEngineView
|
from webdrop_bridge.ui.restricted_web_view import RestrictedWebEngineView
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Default welcome page HTML when no webapp is configured
|
# Default welcome page HTML when no webapp is configured
|
||||||
DEFAULT_WELCOME_PAGE = """
|
DEFAULT_WELCOME_PAGE = """
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
|
@ -184,39 +166,6 @@ DEFAULT_WELCOME_PAGE = """
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class _DragBridge(QObject):
|
|
||||||
"""JavaScript bridge for drag operations via QWebChannel.
|
|
||||||
|
|
||||||
Exposed to JavaScript as 'bridge' object.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, window: 'MainWindow', parent: Optional[QObject] = None):
|
|
||||||
"""Initialize the drag bridge.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
window: MainWindow instance
|
|
||||||
parent: Parent QObject
|
|
||||||
"""
|
|
||||||
super().__init__(parent)
|
|
||||||
self.window = window
|
|
||||||
|
|
||||||
@Slot(str)
|
|
||||||
def start_file_drag(self, path_text: str) -> None:
|
|
||||||
"""Start a native file drag for the given path.
|
|
||||||
|
|
||||||
Called from JavaScript when user drags a Z:\ path item.
|
|
||||||
Defers execution to avoid Qt drag manager state issues.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
path_text: File path string to drag
|
|
||||||
"""
|
|
||||||
logger.debug(f"Bridge: start_file_drag called for {path_text}")
|
|
||||||
|
|
||||||
# Defer to avoid drag manager state issues
|
|
||||||
# initiate_drag() handles validation internally
|
|
||||||
QTimer.singleShot(0, lambda: self.window.drag_interceptor.initiate_drag([path_text]))
|
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
"""Main application window for WebDrop Bridge.
|
"""Main application window for WebDrop Bridge.
|
||||||
|
|
||||||
|
|
@ -224,10 +173,6 @@ class MainWindow(QMainWindow):
|
||||||
integration with the native filesystem.
|
integration with the native filesystem.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Signals
|
|
||||||
check_for_updates = Signal()
|
|
||||||
update_available = Signal(object) # Emits Release object
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
config: Config,
|
config: Config,
|
||||||
|
|
@ -241,13 +186,9 @@ class MainWindow(QMainWindow):
|
||||||
"""
|
"""
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.config = config
|
self.config = config
|
||||||
self._background_threads = [] # Keep references to background threads
|
|
||||||
self._background_workers = {} # Keep references to background workers
|
|
||||||
self.checking_dialog = None # Track the checking dialog
|
|
||||||
self._is_manual_check = False # Track if this is a manual check (for UI feedback)
|
|
||||||
|
|
||||||
# Set window properties
|
# Set window properties
|
||||||
self.setWindowTitle(config.window_title)
|
self.setWindowTitle(f"{config.app_name} v{config.app_version}")
|
||||||
self.setGeometry(
|
self.setGeometry(
|
||||||
100,
|
100,
|
||||||
100,
|
100,
|
||||||
|
|
@ -261,11 +202,9 @@ class MainWindow(QMainWindow):
|
||||||
# Create navigation toolbar (Kiosk-mode navigation)
|
# Create navigation toolbar (Kiosk-mode navigation)
|
||||||
self._create_navigation_toolbar()
|
self._create_navigation_toolbar()
|
||||||
|
|
||||||
# Create status bar
|
|
||||||
self._create_status_bar()
|
|
||||||
|
|
||||||
# Create drag interceptor
|
# Create drag interceptor
|
||||||
self.drag_interceptor = DragInterceptor()
|
self.drag_interceptor = DragInterceptor()
|
||||||
|
|
||||||
# Set up path validator
|
# Set up path validator
|
||||||
validator = PathValidator(config.allowed_roots)
|
validator = PathValidator(config.allowed_roots)
|
||||||
self.drag_interceptor.set_validator(validator)
|
self.drag_interceptor.set_validator(validator)
|
||||||
|
|
@ -274,15 +213,6 @@ class MainWindow(QMainWindow):
|
||||||
self.drag_interceptor.drag_started.connect(self._on_drag_started)
|
self.drag_interceptor.drag_started.connect(self._on_drag_started)
|
||||||
self.drag_interceptor.drag_failed.connect(self._on_drag_failed)
|
self.drag_interceptor.drag_failed.connect(self._on_drag_failed)
|
||||||
|
|
||||||
# Set up JavaScript bridge with QWebChannel
|
|
||||||
self._drag_bridge = _DragBridge(self)
|
|
||||||
web_channel = QWebChannel(self)
|
|
||||||
web_channel.registerObject("bridge", self._drag_bridge)
|
|
||||||
self.web_view.page().setWebChannel(web_channel)
|
|
||||||
|
|
||||||
# Install the drag bridge script
|
|
||||||
self._install_bridge_script()
|
|
||||||
|
|
||||||
# Set up central widget with layout
|
# Set up central widget with layout
|
||||||
central_widget = QWidget()
|
central_widget = QWidget()
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|
@ -301,7 +231,6 @@ class MainWindow(QMainWindow):
|
||||||
"""Load the web application.
|
"""Load the web application.
|
||||||
|
|
||||||
Loads HTML from the configured webapp URL or from local file.
|
Loads HTML from the configured webapp URL or from local file.
|
||||||
Injects the WebChannel bridge JavaScript for drag-and-drop.
|
|
||||||
Supports both bundled apps (PyInstaller) and development mode.
|
Supports both bundled apps (PyInstaller) and development mode.
|
||||||
Falls back to default welcome page if webapp not found.
|
Falls back to default welcome page if webapp not found.
|
||||||
"""
|
"""
|
||||||
|
|
@ -336,55 +265,15 @@ class MainWindow(QMainWindow):
|
||||||
self.web_view.setHtml(welcome_html)
|
self.web_view.setHtml(welcome_html)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Load local file
|
# Load local file as file:// URL
|
||||||
html_content = file_path.read_text(encoding='utf-8')
|
file_url = file_path.as_uri()
|
||||||
|
self.web_view.load(QUrl(file_url))
|
||||||
# Inject WebChannel bridge JavaScript
|
|
||||||
injected_html = self._inject_drag_bridge(html_content)
|
|
||||||
|
|
||||||
# Load the modified HTML
|
|
||||||
self.web_view.setHtml(injected_html, QUrl.fromLocalFile(file_path.parent))
|
|
||||||
|
|
||||||
except (OSError, ValueError) as e:
|
except (OSError, ValueError) as e:
|
||||||
# Show welcome page on error
|
# Show welcome page on error
|
||||||
welcome_html = DEFAULT_WELCOME_PAGE.format(version=self.config.app_version)
|
welcome_html = DEFAULT_WELCOME_PAGE.format(version=self.config.app_version)
|
||||||
self.web_view.setHtml(welcome_html)
|
self.web_view.setHtml(welcome_html)
|
||||||
|
|
||||||
def _install_bridge_script(self) -> None:
|
|
||||||
"""Install the drag bridge JavaScript via QWebEngineScript.
|
|
||||||
|
|
||||||
Follows the POC pattern for proper script injection and QWebChannel setup.
|
|
||||||
"""
|
|
||||||
script = QWebEngineScript()
|
|
||||||
script.setName("webdrop-bridge")
|
|
||||||
script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady)
|
|
||||||
script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
|
|
||||||
script.setRunsOnSubFrames(False)
|
|
||||||
|
|
||||||
# Load bridge script from file
|
|
||||||
script_path = Path(__file__).parent / "bridge_script.js"
|
|
||||||
try:
|
|
||||||
with open(script_path, 'r', encoding='utf-8') as f:
|
|
||||||
script.setSourceCode(f.read())
|
|
||||||
self.web_view.page().scripts().insert(script)
|
|
||||||
logger.debug(f"Installed bridge script from {script_path}")
|
|
||||||
except (OSError, IOError) as e:
|
|
||||||
logger.warning(f"Failed to load bridge script: {e}")
|
|
||||||
|
|
||||||
def _inject_drag_bridge(self, html_content: str) -> str:
|
|
||||||
"""Return HTML content unmodified.
|
|
||||||
|
|
||||||
The drag bridge script is now injected via QWebEngineScript in _install_bridge_script().
|
|
||||||
This method is kept for compatibility but does nothing.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
html_content: Original HTML content
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
HTML unchanged
|
|
||||||
"""
|
|
||||||
return html_content
|
|
||||||
|
|
||||||
def _apply_stylesheet(self) -> None:
|
def _apply_stylesheet(self) -> None:
|
||||||
"""Apply application stylesheet if available."""
|
"""Apply application stylesheet if available."""
|
||||||
stylesheet_path = Path(__file__).parent.parent.parent.parent / \
|
stylesheet_path = Path(__file__).parent.parent.parent.parent / \
|
||||||
|
|
@ -421,7 +310,6 @@ class MainWindow(QMainWindow):
|
||||||
"""Create navigation toolbar with Home, Back, Forward, Refresh buttons.
|
"""Create navigation toolbar with Home, Back, Forward, Refresh buttons.
|
||||||
|
|
||||||
In Kiosk-mode, users can navigate history but cannot freely browse.
|
In Kiosk-mode, users can navigate history but cannot freely browse.
|
||||||
Help actions are positioned on the right side of the toolbar.
|
|
||||||
"""
|
"""
|
||||||
toolbar = QToolBar("Navigation")
|
toolbar = QToolBar("Navigation")
|
||||||
toolbar.setMovable(False)
|
toolbar.setMovable(False)
|
||||||
|
|
@ -430,13 +318,13 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
# Back button
|
# Back button
|
||||||
back_action = self.web_view.pageAction(
|
back_action = self.web_view.pageAction(
|
||||||
self.web_view.page().WebAction.Back
|
self.web_view.page().WebAction.Back # type: ignore
|
||||||
)
|
)
|
||||||
toolbar.addAction(back_action)
|
toolbar.addAction(back_action)
|
||||||
|
|
||||||
# Forward button
|
# Forward button
|
||||||
forward_action = self.web_view.pageAction(
|
forward_action = self.web_view.pageAction(
|
||||||
self.web_view.page().WebAction.Forward
|
self.web_view.page().WebAction.Forward # type: ignore
|
||||||
)
|
)
|
||||||
toolbar.addAction(forward_action)
|
toolbar.addAction(forward_action)
|
||||||
|
|
||||||
|
|
@ -444,99 +332,15 @@ class MainWindow(QMainWindow):
|
||||||
toolbar.addSeparator()
|
toolbar.addSeparator()
|
||||||
|
|
||||||
# Home button
|
# Home button
|
||||||
home_action = toolbar.addAction(self.style().standardIcon(self.style().StandardPixmap.SP_DirHomeIcon), "")
|
home_action = toolbar.addAction("Home")
|
||||||
home_action.setToolTip("Home")
|
|
||||||
home_action.triggered.connect(self._navigate_home)
|
home_action.triggered.connect(self._navigate_home)
|
||||||
|
|
||||||
# Refresh button
|
# Refresh button
|
||||||
refresh_action = self.web_view.pageAction(
|
refresh_action = self.web_view.pageAction(
|
||||||
self.web_view.page().WebAction.Reload
|
self.web_view.page().WebAction.Reload # type: ignore
|
||||||
)
|
)
|
||||||
toolbar.addAction(refresh_action)
|
toolbar.addAction(refresh_action)
|
||||||
|
|
||||||
# Add stretch spacer to push help buttons to the right
|
|
||||||
spacer = QWidget()
|
|
||||||
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
|
||||||
toolbar.addWidget(spacer)
|
|
||||||
|
|
||||||
# About button (info icon) on the right
|
|
||||||
about_action = toolbar.addAction("ℹ️")
|
|
||||||
about_action.setToolTip("About WebDrop Bridge")
|
|
||||||
about_action.triggered.connect(self._show_about_dialog)
|
|
||||||
|
|
||||||
# Settings button on the right
|
|
||||||
settings_action = toolbar.addAction("⚙️")
|
|
||||||
settings_action.setToolTip("Settings")
|
|
||||||
settings_action.triggered.connect(self._show_settings_dialog)
|
|
||||||
|
|
||||||
# Check for Updates button on the right
|
|
||||||
check_updates_action = toolbar.addAction("🔄")
|
|
||||||
check_updates_action.setToolTip("Check for Updates")
|
|
||||||
check_updates_action.triggered.connect(self._on_manual_check_for_updates)
|
|
||||||
|
|
||||||
def _create_status_bar(self) -> None:
|
|
||||||
"""Create status bar with update status indicator."""
|
|
||||||
self.status_bar = self.statusBar()
|
|
||||||
|
|
||||||
# Update status label
|
|
||||||
self.update_status_label = QLabel("Ready")
|
|
||||||
self.update_status_label.setStyleSheet("margin-right: 10px;")
|
|
||||||
self.status_bar.addPermanentWidget(self.update_status_label)
|
|
||||||
|
|
||||||
def set_update_status(self, status: str, emoji: str = "") -> None:
|
|
||||||
"""Update the status bar with update information.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
status: Status text to display
|
|
||||||
emoji: Optional emoji prefix (🔄, ✅, ⬇️, ⚠️)
|
|
||||||
"""
|
|
||||||
if emoji:
|
|
||||||
self.update_status_label.setText(f"{emoji} {status}")
|
|
||||||
else:
|
|
||||||
self.update_status_label.setText(status)
|
|
||||||
|
|
||||||
def _on_manual_check_for_updates(self) -> None:
|
|
||||||
"""Handle manual check for updates from menu.
|
|
||||||
|
|
||||||
Triggers an immediate update check (bypass cache) with user feedback dialog.
|
|
||||||
"""
|
|
||||||
logger.info("Manual update check requested from menu")
|
|
||||||
|
|
||||||
# Show "Checking for Updates..." dialog
|
|
||||||
from webdrop_bridge.ui.update_manager_ui import CheckingDialog
|
|
||||||
|
|
||||||
self.checking_dialog = CheckingDialog(self)
|
|
||||||
self._is_manual_check = True
|
|
||||||
|
|
||||||
# Start the update check
|
|
||||||
self.check_for_updates_startup()
|
|
||||||
|
|
||||||
# Show the dialog
|
|
||||||
self.checking_dialog.show()
|
|
||||||
|
|
||||||
def _show_about_dialog(self) -> None:
|
|
||||||
"""Show About dialog with version and information."""
|
|
||||||
from PySide6.QtWidgets import QMessageBox
|
|
||||||
|
|
||||||
about_text = (
|
|
||||||
f"<b>{self.config.app_name}</b><br>"
|
|
||||||
f"Version: {self.config.app_version}<br>"
|
|
||||||
f"<br>"
|
|
||||||
f"A professional Qt-based desktop application that converts "
|
|
||||||
f"web-based drag-and-drop text paths into native file operations.<br>"
|
|
||||||
f"<br>"
|
|
||||||
f"<small>© 2026 WebDrop Bridge Contributors</small>"
|
|
||||||
)
|
|
||||||
|
|
||||||
QMessageBox.about(self, f"About {self.config.app_name}", about_text)
|
|
||||||
|
|
||||||
def _show_settings_dialog(self) -> None:
|
|
||||||
"""Show Settings dialog for configuration management."""
|
|
||||||
from webdrop_bridge.ui.settings_dialog import SettingsDialog
|
|
||||||
|
|
||||||
dialog = SettingsDialog(self.config, self)
|
|
||||||
dialog.exec()
|
|
||||||
|
|
||||||
def _navigate_home(self) -> None:
|
def _navigate_home(self) -> None:
|
||||||
"""Navigate to the home (start) URL."""
|
"""Navigate to the home (start) URL."""
|
||||||
home_url = self.config.webapp_url
|
home_url = self.config.webapp_url
|
||||||
|
|
@ -571,540 +375,3 @@ class MainWindow(QMainWindow):
|
||||||
True if drag was initiated successfully
|
True if drag was initiated successfully
|
||||||
"""
|
"""
|
||||||
return self.drag_interceptor.initiate_drag(file_paths)
|
return self.drag_interceptor.initiate_drag(file_paths)
|
||||||
|
|
||||||
def check_for_updates_startup(self) -> None:
|
|
||||||
"""Check for updates on application startup.
|
|
||||||
|
|
||||||
Runs asynchronously in background without blocking UI.
|
|
||||||
Uses 24h cache so won't hammer the API.
|
|
||||||
"""
|
|
||||||
from webdrop_bridge.core.updater import UpdateManager
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Create update manager
|
|
||||||
cache_dir = Path.home() / ".webdrop-bridge"
|
|
||||||
manager = UpdateManager(
|
|
||||||
current_version=self.config.app_version,
|
|
||||||
config_dir=cache_dir
|
|
||||||
)
|
|
||||||
|
|
||||||
# Run async check in background
|
|
||||||
self._run_async_check(manager)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to initialize update check: {e}")
|
|
||||||
|
|
||||||
def _run_async_check(self, manager) -> None:
|
|
||||||
"""Run update check in background thread with safety timeout.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
manager: UpdateManager instance
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
logger.debug("_run_async_check() starting")
|
|
||||||
|
|
||||||
# Create and start background thread
|
|
||||||
thread = QThread()
|
|
||||||
worker = UpdateCheckWorker(manager, self.config.app_version)
|
|
||||||
|
|
||||||
# IMPORTANT: Keep references to prevent garbage collection
|
|
||||||
# Store in a list to keep worker alive during thread execution
|
|
||||||
self._background_threads.append(thread)
|
|
||||||
self._background_workers = getattr(self, '_background_workers', {})
|
|
||||||
self._background_workers[id(thread)] = worker
|
|
||||||
|
|
||||||
logger.debug(f"Created worker and thread, thread id: {id(thread)}")
|
|
||||||
|
|
||||||
# Create a safety timeout timer (but don't start it yet)
|
|
||||||
# Use a flag-based approach to avoid thread issues with stopping timers
|
|
||||||
check_started_time = [datetime.now()] # Track when check started
|
|
||||||
check_completed = [False] # Flag to mark when check completes
|
|
||||||
|
|
||||||
def force_close_timeout():
|
|
||||||
# Check if already completed - if so, don't show error
|
|
||||||
if check_completed[0]:
|
|
||||||
logger.debug("Timeout fired but check already completed, suppressing error")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.warning("Update check taking too long (30s timeout)")
|
|
||||||
if hasattr(self, 'checking_dialog') and self.checking_dialog:
|
|
||||||
self.checking_dialog.close()
|
|
||||||
self.set_update_status("Check timed out - no server response", emoji="⏱️")
|
|
||||||
|
|
||||||
# Show error dialog
|
|
||||||
from PySide6.QtWidgets import QMessageBox
|
|
||||||
QMessageBox.warning(
|
|
||||||
self,
|
|
||||||
"Update Check Timeout",
|
|
||||||
"The server did not respond within 30 seconds.\n\n"
|
|
||||||
"This may be due to a network issue or server unavailability.\n\n"
|
|
||||||
"Please check your connection and try again."
|
|
||||||
)
|
|
||||||
|
|
||||||
safety_timer = QTimer()
|
|
||||||
safety_timer.setSingleShot(True)
|
|
||||||
safety_timer.setInterval(30000) # 30 seconds
|
|
||||||
safety_timer.timeout.connect(force_close_timeout)
|
|
||||||
|
|
||||||
# Mark check as completed when any result arrives
|
|
||||||
def on_check_done():
|
|
||||||
logger.debug("Check finished, marking as completed to prevent timeout error")
|
|
||||||
check_completed[0] = True
|
|
||||||
|
|
||||||
# Connect signals
|
|
||||||
worker.update_available.connect(self._on_update_available)
|
|
||||||
worker.update_available.connect(on_check_done)
|
|
||||||
worker.update_status.connect(self._on_update_status)
|
|
||||||
worker.update_status.connect(on_check_done) # "Ready" status means check done
|
|
||||||
worker.check_failed.connect(self._on_check_failed)
|
|
||||||
worker.check_failed.connect(on_check_done)
|
|
||||||
worker.finished.connect(thread.quit)
|
|
||||||
worker.finished.connect(worker.deleteLater)
|
|
||||||
thread.finished.connect(thread.deleteLater)
|
|
||||||
|
|
||||||
# Clean up finished threads and workers from list
|
|
||||||
def cleanup_thread():
|
|
||||||
logger.debug(f"Cleaning up thread {id(thread)}")
|
|
||||||
if thread in self._background_threads:
|
|
||||||
self._background_threads.remove(thread)
|
|
||||||
if id(thread) in self._background_workers:
|
|
||||||
del self._background_workers[id(thread)]
|
|
||||||
|
|
||||||
thread.finished.connect(cleanup_thread)
|
|
||||||
|
|
||||||
# Move worker to thread and start
|
|
||||||
logger.debug("Moving worker to thread and connecting started signal")
|
|
||||||
worker.moveToThread(thread)
|
|
||||||
thread.started.connect(worker.run)
|
|
||||||
|
|
||||||
logger.debug("Starting thread...")
|
|
||||||
thread.start()
|
|
||||||
logger.debug("Thread started, starting safety timer")
|
|
||||||
|
|
||||||
# Start the safety timeout
|
|
||||||
safety_timer.start()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to start update check thread: {e}", exc_info=True)
|
|
||||||
|
|
||||||
def _on_update_status(self, status: str, emoji: str) -> None:
|
|
||||||
"""Handle update status changes.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
status: Status text
|
|
||||||
emoji: Status emoji
|
|
||||||
"""
|
|
||||||
self.set_update_status(status, emoji)
|
|
||||||
|
|
||||||
# If this is a manual check and we get the "Ready" status, it means no updates
|
|
||||||
if self._is_manual_check and status == "Ready":
|
|
||||||
# Close checking dialog first, then show result
|
|
||||||
if hasattr(self, 'checking_dialog') and self.checking_dialog:
|
|
||||||
self.checking_dialog.close()
|
|
||||||
|
|
||||||
from webdrop_bridge.ui.update_manager_ui import NoUpdateDialog
|
|
||||||
dialog = NoUpdateDialog(parent=self)
|
|
||||||
self._is_manual_check = False
|
|
||||||
dialog.exec()
|
|
||||||
|
|
||||||
def _on_check_failed(self, error_message: str) -> None:
|
|
||||||
"""Handle update check failure.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
error_message: Error description
|
|
||||||
"""
|
|
||||||
logger.error(f"Update check failed: {error_message}")
|
|
||||||
self.set_update_status(f"Check failed: {error_message}", emoji="❌")
|
|
||||||
self._is_manual_check = False
|
|
||||||
|
|
||||||
# Close checking dialog first, then show error
|
|
||||||
if hasattr(self, 'checking_dialog') and self.checking_dialog:
|
|
||||||
self.checking_dialog.close()
|
|
||||||
|
|
||||||
from PySide6.QtWidgets import QMessageBox
|
|
||||||
QMessageBox.warning(
|
|
||||||
self,
|
|
||||||
"Update Check Failed",
|
|
||||||
f"Could not check for updates:\n\n{error_message}\n\nPlease try again later."
|
|
||||||
)
|
|
||||||
|
|
||||||
def _on_update_available(self, release) -> None:
|
|
||||||
"""Handle update available notification.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
release: Release object with update info
|
|
||||||
"""
|
|
||||||
# Update status to show update available
|
|
||||||
self.set_update_status(f"Update available: v{release.version}", emoji="✅")
|
|
||||||
|
|
||||||
# Show update available dialog
|
|
||||||
from webdrop_bridge.ui.update_manager_ui import UpdateAvailableDialog
|
|
||||||
|
|
||||||
dialog = UpdateAvailableDialog(
|
|
||||||
version=release.version,
|
|
||||||
changelog=release.body,
|
|
||||||
parent=self
|
|
||||||
)
|
|
||||||
|
|
||||||
# Connect dialog signals
|
|
||||||
dialog.update_now.connect(lambda: self._on_user_update_now(release))
|
|
||||||
dialog.update_later.connect(lambda: self._on_user_update_later())
|
|
||||||
dialog.skip_version.connect(lambda: self._on_user_skip_version(release.version))
|
|
||||||
|
|
||||||
# Show dialog (modal)
|
|
||||||
dialog.exec()
|
|
||||||
|
|
||||||
def _on_user_update_now(self, release) -> None:
|
|
||||||
"""Handle user clicking 'Update Now' button.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
release: Release object to download and install
|
|
||||||
"""
|
|
||||||
logger.info(f"User clicked 'Update Now' for v{release.version}")
|
|
||||||
|
|
||||||
# Start download
|
|
||||||
self._start_update_download(release)
|
|
||||||
|
|
||||||
def _on_user_update_later(self) -> None:
|
|
||||||
"""Handle user clicking 'Later' button."""
|
|
||||||
logger.info("User deferred update")
|
|
||||||
self.set_update_status("Update deferred", emoji="")
|
|
||||||
|
|
||||||
def _on_user_skip_version(self, version: str) -> None:
|
|
||||||
"""Handle user clicking 'Skip Version' button.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
version: Version to skip
|
|
||||||
"""
|
|
||||||
logger.info(f"User skipped version {version}")
|
|
||||||
|
|
||||||
# Store skipped version in preferences
|
|
||||||
skipped_file = Path.home() / ".webdrop-bridge" / "skipped_version.txt"
|
|
||||||
skipped_file.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
skipped_file.write_text(version)
|
|
||||||
|
|
||||||
self.set_update_status(f"Skipped v{version}", emoji="")
|
|
||||||
|
|
||||||
def _start_update_download(self, release) -> None:
|
|
||||||
"""Start downloading the update in background thread.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
release: Release object to download
|
|
||||||
"""
|
|
||||||
logger.info(f"Starting download for v{release.version}")
|
|
||||||
self.set_update_status(f"Downloading v{release.version}", emoji="⬇️")
|
|
||||||
|
|
||||||
# Run download in background thread to avoid blocking UI
|
|
||||||
self._perform_update_async(release)
|
|
||||||
|
|
||||||
def _perform_update_async(self, release) -> None:
|
|
||||||
"""Download and install update asynchronously in background thread.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
release: Release object to download and install
|
|
||||||
"""
|
|
||||||
from webdrop_bridge.core.updater import UpdateManager
|
|
||||||
|
|
||||||
try:
|
|
||||||
logger.debug("_perform_update_async() starting")
|
|
||||||
|
|
||||||
# Create update manager
|
|
||||||
manager = UpdateManager(
|
|
||||||
current_version=self.config.app_version,
|
|
||||||
config_dir=Path.home() / ".webdrop-bridge"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create and start background thread
|
|
||||||
thread = QThread()
|
|
||||||
worker = UpdateDownloadWorker(manager, release, self.config.app_version)
|
|
||||||
|
|
||||||
# IMPORTANT: Keep references to prevent garbage collection
|
|
||||||
self._background_threads.append(thread)
|
|
||||||
self._background_workers[id(thread)] = worker
|
|
||||||
|
|
||||||
logger.debug(f"Created download worker and thread, thread id: {id(thread)}")
|
|
||||||
|
|
||||||
# Connect signals
|
|
||||||
worker.download_complete.connect(self._on_download_complete)
|
|
||||||
worker.download_failed.connect(self._on_download_failed)
|
|
||||||
worker.update_status.connect(self._on_update_status)
|
|
||||||
worker.finished.connect(thread.quit)
|
|
||||||
worker.finished.connect(worker.deleteLater)
|
|
||||||
thread.finished.connect(thread.deleteLater)
|
|
||||||
|
|
||||||
# Create a safety timeout timer for download (10 minutes)
|
|
||||||
# Use a flag-based approach to avoid thread issues with stopping timers
|
|
||||||
download_started_time = [datetime.now()] # Track when download started
|
|
||||||
download_completed = [False] # Flag to mark when download completes
|
|
||||||
|
|
||||||
def force_timeout():
|
|
||||||
# Check if already completed - if so, don't show error
|
|
||||||
if download_completed[0]:
|
|
||||||
logger.debug("Timeout fired but download already completed, suppressing error")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.error("Download taking too long (10 minute timeout)")
|
|
||||||
self.set_update_status("Download timed out - no server response", emoji="⏱️")
|
|
||||||
worker.download_failed.emit("Download took too long with no response")
|
|
||||||
thread.quit()
|
|
||||||
thread.wait()
|
|
||||||
|
|
||||||
safety_timer = QTimer()
|
|
||||||
safety_timer.setSingleShot(True)
|
|
||||||
safety_timer.setInterval(600000) # 10 minutes
|
|
||||||
safety_timer.timeout.connect(force_timeout)
|
|
||||||
|
|
||||||
# Mark download as completed when it finishes
|
|
||||||
def on_download_done():
|
|
||||||
logger.debug("Download finished, marking as completed to prevent timeout error")
|
|
||||||
download_completed[0] = True
|
|
||||||
|
|
||||||
worker.download_complete.connect(on_download_done)
|
|
||||||
worker.download_failed.connect(on_download_done)
|
|
||||||
|
|
||||||
# Clean up finished threads from list
|
|
||||||
def cleanup_thread():
|
|
||||||
logger.debug(f"Cleaning up download thread {id(thread)}")
|
|
||||||
if thread in self._background_threads:
|
|
||||||
self._background_threads.remove(thread)
|
|
||||||
if id(thread) in self._background_workers:
|
|
||||||
del self._background_workers[id(thread)]
|
|
||||||
|
|
||||||
thread.finished.connect(cleanup_thread)
|
|
||||||
|
|
||||||
# Start thread
|
|
||||||
logger.debug("Moving download worker to thread and connecting started signal")
|
|
||||||
worker.moveToThread(thread)
|
|
||||||
thread.started.connect(worker.run)
|
|
||||||
logger.debug("Starting download thread...")
|
|
||||||
thread.start()
|
|
||||||
logger.debug("Download thread started, starting safety timer")
|
|
||||||
|
|
||||||
# Start the safety timeout
|
|
||||||
safety_timer.start()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to start update download: {e}")
|
|
||||||
self.set_update_status(f"Update failed: {str(e)[:30]}", emoji="❌")
|
|
||||||
|
|
||||||
def _on_download_complete(self, installer_path: Path) -> None:
|
|
||||||
"""Handle successful download and verification.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
installer_path: Path to downloaded and verified installer
|
|
||||||
"""
|
|
||||||
from webdrop_bridge.ui.update_manager_ui import InstallDialog
|
|
||||||
|
|
||||||
logger.info(f"Download complete: {installer_path}")
|
|
||||||
self.set_update_status("Ready to install", emoji="✅")
|
|
||||||
|
|
||||||
# Show install confirmation dialog
|
|
||||||
install_dialog = InstallDialog(parent=self)
|
|
||||||
install_dialog.install_now.connect(
|
|
||||||
lambda: self._do_install(installer_path)
|
|
||||||
)
|
|
||||||
install_dialog.exec()
|
|
||||||
|
|
||||||
def _on_download_failed(self, error: str) -> None:
|
|
||||||
"""Handle download failure.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
error: Error message
|
|
||||||
"""
|
|
||||||
logger.error(f"Download failed: {error}")
|
|
||||||
self.set_update_status(error, emoji="❌")
|
|
||||||
|
|
||||||
from PySide6.QtWidgets import QMessageBox
|
|
||||||
QMessageBox.critical(
|
|
||||||
self,
|
|
||||||
"Download Failed",
|
|
||||||
f"Could not download the update:\n\n{error}\n\nPlease try again later."
|
|
||||||
)
|
|
||||||
|
|
||||||
def _do_install(self, installer_path: Path) -> None:
|
|
||||||
"""Execute the installer.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
installer_path: Path to installer executable
|
|
||||||
"""
|
|
||||||
logger.info(f"Installing from {installer_path}")
|
|
||||||
|
|
||||||
from webdrop_bridge.core.updater import UpdateManager
|
|
||||||
|
|
||||||
manager = UpdateManager(
|
|
||||||
current_version=self.config.app_version,
|
|
||||||
config_dir=Path.home() / ".webdrop-bridge"
|
|
||||||
)
|
|
||||||
|
|
||||||
if manager.install_update(installer_path):
|
|
||||||
self.set_update_status("Installation started", emoji="✅")
|
|
||||||
logger.info("Update installer launched successfully")
|
|
||||||
else:
|
|
||||||
self.set_update_status("Installation failed", emoji="❌")
|
|
||||||
logger.error("Failed to launch update installer")
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateCheckWorker(QObject):
|
|
||||||
"""Worker for running update check asynchronously."""
|
|
||||||
|
|
||||||
# Define signals at class level
|
|
||||||
update_available = Signal(object) # Emits Release object
|
|
||||||
update_status = Signal(str, str) # Emits (status_text, emoji)
|
|
||||||
check_failed = Signal(str) # Emits error message
|
|
||||||
finished = Signal()
|
|
||||||
|
|
||||||
def __init__(self, manager, current_version: str):
|
|
||||||
"""Initialize worker.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
manager: UpdateManager instance
|
|
||||||
current_version: Current app version
|
|
||||||
"""
|
|
||||||
super().__init__()
|
|
||||||
self.manager = manager
|
|
||||||
self.current_version = current_version
|
|
||||||
|
|
||||||
def run(self) -> None:
|
|
||||||
"""Run the update check."""
|
|
||||||
loop = None
|
|
||||||
try:
|
|
||||||
logger.debug("UpdateCheckWorker.run() starting")
|
|
||||||
|
|
||||||
# Notify checking status
|
|
||||||
self.update_status.emit("Checking for updates", "🔄")
|
|
||||||
|
|
||||||
# Create a fresh event loop for this thread
|
|
||||||
logger.debug("Creating new event loop for worker thread")
|
|
||||||
loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(loop)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Check for updates with short timeout (network call has its own timeout)
|
|
||||||
logger.debug("Starting update check with 10-second timeout")
|
|
||||||
release = loop.run_until_complete(
|
|
||||||
asyncio.wait_for(
|
|
||||||
self.manager.check_for_updates(),
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
)
|
|
||||||
logger.debug(f"Update check completed, release={release}")
|
|
||||||
|
|
||||||
# Emit result
|
|
||||||
if release:
|
|
||||||
logger.info(f"Update available: {release.version}")
|
|
||||||
self.update_available.emit(release)
|
|
||||||
else:
|
|
||||||
# No update available - show ready status
|
|
||||||
logger.info("No update available")
|
|
||||||
self.update_status.emit("Ready", "")
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning("Update check timed out - server not responding")
|
|
||||||
self.check_failed.emit("Server not responding - check again later")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Update check failed: {e}", exc_info=True)
|
|
||||||
self.check_failed.emit(f"Check failed: {str(e)[:50]}")
|
|
||||||
finally:
|
|
||||||
# Properly close the event loop
|
|
||||||
if loop is not None:
|
|
||||||
try:
|
|
||||||
if not loop.is_closed():
|
|
||||||
loop.close()
|
|
||||||
logger.debug("Event loop closed")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Error closing event loop: {e}")
|
|
||||||
self.finished.emit()
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateDownloadWorker(QObject):
|
|
||||||
"""Worker for downloading and verifying update asynchronously."""
|
|
||||||
|
|
||||||
# Define signals at class level
|
|
||||||
download_complete = Signal(Path) # Emits installer_path
|
|
||||||
download_failed = Signal(str) # Emits error message
|
|
||||||
update_status = Signal(str, str) # Emits (status_text, emoji)
|
|
||||||
finished = Signal()
|
|
||||||
|
|
||||||
def __init__(self, manager, release, current_version: str):
|
|
||||||
"""Initialize worker.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
manager: UpdateManager instance
|
|
||||||
release: Release object to download
|
|
||||||
current_version: Current app version
|
|
||||||
"""
|
|
||||||
super().__init__()
|
|
||||||
self.manager = manager
|
|
||||||
self.release = release
|
|
||||||
self.current_version = current_version
|
|
||||||
|
|
||||||
def run(self) -> None:
|
|
||||||
"""Run the download and verification."""
|
|
||||||
loop = None
|
|
||||||
try:
|
|
||||||
# Download the update
|
|
||||||
self.update_status.emit(f"Downloading v{self.release.version}", "⬇️")
|
|
||||||
|
|
||||||
# Create a fresh event loop for this thread
|
|
||||||
loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(loop)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Download with 5 minute timeout (300 seconds)
|
|
||||||
logger.info("Starting download with 5-minute timeout")
|
|
||||||
installer_path = loop.run_until_complete(
|
|
||||||
asyncio.wait_for(
|
|
||||||
self.manager.download_update(self.release),
|
|
||||||
timeout=300
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not installer_path:
|
|
||||||
self.update_status.emit("Download failed", "❌")
|
|
||||||
self.download_failed.emit("No installer found in release")
|
|
||||||
logger.error("Download failed - no installer found")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(f"Downloaded to {installer_path}")
|
|
||||||
self.update_status.emit("Verifying download", "🔍")
|
|
||||||
|
|
||||||
# Verify checksum with 30 second timeout
|
|
||||||
logger.info("Starting checksum verification")
|
|
||||||
checksum_ok = loop.run_until_complete(
|
|
||||||
asyncio.wait_for(
|
|
||||||
self.manager.verify_checksum(installer_path, self.release),
|
|
||||||
timeout=30
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not checksum_ok:
|
|
||||||
self.update_status.emit("Verification failed", "❌")
|
|
||||||
self.download_failed.emit("Checksum verification failed")
|
|
||||||
logger.error("Checksum verification failed")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Checksum verification passed")
|
|
||||||
self.download_complete.emit(installer_path)
|
|
||||||
|
|
||||||
except asyncio.TimeoutError as e:
|
|
||||||
logger.error(f"Download/verification timed out: {e}")
|
|
||||||
self.update_status.emit("Operation timed out", "⏱️")
|
|
||||||
self.download_failed.emit("Download or verification timed out (no response from server)")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error during download: {e}")
|
|
||||||
self.download_failed.emit(f"Download error: {str(e)[:50]}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Download worker failed: {e}")
|
|
||||||
self.download_failed.emit(f"Download error: {str(e)[:50]}")
|
|
||||||
finally:
|
|
||||||
# Properly close the event loop
|
|
||||||
if loop is not None:
|
|
||||||
try:
|
|
||||||
if not loop.is_closed():
|
|
||||||
loop.close()
|
|
||||||
logger.debug("Event loop closed")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Error closing event loop: {e}")
|
|
||||||
self.finished.emit()
|
|
||||||
|
|
|
||||||
|
|
@ -38,20 +38,20 @@ class RestrictedWebEngineView(QWebEngineView):
|
||||||
Args:
|
Args:
|
||||||
request: Navigation request to process
|
request: Navigation request to process
|
||||||
"""
|
"""
|
||||||
url = request.url
|
url = request.url
|
||||||
|
|
||||||
# If no restrictions, allow all URLs
|
# If no restrictions, allow all URLs
|
||||||
if not self.allowed_urls:
|
if not self.allowed_urls:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if URL matches whitelist
|
# Check if URL matches whitelist
|
||||||
if self._is_url_allowed(url): # type: ignore[operator]
|
if self._is_url_allowed(url):
|
||||||
# Allow the navigation (default behavior)
|
# Allow the navigation (default behavior)
|
||||||
return
|
return
|
||||||
|
|
||||||
# URL not whitelisted - open in system browser
|
# URL not whitelisted - open in system browser
|
||||||
request.reject()
|
request.reject()
|
||||||
QDesktopServices.openUrl(url) # type: ignore[operator]
|
QDesktopServices.openUrl(url)
|
||||||
|
|
||||||
def _is_url_allowed(self, url: QUrl) -> bool:
|
def _is_url_allowed(self, url: QUrl) -> bool:
|
||||||
"""Check if a URL matches the whitelist.
|
"""Check if a URL matches the whitelist.
|
||||||
|
|
@ -98,4 +98,3 @@ class RestrictedWebEngineView(QWebEngineView):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,435 +0,0 @@
|
||||||
"""Settings dialog for configuration management."""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from PySide6.QtCore import Qt
|
|
||||||
from PySide6.QtWidgets import (
|
|
||||||
QDialog,
|
|
||||||
QDialogButtonBox,
|
|
||||||
QFileDialog,
|
|
||||||
QHBoxLayout,
|
|
||||||
QLabel,
|
|
||||||
QLineEdit,
|
|
||||||
QListWidget,
|
|
||||||
QListWidgetItem,
|
|
||||||
QPushButton,
|
|
||||||
QSpinBox,
|
|
||||||
QTabWidget,
|
|
||||||
QVBoxLayout,
|
|
||||||
QWidget,
|
|
||||||
)
|
|
||||||
|
|
||||||
from webdrop_bridge.config import Config, ConfigurationError
|
|
||||||
from webdrop_bridge.core.config_manager import ConfigExporter, ConfigProfile, ConfigValidator
|
|
||||||
|
|
||||||
|
|
||||||
class SettingsDialog(QDialog):
|
|
||||||
"""Dialog for managing application settings and configuration.
|
|
||||||
|
|
||||||
Provides tabs for:
|
|
||||||
- Paths: Manage allowed root directories
|
|
||||||
- URLs: Manage allowed web URLs
|
|
||||||
- Logging: Configure logging settings
|
|
||||||
- Window: Manage window size and behavior
|
|
||||||
- Profiles: Save/load/delete configuration profiles
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, config: Config, parent=None):
|
|
||||||
"""Initialize the settings dialog.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config: Current application configuration
|
|
||||||
parent: Parent widget
|
|
||||||
"""
|
|
||||||
super().__init__(parent)
|
|
||||||
self.config = config
|
|
||||||
self.profile_manager = ConfigProfile()
|
|
||||||
self.setWindowTitle("Settings")
|
|
||||||
self.setGeometry(100, 100, 600, 500)
|
|
||||||
|
|
||||||
self.setup_ui()
|
|
||||||
|
|
||||||
def setup_ui(self) -> None:
|
|
||||||
"""Set up the dialog UI with tabs."""
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
|
|
||||||
# Create tab widget
|
|
||||||
self.tabs = QTabWidget()
|
|
||||||
|
|
||||||
# Add tabs
|
|
||||||
self.tabs.addTab(self._create_paths_tab(), "Paths")
|
|
||||||
self.tabs.addTab(self._create_urls_tab(), "URLs")
|
|
||||||
self.tabs.addTab(self._create_logging_tab(), "Logging")
|
|
||||||
self.tabs.addTab(self._create_window_tab(), "Window")
|
|
||||||
self.tabs.addTab(self._create_profiles_tab(), "Profiles")
|
|
||||||
|
|
||||||
layout.addWidget(self.tabs)
|
|
||||||
|
|
||||||
# Add buttons
|
|
||||||
button_box = QDialogButtonBox(
|
|
||||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
|
||||||
)
|
|
||||||
button_box.accepted.connect(self.accept)
|
|
||||||
button_box.rejected.connect(self.reject)
|
|
||||||
layout.addWidget(button_box)
|
|
||||||
|
|
||||||
self.setLayout(layout)
|
|
||||||
|
|
||||||
def _create_paths_tab(self) -> QWidget:
|
|
||||||
"""Create paths configuration tab."""
|
|
||||||
widget = QWidget()
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
|
|
||||||
layout.addWidget(QLabel("Allowed root directories for file access:"))
|
|
||||||
|
|
||||||
# List widget for paths
|
|
||||||
self.paths_list = QListWidget()
|
|
||||||
for path in self.config.allowed_roots:
|
|
||||||
self.paths_list.addItem(str(path))
|
|
||||||
layout.addWidget(self.paths_list)
|
|
||||||
|
|
||||||
# Buttons for path management
|
|
||||||
button_layout = QHBoxLayout()
|
|
||||||
|
|
||||||
add_path_btn = QPushButton("Add Path")
|
|
||||||
add_path_btn.clicked.connect(self._add_path)
|
|
||||||
button_layout.addWidget(add_path_btn)
|
|
||||||
|
|
||||||
remove_path_btn = QPushButton("Remove Selected")
|
|
||||||
remove_path_btn.clicked.connect(self._remove_path)
|
|
||||||
button_layout.addWidget(remove_path_btn)
|
|
||||||
|
|
||||||
layout.addLayout(button_layout)
|
|
||||||
layout.addStretch()
|
|
||||||
|
|
||||||
widget.setLayout(layout)
|
|
||||||
return widget
|
|
||||||
|
|
||||||
def _create_urls_tab(self) -> QWidget:
|
|
||||||
"""Create URLs configuration tab."""
|
|
||||||
widget = QWidget()
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
|
|
||||||
layout.addWidget(QLabel("Allowed web URLs (supports wildcards like http://*.example.com):"))
|
|
||||||
|
|
||||||
# List widget for URLs
|
|
||||||
self.urls_list = QListWidget()
|
|
||||||
for url in self.config.allowed_urls:
|
|
||||||
self.urls_list.addItem(url)
|
|
||||||
layout.addWidget(self.urls_list)
|
|
||||||
|
|
||||||
# Buttons for URL management
|
|
||||||
button_layout = QHBoxLayout()
|
|
||||||
|
|
||||||
add_url_btn = QPushButton("Add URL")
|
|
||||||
add_url_btn.clicked.connect(self._add_url)
|
|
||||||
button_layout.addWidget(add_url_btn)
|
|
||||||
|
|
||||||
remove_url_btn = QPushButton("Remove Selected")
|
|
||||||
remove_url_btn.clicked.connect(self._remove_url)
|
|
||||||
button_layout.addWidget(remove_url_btn)
|
|
||||||
|
|
||||||
layout.addLayout(button_layout)
|
|
||||||
layout.addStretch()
|
|
||||||
|
|
||||||
widget.setLayout(layout)
|
|
||||||
return widget
|
|
||||||
|
|
||||||
def _create_logging_tab(self) -> QWidget:
|
|
||||||
"""Create logging configuration tab."""
|
|
||||||
widget = QWidget()
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
|
|
||||||
# Log level selection
|
|
||||||
layout.addWidget(QLabel("Log Level:"))
|
|
||||||
from PySide6.QtWidgets import QComboBox
|
|
||||||
self.log_level_combo: QComboBox = self._create_log_level_widget()
|
|
||||||
layout.addWidget(self.log_level_combo)
|
|
||||||
|
|
||||||
# Log file path
|
|
||||||
layout.addWidget(QLabel("Log File (optional):"))
|
|
||||||
log_file_layout = QHBoxLayout()
|
|
||||||
|
|
||||||
self.log_file_input = QLineEdit()
|
|
||||||
self.log_file_input.setText(str(self.config.log_file) if self.config.log_file else "")
|
|
||||||
log_file_layout.addWidget(self.log_file_input)
|
|
||||||
|
|
||||||
browse_btn = QPushButton("Browse...")
|
|
||||||
browse_btn.clicked.connect(self._browse_log_file)
|
|
||||||
log_file_layout.addWidget(browse_btn)
|
|
||||||
|
|
||||||
layout.addLayout(log_file_layout)
|
|
||||||
|
|
||||||
layout.addStretch()
|
|
||||||
widget.setLayout(layout)
|
|
||||||
return widget
|
|
||||||
|
|
||||||
def _create_window_tab(self) -> QWidget:
|
|
||||||
"""Create window settings tab."""
|
|
||||||
widget = QWidget()
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
|
|
||||||
# Window width
|
|
||||||
width_layout = QHBoxLayout()
|
|
||||||
width_layout.addWidget(QLabel("Window Width:"))
|
|
||||||
self.width_spin = QSpinBox()
|
|
||||||
self.width_spin.setMinimum(400)
|
|
||||||
self.width_spin.setMaximum(5000)
|
|
||||||
self.width_spin.setValue(self.config.window_width)
|
|
||||||
width_layout.addWidget(self.width_spin)
|
|
||||||
width_layout.addStretch()
|
|
||||||
layout.addLayout(width_layout)
|
|
||||||
|
|
||||||
# Window height
|
|
||||||
height_layout = QHBoxLayout()
|
|
||||||
height_layout.addWidget(QLabel("Window Height:"))
|
|
||||||
self.height_spin = QSpinBox()
|
|
||||||
self.height_spin.setMinimum(300)
|
|
||||||
self.height_spin.setMaximum(5000)
|
|
||||||
self.height_spin.setValue(self.config.window_height)
|
|
||||||
height_layout.addWidget(self.height_spin)
|
|
||||||
height_layout.addStretch()
|
|
||||||
layout.addLayout(height_layout)
|
|
||||||
|
|
||||||
layout.addStretch()
|
|
||||||
widget.setLayout(layout)
|
|
||||||
return widget
|
|
||||||
|
|
||||||
def _create_profiles_tab(self) -> QWidget:
|
|
||||||
"""Create profiles management tab."""
|
|
||||||
widget = QWidget()
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
|
|
||||||
layout.addWidget(QLabel("Saved Configuration Profiles:"))
|
|
||||||
|
|
||||||
# List of profiles
|
|
||||||
self.profiles_list = QListWidget()
|
|
||||||
self._refresh_profiles_list()
|
|
||||||
layout.addWidget(self.profiles_list)
|
|
||||||
|
|
||||||
# Profile management buttons
|
|
||||||
button_layout = QHBoxLayout()
|
|
||||||
|
|
||||||
save_profile_btn = QPushButton("Save as Profile")
|
|
||||||
save_profile_btn.clicked.connect(self._save_profile)
|
|
||||||
button_layout.addWidget(save_profile_btn)
|
|
||||||
|
|
||||||
load_profile_btn = QPushButton("Load Profile")
|
|
||||||
load_profile_btn.clicked.connect(self._load_profile)
|
|
||||||
button_layout.addWidget(load_profile_btn)
|
|
||||||
|
|
||||||
delete_profile_btn = QPushButton("Delete Profile")
|
|
||||||
delete_profile_btn.clicked.connect(self._delete_profile)
|
|
||||||
button_layout.addWidget(delete_profile_btn)
|
|
||||||
|
|
||||||
layout.addLayout(button_layout)
|
|
||||||
|
|
||||||
# Export/Import buttons
|
|
||||||
export_layout = QHBoxLayout()
|
|
||||||
|
|
||||||
export_btn = QPushButton("Export Configuration")
|
|
||||||
export_btn.clicked.connect(self._export_config)
|
|
||||||
export_layout.addWidget(export_btn)
|
|
||||||
|
|
||||||
import_btn = QPushButton("Import Configuration")
|
|
||||||
import_btn.clicked.connect(self._import_config)
|
|
||||||
export_layout.addWidget(import_btn)
|
|
||||||
|
|
||||||
layout.addLayout(export_layout)
|
|
||||||
layout.addStretch()
|
|
||||||
|
|
||||||
widget.setLayout(layout)
|
|
||||||
return widget
|
|
||||||
|
|
||||||
def _create_log_level_widget(self):
|
|
||||||
"""Create log level selection widget."""
|
|
||||||
from PySide6.QtWidgets import QComboBox
|
|
||||||
|
|
||||||
combo = QComboBox()
|
|
||||||
levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
||||||
combo.addItems(levels)
|
|
||||||
combo.setCurrentText(self.config.log_level)
|
|
||||||
return combo
|
|
||||||
|
|
||||||
def _add_path(self) -> None:
|
|
||||||
"""Add a new allowed path."""
|
|
||||||
path = QFileDialog.getExistingDirectory(self, "Select Directory to Allow")
|
|
||||||
if path:
|
|
||||||
self.paths_list.addItem(path)
|
|
||||||
|
|
||||||
def _remove_path(self) -> None:
|
|
||||||
"""Remove selected path."""
|
|
||||||
if self.paths_list.currentItem():
|
|
||||||
self.paths_list.takeItem(self.paths_list.row(self.paths_list.currentItem()))
|
|
||||||
|
|
||||||
def _add_url(self) -> None:
|
|
||||||
"""Add a new allowed URL."""
|
|
||||||
from PySide6.QtWidgets import QInputDialog
|
|
||||||
|
|
||||||
url, ok = QInputDialog.getText(
|
|
||||||
self,
|
|
||||||
"Add URL",
|
|
||||||
"Enter URL pattern (e.g., http://example.com or http://*.example.com):"
|
|
||||||
)
|
|
||||||
if ok and url:
|
|
||||||
self.urls_list.addItem(url)
|
|
||||||
|
|
||||||
def _remove_url(self) -> None:
|
|
||||||
"""Remove selected URL."""
|
|
||||||
if self.urls_list.currentItem():
|
|
||||||
self.urls_list.takeItem(self.urls_list.row(self.urls_list.currentItem()))
|
|
||||||
|
|
||||||
def _browse_log_file(self) -> None:
|
|
||||||
"""Browse for log file location."""
|
|
||||||
file_path, _ = QFileDialog.getSaveFileName(
|
|
||||||
self,
|
|
||||||
"Select Log File",
|
|
||||||
str(Path.home()),
|
|
||||||
"Log Files (*.log);;All Files (*)"
|
|
||||||
)
|
|
||||||
if file_path:
|
|
||||||
self.log_file_input.setText(file_path)
|
|
||||||
|
|
||||||
def _refresh_profiles_list(self) -> None:
|
|
||||||
"""Refresh the list of available profiles."""
|
|
||||||
self.profiles_list.clear()
|
|
||||||
for profile_name in self.profile_manager.list_profiles():
|
|
||||||
self.profiles_list.addItem(profile_name)
|
|
||||||
|
|
||||||
def _save_profile(self) -> None:
|
|
||||||
"""Save current configuration as a profile."""
|
|
||||||
from PySide6.QtWidgets import QInputDialog
|
|
||||||
|
|
||||||
profile_name, ok = QInputDialog.getText(
|
|
||||||
self,
|
|
||||||
"Save Profile",
|
|
||||||
"Enter profile name (e.g., work, personal):"
|
|
||||||
)
|
|
||||||
|
|
||||||
if ok and profile_name:
|
|
||||||
try:
|
|
||||||
self.profile_manager.save_profile(profile_name, self.config)
|
|
||||||
self._refresh_profiles_list()
|
|
||||||
except ConfigurationError as e:
|
|
||||||
self._show_error(f"Failed to save profile: {e}")
|
|
||||||
|
|
||||||
def _load_profile(self) -> None:
|
|
||||||
"""Load a saved profile."""
|
|
||||||
current_item = self.profiles_list.currentItem()
|
|
||||||
if not current_item:
|
|
||||||
self._show_error("Please select a profile to load")
|
|
||||||
return
|
|
||||||
|
|
||||||
profile_name = current_item.text()
|
|
||||||
try:
|
|
||||||
config_data = self.profile_manager.load_profile(profile_name)
|
|
||||||
self._apply_config_data(config_data)
|
|
||||||
except ConfigurationError as e:
|
|
||||||
self._show_error(f"Failed to load profile: {e}")
|
|
||||||
|
|
||||||
def _delete_profile(self) -> None:
|
|
||||||
"""Delete a saved profile."""
|
|
||||||
current_item = self.profiles_list.currentItem()
|
|
||||||
if not current_item:
|
|
||||||
self._show_error("Please select a profile to delete")
|
|
||||||
return
|
|
||||||
|
|
||||||
profile_name = current_item.text()
|
|
||||||
try:
|
|
||||||
self.profile_manager.delete_profile(profile_name)
|
|
||||||
self._refresh_profiles_list()
|
|
||||||
except ConfigurationError as e:
|
|
||||||
self._show_error(f"Failed to delete profile: {e}")
|
|
||||||
|
|
||||||
def _export_config(self) -> None:
|
|
||||||
"""Export configuration to file."""
|
|
||||||
file_path, _ = QFileDialog.getSaveFileName(
|
|
||||||
self,
|
|
||||||
"Export Configuration",
|
|
||||||
str(Path.home()),
|
|
||||||
"JSON Files (*.json);;All Files (*)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if file_path:
|
|
||||||
try:
|
|
||||||
ConfigExporter.export_to_json(self.config, Path(file_path))
|
|
||||||
except ConfigurationError as e:
|
|
||||||
self._show_error(f"Failed to export configuration: {e}")
|
|
||||||
|
|
||||||
def _import_config(self) -> None:
|
|
||||||
"""Import configuration from file."""
|
|
||||||
file_path, _ = QFileDialog.getOpenFileName(
|
|
||||||
self,
|
|
||||||
"Import Configuration",
|
|
||||||
str(Path.home()),
|
|
||||||
"JSON Files (*.json);;All Files (*)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if file_path:
|
|
||||||
try:
|
|
||||||
config_data = ConfigExporter.import_from_json(Path(file_path))
|
|
||||||
self._apply_config_data(config_data)
|
|
||||||
except ConfigurationError as e:
|
|
||||||
self._show_error(f"Failed to import configuration: {e}")
|
|
||||||
|
|
||||||
def _apply_config_data(self, config_data: dict) -> None:
|
|
||||||
"""Apply imported configuration data to UI.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config_data: Configuration dictionary
|
|
||||||
"""
|
|
||||||
# Apply paths
|
|
||||||
self.paths_list.clear()
|
|
||||||
for path in config_data.get("allowed_roots", []):
|
|
||||||
self.paths_list.addItem(str(path))
|
|
||||||
|
|
||||||
# Apply URLs
|
|
||||||
self.urls_list.clear()
|
|
||||||
for url in config_data.get("allowed_urls", []):
|
|
||||||
self.urls_list.addItem(url)
|
|
||||||
|
|
||||||
# Apply logging settings
|
|
||||||
self.log_level_combo.setCurrentText(config_data.get("log_level", "INFO"))
|
|
||||||
log_file = config_data.get("log_file")
|
|
||||||
self.log_file_input.setText(str(log_file) if log_file else "")
|
|
||||||
|
|
||||||
# Apply window settings
|
|
||||||
self.width_spin.setValue(config_data.get("window_width", 800))
|
|
||||||
self.height_spin.setValue(config_data.get("window_height", 600))
|
|
||||||
|
|
||||||
def get_config_data(self) -> dict:
|
|
||||||
"""Get updated configuration data from dialog.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Configuration dictionary
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ConfigurationError: If configuration is invalid
|
|
||||||
"""
|
|
||||||
config_data = {
|
|
||||||
"app_name": self.config.app_name,
|
|
||||||
"app_version": self.config.app_version,
|
|
||||||
"log_level": self.log_level_combo.currentText(),
|
|
||||||
"log_file": self.log_file_input.text() or None,
|
|
||||||
"allowed_roots": [self.paths_list.item(i).text() for i in range(self.paths_list.count())],
|
|
||||||
"allowed_urls": [self.urls_list.item(i).text() for i in range(self.urls_list.count())],
|
|
||||||
"webapp_url": self.config.webapp_url,
|
|
||||||
"window_width": self.width_spin.value(),
|
|
||||||
"window_height": self.height_spin.value(),
|
|
||||||
"enable_logging": self.config.enable_logging,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Validate
|
|
||||||
ConfigValidator.validate_or_raise(config_data)
|
|
||||||
|
|
||||||
return config_data
|
|
||||||
|
|
||||||
def _show_error(self, message: str) -> None:
|
|
||||||
"""Show error message to user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
message: Error message
|
|
||||||
"""
|
|
||||||
from PySide6.QtWidgets import QMessageBox
|
|
||||||
QMessageBox.critical(self, "Error", message)
|
|
||||||
|
|
@ -1,400 +0,0 @@
|
||||||
"""UI components for the auto-update system.
|
|
||||||
|
|
||||||
Provides 6 dialogs for update checking, downloading, and installation:
|
|
||||||
1. CheckingDialog - Shows while checking for updates
|
|
||||||
2. UpdateAvailableDialog - Shows when update is available
|
|
||||||
3. DownloadingDialog - Shows download progress
|
|
||||||
4. InstallDialog - Confirms installation and restart
|
|
||||||
5. NoUpdateDialog - Shows when no updates available
|
|
||||||
6. ErrorDialog - Shows when update check or install fails
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, Signal
|
|
||||||
from PySide6.QtGui import QIcon
|
|
||||||
from PySide6.QtWidgets import (
|
|
||||||
QDialog,
|
|
||||||
QHBoxLayout,
|
|
||||||
QLabel,
|
|
||||||
QMessageBox,
|
|
||||||
QProgressBar,
|
|
||||||
QPushButton,
|
|
||||||
QTextEdit,
|
|
||||||
QVBoxLayout,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class CheckingDialog(QDialog):
|
|
||||||
"""Dialog shown while checking for updates.
|
|
||||||
|
|
||||||
Shows an animated progress indicator and times out after 10 seconds.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
|
||||||
"""Initialize checking dialog.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
parent: Parent widget
|
|
||||||
"""
|
|
||||||
super().__init__(parent)
|
|
||||||
self.setWindowTitle("Checking for Updates")
|
|
||||||
self.setModal(True)
|
|
||||||
self.setMinimumWidth(300)
|
|
||||||
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
|
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
|
|
||||||
# Status label
|
|
||||||
self.label = QLabel("Checking for updates...")
|
|
||||||
layout.addWidget(self.label)
|
|
||||||
|
|
||||||
# Animated progress bar
|
|
||||||
self.progress = QProgressBar()
|
|
||||||
self.progress.setMaximum(0) # Makes it animated
|
|
||||||
layout.addWidget(self.progress)
|
|
||||||
|
|
||||||
# Timeout info
|
|
||||||
info_label = QLabel("This may take up to 10 seconds")
|
|
||||||
info_label.setStyleSheet("color: gray; font-size: 11px;")
|
|
||||||
layout.addWidget(info_label)
|
|
||||||
|
|
||||||
self.setLayout(layout)
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateAvailableDialog(QDialog):
|
|
||||||
"""Dialog shown when an update is available.
|
|
||||||
|
|
||||||
Displays:
|
|
||||||
- Current version
|
|
||||||
- Available version
|
|
||||||
- Changelog/release notes
|
|
||||||
- Buttons: Update Now, Update Later, Skip This Version
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Signals
|
|
||||||
update_now = Signal()
|
|
||||||
update_later = Signal()
|
|
||||||
skip_version = Signal()
|
|
||||||
|
|
||||||
def __init__(self, version: str, changelog: str, parent=None):
|
|
||||||
"""Initialize update available dialog.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
version: New version string (e.g., "0.0.2")
|
|
||||||
changelog: Release notes text
|
|
||||||
parent: Parent widget
|
|
||||||
"""
|
|
||||||
super().__init__(parent)
|
|
||||||
self.setWindowTitle("Update Available")
|
|
||||||
self.setModal(True)
|
|
||||||
self.setMinimumWidth(400)
|
|
||||||
self.setMinimumHeight(300)
|
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
|
|
||||||
# Header
|
|
||||||
header = QLabel(f"WebDrop Bridge v{version} is available")
|
|
||||||
header.setStyleSheet("font-weight: bold; font-size: 14px;")
|
|
||||||
layout.addWidget(header)
|
|
||||||
|
|
||||||
# Changelog
|
|
||||||
changelog_label = QLabel("Release Notes:")
|
|
||||||
changelog_label.setStyleSheet("font-weight: bold; margin-top: 10px;")
|
|
||||||
layout.addWidget(changelog_label)
|
|
||||||
|
|
||||||
self.changelog = QTextEdit()
|
|
||||||
self.changelog.setText(changelog)
|
|
||||||
self.changelog.setReadOnly(True)
|
|
||||||
layout.addWidget(self.changelog)
|
|
||||||
|
|
||||||
# Buttons
|
|
||||||
button_layout = QHBoxLayout()
|
|
||||||
|
|
||||||
self.update_now_btn = QPushButton("Update Now")
|
|
||||||
self.update_now_btn.clicked.connect(self._on_update_now)
|
|
||||||
button_layout.addWidget(self.update_now_btn)
|
|
||||||
|
|
||||||
self.update_later_btn = QPushButton("Later")
|
|
||||||
self.update_later_btn.clicked.connect(self._on_update_later)
|
|
||||||
button_layout.addWidget(self.update_later_btn)
|
|
||||||
|
|
||||||
self.skip_btn = QPushButton("Skip Version")
|
|
||||||
self.skip_btn.clicked.connect(self._on_skip)
|
|
||||||
button_layout.addWidget(self.skip_btn)
|
|
||||||
|
|
||||||
layout.addLayout(button_layout)
|
|
||||||
self.setLayout(layout)
|
|
||||||
|
|
||||||
def _on_update_now(self):
|
|
||||||
"""Handle update now button click."""
|
|
||||||
self.update_now.emit()
|
|
||||||
self.accept()
|
|
||||||
|
|
||||||
def _on_update_later(self):
|
|
||||||
"""Handle update later button click."""
|
|
||||||
self.update_later.emit()
|
|
||||||
self.reject()
|
|
||||||
|
|
||||||
def _on_skip(self):
|
|
||||||
"""Handle skip version button click."""
|
|
||||||
self.skip_version.emit()
|
|
||||||
self.reject()
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadingDialog(QDialog):
|
|
||||||
"""Dialog shown while downloading the update.
|
|
||||||
|
|
||||||
Displays:
|
|
||||||
- Download progress bar
|
|
||||||
- Current file being downloaded
|
|
||||||
- Cancel button
|
|
||||||
"""
|
|
||||||
|
|
||||||
cancel_download = Signal()
|
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
|
||||||
"""Initialize downloading dialog.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
parent: Parent widget
|
|
||||||
"""
|
|
||||||
super().__init__(parent)
|
|
||||||
self.setWindowTitle("Downloading Update")
|
|
||||||
self.setModal(True)
|
|
||||||
self.setMinimumWidth(350)
|
|
||||||
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
|
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
|
|
||||||
# Header
|
|
||||||
header = QLabel("Downloading update...")
|
|
||||||
header.setStyleSheet("font-weight: bold;")
|
|
||||||
layout.addWidget(header)
|
|
||||||
|
|
||||||
# File label
|
|
||||||
self.file_label = QLabel("Preparing download")
|
|
||||||
layout.addWidget(self.file_label)
|
|
||||||
|
|
||||||
# Progress bar
|
|
||||||
self.progress = QProgressBar()
|
|
||||||
self.progress.setMinimum(0)
|
|
||||||
self.progress.setMaximum(100)
|
|
||||||
self.progress.setValue(0)
|
|
||||||
layout.addWidget(self.progress)
|
|
||||||
|
|
||||||
# Size info
|
|
||||||
self.size_label = QLabel("0 MB / 0 MB")
|
|
||||||
self.size_label.setStyleSheet("color: gray; font-size: 11px;")
|
|
||||||
layout.addWidget(self.size_label)
|
|
||||||
|
|
||||||
# Cancel button
|
|
||||||
self.cancel_btn = QPushButton("Cancel")
|
|
||||||
self.cancel_btn.clicked.connect(self._on_cancel)
|
|
||||||
layout.addWidget(self.cancel_btn)
|
|
||||||
|
|
||||||
self.setLayout(layout)
|
|
||||||
|
|
||||||
def set_progress(self, current: int, total: int):
|
|
||||||
"""Update progress bar.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
current: Current bytes downloaded
|
|
||||||
total: Total bytes to download
|
|
||||||
"""
|
|
||||||
if total > 0:
|
|
||||||
percentage = int((current / total) * 100)
|
|
||||||
self.progress.setValue(percentage)
|
|
||||||
|
|
||||||
# Format size display
|
|
||||||
current_mb = current / (1024 * 1024)
|
|
||||||
total_mb = total / (1024 * 1024)
|
|
||||||
self.size_label.setText(f"{current_mb:.1f} MB / {total_mb:.1f} MB")
|
|
||||||
|
|
||||||
def set_filename(self, filename: str):
|
|
||||||
"""Set the filename being downloaded.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filename: Name of file being downloaded
|
|
||||||
"""
|
|
||||||
self.file_label.setText(f"Downloading: {filename}")
|
|
||||||
|
|
||||||
def _on_cancel(self):
|
|
||||||
"""Handle cancel button click."""
|
|
||||||
self.cancel_download.emit()
|
|
||||||
self.reject()
|
|
||||||
|
|
||||||
|
|
||||||
class InstallDialog(QDialog):
|
|
||||||
"""Dialog shown before installing update and restarting.
|
|
||||||
|
|
||||||
Displays:
|
|
||||||
- Installation confirmation message
|
|
||||||
- Warning about unsaved changes
|
|
||||||
- Buttons: Install Now, Cancel
|
|
||||||
"""
|
|
||||||
|
|
||||||
install_now = Signal()
|
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
|
||||||
"""Initialize install dialog.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
parent: Parent widget
|
|
||||||
"""
|
|
||||||
super().__init__(parent)
|
|
||||||
self.setWindowTitle("Install Update")
|
|
||||||
self.setModal(True)
|
|
||||||
self.setMinimumWidth(350)
|
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
|
|
||||||
# Header
|
|
||||||
header = QLabel("Ready to Install")
|
|
||||||
header.setStyleSheet("font-weight: bold; font-size: 14px;")
|
|
||||||
layout.addWidget(header)
|
|
||||||
|
|
||||||
# Message
|
|
||||||
message = QLabel("The update is ready to install. The application will restart.")
|
|
||||||
layout.addWidget(message)
|
|
||||||
|
|
||||||
# Warning
|
|
||||||
warning = QLabel(
|
|
||||||
"⚠️ Please save any unsaved work before continuing.\n"
|
|
||||||
"The application will close and restart."
|
|
||||||
)
|
|
||||||
warning.setStyleSheet("background-color: #fff3cd; padding: 10px; border-radius: 4px;")
|
|
||||||
warning.setWordWrap(True)
|
|
||||||
layout.addWidget(warning)
|
|
||||||
|
|
||||||
# Buttons
|
|
||||||
button_layout = QHBoxLayout()
|
|
||||||
|
|
||||||
self.install_btn = QPushButton("Install Now")
|
|
||||||
self.install_btn.setStyleSheet("background-color: #28a745; color: white;")
|
|
||||||
self.install_btn.clicked.connect(self._on_install)
|
|
||||||
button_layout.addWidget(self.install_btn)
|
|
||||||
|
|
||||||
self.cancel_btn = QPushButton("Cancel")
|
|
||||||
self.cancel_btn.clicked.connect(self.reject)
|
|
||||||
button_layout.addWidget(self.cancel_btn)
|
|
||||||
|
|
||||||
layout.addLayout(button_layout)
|
|
||||||
self.setLayout(layout)
|
|
||||||
|
|
||||||
def _on_install(self):
|
|
||||||
"""Handle install now button click."""
|
|
||||||
self.install_now.emit()
|
|
||||||
self.accept()
|
|
||||||
|
|
||||||
|
|
||||||
class NoUpdateDialog(QDialog):
|
|
||||||
"""Dialog shown when no updates are available.
|
|
||||||
|
|
||||||
Simple confirmation that the application is up to date.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
|
||||||
"""Initialize no update dialog.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
parent: Parent widget
|
|
||||||
"""
|
|
||||||
super().__init__(parent)
|
|
||||||
self.setWindowTitle("No Updates Available")
|
|
||||||
self.setModal(True)
|
|
||||||
self.setMinimumWidth(300)
|
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
|
|
||||||
# Message
|
|
||||||
message = QLabel("✓ You're using the latest version")
|
|
||||||
message.setStyleSheet("font-weight: bold; font-size: 12px; color: #28a745;")
|
|
||||||
layout.addWidget(message)
|
|
||||||
|
|
||||||
info = QLabel("WebDrop Bridge is up to date.")
|
|
||||||
layout.addWidget(info)
|
|
||||||
|
|
||||||
# Close button
|
|
||||||
close_btn = QPushButton("OK")
|
|
||||||
close_btn.clicked.connect(self.accept)
|
|
||||||
layout.addWidget(close_btn)
|
|
||||||
|
|
||||||
self.setLayout(layout)
|
|
||||||
|
|
||||||
|
|
||||||
class ErrorDialog(QDialog):
|
|
||||||
"""Dialog shown when update check or installation fails.
|
|
||||||
|
|
||||||
Displays:
|
|
||||||
- Error message
|
|
||||||
- Buttons: Retry, Manual Download, Cancel
|
|
||||||
"""
|
|
||||||
|
|
||||||
retry = Signal()
|
|
||||||
manual_download = Signal()
|
|
||||||
|
|
||||||
def __init__(self, error_message: str, parent=None):
|
|
||||||
"""Initialize error dialog.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
error_message: Description of the error
|
|
||||||
parent: Parent widget
|
|
||||||
"""
|
|
||||||
super().__init__(parent)
|
|
||||||
self.setWindowTitle("Update Failed")
|
|
||||||
self.setModal(True)
|
|
||||||
self.setMinimumWidth(350)
|
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
|
|
||||||
# Header
|
|
||||||
header = QLabel("⚠️ Update Failed")
|
|
||||||
header.setStyleSheet("font-weight: bold; font-size: 14px; color: #dc3545;")
|
|
||||||
layout.addWidget(header)
|
|
||||||
|
|
||||||
# Error message
|
|
||||||
self.error_text = QTextEdit()
|
|
||||||
self.error_text.setText(error_message)
|
|
||||||
self.error_text.setReadOnly(True)
|
|
||||||
self.error_text.setMaximumHeight(100)
|
|
||||||
layout.addWidget(self.error_text)
|
|
||||||
|
|
||||||
# Info message
|
|
||||||
info = QLabel(
|
|
||||||
"Please try again or visit the website to download the update manually."
|
|
||||||
)
|
|
||||||
info.setWordWrap(True)
|
|
||||||
info.setStyleSheet("color: gray; font-size: 11px;")
|
|
||||||
layout.addWidget(info)
|
|
||||||
|
|
||||||
# Buttons
|
|
||||||
button_layout = QHBoxLayout()
|
|
||||||
|
|
||||||
self.retry_btn = QPushButton("Retry")
|
|
||||||
self.retry_btn.clicked.connect(self._on_retry)
|
|
||||||
button_layout.addWidget(self.retry_btn)
|
|
||||||
|
|
||||||
self.manual_btn = QPushButton("Download Manually")
|
|
||||||
self.manual_btn.clicked.connect(self._on_manual)
|
|
||||||
button_layout.addWidget(self.manual_btn)
|
|
||||||
|
|
||||||
self.cancel_btn = QPushButton("Cancel")
|
|
||||||
self.cancel_btn.clicked.connect(self.reject)
|
|
||||||
button_layout.addWidget(self.cancel_btn)
|
|
||||||
|
|
||||||
layout.addLayout(button_layout)
|
|
||||||
self.setLayout(layout)
|
|
||||||
|
|
||||||
def _on_retry(self):
|
|
||||||
"""Handle retry button click."""
|
|
||||||
self.retry.emit()
|
|
||||||
self.accept()
|
|
||||||
|
|
||||||
def _on_manual(self):
|
|
||||||
"""Handle manual download button click."""
|
|
||||||
self.manual_download.emit()
|
|
||||||
self.accept()
|
|
||||||
|
|
@ -1,74 +1,9 @@
|
||||||
"""Logging configuration and utilities for WebDrop Bridge."""
|
"""Logging configuration and utilities for WebDrop Bridge."""
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import logging.handlers
|
import logging.handlers
|
||||||
import time
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
class JSONFormatter(logging.Formatter):
|
|
||||||
"""Custom JSON formatter for structured logging.
|
|
||||||
|
|
||||||
Formats log records as JSON for better parsing and analysis.
|
|
||||||
Includes timestamp, level, message, module, and optional context.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def format(self, record: logging.LogRecord) -> str:
|
|
||||||
"""Format log record as JSON string.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
record: LogRecord to format
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON string containing log data
|
|
||||||
"""
|
|
||||||
log_data: Dict[str, Any] = {
|
|
||||||
"timestamp": datetime.fromtimestamp(record.created).isoformat(),
|
|
||||||
"level": record.levelname,
|
|
||||||
"logger": record.name,
|
|
||||||
"message": record.getMessage(),
|
|
||||||
"module": record.module,
|
|
||||||
"function": record.funcName,
|
|
||||||
"line": record.lineno,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add exception info if present
|
|
||||||
if record.exc_info:
|
|
||||||
log_data["exception"] = self.formatException(record.exc_info)
|
|
||||||
|
|
||||||
# Add any extra context from the LogRecord
|
|
||||||
# Attributes added via record.__dict__['key'] = value
|
|
||||||
for key, value in record.__dict__.items():
|
|
||||||
if key not in (
|
|
||||||
"name",
|
|
||||||
"msg",
|
|
||||||
"args",
|
|
||||||
"created",
|
|
||||||
"filename",
|
|
||||||
"funcName",
|
|
||||||
"levelname",
|
|
||||||
"levelno",
|
|
||||||
"lineno",
|
|
||||||
"module",
|
|
||||||
"msecs",
|
|
||||||
"message",
|
|
||||||
"pathname",
|
|
||||||
"process",
|
|
||||||
"processName",
|
|
||||||
"relativeCreated",
|
|
||||||
"thread",
|
|
||||||
"threadName",
|
|
||||||
"exc_info",
|
|
||||||
"exc_text",
|
|
||||||
"stack_info",
|
|
||||||
):
|
|
||||||
log_data[key] = value
|
|
||||||
|
|
||||||
return json.dumps(log_data, default=str)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def setup_logging(
|
def setup_logging(
|
||||||
|
|
@ -76,7 +11,6 @@ def setup_logging(
|
||||||
level: str = "INFO",
|
level: str = "INFO",
|
||||||
log_file: Optional[Path] = None,
|
log_file: Optional[Path] = None,
|
||||||
fmt: Optional[str] = None,
|
fmt: Optional[str] = None,
|
||||||
json_format: bool = False,
|
|
||||||
) -> logging.Logger:
|
) -> logging.Logger:
|
||||||
"""Configure application-wide logging.
|
"""Configure application-wide logging.
|
||||||
|
|
||||||
|
|
@ -90,7 +24,6 @@ def setup_logging(
|
||||||
to this file in addition to console
|
to this file in addition to console
|
||||||
fmt: Optional custom format string. If None, uses default format.
|
fmt: Optional custom format string. If None, uses default format.
|
||||||
Default: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
Default: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
json_format: If True, use JSON format for logs. Ignores fmt parameter.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
logging.Logger: Configured logger instance
|
logging.Logger: Configured logger instance
|
||||||
|
|
@ -105,14 +38,12 @@ def setup_logging(
|
||||||
except AttributeError as e:
|
except AttributeError as e:
|
||||||
raise KeyError(f"Invalid logging level: {level}") from e
|
raise KeyError(f"Invalid logging level: {level}") from e
|
||||||
|
|
||||||
# Create formatter based on format type
|
# Use default format if not provided
|
||||||
if json_format:
|
if fmt is None:
|
||||||
formatter = JSONFormatter()
|
fmt = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
else:
|
|
||||||
# Use default format if not provided
|
# Create formatter
|
||||||
if fmt is None:
|
formatter = logging.Formatter(fmt)
|
||||||
fmt = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
||||||
formatter = logging.Formatter(fmt)
|
|
||||||
|
|
||||||
# Get or create logger
|
# Get or create logger
|
||||||
logger = logging.getLogger(name)
|
logger = logging.getLogger(name)
|
||||||
|
|
@ -133,9 +64,6 @@ def setup_logging(
|
||||||
# Create parent directories if needed
|
# Create parent directories if needed
|
||||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Archive old logs before creating new handler
|
|
||||||
_archive_old_logs(log_file)
|
|
||||||
|
|
||||||
# Use rotating file handler to manage log file size
|
# Use rotating file handler to manage log file size
|
||||||
# Max 10 MB per file, keep 5 backups
|
# Max 10 MB per file, keep 5 backups
|
||||||
file_handler = logging.handlers.RotatingFileHandler(
|
file_handler = logging.handlers.RotatingFileHandler(
|
||||||
|
|
@ -170,90 +98,3 @@ def get_logger(name: str = __name__) -> logging.Logger:
|
||||||
logging.Logger: Logger instance for the given name
|
logging.Logger: Logger instance for the given name
|
||||||
"""
|
"""
|
||||||
return logging.getLogger(name)
|
return logging.getLogger(name)
|
||||||
|
|
||||||
|
|
||||||
def _archive_old_logs(log_file: Path, retention_days: int = 30) -> None:
|
|
||||||
"""Archive logs older than retention period.
|
|
||||||
|
|
||||||
Removes log files older than the specified retention period.
|
|
||||||
Called automatically by setup_logging.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
log_file: Path to the current log file
|
|
||||||
retention_days: Number of days to keep old logs (default: 30)
|
|
||||||
"""
|
|
||||||
if not log_file.parent.exists():
|
|
||||||
return
|
|
||||||
|
|
||||||
now = datetime.now()
|
|
||||||
cutoff = now - timedelta(days=retention_days)
|
|
||||||
|
|
||||||
# Check for backup log files (*.log.1, *.log.2, etc.)
|
|
||||||
for log_path in log_file.parent.glob(f"{log_file.name}.*"):
|
|
||||||
try:
|
|
||||||
# Get file modification time
|
|
||||||
mtime = datetime.fromtimestamp(log_path.stat().st_mtime)
|
|
||||||
if mtime < cutoff:
|
|
||||||
log_path.unlink()
|
|
||||||
except (OSError, IOError):
|
|
||||||
# Silently skip if we can't delete
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class PerformanceTracker:
|
|
||||||
"""Track performance metrics for application operations.
|
|
||||||
|
|
||||||
Provides context manager interface for timing code blocks
|
|
||||||
and logging performance data.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
with PerformanceTracker("drag_operation") as tracker:
|
|
||||||
# Your code here
|
|
||||||
pass
|
|
||||||
# Logs elapsed time automatically
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, operation_name: str, logger: Optional[logging.Logger] = None):
|
|
||||||
"""Initialize performance tracker.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
operation_name: Name of the operation being tracked
|
|
||||||
logger: Logger instance to use (uses root logger if None)
|
|
||||||
"""
|
|
||||||
self.operation_name = operation_name
|
|
||||||
self.logger = logger or logging.getLogger("webdrop_bridge")
|
|
||||||
self.start_time: Optional[float] = None
|
|
||||||
self.elapsed_time: float = 0.0
|
|
||||||
|
|
||||||
def __enter__(self) -> "PerformanceTracker":
|
|
||||||
"""Enter context manager."""
|
|
||||||
self.start_time = time.time()
|
|
||||||
self.logger.debug(f"Starting: {self.operation_name}")
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
||||||
"""Exit context manager and log elapsed time."""
|
|
||||||
if self.start_time is not None:
|
|
||||||
self.elapsed_time = time.time() - self.start_time
|
|
||||||
|
|
||||||
# Log with appropriate level based on execution
|
|
||||||
if exc_type is not None:
|
|
||||||
self.logger.warning(
|
|
||||||
f"Completed (with error): {self.operation_name}",
|
|
||||||
extra={"duration_seconds": self.elapsed_time, "error": str(exc_val)},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.logger.debug(
|
|
||||||
f"Completed: {self.operation_name}",
|
|
||||||
extra={"duration_seconds": self.elapsed_time},
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_elapsed(self) -> float:
|
|
||||||
"""Get elapsed time in seconds.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Elapsed time or 0 if context not yet exited
|
|
||||||
"""
|
|
||||||
if self.start_time is None:
|
|
||||||
return 0.0
|
|
||||||
return time.time() - self.start_time
|
|
||||||
|
|
|
||||||
BIN
test_output.txt
BIN
test_output.txt
Binary file not shown.
BIN
test_results.txt
BIN
test_results.txt
Binary file not shown.
|
|
@ -1,107 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
"""Test timeout handling in update feature."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
|
||||||
|
|
||||||
from webdrop_bridge.core.updater import UpdateManager
|
|
||||||
from webdrop_bridge.ui.main_window import UpdateCheckWorker, UpdateDownloadWorker
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
print("\n" + "="*70)
|
|
||||||
print("TIMEOUT HANDLING VERIFICATION")
|
|
||||||
print("="*70 + "\n")
|
|
||||||
|
|
||||||
# Test 1: UpdateCheckWorker handles timeout
|
|
||||||
print("Test 1: UpdateCheckWorker handles network timeout gracefully")
|
|
||||||
print("-" * 70)
|
|
||||||
|
|
||||||
async def test_check_timeout():
|
|
||||||
"""Test that check_for_updates respects timeout."""
|
|
||||||
manager = Mock(spec=UpdateManager)
|
|
||||||
|
|
||||||
# Simulate a timeout
|
|
||||||
async def slow_check():
|
|
||||||
await asyncio.sleep(20) # Longer than 15-second timeout
|
|
||||||
return None
|
|
||||||
|
|
||||||
manager.check_for_updates = slow_check
|
|
||||||
|
|
||||||
# This should timeout after 15 seconds
|
|
||||||
try:
|
|
||||||
result = await asyncio.wait_for(manager.check_for_updates(), timeout=15)
|
|
||||||
print("❌ Should have timed out!")
|
|
||||||
return False
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
print("✓ Correctly timed out after 15 seconds")
|
|
||||||
print("✓ User gets 'Ready' status and app doesn't hang")
|
|
||||||
return True
|
|
||||||
|
|
||||||
result1 = asyncio.run(test_check_timeout())
|
|
||||||
|
|
||||||
# Test 2: UpdateDownloadWorker handles timeout
|
|
||||||
print("\nTest 2: UpdateDownloadWorker handles network timeout gracefully")
|
|
||||||
print("-" * 70)
|
|
||||||
|
|
||||||
async def test_download_timeout():
|
|
||||||
"""Test that download respects timeout."""
|
|
||||||
manager = Mock(spec=UpdateManager)
|
|
||||||
|
|
||||||
# Simulate a timeout
|
|
||||||
async def slow_download(release):
|
|
||||||
await asyncio.sleep(400) # Longer than 300-second timeout
|
|
||||||
return None
|
|
||||||
|
|
||||||
manager.download_update = slow_download
|
|
||||||
|
|
||||||
# This should timeout after 300 seconds
|
|
||||||
try:
|
|
||||||
result = await asyncio.wait_for(manager.download_update(None), timeout=300)
|
|
||||||
print("❌ Should have timed out!")
|
|
||||||
return False
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
print("✓ Correctly timed out after 300 seconds")
|
|
||||||
print("✓ User gets 'Operation timed out' error message")
|
|
||||||
print("✓ App shows specific timeout error instead of hanging")
|
|
||||||
return True
|
|
||||||
|
|
||||||
result2 = asyncio.run(test_download_timeout())
|
|
||||||
|
|
||||||
# Test 3: Verify error messages
|
|
||||||
print("\nTest 3: Timeout errors show helpful messages")
|
|
||||||
print("-" * 70)
|
|
||||||
|
|
||||||
messages = [
|
|
||||||
("Update check timed out", "Update check timeout produces helpful message"),
|
|
||||||
("Download or verification timed out", "Download timeout produces helpful message"),
|
|
||||||
("no response from server", "Error explains what happened (no server response)"),
|
|
||||||
]
|
|
||||||
|
|
||||||
all_good = True
|
|
||||||
for msg, description in messages:
|
|
||||||
print(f"✓ {description}")
|
|
||||||
print(f" → Message: '{msg}'")
|
|
||||||
|
|
||||||
result3 = True
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
print("\n" + "="*70)
|
|
||||||
if result1 and result2 and result3:
|
|
||||||
print("✅ TIMEOUT HANDLING WORKS CORRECTLY!")
|
|
||||||
print("="*70)
|
|
||||||
print("\nThe update feature now:")
|
|
||||||
print(" 1. Has 15-second timeout for update checks")
|
|
||||||
print(" 2. Has 300-second timeout for download operations")
|
|
||||||
print(" 3. Has 30-second timeout for checksum verification")
|
|
||||||
print(" 4. Shows helpful error messages when timeouts occur")
|
|
||||||
print(" 5. Prevents the application from hanging indefinitely")
|
|
||||||
print(" 6. Allows user to retry or cancel")
|
|
||||||
else:
|
|
||||||
print("❌ SOME TESTS FAILED")
|
|
||||||
print("="*70)
|
|
||||||
|
|
||||||
print()
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
"""Test script to verify the update feature no longer hangs the UI.
|
|
||||||
|
|
||||||
This script demonstrates that the update download happens in a background
|
|
||||||
thread and doesn't block the UI thread.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock, Mock, patch
|
|
||||||
|
|
||||||
from PySide6.QtCore import QCoreApplication, QThread, QTimer
|
|
||||||
|
|
||||||
from webdrop_bridge.config import Config
|
|
||||||
from webdrop_bridge.core.updater import Release, UpdateManager
|
|
||||||
from webdrop_bridge.ui.main_window import MainWindow, UpdateDownloadWorker
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_download_runs_in_background():
|
|
||||||
"""Verify that update download runs in a background thread."""
|
|
||||||
print("\n=== Testing Update Download Background Thread ===\n")
|
|
||||||
|
|
||||||
app = QCoreApplication.instance() or QCoreApplication([])
|
|
||||||
|
|
||||||
# Create a mock release
|
|
||||||
release = Release(
|
|
||||||
tag_name="v0.0.2",
|
|
||||||
name="Release 0.0.2",
|
|
||||||
version="0.0.2",
|
|
||||||
body="Test release notes",
|
|
||||||
assets=[{"name": "installer.msi", "browser_download_url": "http://example.com/installer.msi"}],
|
|
||||||
published_at="2026-01-30T00:00:00Z"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create a mock update manager
|
|
||||||
manager = Mock(spec=UpdateManager)
|
|
||||||
|
|
||||||
# Track if download_update was called
|
|
||||||
download_called = False
|
|
||||||
download_thread_id = None
|
|
||||||
|
|
||||||
async def mock_download(rel):
|
|
||||||
nonlocal download_called, download_thread_id
|
|
||||||
download_called = True
|
|
||||||
download_thread_id = QThread.currentThreadId()
|
|
||||||
# Simulate network operation
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
return Path("/tmp/fake_installer.msi")
|
|
||||||
|
|
||||||
async def mock_verify(file_path, rel):
|
|
||||||
nonlocal download_thread_id
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
return True
|
|
||||||
|
|
||||||
manager.download_update = mock_download
|
|
||||||
manager.verify_checksum = mock_verify
|
|
||||||
|
|
||||||
# Create the worker
|
|
||||||
worker = UpdateDownloadWorker(manager, release, "0.0.1")
|
|
||||||
|
|
||||||
# Track signals
|
|
||||||
signals_emitted = []
|
|
||||||
worker.download_complete.connect(lambda p: signals_emitted.append(("complete", p)))
|
|
||||||
worker.download_failed.connect(lambda e: signals_emitted.append(("failed", e)))
|
|
||||||
worker.finished.connect(lambda: signals_emitted.append(("finished",)))
|
|
||||||
|
|
||||||
# Create a thread and move worker to it
|
|
||||||
thread = QThread()
|
|
||||||
worker.moveToThread(thread)
|
|
||||||
|
|
||||||
# Track if worker runs in different thread
|
|
||||||
main_thread_id = QThread.currentThreadId()
|
|
||||||
worker_thread_id = None
|
|
||||||
|
|
||||||
def on_worker_run_started():
|
|
||||||
nonlocal worker_thread_id
|
|
||||||
worker_thread_id = QThread.currentThreadId()
|
|
||||||
logger.info(f"Worker running in thread: {worker_thread_id}")
|
|
||||||
logger.info(f"Main thread: {main_thread_id}")
|
|
||||||
|
|
||||||
thread.started.connect(on_worker_run_started)
|
|
||||||
thread.started.connect(worker.run)
|
|
||||||
|
|
||||||
# Start the thread and process events until done
|
|
||||||
thread.start()
|
|
||||||
|
|
||||||
# Wait for completion with timeout
|
|
||||||
start_time = asyncio.get_event_loop().time() if hasattr(asyncio.get_event_loop(), 'time') else 0
|
|
||||||
while not download_called and len(signals_emitted) < 3:
|
|
||||||
app.processEvents()
|
|
||||||
QTimer.singleShot(10, app.quit)
|
|
||||||
app.exec()
|
|
||||||
if len(signals_emitted) >= 3:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
thread.quit()
|
|
||||||
thread.wait()
|
|
||||||
|
|
||||||
# Verify results
|
|
||||||
print(f"\n✓ Download called: {download_called}")
|
|
||||||
print(f"✓ Signals emitted: {len(signals_emitted)}")
|
|
||||||
|
|
||||||
# Check if completion signal was emitted (shows async operations completed)
|
|
||||||
has_complete_or_failed = any(sig[0] in ("complete", "failed") for sig in signals_emitted)
|
|
||||||
has_finished = any(sig[0] == "finished" for sig in signals_emitted)
|
|
||||||
|
|
||||||
print(f"✓ Completion/Failed signal emitted: {has_complete_or_failed}")
|
|
||||||
print(f"✓ Finished signal emitted: {has_finished}")
|
|
||||||
|
|
||||||
if has_complete_or_failed and has_finished:
|
|
||||||
print("\n✅ SUCCESS: Update download runs asynchronously without blocking UI!")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print("\n❌ FAILED: Signals not emitted properly")
|
|
||||||
print(f" Signals: {signals_emitted}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_download_worker_exists():
|
|
||||||
"""Verify that UpdateDownloadWorker class exists and has correct signals."""
|
|
||||||
print("\n=== Testing UpdateDownloadWorker Class ===\n")
|
|
||||||
|
|
||||||
# Check class exists
|
|
||||||
assert hasattr(UpdateDownloadWorker, '__init__'), "UpdateDownloadWorker missing __init__"
|
|
||||||
print("✓ UpdateDownloadWorker class exists")
|
|
||||||
|
|
||||||
# Check signals
|
|
||||||
required_signals = ['download_complete', 'download_failed', 'update_status', 'finished']
|
|
||||||
for signal_name in required_signals:
|
|
||||||
assert hasattr(UpdateDownloadWorker, signal_name), f"Missing signal: {signal_name}"
|
|
||||||
print(f"✓ Signal '{signal_name}' defined")
|
|
||||||
|
|
||||||
# Check methods
|
|
||||||
assert hasattr(UpdateDownloadWorker, 'run'), "UpdateDownloadWorker missing run method"
|
|
||||||
print("✓ Method 'run' defined")
|
|
||||||
|
|
||||||
print("\n✅ SUCCESS: UpdateDownloadWorker properly implemented!")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def test_main_window_uses_async_download():
|
|
||||||
"""Verify that MainWindow uses async download instead of blocking."""
|
|
||||||
print("\n=== Testing MainWindow Async Download Integration ===\n")
|
|
||||||
|
|
||||||
# Check that _perform_update_async exists (new async version)
|
|
||||||
assert hasattr(MainWindow, '_perform_update_async'), "MainWindow missing _perform_update_async"
|
|
||||||
print("✓ Method '_perform_update_async' exists (new async version)")
|
|
||||||
|
|
||||||
# Check that old blocking _perform_update is gone
|
|
||||||
assert not hasattr(MainWindow, '_perform_update'), \
|
|
||||||
"MainWindow still has old blocking _perform_update method"
|
|
||||||
print("✓ Old blocking '_perform_update' method removed")
|
|
||||||
|
|
||||||
# Check download/failed handlers exist
|
|
||||||
assert hasattr(MainWindow, '_on_download_complete'), "MainWindow missing _on_download_complete"
|
|
||||||
assert hasattr(MainWindow, '_on_download_failed'), "MainWindow missing _on_download_failed"
|
|
||||||
print("✓ Download completion handlers exist")
|
|
||||||
|
|
||||||
print("\n✅ SUCCESS: MainWindow properly integrated with async download!")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
print("\n" + "="*60)
|
|
||||||
print("UPDATE FEATURE FIX VERIFICATION")
|
|
||||||
print("="*60)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Test 1: Worker exists
|
|
||||||
test1 = test_update_download_worker_exists()
|
|
||||||
|
|
||||||
# Test 2: MainWindow integration
|
|
||||||
test2 = test_main_window_uses_async_download()
|
|
||||||
|
|
||||||
# Test 3: Async operation
|
|
||||||
test3 = test_update_download_runs_in_background()
|
|
||||||
|
|
||||||
print("\n" + "="*60)
|
|
||||||
if test1 and test2 and test3:
|
|
||||||
print("✅ ALL TESTS PASSED - UPDATE FEATURE HANG FIXED!")
|
|
||||||
print("="*60 + "\n")
|
|
||||||
print("Summary of changes:")
|
|
||||||
print("- Created UpdateDownloadWorker class for async downloads")
|
|
||||||
print("- Moved blocking operations from UI thread to background thread")
|
|
||||||
print("- Added handlers for download completion/failure")
|
|
||||||
print("- UI now stays responsive during update download")
|
|
||||||
else:
|
|
||||||
print("❌ SOME TESTS FAILED")
|
|
||||||
print("="*60 + "\n")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"\n❌ ERROR: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
@ -1,209 +0,0 @@
|
||||||
"""Integration tests for the complete update flow."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from webdrop_bridge.config import Config
|
|
||||||
from webdrop_bridge.core.updater import Release, UpdateManager
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def config(tmp_path):
|
|
||||||
"""Create test config."""
|
|
||||||
return Config(
|
|
||||||
app_name="Test WebDrop",
|
|
||||||
app_version="0.0.1",
|
|
||||||
log_level="INFO",
|
|
||||||
log_file=None,
|
|
||||||
allowed_roots=[tmp_path],
|
|
||||||
allowed_urls=[],
|
|
||||||
webapp_url="file:///./webapp/index.html",
|
|
||||||
window_width=800,
|
|
||||||
window_height=600,
|
|
||||||
window_title="Test WebDrop v0.0.1",
|
|
||||||
enable_logging=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_forgejo_response():
|
|
||||||
"""Mock Forgejo API response - formatted as returned by _fetch_release."""
|
|
||||||
return {
|
|
||||||
"tag_name": "v0.0.2",
|
|
||||||
"name": "WebDropBridge v0.0.2",
|
|
||||||
"version": "0.0.2", # _fetch_release adds this
|
|
||||||
"body": "## Bug Fixes\n- Fixed drag and drop on macOS",
|
|
||||||
"assets": [
|
|
||||||
{
|
|
||||||
"name": "WebDropBridge.exe",
|
|
||||||
"browser_download_url": "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.0.2/WebDropBridge.exe",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "WebDropBridge.exe.sha256",
|
|
||||||
"browser_download_url": "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.0.2/WebDropBridge.exe.sha256",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"published_at": "2026-01-29T10:00:00Z",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateFlowIntegration:
|
|
||||||
"""Integration tests for the complete update check flow."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_full_update_check_flow(self, config, mock_forgejo_response, tmp_path):
|
|
||||||
"""Test complete flow: API query -> version check -> signal."""
|
|
||||||
manager = UpdateManager(
|
|
||||||
current_version=config.app_version,
|
|
||||||
config_dir=tmp_path
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mock the API fetch
|
|
||||||
with patch.object(manager, "_fetch_release") as mock_fetch:
|
|
||||||
mock_fetch.return_value = mock_forgejo_response
|
|
||||||
|
|
||||||
# Run check
|
|
||||||
release = await manager.check_for_updates()
|
|
||||||
|
|
||||||
# Verify API was called
|
|
||||||
mock_fetch.assert_called_once()
|
|
||||||
|
|
||||||
# Verify we got a release
|
|
||||||
assert release is not None
|
|
||||||
assert release.version == "0.0.2"
|
|
||||||
assert release.tag_name == "v0.0.2"
|
|
||||||
assert len(release.assets) == 2
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_check_with_cache(self, config, mock_forgejo_response, tmp_path):
|
|
||||||
"""Test that cache is used on second call."""
|
|
||||||
manager = UpdateManager(
|
|
||||||
current_version=config.app_version,
|
|
||||||
config_dir=tmp_path
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch.object(manager, "_fetch_release") as mock_fetch:
|
|
||||||
mock_fetch.return_value = mock_forgejo_response
|
|
||||||
|
|
||||||
# First call - should fetch from API
|
|
||||||
release1 = await manager.check_for_updates()
|
|
||||||
assert mock_fetch.call_count == 1
|
|
||||||
|
|
||||||
# Second call - should use cache
|
|
||||||
release2 = await manager.check_for_updates()
|
|
||||||
assert mock_fetch.call_count == 1 # Still 1, cache used
|
|
||||||
|
|
||||||
# Verify both got same result
|
|
||||||
assert release1 is not None
|
|
||||||
assert release2 is not None
|
|
||||||
assert release1.version == release2.version
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_check_no_newer_version(self, config, tmp_path):
|
|
||||||
"""Test that no update available when latest is same version."""
|
|
||||||
manager = UpdateManager(
|
|
||||||
current_version="0.0.2",
|
|
||||||
config_dir=tmp_path
|
|
||||||
)
|
|
||||||
|
|
||||||
response = {
|
|
||||||
"tag_name": "v0.0.2",
|
|
||||||
"name": "WebDropBridge v0.0.2",
|
|
||||||
"body": "",
|
|
||||||
"assets": [],
|
|
||||||
"published_at": "2026-01-29T10:00:00Z",
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch.object(manager, "_fetch_release") as mock_fetch:
|
|
||||||
mock_fetch.return_value = response
|
|
||||||
|
|
||||||
release = await manager.check_for_updates()
|
|
||||||
|
|
||||||
# Should return None since version is not newer
|
|
||||||
assert release is None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_check_network_error(self, config, tmp_path):
|
|
||||||
"""Test graceful handling of network errors."""
|
|
||||||
manager = UpdateManager(
|
|
||||||
current_version=config.app_version,
|
|
||||||
config_dir=tmp_path
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mock network error
|
|
||||||
with patch.object(manager, "_fetch_release") as mock_fetch:
|
|
||||||
mock_fetch.side_effect = Exception("Connection timeout")
|
|
||||||
|
|
||||||
release = await manager.check_for_updates()
|
|
||||||
|
|
||||||
# Should return None on error
|
|
||||||
assert release is None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_version_parsing_in_api_response(self, config, tmp_path):
|
|
||||||
"""Test that version is correctly extracted from tag_name."""
|
|
||||||
manager = UpdateManager(
|
|
||||||
current_version=config.app_version,
|
|
||||||
config_dir=tmp_path
|
|
||||||
)
|
|
||||||
|
|
||||||
# API returns version with 'v' prefix - but _fetch_release processes it
|
|
||||||
response = {
|
|
||||||
"tag_name": "v1.2.3",
|
|
||||||
"name": "Release",
|
|
||||||
"version": "1.2.3", # _fetch_release adds this
|
|
||||||
"body": "",
|
|
||||||
"assets": [],
|
|
||||||
"published_at": "2026-01-29T10:00:00Z",
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch.object(manager, "_fetch_release") as mock_fetch:
|
|
||||||
mock_fetch.return_value = response
|
|
||||||
|
|
||||||
release = await manager.check_for_updates()
|
|
||||||
|
|
||||||
# Version should be extracted correctly (without 'v')
|
|
||||||
assert release is not None
|
|
||||||
assert release.version == "1.2.3"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_asset_parsing_in_release(self, config, mock_forgejo_response, tmp_path):
|
|
||||||
"""Test that release assets are correctly parsed."""
|
|
||||||
manager = UpdateManager(
|
|
||||||
current_version=config.app_version,
|
|
||||||
config_dir=tmp_path
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch.object(manager, "_fetch_release") as mock_fetch:
|
|
||||||
mock_fetch.return_value = mock_forgejo_response
|
|
||||||
|
|
||||||
release = await manager.check_for_updates()
|
|
||||||
|
|
||||||
# Should have both exe and checksum
|
|
||||||
assert release is not None
|
|
||||||
assert len(release.assets) == 2
|
|
||||||
asset_names = [a["name"] for a in release.assets]
|
|
||||||
assert "WebDropBridge.exe" in asset_names
|
|
||||||
assert "WebDropBridge.exe.sha256" in asset_names
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_changelog_preserved(self, config, mock_forgejo_response, tmp_path):
|
|
||||||
"""Test that release notes/changelog are preserved."""
|
|
||||||
manager = UpdateManager(
|
|
||||||
current_version=config.app_version,
|
|
||||||
config_dir=tmp_path
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch.object(manager, "_fetch_release") as mock_fetch:
|
|
||||||
mock_fetch.return_value = mock_forgejo_response
|
|
||||||
|
|
||||||
release = await manager.check_for_updates()
|
|
||||||
|
|
||||||
# Changelog should be available
|
|
||||||
assert release is not None
|
|
||||||
assert release.body == mock_forgejo_response["body"]
|
|
||||||
assert "Bug Fixes" in release.body
|
|
||||||
|
|
@ -70,9 +70,7 @@ class TestConfigFromEnv:
|
||||||
config = Config.from_env(str(env_file))
|
config = Config.from_env(str(env_file))
|
||||||
|
|
||||||
assert config.app_name == "WebDrop Bridge"
|
assert config.app_name == "WebDrop Bridge"
|
||||||
# Version should come from __init__.py (dynamic, not hardcoded)
|
assert config.app_version == "1.0.0"
|
||||||
from webdrop_bridge import __version__
|
|
||||||
assert config.app_version == __version__
|
|
||||||
assert config.log_level == "INFO"
|
assert config.log_level == "INFO"
|
||||||
assert config.window_width == 1024
|
assert config.window_width == 1024
|
||||||
assert config.window_height == 768
|
assert config.window_height == 768
|
||||||
|
|
|
||||||
|
|
@ -1,303 +0,0 @@
|
||||||
"""Tests for configuration management module."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from webdrop_bridge.config import Config, ConfigurationError
|
|
||||||
from webdrop_bridge.core.config_manager import ConfigExporter, ConfigProfile, ConfigValidator
|
|
||||||
|
|
||||||
|
|
||||||
class TestConfigValidator:
|
|
||||||
"""Test configuration validation."""
|
|
||||||
|
|
||||||
def test_validate_valid_config(self):
|
|
||||||
"""Test validation passes for valid configuration."""
|
|
||||||
config_dict = {
|
|
||||||
"app_name": "WebDrop Bridge",
|
|
||||||
"app_version": "1.0.0",
|
|
||||||
"log_level": "INFO",
|
|
||||||
"log_file": None,
|
|
||||||
"allowed_roots": ["/home", "/data"],
|
|
||||||
"allowed_urls": ["http://example.com"],
|
|
||||||
"webapp_url": "http://localhost:8080",
|
|
||||||
"window_width": 800,
|
|
||||||
"window_height": 600,
|
|
||||||
"enable_logging": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
errors = ConfigValidator.validate(config_dict)
|
|
||||||
assert errors == []
|
|
||||||
|
|
||||||
def test_validate_missing_required_field(self):
|
|
||||||
"""Test validation fails for missing required fields."""
|
|
||||||
config_dict = {
|
|
||||||
"app_name": "WebDrop Bridge",
|
|
||||||
"app_version": "1.0.0",
|
|
||||||
}
|
|
||||||
|
|
||||||
errors = ConfigValidator.validate(config_dict)
|
|
||||||
assert len(errors) > 0
|
|
||||||
assert any("log_level" in e for e in errors)
|
|
||||||
|
|
||||||
def test_validate_invalid_type(self):
|
|
||||||
"""Test validation fails for invalid type."""
|
|
||||||
config_dict = {
|
|
||||||
"app_name": "WebDrop Bridge",
|
|
||||||
"app_version": "1.0.0",
|
|
||||||
"log_level": "INFO",
|
|
||||||
"log_file": None,
|
|
||||||
"allowed_roots": ["/home"],
|
|
||||||
"allowed_urls": ["http://example.com"],
|
|
||||||
"webapp_url": "http://localhost:8080",
|
|
||||||
"window_width": "800", # Should be int
|
|
||||||
"window_height": 600,
|
|
||||||
"enable_logging": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
errors = ConfigValidator.validate(config_dict)
|
|
||||||
assert len(errors) > 0
|
|
||||||
assert any("window_width" in e for e in errors)
|
|
||||||
|
|
||||||
def test_validate_invalid_log_level(self):
|
|
||||||
"""Test validation fails for invalid log level."""
|
|
||||||
config_dict = {
|
|
||||||
"app_name": "WebDrop Bridge",
|
|
||||||
"app_version": "1.0.0",
|
|
||||||
"log_level": "TRACE", # Invalid
|
|
||||||
"log_file": None,
|
|
||||||
"allowed_roots": [],
|
|
||||||
"allowed_urls": [],
|
|
||||||
"webapp_url": "http://localhost:8080",
|
|
||||||
"window_width": 800,
|
|
||||||
"window_height": 600,
|
|
||||||
"enable_logging": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
errors = ConfigValidator.validate(config_dict)
|
|
||||||
assert len(errors) > 0
|
|
||||||
assert any("log_level" in e for e in errors)
|
|
||||||
|
|
||||||
def test_validate_invalid_version_format(self):
|
|
||||||
"""Test validation fails for invalid version format."""
|
|
||||||
config_dict = {
|
|
||||||
"app_name": "WebDrop Bridge",
|
|
||||||
"app_version": "1.0", # Should be X.Y.Z
|
|
||||||
"log_level": "INFO",
|
|
||||||
"log_file": None,
|
|
||||||
"allowed_roots": [],
|
|
||||||
"allowed_urls": [],
|
|
||||||
"webapp_url": "http://localhost:8080",
|
|
||||||
"window_width": 800,
|
|
||||||
"window_height": 600,
|
|
||||||
"enable_logging": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
errors = ConfigValidator.validate(config_dict)
|
|
||||||
# Note: Current implementation doesn't check regex pattern
|
|
||||||
# This test documents the expected behavior for future enhancement
|
|
||||||
|
|
||||||
def test_validate_out_of_range_value(self):
|
|
||||||
"""Test validation fails for values outside allowed range."""
|
|
||||||
config_dict = {
|
|
||||||
"app_name": "WebDrop Bridge",
|
|
||||||
"app_version": "1.0.0",
|
|
||||||
"log_level": "INFO",
|
|
||||||
"log_file": None,
|
|
||||||
"allowed_roots": [],
|
|
||||||
"allowed_urls": [],
|
|
||||||
"webapp_url": "http://localhost:8080",
|
|
||||||
"window_width": 100, # Below minimum of 400
|
|
||||||
"window_height": 600,
|
|
||||||
"enable_logging": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
errors = ConfigValidator.validate(config_dict)
|
|
||||||
assert len(errors) > 0
|
|
||||||
assert any("window_width" in e for e in errors)
|
|
||||||
|
|
||||||
def test_validate_or_raise_valid(self):
|
|
||||||
"""Test validate_or_raise succeeds for valid config."""
|
|
||||||
config_dict = {
|
|
||||||
"app_name": "WebDrop Bridge",
|
|
||||||
"app_version": "1.0.0",
|
|
||||||
"log_level": "INFO",
|
|
||||||
"log_file": None,
|
|
||||||
"allowed_roots": [],
|
|
||||||
"allowed_urls": [],
|
|
||||||
"webapp_url": "http://localhost:8080",
|
|
||||||
"window_width": 800,
|
|
||||||
"window_height": 600,
|
|
||||||
"enable_logging": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Should not raise
|
|
||||||
ConfigValidator.validate_or_raise(config_dict)
|
|
||||||
|
|
||||||
def test_validate_or_raise_invalid(self):
|
|
||||||
"""Test validate_or_raise raises for invalid config."""
|
|
||||||
config_dict = {
|
|
||||||
"app_name": "WebDrop Bridge",
|
|
||||||
"app_version": "1.0.0",
|
|
||||||
}
|
|
||||||
|
|
||||||
with pytest.raises(ConfigurationError) as exc_info:
|
|
||||||
ConfigValidator.validate_or_raise(config_dict)
|
|
||||||
|
|
||||||
assert "Configuration validation failed" in str(exc_info.value)
|
|
||||||
|
|
||||||
|
|
||||||
class TestConfigProfile:
|
|
||||||
"""Test configuration profile management."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def profile_manager(self, tmp_path, monkeypatch):
|
|
||||||
"""Create profile manager with temporary directory."""
|
|
||||||
monkeypatch.setattr(ConfigProfile, "PROFILES_DIR", tmp_path / "profiles")
|
|
||||||
return ConfigProfile()
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_config(self):
|
|
||||||
"""Create sample configuration."""
|
|
||||||
return Config(
|
|
||||||
app_name="WebDrop Bridge",
|
|
||||||
app_version="1.0.0",
|
|
||||||
log_level="INFO",
|
|
||||||
log_file=None,
|
|
||||||
allowed_roots=[Path("/home"), Path("/data")],
|
|
||||||
allowed_urls=["http://example.com"],
|
|
||||||
webapp_url="http://localhost:8080",
|
|
||||||
window_width=800,
|
|
||||||
window_height=600,
|
|
||||||
enable_logging=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_save_profile(self, profile_manager, sample_config):
|
|
||||||
"""Test saving a profile."""
|
|
||||||
profile_path = profile_manager.save_profile("work", sample_config)
|
|
||||||
|
|
||||||
assert profile_path.exists()
|
|
||||||
assert profile_path.name == "work.json"
|
|
||||||
|
|
||||||
def test_load_profile(self, profile_manager, sample_config):
|
|
||||||
"""Test loading a profile."""
|
|
||||||
profile_manager.save_profile("work", sample_config)
|
|
||||||
loaded = profile_manager.load_profile("work")
|
|
||||||
|
|
||||||
assert loaded["app_name"] == "WebDrop Bridge"
|
|
||||||
assert loaded["log_level"] == "INFO"
|
|
||||||
assert loaded["window_width"] == 800
|
|
||||||
|
|
||||||
def test_load_nonexistent_profile(self, profile_manager):
|
|
||||||
"""Test loading nonexistent profile raises error."""
|
|
||||||
with pytest.raises(ConfigurationError) as exc_info:
|
|
||||||
profile_manager.load_profile("nonexistent")
|
|
||||||
|
|
||||||
assert "Profile not found" in str(exc_info.value)
|
|
||||||
|
|
||||||
def test_list_profiles(self, profile_manager, sample_config):
|
|
||||||
"""Test listing profiles."""
|
|
||||||
profile_manager.save_profile("work", sample_config)
|
|
||||||
profile_manager.save_profile("personal", sample_config)
|
|
||||||
|
|
||||||
profiles = profile_manager.list_profiles()
|
|
||||||
|
|
||||||
assert "work" in profiles
|
|
||||||
assert "personal" in profiles
|
|
||||||
assert len(profiles) == 2
|
|
||||||
|
|
||||||
def test_delete_profile(self, profile_manager, sample_config):
|
|
||||||
"""Test deleting a profile."""
|
|
||||||
profile_manager.save_profile("work", sample_config)
|
|
||||||
assert profile_manager.list_profiles() == ["work"]
|
|
||||||
|
|
||||||
profile_manager.delete_profile("work")
|
|
||||||
assert profile_manager.list_profiles() == []
|
|
||||||
|
|
||||||
def test_delete_nonexistent_profile(self, profile_manager):
|
|
||||||
"""Test deleting nonexistent profile raises error."""
|
|
||||||
with pytest.raises(ConfigurationError) as exc_info:
|
|
||||||
profile_manager.delete_profile("nonexistent")
|
|
||||||
|
|
||||||
assert "Profile not found" in str(exc_info.value)
|
|
||||||
|
|
||||||
def test_invalid_profile_name(self, profile_manager, sample_config):
|
|
||||||
"""Test invalid profile names are rejected."""
|
|
||||||
with pytest.raises(ConfigurationError) as exc_info:
|
|
||||||
profile_manager.save_profile("work/personal", sample_config)
|
|
||||||
|
|
||||||
assert "Invalid profile name" in str(exc_info.value)
|
|
||||||
|
|
||||||
|
|
||||||
class TestConfigExporter:
|
|
||||||
"""Test configuration export/import."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_config(self):
|
|
||||||
"""Create sample configuration."""
|
|
||||||
return Config(
|
|
||||||
app_name="WebDrop Bridge",
|
|
||||||
app_version="1.0.0",
|
|
||||||
log_level="INFO",
|
|
||||||
log_file=None,
|
|
||||||
allowed_roots=[Path("/home"), Path("/data")],
|
|
||||||
allowed_urls=["http://example.com"],
|
|
||||||
webapp_url="http://localhost:8080",
|
|
||||||
window_width=800,
|
|
||||||
window_height=600,
|
|
||||||
window_title="WebDrop Bridge v1.0.0",
|
|
||||||
enable_logging=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_export_to_json(self, tmp_path, sample_config):
|
|
||||||
"""Test exporting configuration to JSON."""
|
|
||||||
output_file = tmp_path / "config.json"
|
|
||||||
|
|
||||||
ConfigExporter.export_to_json(sample_config, output_file)
|
|
||||||
|
|
||||||
assert output_file.exists()
|
|
||||||
|
|
||||||
data = json.loads(output_file.read_text())
|
|
||||||
assert data["app_name"] == "WebDrop Bridge"
|
|
||||||
assert data["log_level"] == "INFO"
|
|
||||||
|
|
||||||
def test_import_from_json(self, tmp_path, sample_config):
|
|
||||||
"""Test importing configuration from JSON."""
|
|
||||||
# Export first
|
|
||||||
output_file = tmp_path / "config.json"
|
|
||||||
ConfigExporter.export_to_json(sample_config, output_file)
|
|
||||||
|
|
||||||
# Import
|
|
||||||
imported = ConfigExporter.import_from_json(output_file)
|
|
||||||
|
|
||||||
assert imported["app_name"] == "WebDrop Bridge"
|
|
||||||
assert imported["log_level"] == "INFO"
|
|
||||||
assert imported["window_width"] == 800
|
|
||||||
|
|
||||||
def test_import_nonexistent_file(self):
|
|
||||||
"""Test importing nonexistent file raises error."""
|
|
||||||
with pytest.raises(ConfigurationError) as exc_info:
|
|
||||||
ConfigExporter.import_from_json(Path("/nonexistent/file.json"))
|
|
||||||
|
|
||||||
assert "File not found" in str(exc_info.value)
|
|
||||||
|
|
||||||
def test_import_invalid_json(self, tmp_path):
|
|
||||||
"""Test importing invalid JSON raises error."""
|
|
||||||
invalid_file = tmp_path / "invalid.json"
|
|
||||||
invalid_file.write_text("{ invalid json }")
|
|
||||||
|
|
||||||
with pytest.raises(ConfigurationError) as exc_info:
|
|
||||||
ConfigExporter.import_from_json(invalid_file)
|
|
||||||
|
|
||||||
assert "Invalid JSON" in str(exc_info.value)
|
|
||||||
|
|
||||||
def test_import_invalid_config(self, tmp_path):
|
|
||||||
"""Test importing JSON with invalid config raises error."""
|
|
||||||
invalid_file = tmp_path / "invalid_config.json"
|
|
||||||
invalid_file.write_text('{"app_name": "test"}') # Missing required fields
|
|
||||||
|
|
||||||
with pytest.raises(ConfigurationError) as exc_info:
|
|
||||||
ConfigExporter.import_from_json(invalid_file)
|
|
||||||
|
|
||||||
assert "Configuration validation failed" in str(exc_info.value)
|
|
||||||
|
|
@ -1,19 +1,12 @@
|
||||||
"""Unit tests for logging module."""
|
"""Unit tests for logging module."""
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import logging.handlers
|
import logging.handlers
|
||||||
import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from webdrop_bridge.utils.logging import (
|
from webdrop_bridge.utils.logging import get_logger, setup_logging
|
||||||
JSONFormatter,
|
|
||||||
PerformanceTracker,
|
|
||||||
get_logger,
|
|
||||||
setup_logging,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestSetupLogging:
|
class TestSetupLogging:
|
||||||
|
|
@ -159,178 +152,3 @@ class TestLogRotation:
|
||||||
# Default: 10 MB max, 5 backups
|
# Default: 10 MB max, 5 backups
|
||||||
assert rotating_handler.maxBytes == 10 * 1024 * 1024
|
assert rotating_handler.maxBytes == 10 * 1024 * 1024
|
||||||
assert rotating_handler.backupCount == 5
|
assert rotating_handler.backupCount == 5
|
||||||
|
|
||||||
|
|
||||||
class TestJSONFormatter:
|
|
||||||
"""Test structured JSON logging."""
|
|
||||||
|
|
||||||
def test_json_formatter_creates_valid_json(self):
|
|
||||||
"""Test that JSONFormatter produces valid JSON."""
|
|
||||||
formatter = JSONFormatter()
|
|
||||||
record = logging.LogRecord(
|
|
||||||
name="test.module",
|
|
||||||
level=logging.INFO,
|
|
||||||
pathname="test.py",
|
|
||||||
lineno=42,
|
|
||||||
msg="Test message",
|
|
||||||
args=(),
|
|
||||||
exc_info=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
output = formatter.format(record)
|
|
||||||
|
|
||||||
# Should be valid JSON
|
|
||||||
data = json.loads(output)
|
|
||||||
assert data["message"] == "Test message"
|
|
||||||
assert data["level"] == "INFO"
|
|
||||||
assert data["logger"] == "test.module"
|
|
||||||
assert data["line"] == 42
|
|
||||||
|
|
||||||
def test_json_formatter_includes_timestamp(self):
|
|
||||||
"""Test that JSON output includes ISO format timestamp."""
|
|
||||||
formatter = JSONFormatter()
|
|
||||||
record = logging.LogRecord(
|
|
||||||
name="test",
|
|
||||||
level=logging.INFO,
|
|
||||||
pathname="test.py",
|
|
||||||
lineno=1,
|
|
||||||
msg="Test",
|
|
||||||
args=(),
|
|
||||||
exc_info=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
output = formatter.format(record)
|
|
||||||
data = json.loads(output)
|
|
||||||
|
|
||||||
assert "timestamp" in data
|
|
||||||
# Should be ISO format like "2026-01-29T12:34:56.789000"
|
|
||||||
assert "T" in data["timestamp"]
|
|
||||||
|
|
||||||
def test_json_formatter_with_exception(self):
|
|
||||||
"""Test JSON formatter handles exceptions."""
|
|
||||||
formatter = JSONFormatter()
|
|
||||||
|
|
||||||
try:
|
|
||||||
raise ValueError("Test error")
|
|
||||||
except ValueError:
|
|
||||||
import sys
|
|
||||||
|
|
||||||
record = logging.LogRecord(
|
|
||||||
name="test",
|
|
||||||
level=logging.ERROR,
|
|
||||||
pathname="test.py",
|
|
||||||
lineno=1,
|
|
||||||
msg="Error occurred",
|
|
||||||
args=(),
|
|
||||||
exc_info=sys.exc_info(),
|
|
||||||
)
|
|
||||||
|
|
||||||
output = formatter.format(record)
|
|
||||||
data = json.loads(output)
|
|
||||||
|
|
||||||
assert "exception" in data
|
|
||||||
assert "ValueError" in data["exception"]
|
|
||||||
assert "Test error" in data["exception"]
|
|
||||||
|
|
||||||
def test_setup_logging_with_json_format(self, tmp_path):
|
|
||||||
"""Test setup_logging with JSON format enabled."""
|
|
||||||
log_file = tmp_path / "test.log"
|
|
||||||
|
|
||||||
logger = setup_logging(
|
|
||||||
name="test_json",
|
|
||||||
level="INFO",
|
|
||||||
log_file=log_file,
|
|
||||||
json_format=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("Test JSON message", extra={"user_id": 123})
|
|
||||||
|
|
||||||
# Read and parse log file
|
|
||||||
content = log_file.read_text()
|
|
||||||
data = json.loads(content)
|
|
||||||
|
|
||||||
assert data["message"] == "Test JSON message"
|
|
||||||
assert data["level"] == "INFO"
|
|
||||||
assert data["user_id"] == 123
|
|
||||||
|
|
||||||
|
|
||||||
class TestLogArchival:
|
|
||||||
"""Test log file archival and rotation."""
|
|
||||||
|
|
||||||
def test_setup_logging_with_log_file_created(self, tmp_path):
|
|
||||||
"""Test that log file is created by setup_logging."""
|
|
||||||
log_file = tmp_path / "test.log"
|
|
||||||
|
|
||||||
logger = setup_logging(
|
|
||||||
name="test_file_creation",
|
|
||||||
level="INFO",
|
|
||||||
log_file=log_file,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("Test message")
|
|
||||||
|
|
||||||
# Check that log file was created
|
|
||||||
assert log_file.exists()
|
|
||||||
assert "Test message" in log_file.read_text()
|
|
||||||
|
|
||||||
def test_archive_old_logs_with_nonexistent_directory(self, tmp_path):
|
|
||||||
"""Test that archive function handles nonexistent directories."""
|
|
||||||
from webdrop_bridge.utils.logging import _archive_old_logs
|
|
||||||
|
|
||||||
nonexistent_log = tmp_path / "nonexistent" / "test.log"
|
|
||||||
|
|
||||||
# Should not raise even if directory doesn't exist
|
|
||||||
_archive_old_logs(nonexistent_log, retention_days=30)
|
|
||||||
assert True # Function completed without error
|
|
||||||
|
|
||||||
|
|
||||||
class TestPerformanceTracker:
|
|
||||||
"""Test performance metrics collection."""
|
|
||||||
|
|
||||||
def test_performance_tracker_context_manager(self):
|
|
||||||
"""Test PerformanceTracker context manager."""
|
|
||||||
tracker = PerformanceTracker("test_operation")
|
|
||||||
|
|
||||||
with tracker as t:
|
|
||||||
time.sleep(0.01) # Sleep for 10ms
|
|
||||||
assert t.start_time is not None
|
|
||||||
|
|
||||||
assert tracker.elapsed_time >= 0.01
|
|
||||||
assert tracker.get_elapsed() >= 0.01
|
|
||||||
|
|
||||||
def test_performance_tracker_logs_timing(self, caplog):
|
|
||||||
"""Test that PerformanceTracker logs elapsed time."""
|
|
||||||
logger = get_logger("test.perf")
|
|
||||||
caplog.set_level(logging.DEBUG)
|
|
||||||
|
|
||||||
with PerformanceTracker("database_query", logger=logger):
|
|
||||||
time.sleep(0.01)
|
|
||||||
|
|
||||||
# Should have logged the operation
|
|
||||||
assert "database_query" in caplog.text
|
|
||||||
|
|
||||||
def test_performance_tracker_logs_errors(self, caplog):
|
|
||||||
"""Test that PerformanceTracker logs errors."""
|
|
||||||
logger = get_logger("test.perf.error")
|
|
||||||
caplog.set_level(logging.WARNING)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with PerformanceTracker("failing_operation", logger=logger):
|
|
||||||
raise ValueError("Test error")
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Should have logged the error
|
|
||||||
assert "failing_operation" in caplog.text
|
|
||||||
assert "error" in caplog.text.lower()
|
|
||||||
|
|
||||||
def test_performance_tracker_get_elapsed_before_exit(self):
|
|
||||||
"""Test getting elapsed time before context exit."""
|
|
||||||
tracker = PerformanceTracker("test")
|
|
||||||
|
|
||||||
with tracker:
|
|
||||||
elapsed = tracker.get_elapsed()
|
|
||||||
assert elapsed >= 0 # Should return time since start
|
|
||||||
|
|
||||||
# After exit, should have final time
|
|
||||||
assert tracker.elapsed_time >= elapsed
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ def sample_config(tmp_path):
|
||||||
webapp_url=str(webapp_file),
|
webapp_url=str(webapp_file),
|
||||||
window_width=800,
|
window_width=800,
|
||||||
window_height=600,
|
window_height=600,
|
||||||
window_title="Test WebDrop v1.0.0",
|
|
||||||
enable_logging=False,
|
enable_logging=False,
|
||||||
)
|
)
|
||||||
return config
|
return config
|
||||||
|
|
@ -324,118 +323,6 @@ class TestMainWindowSignals:
|
||||||
mock_handler.assert_called_once()
|
mock_handler.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
class TestMainWindowMenuBar:
|
|
||||||
"""Test toolbar help actions integration."""
|
|
||||||
|
|
||||||
def test_navigation_toolbar_created(self, qtbot, sample_config):
|
|
||||||
"""Test navigation toolbar is created with help buttons."""
|
|
||||||
window = MainWindow(sample_config)
|
|
||||||
qtbot.addWidget(window)
|
|
||||||
|
|
||||||
# Check that toolbar exists
|
|
||||||
assert len(window.findChildren(QToolBar)) > 0
|
|
||||||
toolbar = window.findChildren(QToolBar)[0]
|
|
||||||
assert toolbar is not None
|
|
||||||
|
|
||||||
def test_window_has_check_for_updates_signal(self, qtbot, sample_config):
|
|
||||||
"""Test window has check_for_updates signal."""
|
|
||||||
window = MainWindow(sample_config)
|
|
||||||
qtbot.addWidget(window)
|
|
||||||
|
|
||||||
# Test that signal exists
|
|
||||||
assert hasattr(window, "check_for_updates")
|
|
||||||
|
|
||||||
# Test that signal is callable (can be emitted)
|
|
||||||
assert callable(window.check_for_updates.emit)
|
|
||||||
|
|
||||||
def test_on_check_for_updates_method_exists(self, qtbot, sample_config):
|
|
||||||
"""Test _on_manual_check_for_updates method exists."""
|
|
||||||
window = MainWindow(sample_config)
|
|
||||||
qtbot.addWidget(window)
|
|
||||||
|
|
||||||
# Test that the method exists
|
|
||||||
assert hasattr(window, "_on_manual_check_for_updates")
|
|
||||||
assert callable(window._on_manual_check_for_updates)
|
|
||||||
|
|
||||||
def test_show_about_dialog_method_exists(self, qtbot, sample_config):
|
|
||||||
"""Test _show_about_dialog method exists."""
|
|
||||||
window = MainWindow(sample_config)
|
|
||||||
qtbot.addWidget(window)
|
|
||||||
|
|
||||||
# Test that the method exists
|
|
||||||
assert hasattr(window, "_show_about_dialog")
|
|
||||||
assert callable(window._show_about_dialog)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMainWindowStatusBar:
|
|
||||||
"""Test status bar and update status."""
|
|
||||||
|
|
||||||
def test_status_bar_created(self, qtbot, sample_config):
|
|
||||||
"""Test status bar is created."""
|
|
||||||
window = MainWindow(sample_config)
|
|
||||||
qtbot.addWidget(window)
|
|
||||||
|
|
||||||
assert window.statusBar() is not None
|
|
||||||
assert hasattr(window, "status_bar")
|
|
||||||
|
|
||||||
def test_update_status_label_created(self, qtbot, sample_config):
|
|
||||||
"""Test update status label exists."""
|
|
||||||
window = MainWindow(sample_config)
|
|
||||||
qtbot.addWidget(window)
|
|
||||||
|
|
||||||
assert hasattr(window, "update_status_label")
|
|
||||||
assert window.update_status_label is not None
|
|
||||||
|
|
||||||
def test_set_update_status_text_only(self, qtbot, sample_config):
|
|
||||||
"""Test setting update status with text only."""
|
|
||||||
window = MainWindow(sample_config)
|
|
||||||
qtbot.addWidget(window)
|
|
||||||
|
|
||||||
window.set_update_status("Checking for updates")
|
|
||||||
assert "Checking for updates" in window.update_status_label.text()
|
|
||||||
|
|
||||||
def test_set_update_status_with_emoji(self, qtbot, sample_config):
|
|
||||||
"""Test setting update status with emoji."""
|
|
||||||
window = MainWindow(sample_config)
|
|
||||||
qtbot.addWidget(window)
|
|
||||||
|
|
||||||
window.set_update_status("Checking", emoji="🔄")
|
|
||||||
assert "🔄" in window.update_status_label.text()
|
|
||||||
assert "Checking" in window.update_status_label.text()
|
|
||||||
|
|
||||||
def test_set_update_status_checking(self, qtbot, sample_config):
|
|
||||||
"""Test checking for updates status."""
|
|
||||||
window = MainWindow(sample_config)
|
|
||||||
qtbot.addWidget(window)
|
|
||||||
|
|
||||||
window.set_update_status("Checking for updates", emoji="🔄")
|
|
||||||
assert "🔄" in window.update_status_label.text()
|
|
||||||
|
|
||||||
def test_set_update_status_available(self, qtbot, sample_config):
|
|
||||||
"""Test update available status."""
|
|
||||||
window = MainWindow(sample_config)
|
|
||||||
qtbot.addWidget(window)
|
|
||||||
|
|
||||||
window.set_update_status("Update available v0.0.2", emoji="✅")
|
|
||||||
assert "✅" in window.update_status_label.text()
|
|
||||||
|
|
||||||
def test_set_update_status_downloading(self, qtbot, sample_config):
|
|
||||||
"""Test downloading status."""
|
|
||||||
window = MainWindow(sample_config)
|
|
||||||
qtbot.addWidget(window)
|
|
||||||
|
|
||||||
window.set_update_status("Downloading update", emoji="⬇️")
|
|
||||||
assert "⬇️" in window.update_status_label.text()
|
|
||||||
|
|
||||||
def test_set_update_status_error(self, qtbot, sample_config):
|
|
||||||
"""Test error status."""
|
|
||||||
window = MainWindow(sample_config)
|
|
||||||
qtbot.addWidget(window)
|
|
||||||
|
|
||||||
window.set_update_status("Update check failed", emoji="⚠️")
|
|
||||||
assert "⚠️" in window.update_status_label.text()
|
|
||||||
|
|
||||||
|
|
||||||
class TestMainWindowStylesheet:
|
class TestMainWindowStylesheet:
|
||||||
"""Test stylesheet application."""
|
"""Test stylesheet application."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,303 +0,0 @@
|
||||||
"""Tests for settings dialog."""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from webdrop_bridge.config import Config, ConfigurationError
|
|
||||||
from webdrop_bridge.ui.settings_dialog import SettingsDialog
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_config(tmp_path):
|
|
||||||
"""Create sample configuration."""
|
|
||||||
return Config(
|
|
||||||
app_name="WebDrop Bridge",
|
|
||||||
app_version="1.0.0",
|
|
||||||
log_level="INFO",
|
|
||||||
log_file=None,
|
|
||||||
allowed_roots=[Path("/home"), Path("/data")],
|
|
||||||
allowed_urls=["http://example.com", "http://*.test.com"],
|
|
||||||
webapp_url="http://localhost:8080",
|
|
||||||
window_width=800,
|
|
||||||
window_height=600,
|
|
||||||
window_title="WebDrop Bridge v1.0.0",
|
|
||||||
enable_logging=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestSettingsDialogInitialization:
|
|
||||||
"""Test settings dialog initialization."""
|
|
||||||
|
|
||||||
def test_dialog_creation(self, qtbot, sample_config):
|
|
||||||
"""Test dialog can be created."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
assert dialog is not None
|
|
||||||
assert dialog.windowTitle() == "Settings"
|
|
||||||
|
|
||||||
def test_dialog_has_tabs(self, qtbot, sample_config):
|
|
||||||
"""Test dialog has all required tabs."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
assert dialog.tabs is not None
|
|
||||||
assert dialog.tabs.count() == 5 # Paths, URLs, Logging, Window, Profiles
|
|
||||||
|
|
||||||
def test_dialog_has_paths_tab(self, qtbot, sample_config):
|
|
||||||
"""Test Paths tab exists."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
assert dialog.tabs.tabText(0) == "Paths"
|
|
||||||
|
|
||||||
def test_dialog_has_urls_tab(self, qtbot, sample_config):
|
|
||||||
"""Test URLs tab exists."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
assert dialog.tabs.tabText(1) == "URLs"
|
|
||||||
|
|
||||||
def test_dialog_has_logging_tab(self, qtbot, sample_config):
|
|
||||||
"""Test Logging tab exists."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
assert dialog.tabs.tabText(2) == "Logging"
|
|
||||||
|
|
||||||
def test_dialog_has_window_tab(self, qtbot, sample_config):
|
|
||||||
"""Test Window tab exists."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
assert dialog.tabs.tabText(3) == "Window"
|
|
||||||
|
|
||||||
def test_dialog_has_profiles_tab(self, qtbot, sample_config):
|
|
||||||
"""Test Profiles tab exists."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
assert dialog.tabs.tabText(4) == "Profiles"
|
|
||||||
|
|
||||||
|
|
||||||
class TestPathsTab:
|
|
||||||
"""Test Paths configuration tab."""
|
|
||||||
|
|
||||||
def test_paths_loaded_from_config(self, qtbot, sample_config):
|
|
||||||
"""Test paths are loaded from configuration."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
items = [dialog.paths_list.item(i).text() for i in range(dialog.paths_list.count())]
|
|
||||||
assert len(items) == 2
|
|
||||||
# Paths are normalized (backslashes on Windows)
|
|
||||||
assert any("home" in item for item in items)
|
|
||||||
assert any("data" in item for item in items)
|
|
||||||
|
|
||||||
def test_add_path_button_exists(self, qtbot, sample_config):
|
|
||||||
"""Test Add Path button exists."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
assert dialog.tabs.currentWidget() is not None
|
|
||||||
|
|
||||||
|
|
||||||
class TestURLsTab:
|
|
||||||
"""Test URLs configuration tab."""
|
|
||||||
|
|
||||||
def test_urls_loaded_from_config(self, qtbot, sample_config):
|
|
||||||
"""Test URLs are loaded from configuration."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
items = [dialog.urls_list.item(i).text() for i in range(dialog.urls_list.count())]
|
|
||||||
assert len(items) == 2
|
|
||||||
assert "http://example.com" in items
|
|
||||||
assert "http://*.test.com" in items
|
|
||||||
|
|
||||||
|
|
||||||
class TestLoggingTab:
|
|
||||||
"""Test Logging configuration tab."""
|
|
||||||
|
|
||||||
def test_log_level_set_from_config(self, qtbot, sample_config):
|
|
||||||
"""Test log level is set from configuration."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
assert dialog.log_level_combo.currentText() == "INFO"
|
|
||||||
|
|
||||||
def test_log_levels_available(self, qtbot, sample_config):
|
|
||||||
"""Test all log levels are available."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
levels = [dialog.log_level_combo.itemText(i) for i in range(dialog.log_level_combo.count())]
|
|
||||||
assert "DEBUG" in levels
|
|
||||||
assert "INFO" in levels
|
|
||||||
assert "WARNING" in levels
|
|
||||||
assert "ERROR" in levels
|
|
||||||
assert "CRITICAL" in levels
|
|
||||||
|
|
||||||
|
|
||||||
class TestWindowTab:
|
|
||||||
"""Test Window configuration tab."""
|
|
||||||
|
|
||||||
def test_window_width_set_from_config(self, qtbot, sample_config):
|
|
||||||
"""Test window width is set from configuration."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
assert dialog.width_spin.value() == 800
|
|
||||||
|
|
||||||
def test_window_height_set_from_config(self, qtbot, sample_config):
|
|
||||||
"""Test window height is set from configuration."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
assert dialog.height_spin.value() == 600
|
|
||||||
|
|
||||||
def test_window_width_has_min_max(self, qtbot, sample_config):
|
|
||||||
"""Test window width spinbox has min/max."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
assert dialog.width_spin.minimum() == 400
|
|
||||||
assert dialog.width_spin.maximum() == 5000
|
|
||||||
|
|
||||||
def test_window_height_has_min_max(self, qtbot, sample_config):
|
|
||||||
"""Test window height spinbox has min/max."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
assert dialog.height_spin.minimum() == 300
|
|
||||||
assert dialog.height_spin.maximum() == 5000
|
|
||||||
|
|
||||||
|
|
||||||
class TestProfilesTab:
|
|
||||||
"""Test Profiles management tab."""
|
|
||||||
|
|
||||||
def test_profiles_list_initialized(self, qtbot, sample_config):
|
|
||||||
"""Test profiles list is initialized."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
assert dialog.profiles_list is not None
|
|
||||||
|
|
||||||
|
|
||||||
class TestConfigDataRetrieval:
|
|
||||||
"""Test getting configuration data from dialog."""
|
|
||||||
|
|
||||||
def test_get_config_data_from_dialog(self, qtbot, sample_config):
|
|
||||||
"""Test retrieving configuration data from dialog."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
config_data = dialog.get_config_data()
|
|
||||||
|
|
||||||
assert config_data["app_name"] == "WebDrop Bridge"
|
|
||||||
assert config_data["log_level"] == "INFO"
|
|
||||||
assert config_data["window_width"] == 800
|
|
||||||
assert config_data["window_height"] == 600
|
|
||||||
|
|
||||||
def test_get_config_data_validates(self, qtbot, sample_config):
|
|
||||||
"""Test get_config_data returns valid configuration data."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
# All default values are valid
|
|
||||||
config_data = dialog.get_config_data()
|
|
||||||
assert config_data is not None
|
|
||||||
assert config_data["window_width"] == 800
|
|
||||||
|
|
||||||
def test_get_config_data_with_modified_values(self, qtbot, sample_config):
|
|
||||||
"""Test get_config_data returns modified values."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
# Modify values
|
|
||||||
dialog.width_spin.setValue(1024)
|
|
||||||
dialog.height_spin.setValue(768)
|
|
||||||
dialog.log_level_combo.setCurrentText("DEBUG")
|
|
||||||
|
|
||||||
config_data = dialog.get_config_data()
|
|
||||||
|
|
||||||
assert config_data["window_width"] == 1024
|
|
||||||
assert config_data["window_height"] == 768
|
|
||||||
assert config_data["log_level"] == "DEBUG"
|
|
||||||
|
|
||||||
|
|
||||||
class TestApplyConfigData:
|
|
||||||
"""Test applying configuration data to dialog."""
|
|
||||||
|
|
||||||
def test_apply_config_data_updates_paths(self, qtbot, sample_config):
|
|
||||||
"""Test applying config data updates paths."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
new_config = {
|
|
||||||
"app_name": "Test",
|
|
||||||
"app_version": "1.0.0",
|
|
||||||
"log_level": "INFO",
|
|
||||||
"log_file": None,
|
|
||||||
"allowed_roots": ["/new/path", "/another/path"],
|
|
||||||
"allowed_urls": [],
|
|
||||||
"webapp_url": "http://localhost",
|
|
||||||
"window_width": 800,
|
|
||||||
"window_height": 600,
|
|
||||||
"enable_logging": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog._apply_config_data(new_config)
|
|
||||||
|
|
||||||
items = [dialog.paths_list.item(i).text() for i in range(dialog.paths_list.count())]
|
|
||||||
assert "/new/path" in items
|
|
||||||
assert "/another/path" in items
|
|
||||||
|
|
||||||
def test_apply_config_data_updates_urls(self, qtbot, sample_config):
|
|
||||||
"""Test applying config data updates URLs."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
new_config = {
|
|
||||||
"app_name": "Test",
|
|
||||||
"app_version": "1.0.0",
|
|
||||||
"log_level": "INFO",
|
|
||||||
"log_file": None,
|
|
||||||
"allowed_roots": [],
|
|
||||||
"allowed_urls": ["http://new.com", "http://test.org"],
|
|
||||||
"webapp_url": "http://localhost",
|
|
||||||
"window_width": 800,
|
|
||||||
"window_height": 600,
|
|
||||||
"enable_logging": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog._apply_config_data(new_config)
|
|
||||||
|
|
||||||
items = [dialog.urls_list.item(i).text() for i in range(dialog.urls_list.count())]
|
|
||||||
assert "http://new.com" in items
|
|
||||||
assert "http://test.org" in items
|
|
||||||
|
|
||||||
def test_apply_config_data_updates_window_size(self, qtbot, sample_config):
|
|
||||||
"""Test applying config data updates window size."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
new_config = {
|
|
||||||
"app_name": "Test",
|
|
||||||
"app_version": "1.0.0",
|
|
||||||
"log_level": "INFO",
|
|
||||||
"log_file": None,
|
|
||||||
"allowed_roots": [],
|
|
||||||
"allowed_urls": [],
|
|
||||||
"webapp_url": "http://localhost",
|
|
||||||
"window_width": 1280,
|
|
||||||
"window_height": 1024,
|
|
||||||
"enable_logging": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog._apply_config_data(new_config)
|
|
||||||
|
|
||||||
assert dialog.width_spin.value() == 1280
|
|
||||||
assert dialog.height_spin.value() == 1024
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
"""Tests for update startup check functionality."""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from webdrop_bridge.config import Config
|
|
||||||
from webdrop_bridge.ui.main_window import UpdateCheckWorker
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_config(tmp_path):
|
|
||||||
"""Create a sample config for testing."""
|
|
||||||
return Config(
|
|
||||||
app_name="Test WebDrop",
|
|
||||||
app_version="1.0.0",
|
|
||||||
log_level="INFO",
|
|
||||||
log_file=None,
|
|
||||||
allowed_roots=[tmp_path],
|
|
||||||
allowed_urls=[],
|
|
||||||
webapp_url="file:///./webapp/index.html",
|
|
||||||
window_width=800,
|
|
||||||
window_height=600,
|
|
||||||
window_title="Test WebDrop v1.0.0",
|
|
||||||
enable_logging=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateCheckWorker:
|
|
||||||
"""Tests for UpdateCheckWorker."""
|
|
||||||
|
|
||||||
def test_worker_initialization(self):
|
|
||||||
"""Test worker can be initialized."""
|
|
||||||
manager = MagicMock()
|
|
||||||
worker = UpdateCheckWorker(manager, "0.0.1")
|
|
||||||
|
|
||||||
assert worker.manager is manager
|
|
||||||
assert worker.current_version == "0.0.1"
|
|
||||||
|
|
||||||
def test_worker_has_signals(self):
|
|
||||||
"""Test worker has required signals."""
|
|
||||||
manager = MagicMock()
|
|
||||||
worker = UpdateCheckWorker(manager, "0.0.1")
|
|
||||||
|
|
||||||
assert hasattr(worker, "update_available")
|
|
||||||
assert hasattr(worker, "update_status")
|
|
||||||
assert hasattr(worker, "finished")
|
|
||||||
|
|
||||||
def test_worker_run_method_exists(self):
|
|
||||||
"""Test worker has run method."""
|
|
||||||
manager = MagicMock()
|
|
||||||
worker = UpdateCheckWorker(manager, "0.0.1")
|
|
||||||
|
|
||||||
assert hasattr(worker, "run")
|
|
||||||
assert callable(worker.run)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMainWindowStartupCheck:
|
|
||||||
"""Test startup check integration in MainWindow."""
|
|
||||||
|
|
||||||
def test_window_has_startup_check_method(self, qtbot, sample_config):
|
|
||||||
"""Test MainWindow has check_for_updates_startup method."""
|
|
||||||
from webdrop_bridge.ui.main_window import MainWindow
|
|
||||||
|
|
||||||
window = MainWindow(sample_config)
|
|
||||||
qtbot.addWidget(window)
|
|
||||||
|
|
||||||
assert hasattr(window, "check_for_updates_startup")
|
|
||||||
assert callable(window.check_for_updates_startup)
|
|
||||||
|
|
||||||
def test_window_has_update_available_signal(self, qtbot, sample_config):
|
|
||||||
"""Test MainWindow has update_available signal."""
|
|
||||||
from webdrop_bridge.ui.main_window import MainWindow
|
|
||||||
|
|
||||||
window = MainWindow(sample_config)
|
|
||||||
qtbot.addWidget(window)
|
|
||||||
|
|
||||||
assert hasattr(window, "update_available")
|
|
||||||
|
|
||||||
def test_startup_check_initializes_without_error(self, qtbot, sample_config):
|
|
||||||
"""Test startup check can be called without raising."""
|
|
||||||
from webdrop_bridge.ui.main_window import MainWindow
|
|
||||||
|
|
||||||
window = MainWindow(sample_config)
|
|
||||||
qtbot.addWidget(window)
|
|
||||||
|
|
||||||
# Should not raise
|
|
||||||
window.check_for_updates_startup()
|
|
||||||
|
|
||||||
def test_on_update_status_updates_status_bar(self, qtbot, sample_config):
|
|
||||||
"""Test _on_update_status updates the status bar."""
|
|
||||||
from webdrop_bridge.ui.main_window import MainWindow
|
|
||||||
|
|
||||||
window = MainWindow(sample_config)
|
|
||||||
qtbot.addWidget(window)
|
|
||||||
|
|
||||||
window._on_update_status("Testing", "✓")
|
|
||||||
assert "Testing" in window.update_status_label.text()
|
|
||||||
assert "✓" in window.update_status_label.text()
|
|
||||||
|
|
||||||
def test_on_update_available_emits_signal(self, qtbot, sample_config):
|
|
||||||
"""Test _on_update_available shows dialog and updates status."""
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from webdrop_bridge.ui.main_window import MainWindow
|
|
||||||
|
|
||||||
window = MainWindow(sample_config)
|
|
||||||
qtbot.addWidget(window)
|
|
||||||
|
|
||||||
# Create mock release
|
|
||||||
mock_release = MagicMock()
|
|
||||||
mock_release.version = "0.0.2"
|
|
||||||
mock_release.body = "Bug fixes"
|
|
||||||
|
|
||||||
# Mock the dialog creation to avoid showing it
|
|
||||||
with patch('webdrop_bridge.ui.update_manager_ui.UpdateAvailableDialog'):
|
|
||||||
window._on_update_available(mock_release)
|
|
||||||
assert "0.0.2" in window.update_status_label.text()
|
|
||||||
|
|
||||||
def test_on_update_available_updates_status(self, qtbot, sample_config):
|
|
||||||
"""Test _on_update_available updates status bar."""
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from webdrop_bridge.ui.main_window import MainWindow
|
|
||||||
|
|
||||||
window = MainWindow(sample_config)
|
|
||||||
qtbot.addWidget(window)
|
|
||||||
|
|
||||||
# Create mock release
|
|
||||||
mock_release = MagicMock()
|
|
||||||
mock_release.version = "0.0.2"
|
|
||||||
mock_release.body = "Bug fixes"
|
|
||||||
|
|
||||||
# Mock the dialog creation to avoid showing it
|
|
||||||
with patch('webdrop_bridge.ui.update_manager_ui.UpdateAvailableDialog'):
|
|
||||||
window._on_update_available(mock_release)
|
|
||||||
assert "0.0.2" in window.update_status_label.text()
|
|
||||||
assert "✅" in window.update_status_label.text()
|
|
||||||
|
|
@ -1,223 +0,0 @@
|
||||||
"""Tests for the update manager UI dialogs."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from PySide6.QtCore import Qt
|
|
||||||
from PySide6.QtTest import QTest
|
|
||||||
from PySide6.QtWidgets import QApplication, QMessageBox
|
|
||||||
|
|
||||||
from webdrop_bridge.ui.update_manager_ui import (
|
|
||||||
CheckingDialog,
|
|
||||||
DownloadingDialog,
|
|
||||||
ErrorDialog,
|
|
||||||
InstallDialog,
|
|
||||||
NoUpdateDialog,
|
|
||||||
UpdateAvailableDialog,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def qapp(qapp):
|
|
||||||
"""Provide QApplication instance."""
|
|
||||||
return qapp
|
|
||||||
|
|
||||||
|
|
||||||
class TestCheckingDialog:
|
|
||||||
"""Tests for CheckingDialog."""
|
|
||||||
|
|
||||||
def test_dialog_creation(self, qapp):
|
|
||||||
"""Test dialog can be created."""
|
|
||||||
dialog = CheckingDialog()
|
|
||||||
assert dialog is not None
|
|
||||||
assert dialog.windowTitle() == "Checking for Updates"
|
|
||||||
|
|
||||||
def test_progress_bar_animated(self, qapp):
|
|
||||||
"""Test progress bar is animated (maximum = 0)."""
|
|
||||||
dialog = CheckingDialog()
|
|
||||||
assert dialog.progress.maximum() == 0
|
|
||||||
|
|
||||||
def test_dialog_modal(self, qapp):
|
|
||||||
"""Test dialog is modal."""
|
|
||||||
dialog = CheckingDialog()
|
|
||||||
assert dialog.isModal()
|
|
||||||
|
|
||||||
def test_no_close_button(self, qapp):
|
|
||||||
"""Test dialog has no close button."""
|
|
||||||
dialog = CheckingDialog()
|
|
||||||
# WindowCloseButtonHint should be removed
|
|
||||||
assert not (dialog.windowFlags() & Qt.WindowType.WindowCloseButtonHint)
|
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateAvailableDialog:
|
|
||||||
"""Tests for UpdateAvailableDialog."""
|
|
||||||
|
|
||||||
def test_dialog_creation(self, qapp):
|
|
||||||
"""Test dialog can be created."""
|
|
||||||
dialog = UpdateAvailableDialog("0.0.2", "## Changes\n- Bug fixes")
|
|
||||||
assert dialog is not None
|
|
||||||
assert dialog.windowTitle() == "Update Available"
|
|
||||||
|
|
||||||
def test_version_displayed(self, qapp):
|
|
||||||
"""Test version is displayed in dialog."""
|
|
||||||
dialog = UpdateAvailableDialog("0.0.2", "## Changes")
|
|
||||||
# The version should be in the dialog
|
|
||||||
assert dialog is not None
|
|
||||||
|
|
||||||
def test_changelog_displayed(self, qapp):
|
|
||||||
"""Test changelog is displayed."""
|
|
||||||
changelog = "## Changes\n- Bug fixes\n- New features"
|
|
||||||
dialog = UpdateAvailableDialog("0.0.2", changelog)
|
|
||||||
assert dialog.changelog.toPlainText() == changelog
|
|
||||||
|
|
||||||
def test_changelog_read_only(self, qapp):
|
|
||||||
"""Test changelog is read-only."""
|
|
||||||
dialog = UpdateAvailableDialog("0.0.2", "Changes")
|
|
||||||
assert dialog.changelog.isReadOnly()
|
|
||||||
|
|
||||||
def test_signals_emitted_update_now(self, qapp, qtbot):
|
|
||||||
"""Test update now signal is emitted."""
|
|
||||||
dialog = UpdateAvailableDialog("0.0.2", "Changes")
|
|
||||||
|
|
||||||
with qtbot.waitSignal(dialog.update_now):
|
|
||||||
dialog.update_now_btn.click()
|
|
||||||
|
|
||||||
def test_signals_emitted_update_later(self, qapp, qtbot):
|
|
||||||
"""Test update later signal is emitted."""
|
|
||||||
dialog = UpdateAvailableDialog("0.0.2", "Changes")
|
|
||||||
|
|
||||||
with qtbot.waitSignal(dialog.update_later):
|
|
||||||
dialog.update_later_btn.click()
|
|
||||||
|
|
||||||
def test_signals_emitted_skip(self, qapp, qtbot):
|
|
||||||
"""Test skip version signal is emitted."""
|
|
||||||
dialog = UpdateAvailableDialog("0.0.2", "Changes")
|
|
||||||
|
|
||||||
with qtbot.waitSignal(dialog.skip_version):
|
|
||||||
dialog.skip_btn.click()
|
|
||||||
|
|
||||||
|
|
||||||
class TestDownloadingDialog:
|
|
||||||
"""Tests for DownloadingDialog."""
|
|
||||||
|
|
||||||
def test_dialog_creation(self, qapp):
|
|
||||||
"""Test dialog can be created."""
|
|
||||||
dialog = DownloadingDialog()
|
|
||||||
assert dialog is not None
|
|
||||||
assert dialog.windowTitle() == "Downloading Update"
|
|
||||||
|
|
||||||
def test_progress_bar_initialized(self, qapp):
|
|
||||||
"""Test progress bar is initialized correctly."""
|
|
||||||
dialog = DownloadingDialog()
|
|
||||||
assert dialog.progress.minimum() == 0
|
|
||||||
assert dialog.progress.maximum() == 100
|
|
||||||
assert dialog.progress.value() == 0
|
|
||||||
|
|
||||||
def test_set_progress(self, qapp):
|
|
||||||
"""Test progress can be updated."""
|
|
||||||
dialog = DownloadingDialog()
|
|
||||||
dialog.set_progress(50, 100)
|
|
||||||
assert dialog.progress.value() == 50
|
|
||||||
|
|
||||||
def test_set_progress_formatting(self, qapp):
|
|
||||||
"""Test progress displays size in MB."""
|
|
||||||
dialog = DownloadingDialog()
|
|
||||||
# 10 MB of 100 MB
|
|
||||||
dialog.set_progress(10 * 1024 * 1024, 100 * 1024 * 1024)
|
|
||||||
assert "10.0 MB" in dialog.size_label.text()
|
|
||||||
assert "100.0 MB" in dialog.size_label.text()
|
|
||||||
|
|
||||||
def test_set_filename(self, qapp):
|
|
||||||
"""Test filename can be set."""
|
|
||||||
dialog = DownloadingDialog()
|
|
||||||
dialog.set_filename("WebDropBridge.msi")
|
|
||||||
assert "WebDropBridge.msi" in dialog.file_label.text()
|
|
||||||
|
|
||||||
def test_cancel_signal(self, qapp, qtbot):
|
|
||||||
"""Test cancel signal is emitted."""
|
|
||||||
dialog = DownloadingDialog()
|
|
||||||
|
|
||||||
with qtbot.waitSignal(dialog.cancel_download):
|
|
||||||
dialog.cancel_btn.click()
|
|
||||||
|
|
||||||
def test_no_close_button(self, qapp):
|
|
||||||
"""Test dialog has no close button."""
|
|
||||||
dialog = DownloadingDialog()
|
|
||||||
assert not (dialog.windowFlags() & Qt.WindowType.WindowCloseButtonHint)
|
|
||||||
|
|
||||||
|
|
||||||
class TestInstallDialog:
|
|
||||||
"""Tests for InstallDialog."""
|
|
||||||
|
|
||||||
def test_dialog_creation(self, qapp):
|
|
||||||
"""Test dialog can be created."""
|
|
||||||
dialog = InstallDialog()
|
|
||||||
assert dialog is not None
|
|
||||||
assert dialog.windowTitle() == "Install Update"
|
|
||||||
|
|
||||||
def test_install_signal(self, qapp, qtbot):
|
|
||||||
"""Test install signal is emitted."""
|
|
||||||
dialog = InstallDialog()
|
|
||||||
|
|
||||||
with qtbot.waitSignal(dialog.install_now):
|
|
||||||
dialog.install_btn.click()
|
|
||||||
|
|
||||||
def test_cancel_button(self, qapp):
|
|
||||||
"""Test cancel button exists."""
|
|
||||||
dialog = InstallDialog()
|
|
||||||
assert dialog.cancel_btn is not None
|
|
||||||
|
|
||||||
def test_warning_displayed(self, qapp):
|
|
||||||
"""Test warning about unsaved changes is displayed."""
|
|
||||||
dialog = InstallDialog()
|
|
||||||
# Warning should be in the dialog
|
|
||||||
assert dialog is not None
|
|
||||||
|
|
||||||
|
|
||||||
class TestNoUpdateDialog:
|
|
||||||
"""Tests for NoUpdateDialog."""
|
|
||||||
|
|
||||||
def test_dialog_creation(self, qapp):
|
|
||||||
"""Test dialog can be created."""
|
|
||||||
dialog = NoUpdateDialog()
|
|
||||||
assert dialog is not None
|
|
||||||
assert dialog.windowTitle() == "No Updates Available"
|
|
||||||
|
|
||||||
def test_dialog_modal(self, qapp):
|
|
||||||
"""Test dialog is modal."""
|
|
||||||
dialog = NoUpdateDialog()
|
|
||||||
assert dialog.isModal()
|
|
||||||
|
|
||||||
|
|
||||||
class TestErrorDialog:
|
|
||||||
"""Tests for ErrorDialog."""
|
|
||||||
|
|
||||||
def test_dialog_creation(self, qapp):
|
|
||||||
"""Test dialog can be created."""
|
|
||||||
error_msg = "Failed to check for updates"
|
|
||||||
dialog = ErrorDialog(error_msg)
|
|
||||||
assert dialog is not None
|
|
||||||
assert dialog.windowTitle() == "Update Failed"
|
|
||||||
|
|
||||||
def test_error_message_displayed(self, qapp):
|
|
||||||
"""Test error message is displayed."""
|
|
||||||
error_msg = "Connection timeout"
|
|
||||||
dialog = ErrorDialog(error_msg)
|
|
||||||
assert dialog.error_text.toPlainText() == error_msg
|
|
||||||
|
|
||||||
def test_error_message_read_only(self, qapp):
|
|
||||||
"""Test error message is read-only."""
|
|
||||||
dialog = ErrorDialog("Error")
|
|
||||||
assert dialog.error_text.isReadOnly()
|
|
||||||
|
|
||||||
def test_retry_signal(self, qapp, qtbot):
|
|
||||||
"""Test retry signal is emitted."""
|
|
||||||
dialog = ErrorDialog("Error")
|
|
||||||
|
|
||||||
with qtbot.waitSignal(dialog.retry):
|
|
||||||
dialog.retry_btn.click()
|
|
||||||
|
|
||||||
def test_manual_download_signal(self, qapp, qtbot):
|
|
||||||
"""Test manual download signal is emitted."""
|
|
||||||
dialog = ErrorDialog("Error")
|
|
||||||
|
|
||||||
with qtbot.waitSignal(dialog.manual_download):
|
|
||||||
dialog.manual_btn.click()
|
|
||||||
|
|
@ -1,370 +0,0 @@
|
||||||
"""Tests for the UpdateManager auto-update system."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from webdrop_bridge.core.updater import Release, UpdateManager
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def update_manager(tmp_path):
|
|
||||||
"""Create UpdateManager instance with temp directory."""
|
|
||||||
return UpdateManager(current_version="0.0.1", config_dir=tmp_path)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_release():
|
|
||||||
"""Sample release data from API."""
|
|
||||||
return {
|
|
||||||
"tag_name": "v0.0.2",
|
|
||||||
"name": "WebDropBridge v0.0.2",
|
|
||||||
"version": "0.0.2",
|
|
||||||
"body": "## Changes\n- Bug fixes",
|
|
||||||
"assets": [
|
|
||||||
{
|
|
||||||
"name": "WebDropBridge.exe",
|
|
||||||
"browser_download_url": "https://example.com/WebDropBridge.exe",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "WebDropBridge.exe.sha256",
|
|
||||||
"browser_download_url": "https://example.com/WebDropBridge.exe.sha256",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"published_at": "2026-01-29T10:00:00Z",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestVersionParsing:
|
|
||||||
"""Test semantic version parsing."""
|
|
||||||
|
|
||||||
def test_parse_valid_version(self, update_manager):
|
|
||||||
"""Test parsing valid version string."""
|
|
||||||
assert update_manager._parse_version("1.2.3") == (1, 2, 3)
|
|
||||||
assert update_manager._parse_version("v1.2.3") == (1, 2, 3)
|
|
||||||
assert update_manager._parse_version("0.0.1") == (0, 0, 1)
|
|
||||||
|
|
||||||
def test_parse_invalid_version(self, update_manager):
|
|
||||||
"""Test parsing invalid version raises error."""
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
update_manager._parse_version("1.2") # Too few parts
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
update_manager._parse_version("a.b.c") # Non-numeric
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
update_manager._parse_version("") # Empty string
|
|
||||||
|
|
||||||
def test_is_newer_version_true(self, update_manager):
|
|
||||||
"""Test version comparison when newer version exists."""
|
|
||||||
assert update_manager._is_newer_version("0.0.2")
|
|
||||||
assert update_manager._is_newer_version("0.1.0")
|
|
||||||
assert update_manager._is_newer_version("1.0.0")
|
|
||||||
|
|
||||||
def test_is_newer_version_false(self, update_manager):
|
|
||||||
"""Test version comparison when version is not newer."""
|
|
||||||
assert not update_manager._is_newer_version("0.0.1") # Same
|
|
||||||
assert not update_manager._is_newer_version("0.0.0") # Older
|
|
||||||
|
|
||||||
|
|
||||||
class TestCaching:
|
|
||||||
"""Test update cache management."""
|
|
||||||
|
|
||||||
def test_save_and_load_cache(self, update_manager, sample_release):
|
|
||||||
"""Test saving and loading cache."""
|
|
||||||
# Save cache
|
|
||||||
update_manager._save_cache(sample_release)
|
|
||||||
assert update_manager.cache_file.exists()
|
|
||||||
|
|
||||||
# Load cache
|
|
||||||
cached = update_manager._load_cache()
|
|
||||||
assert cached is not None
|
|
||||||
assert cached["release"]["tag_name"] == "v0.0.2"
|
|
||||||
|
|
||||||
def test_cache_expiration(self, update_manager, sample_release):
|
|
||||||
"""Test cache expiration after TTL."""
|
|
||||||
# Save cache
|
|
||||||
update_manager._save_cache(sample_release)
|
|
||||||
|
|
||||||
# Manually set old timestamp
|
|
||||||
with open(update_manager.cache_file) as f:
|
|
||||||
cache_data = json.load(f)
|
|
||||||
|
|
||||||
cache_data["timestamp"] = "2020-01-01T00:00:00"
|
|
||||||
|
|
||||||
with open(update_manager.cache_file, "w") as f:
|
|
||||||
json.dump(cache_data, f)
|
|
||||||
|
|
||||||
# Cache should be expired
|
|
||||||
cached = update_manager._load_cache()
|
|
||||||
assert cached is None
|
|
||||||
assert not update_manager.cache_file.exists()
|
|
||||||
|
|
||||||
def test_corrupted_cache_cleanup(self, update_manager):
|
|
||||||
"""Test corrupted cache is cleaned up."""
|
|
||||||
# Write invalid JSON
|
|
||||||
update_manager.cache_file.write_text("invalid json")
|
|
||||||
|
|
||||||
# Attempt to load
|
|
||||||
cached = update_manager._load_cache()
|
|
||||||
assert cached is None
|
|
||||||
assert not update_manager.cache_file.exists()
|
|
||||||
|
|
||||||
|
|
||||||
class TestFetching:
|
|
||||||
"""Test API fetching."""
|
|
||||||
|
|
||||||
@patch("webdrop_bridge.core.updater.urlopen")
|
|
||||||
def test_fetch_release_success(self, mock_urlopen, update_manager):
|
|
||||||
"""Test successful release fetch."""
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.read.return_value = json.dumps(
|
|
||||||
{
|
|
||||||
"tag_name": "v0.0.2",
|
|
||||||
"name": "WebDropBridge v0.0.2",
|
|
||||||
"body": "Release notes",
|
|
||||||
"assets": [],
|
|
||||||
"published_at": "2026-01-29T10:00:00Z",
|
|
||||||
}
|
|
||||||
).encode()
|
|
||||||
mock_response.__enter__.return_value = mock_response
|
|
||||||
mock_urlopen.return_value = mock_response
|
|
||||||
|
|
||||||
result = update_manager._fetch_release()
|
|
||||||
assert result is not None
|
|
||||||
assert result["tag_name"] == "v0.0.2"
|
|
||||||
assert result["version"] == "0.0.2"
|
|
||||||
|
|
||||||
@patch("webdrop_bridge.core.updater.urlopen")
|
|
||||||
def test_fetch_release_network_error(self, mock_urlopen, update_manager):
|
|
||||||
"""Test fetch handles network errors."""
|
|
||||||
from urllib.error import URLError
|
|
||||||
|
|
||||||
mock_urlopen.side_effect = URLError("Connection failed")
|
|
||||||
|
|
||||||
result = update_manager._fetch_release()
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestCheckForUpdates:
|
|
||||||
"""Test checking for updates."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch.object(UpdateManager, "_fetch_release")
|
|
||||||
async def test_check_for_updates_newer_available(
|
|
||||||
self, mock_fetch, update_manager, sample_release
|
|
||||||
):
|
|
||||||
"""Test detecting available update."""
|
|
||||||
mock_fetch.return_value = sample_release
|
|
||||||
|
|
||||||
release = await update_manager.check_for_updates()
|
|
||||||
assert release is not None
|
|
||||||
assert release.version == "0.0.2"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch.object(UpdateManager, "_fetch_release")
|
|
||||||
async def test_check_for_updates_no_update(
|
|
||||||
self, mock_fetch, update_manager
|
|
||||||
):
|
|
||||||
"""Test no update available."""
|
|
||||||
mock_fetch.return_value = {
|
|
||||||
"tag_name": "v0.0.1",
|
|
||||||
"name": "WebDropBridge v0.0.1",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"body": "",
|
|
||||||
"assets": [],
|
|
||||||
"published_at": "2026-01-29T10:00:00Z",
|
|
||||||
}
|
|
||||||
|
|
||||||
release = await update_manager.check_for_updates()
|
|
||||||
assert release is None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch.object(UpdateManager, "_fetch_release")
|
|
||||||
async def test_check_for_updates_uses_cache(
|
|
||||||
self, mock_fetch, update_manager, sample_release
|
|
||||||
):
|
|
||||||
"""Test cache is used on subsequent calls."""
|
|
||||||
mock_fetch.return_value = sample_release
|
|
||||||
|
|
||||||
# First call
|
|
||||||
release1 = await update_manager.check_for_updates()
|
|
||||||
assert release1 is not None
|
|
||||||
|
|
||||||
# Second call should use cache (reset mock)
|
|
||||||
mock_fetch.reset_mock()
|
|
||||||
release2 = await update_manager.check_for_updates()
|
|
||||||
|
|
||||||
# Fetch should not be called again
|
|
||||||
mock_fetch.assert_not_called()
|
|
||||||
assert release2 is not None # Cache returns same release
|
|
||||||
|
|
||||||
|
|
||||||
class TestDownloading:
|
|
||||||
"""Test update downloading."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_download_update_success(
|
|
||||||
self, update_manager, tmp_path
|
|
||||||
):
|
|
||||||
"""Test successful update download."""
|
|
||||||
# Create release with .msi asset
|
|
||||||
release_data = {
|
|
||||||
"tag_name": "v0.0.2",
|
|
||||||
"name": "WebDropBridge v0.0.2",
|
|
||||||
"version": "0.0.2",
|
|
||||||
"body": "Release notes",
|
|
||||||
"assets": [
|
|
||||||
{
|
|
||||||
"name": "WebDropBridge-1.0.0-Setup.msi",
|
|
||||||
"browser_download_url": "https://example.com/WebDropBridge.msi",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"published_at": "2026-01-29T10:00:00Z",
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch.object(UpdateManager, "_download_file") as mock_download:
|
|
||||||
mock_download.return_value = True
|
|
||||||
|
|
||||||
release = Release(**release_data)
|
|
||||||
result = await update_manager.download_update(release, tmp_path)
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert result.name == "WebDropBridge-1.0.0-Setup.msi"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch.object(UpdateManager, "_download_file")
|
|
||||||
async def test_download_update_no_installer(
|
|
||||||
self, mock_download, update_manager
|
|
||||||
):
|
|
||||||
"""Test download fails when no installer in release."""
|
|
||||||
release_data = {
|
|
||||||
"tag_name": "v0.0.2",
|
|
||||||
"name": "Test",
|
|
||||||
"version": "0.0.2",
|
|
||||||
"body": "",
|
|
||||||
"assets": [
|
|
||||||
{
|
|
||||||
"name": "README.txt",
|
|
||||||
"browser_download_url": "https://example.com/README.txt",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"published_at": "2026-01-29T10:00:00Z",
|
|
||||||
}
|
|
||||||
|
|
||||||
release = Release(**release_data)
|
|
||||||
result = await update_manager.download_update(release)
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestChecksumVerification:
|
|
||||||
"""Test checksum verification."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch.object(UpdateManager, "_download_checksum")
|
|
||||||
async def test_verify_checksum_success(
|
|
||||||
self, mock_download_checksum, update_manager, sample_release, tmp_path
|
|
||||||
):
|
|
||||||
"""Test successful checksum verification."""
|
|
||||||
# Create test file
|
|
||||||
test_file = tmp_path / "test.exe"
|
|
||||||
test_file.write_bytes(b"test content")
|
|
||||||
|
|
||||||
# Calculate actual checksum
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
sha256 = hashlib.sha256(b"test content").hexdigest()
|
|
||||||
mock_download_checksum.return_value = sha256
|
|
||||||
|
|
||||||
release = Release(**sample_release)
|
|
||||||
result = await update_manager.verify_checksum(test_file, release)
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch.object(UpdateManager, "_download_checksum")
|
|
||||||
async def test_verify_checksum_mismatch(
|
|
||||||
self, mock_download_checksum, update_manager, sample_release, tmp_path
|
|
||||||
):
|
|
||||||
"""Test checksum verification fails on mismatch."""
|
|
||||||
test_file = tmp_path / "test.exe"
|
|
||||||
test_file.write_bytes(b"test content")
|
|
||||||
|
|
||||||
# Return wrong checksum
|
|
||||||
mock_download_checksum.return_value = "0" * 64
|
|
||||||
|
|
||||||
release = Release(**sample_release)
|
|
||||||
result = await update_manager.verify_checksum(test_file, release)
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_verify_checksum_no_checksum_file(
|
|
||||||
self, update_manager, tmp_path
|
|
||||||
):
|
|
||||||
"""Test verification skipped when no checksum file in release."""
|
|
||||||
test_file = tmp_path / "test.exe"
|
|
||||||
test_file.write_bytes(b"test content")
|
|
||||||
|
|
||||||
release_data = {
|
|
||||||
"tag_name": "v0.0.2",
|
|
||||||
"name": "Test",
|
|
||||||
"version": "0.0.2",
|
|
||||||
"body": "",
|
|
||||||
"assets": [
|
|
||||||
{
|
|
||||||
"name": "WebDropBridge.exe",
|
|
||||||
"browser_download_url": "https://example.com/WebDropBridge.exe",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"published_at": "2026-01-29T10:00:00Z",
|
|
||||||
}
|
|
||||||
|
|
||||||
release = Release(**release_data)
|
|
||||||
result = await update_manager.verify_checksum(test_file, release)
|
|
||||||
|
|
||||||
# Should return True (skip verification)
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestInstallation:
|
|
||||||
"""Test update installation."""
|
|
||||||
|
|
||||||
@patch("subprocess.Popen")
|
|
||||||
@patch("platform.system")
|
|
||||||
def test_install_update_windows(
|
|
||||||
self, mock_platform, mock_popen, update_manager, tmp_path
|
|
||||||
):
|
|
||||||
"""Test installation on Windows."""
|
|
||||||
mock_platform.return_value = "Windows"
|
|
||||||
installer = tmp_path / "WebDropBridge.msi"
|
|
||||||
installer.touch()
|
|
||||||
|
|
||||||
result = update_manager.install_update(installer)
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
mock_popen.assert_called_once()
|
|
||||||
|
|
||||||
@patch("subprocess.Popen")
|
|
||||||
@patch("platform.system")
|
|
||||||
def test_install_update_macos(
|
|
||||||
self, mock_platform, mock_popen, update_manager, tmp_path
|
|
||||||
):
|
|
||||||
"""Test installation on macOS."""
|
|
||||||
mock_platform.return_value = "Darwin"
|
|
||||||
installer = tmp_path / "WebDropBridge.dmg"
|
|
||||||
installer.touch()
|
|
||||||
|
|
||||||
result = update_manager.install_update(installer)
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
mock_popen.assert_called_once_with(["open", str(installer)])
|
|
||||||
|
|
||||||
def test_install_update_file_not_found(self, update_manager):
|
|
||||||
"""Test installation fails when file not found."""
|
|
||||||
result = update_manager.install_update(Path("/nonexistent/file.msi"))
|
|
||||||
assert result is False
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
"""Quick verification that the update hang fix is in place."""
|
|
||||||
|
|
||||||
import inspect
|
|
||||||
|
|
||||||
from webdrop_bridge.ui.main_window import MainWindow, UpdateDownloadWorker
|
|
||||||
|
|
||||||
print("\n" + "="*70)
|
|
||||||
print("VERIFICATION: Update Feature Hang Fix")
|
|
||||||
print("="*70 + "\n")
|
|
||||||
|
|
||||||
# Check 1: UpdateDownloadWorker exists
|
|
||||||
print("✓ UpdateDownloadWorker class exists")
|
|
||||||
print(f" - Location: {inspect.getfile(UpdateDownloadWorker)}")
|
|
||||||
|
|
||||||
# Check 2: Verify signals are defined
|
|
||||||
signals = ['download_complete', 'download_failed', 'update_status', 'finished']
|
|
||||||
print(f"\n✓ UpdateDownloadWorker has required signals:")
|
|
||||||
for sig in signals:
|
|
||||||
assert hasattr(UpdateDownloadWorker, sig)
|
|
||||||
print(f" - {sig}")
|
|
||||||
|
|
||||||
# Check 3: Verify run method exists
|
|
||||||
assert hasattr(UpdateDownloadWorker, 'run')
|
|
||||||
print(f"\n✓ UpdateDownloadWorker.run() method exists")
|
|
||||||
|
|
||||||
# Check 4: Verify MainWindow uses async download
|
|
||||||
print(f"\n✓ MainWindow changes:")
|
|
||||||
assert hasattr(MainWindow, '_perform_update_async')
|
|
||||||
print(f" - Has _perform_update_async() method (new async version)")
|
|
||||||
assert hasattr(MainWindow, '_on_download_complete')
|
|
||||||
print(f" - Has _on_download_complete() handler")
|
|
||||||
assert hasattr(MainWindow, '_on_download_failed')
|
|
||||||
print(f" - Has _on_download_failed() handler")
|
|
||||||
assert not hasattr(MainWindow, '_perform_update')
|
|
||||||
print(f" - Old blocking _perform_update() method removed")
|
|
||||||
|
|
||||||
# Check 5: Verify the fix: Look at _perform_update_async source
|
|
||||||
source = inspect.getsource(MainWindow._perform_update_async)
|
|
||||||
assert 'QThread()' in source
|
|
||||||
print(f"\n✓ _perform_update_async uses background thread:")
|
|
||||||
assert 'UpdateDownloadWorker' in source
|
|
||||||
print(f" - Creates UpdateDownloadWorker")
|
|
||||||
assert 'worker.moveToThread(thread)' in source
|
|
||||||
print(f" - Moves worker to background thread")
|
|
||||||
assert 'thread.start()' in source
|
|
||||||
print(f" - Starts the thread")
|
|
||||||
|
|
||||||
print("\n" + "="*70)
|
|
||||||
print("✅ VERIFICATION SUCCESSFUL!")
|
|
||||||
print("="*70)
|
|
||||||
print("\nFIX SUMMARY:")
|
|
||||||
print("-" * 70)
|
|
||||||
print("""
|
|
||||||
The update feature hang issue has been fixed by:
|
|
||||||
|
|
||||||
1. Created UpdateDownloadWorker class that runs async operations in a
|
|
||||||
background thread (instead of blocking the UI thread).
|
|
||||||
|
|
||||||
2. The worker properly handles:
|
|
||||||
- Downloading the update asynchronously
|
|
||||||
- Verifying checksums asynchronously
|
|
||||||
- Emitting signals for UI updates
|
|
||||||
|
|
||||||
3. MainWindow's _perform_update_async() method now:
|
|
||||||
- Creates a background thread for the worker
|
|
||||||
- Connects signals for download complete/failure handlers
|
|
||||||
- Keeps a reference to prevent garbage collection
|
|
||||||
- Properly cleans up threads after completion
|
|
||||||
|
|
||||||
Result: The update dialog now displays without freezing the application!
|
|
||||||
The user can interact with the UI while the download happens.
|
|
||||||
""")
|
|
||||||
print("-" * 70 + "\n")
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
"""Verify timeout and error handling in update feature."""
|
|
||||||
|
|
||||||
import inspect
|
|
||||||
|
|
||||||
from webdrop_bridge.core.updater import UpdateManager
|
|
||||||
from webdrop_bridge.ui.main_window import UpdateCheckWorker, UpdateDownloadWorker
|
|
||||||
|
|
||||||
print("\n" + "="*70)
|
|
||||||
print("TIMEOUT AND ERROR HANDLING VERIFICATION")
|
|
||||||
print("="*70 + "\n")
|
|
||||||
|
|
||||||
print("Test 1: UpdateCheckWorker timeout handling")
|
|
||||||
print("-" * 70)
|
|
||||||
|
|
||||||
# Check UpdateCheckWorker source for asyncio.wait_for
|
|
||||||
source = inspect.getsource(UpdateCheckWorker.run)
|
|
||||||
if "asyncio.wait_for" in source and "timeout=15" in source:
|
|
||||||
print("✓ UpdateCheckWorker has 15-second timeout")
|
|
||||||
print(" await asyncio.wait_for(..., timeout=15)")
|
|
||||||
else:
|
|
||||||
print("❌ Missing timeout in UpdateCheckWorker")
|
|
||||||
|
|
||||||
if "asyncio.TimeoutError" in source:
|
|
||||||
print("✓ Handles asyncio.TimeoutError exception")
|
|
||||||
else:
|
|
||||||
print("❌ Missing TimeoutError handling")
|
|
||||||
|
|
||||||
if "loop.close()" in source:
|
|
||||||
print("✓ Properly closes event loop in finally block")
|
|
||||||
else:
|
|
||||||
print("❌ Missing loop.close() cleanup")
|
|
||||||
|
|
||||||
print("\nTest 2: UpdateDownloadWorker timeout handling")
|
|
||||||
print("-" * 70)
|
|
||||||
|
|
||||||
source = inspect.getsource(UpdateDownloadWorker.run)
|
|
||||||
if "asyncio.wait_for" in source:
|
|
||||||
print("✓ UpdateDownloadWorker uses asyncio.wait_for")
|
|
||||||
if "timeout=300" in source:
|
|
||||||
print(" → Download timeout: 300 seconds (5 minutes)")
|
|
||||||
if "timeout=30" in source:
|
|
||||||
print(" → Verification timeout: 30 seconds")
|
|
||||||
else:
|
|
||||||
print("❌ Missing timeout in UpdateDownloadWorker")
|
|
||||||
|
|
||||||
if "asyncio.TimeoutError" in source:
|
|
||||||
print("✓ Handles asyncio.TimeoutError exception")
|
|
||||||
if "Operation timed out" in source:
|
|
||||||
print(" → Shows 'Operation timed out' message")
|
|
||||||
else:
|
|
||||||
print("❌ Missing TimeoutError handling")
|
|
||||||
|
|
||||||
if "loop.close()" in source:
|
|
||||||
print("✓ Properly closes event loop in finally block")
|
|
||||||
else:
|
|
||||||
print("❌ Missing loop.close() cleanup")
|
|
||||||
|
|
||||||
print("\nTest 3: UpdateManager timeout handling")
|
|
||||||
print("-" * 70)
|
|
||||||
|
|
||||||
source = inspect.getsource(UpdateManager.check_for_updates)
|
|
||||||
if "asyncio.wait_for" in source:
|
|
||||||
print("✓ check_for_updates has timeout")
|
|
||||||
if "timeout=10" in source:
|
|
||||||
print(" → API check timeout: 10 seconds")
|
|
||||||
else:
|
|
||||||
print("❌ Missing timeout in check_for_updates")
|
|
||||||
|
|
||||||
if "asyncio.TimeoutError" in source:
|
|
||||||
print("✓ Handles asyncio.TimeoutError")
|
|
||||||
if "timed out" in source or "timeout" in source.lower():
|
|
||||||
print(" → Logs timeout message")
|
|
||||||
else:
|
|
||||||
print("❌ Missing TimeoutError handling")
|
|
||||||
|
|
||||||
# Check download_update timeout
|
|
||||||
source = inspect.getsource(UpdateManager.download_update)
|
|
||||||
if "asyncio.wait_for" in source:
|
|
||||||
print("\n✓ download_update has timeout")
|
|
||||||
if "timeout=300" in source:
|
|
||||||
print(" → Download timeout: 300 seconds (5 minutes)")
|
|
||||||
else:
|
|
||||||
print("❌ Missing timeout in download_update")
|
|
||||||
|
|
||||||
# Check verify_checksum timeout
|
|
||||||
source = inspect.getsource(UpdateManager.verify_checksum)
|
|
||||||
if "asyncio.wait_for" in source:
|
|
||||||
print("✓ verify_checksum has timeout")
|
|
||||||
if "timeout=30" in source:
|
|
||||||
print(" → Checksum verification timeout: 30 seconds")
|
|
||||||
else:
|
|
||||||
print("❌ Missing timeout in verify_checksum")
|
|
||||||
|
|
||||||
print("\n" + "="*70)
|
|
||||||
print("✅ TIMEOUT HANDLING PROPERLY IMPLEMENTED!")
|
|
||||||
print("="*70)
|
|
||||||
print("\nSummary of timeout protection:")
|
|
||||||
print(" • Update check: 15 seconds")
|
|
||||||
print(" • API fetch: 10 seconds")
|
|
||||||
print(" • Download: 5 minutes (300 seconds)")
|
|
||||||
print(" • Checksum verification: 30 seconds")
|
|
||||||
print("\nWhen timeouts occur:")
|
|
||||||
print(" • User-friendly error message is shown")
|
|
||||||
print(" • Event loops are properly closed")
|
|
||||||
print(" • Application doesn't hang indefinitely")
|
|
||||||
print(" • User can retry or cancel the operation")
|
|
||||||
print("="*70 + "\n")
|
|
||||||
|
|
@ -163,13 +163,13 @@
|
||||||
<div class="drag-item" draggable="true" id="dragItem1">
|
<div class="drag-item" draggable="true" id="dragItem1">
|
||||||
<div class="icon">🖼️</div>
|
<div class="icon">🖼️</div>
|
||||||
<h3>Sample Image</h3>
|
<h3>Sample Image</h3>
|
||||||
<p id="path1">Z:\data\test-image.jpg</p>
|
<p id="path1">Z:\samples\image.psd</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="drag-item" draggable="true" id="dragItem2">
|
<div class="drag-item" draggable="true" id="dragItem2">
|
||||||
<div class="icon">📄</div>
|
<div class="icon">📄</div>
|
||||||
<h3>Sample Document</h3>
|
<h3>Sample Document</h3>
|
||||||
<p id="path2">Z:\data\API_DOCUMENTATION.pdf</p>
|
<p id="path2">Z:\samples\document.indd</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="drag-item" draggable="true" id="dragItem3">
|
<div class="drag-item" draggable="true" id="dragItem3">
|
||||||
|
|
@ -193,5 +193,57 @@
|
||||||
<p>WebDrop Bridge v1.0.0 | Built with Qt and PySide6</p>
|
<p>WebDrop Bridge v1.0.0 | Built with Qt and PySide6</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const items = document.querySelectorAll('.drag-item');
|
||||||
|
const statusMessage = document.getElementById('statusMessage');
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
item.addEventListener('dragstart', (e) => {
|
||||||
|
const pathElement = item.querySelector('p');
|
||||||
|
const path = pathElement.textContent.trim();
|
||||||
|
|
||||||
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
|
e.dataTransfer.setData('text/plain', path);
|
||||||
|
|
||||||
|
statusMessage.textContent = `Dragging: ${path}`;
|
||||||
|
statusMessage.className = 'status-message info';
|
||||||
|
|
||||||
|
console.log('🚀 Drag started:', path);
|
||||||
|
console.log('📋 DataTransfer types:', e.dataTransfer.types);
|
||||||
|
});
|
||||||
|
|
||||||
|
item.addEventListener('dragend', (e) => {
|
||||||
|
const pathElement = item.querySelector('p');
|
||||||
|
const path = pathElement.textContent.trim();
|
||||||
|
|
||||||
|
if (e.dataTransfer.dropEffect === 'none') {
|
||||||
|
statusMessage.textContent = `❌ Drop failed or cancelled`;
|
||||||
|
statusMessage.className = 'status-message info';
|
||||||
|
} else {
|
||||||
|
statusMessage.textContent = `✅ Drop completed: ${e.dataTransfer.dropEffect}`;
|
||||||
|
statusMessage.className = 'status-message success';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🏁 Drag ended:', e.dataTransfer.dropEffect);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Visual feedback
|
||||||
|
item.addEventListener('dragstart', () => {
|
||||||
|
item.style.opacity = '0.5';
|
||||||
|
item.style.transform = 'scale(0.95)';
|
||||||
|
});
|
||||||
|
|
||||||
|
item.addEventListener('dragend', () => {
|
||||||
|
item.style.opacity = '1';
|
||||||
|
item.style.transform = 'scale(1)';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Application info
|
||||||
|
console.log('%cWebDrop Bridge', 'font-size: 18px; font-weight: bold; color: #667eea;');
|
||||||
|
console.log('Ready for testing. Drag items to other applications.');
|
||||||
|
console.log('Check the path values in the drag items above.');
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue