diff --git a/.env.example b/.env.example
index 418a2f1..2b750df 100644
--- a/.env.example
+++ b/.env.example
@@ -2,7 +2,8 @@
# Application
APP_NAME=WebDrop Bridge
-APP_VERSION=0.1.0
+APP_VERSION=1.0.0
+APP_ENV=development
# Web App
WEBAPP_URL=file:///./webapp/index.html
@@ -11,13 +12,15 @@ WEBAPP_URL=file:///./webapp/index.html
# Logging
LOG_LEVEL=DEBUG
LOG_FILE=logs/webdrop_bridge.log
-ENABLE_LOGGING=true
# Security - Path Whitelist
ALLOWED_ROOTS=Z:/,C:/Users/Public
-ALLOWED_URLS=
# UI
WINDOW_WIDTH=1024
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
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 9ff940c..28404fa 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -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/core/validator.py` | Path validation and security |
| `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 |
| `tests/` | Pytest-based test suite |
| `pyproject.toml` | Modern Python packaging |
@@ -37,11 +36,11 @@ WebDrop Bridge is a professional Qt-based desktop application that converts web-
```python
def validate_path(path: Path, allowed_roots: List[Path]) -> bool:
"""Validate path against allowed roots.
-
+
Args:
path: File path to validate
allowed_roots: List of allowed root directories
-
+
Returns:
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`
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
```bash
-# Setup (one-time)
+# Setup
pip install -r requirements-dev.txt
-# Testing (uses .venv automatically)
+# Testing
pytest tests -v
pytest tests --cov=src/webdrop_bridge --cov-report=html
-# Quality checks
+# Quality
tox -e lint # Ruff + Black checks
tox -e type # mypy type checking
tox -e format # Auto-format code
@@ -103,12 +96,6 @@ bash build/scripts/build_macos.sh # macOS
- `LocalContentCanAccessFileUrls`: True (required for drag)
- `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
- Use PySide6 APIs that work on both Windows and macOS
- 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_drag_interceptor.py
-# Integration tests: Component interaction and update flow
+# Integration tests: Component interaction
tests/integration/test_drag_workflow.py
tests/integration/test_end_to_end.py
-tests/integration/test_update_flow.py
# Fixtures: Reusable test data
tests/conftest.py
@@ -144,7 +130,6 @@ Target: 80%+ code coverage
- **Public APIs**: Docstrings required
- **Modules**: Add docstring at top of file
- **Features**: Update README.md and docs/
-- **Integration tests**: Reference and document in README.md and docs/ARCHITECTURE.md
- **Breaking changes**: Update DEVELOPMENT_PLAN.md
## Git Workflow
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5ab950f..f2e354f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,11 +1,3 @@
-## [0.1.0] - 2026-01-30
-
-### Added
-
-### Changed
-
-### Fixed
-
# Changelog
All notable changes to WebDrop Bridge will be documented in this file.
diff --git a/CONFIGURATION_BUNDLING_SUMMARY.md b/CONFIGURATION_BUNDLING_SUMMARY.md
deleted file mode 100644
index fb7eeac..0000000
--- a/CONFIGURATION_BUNDLING_SUMMARY.md
+++ /dev/null
@@ -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
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8112350..11bce84 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -308,165 +308,33 @@ start docs\_build\html\index.html # Windows
- Add screenshots for UI features
- 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
-### 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
-__version__ = "1.0.0"
-```
+### Creating a Release
-**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:
-- `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
+2. Update CHANGELOG.md
-### 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)
-
-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.
+4. GitHub Actions will automatically build installers
## Getting Help
diff --git a/DEVELOPMENT_PLAN.md b/DEVELOPMENT_PLAN.md
index d941b72..fdc0144 100644
--- a/DEVELOPMENT_PLAN.md
+++ b/DEVELOPMENT_PLAN.md
@@ -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.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
**Forgejo Configuration:**
@@ -810,191 +784,40 @@ AUTO_UPDATE_NOTIFY=true
- Security: HTTPS-only, checksum verification
**Deliverables:**
-- [x] `src/webdrop_bridge/core/updater.py` - Update manager (COMPLETE)
-- [x] Unit tests for update checking and downloading (20 tests passing)
-- [x] Integration with Forgejo API (async queries working)
-- [x] Menu item for manual update check (COMPLETE - Priority 2)
-- [x] Update notification dialog (COMPLETE - Priority 2)
+- [ ] `src/webdrop_bridge/core/updater.py` - Update manager
+- [ ] Menu item for manual update check
+- [ ] Update notification dialog
+- [ ] Unit tests for update checking and downloading
+- [ ] Integration with Forgejo API
**Acceptance Criteria:**
-- [x] Can query Forgejo releases API
-- [x] Detects new versions correctly
-- [x] Downloads and verifies checksums
-- [x] Gracefully handles network errors
-- [x] Version comparison uses semantic versioning
-- [x] Manual check works from menu (COMPLETE - Priority 2)
-- [x] Prompts user for restart (COMPLETE - Priority 2)
-
----
-
-#### 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)
+- Can query Forgejo releases API
+- Detects new versions correctly
+- Downloads and verifies checksums
+- Prompts user for restart
+- Manual check works from menu
+- Gracefully handles network errors
+- Version comparison uses semantic versioning
---
### 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:**
-- [x] Structured logging (JSON format option) - JSONFormatter class supports JSON output
-- [x] Log rotation/archival - _archive_old_logs() manages old logs with 30-day retention
-- [x] Performance metrics collection - PerformanceTracker context manager for timing operations
- ```python
- 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
- ```
+- [ ] Structured logging (JSON format option)
+- [ ] Log rotation/archival
+- [ ] Performance metrics collection
+- [ ] Crash reporting (optional)
---
### 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:**
-- [x] Configuration validation schema - ConfigValidator class with 8-test suite
- - Validates all config fields with detailed error messages
- - Enforces type constraints, ranges, and allowed values
- - Used throughout to ensure config consistency
-
-- [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
+- [ ] UI settings dialog
+- [ ] Configuration validation schema
+- [ ] Profile support (work, personal, etc.)
+- [ ] Export/import settings
---
@@ -1210,15 +1033,28 @@ February 2026
---
-## Current Phase
-
-Pre-release development (Phase 1-2). Integration tests for update flow implemented.
-
## Next Steps
-- Finalize auto-update system
-- Expand integration test coverage (see `tests/integration/test_update_flow.py`)
-- Update documentation for new features
+1. **Immediate** (This week):
+ - [ ] Set up project directories ✅
+ - [ ] 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
---
diff --git a/FILE_LISTING.md b/FILE_LISTING.md
index 95c13ba..3001401 100644
--- a/FILE_LISTING.md
+++ b/FILE_LISTING.md
@@ -64,21 +64,11 @@ src/webdrop_bridge/
└── __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:
- `src/webdrop_bridge/main.py` (to implement)
- `src/webdrop_bridge/config.py` (to implement)
- `src/webdrop_bridge/core/validator.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/utils/logging.py` (to implement)
@@ -99,14 +89,6 @@ tests/
└── (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)
diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md
index d8b1cc4..cd7a09d 100644
--- a/IMPLEMENTATION_CHECKLIST.md
+++ b/IMPLEMENTATION_CHECKLIST.md
@@ -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
### Before Committing
diff --git a/PHASE_4_3_SUMMARY.md b/PHASE_4_3_SUMMARY.md
deleted file mode 100644
index 03d0268..0000000
--- a/PHASE_4_3_SUMMARY.md
+++ /dev/null
@@ -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
-"""
\ No newline at end of file
diff --git a/PROJECT_SETUP_SUMMARY.md b/PROJECT_SETUP_SUMMARY.md
index 6b3ab5b..bd72686 100644
--- a/PROJECT_SETUP_SUMMARY.md
+++ b/PROJECT_SETUP_SUMMARY.md
@@ -76,12 +76,6 @@ Build Scripts: Windows & macOS
CI/CD Workflows: Automated testing & building
```
-## Statistics
-
-- Source files: 6
-- Test files: 5
-- Documentation files: 9
-
---
## 🚀 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
**Next Phase**: Implement Core Components (Phase 1)
**Timeline**: 12 weeks to complete all phases
diff --git a/QUICKSTART.md b/QUICKSTART.md
index 3752005..1525b2f 100644
--- a/QUICKSTART.md
+++ b/QUICKSTART.md
@@ -110,12 +110,6 @@ pytest tests/unit/ -v # Unit tests
pytest tests/integration/ -v # Integration tests
```
-### Running Integration Tests
-
-```bash
-pytest tests/integration/ -v
-```
-
### Code Quality
```bash
diff --git a/README.md b/README.md
index 36243c0..74c8c82 100644
--- a/README.md
+++ b/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.)
-  
+  
## 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
- ✅ **Drag Interception** - Converts text paths to native file operations
- ✅ **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
-- ✅ **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
-- ✅ **Structured Logging** - File-based logging with configurable levels
## Quick Start
### Requirements
- Python 3.10+
- Windows 10/11 or macOS 12+
-- 200 MB disk space (includes Chromium from PyInstaller)
+- 100 MB disk space
### 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
cd webdrop-bridge
-# Create and activate virtual environment
+# Create virtual environment
python -m venv venv
-source venv/bin/activate # macOS/Linux
-# venv\Scripts\activate.ps1 # Windows (PowerShell)
-# venv\Scripts\activate.bat # Windows (cmd.exe)
+source venv/bin/activate # macOS/Linux
+# venv\Scripts\activate # Windows
# Install dependencies
pip install -r requirements.txt
@@ -65,14 +60,14 @@ python -m webdrop_bridge.main
pip install -r requirements-dev.txt
# Run tests
-pytest tests -v
+pytest
-# Run all quality checks (lint, type, format)
-tox
+# Run linting checks
+tox -e lint
-# Build installers
-python build/scripts/build_windows.py # Windows MSI
-bash build/scripts/build_macos.sh # macOS DMG
+# Build for your platform
+tox -e build-windows # Windows
+tox -e build-macos # macOS
```
## Project Structure
@@ -135,139 +130,55 @@ webdrop-bridge/
## Configuration
-WebDrop Bridge supports two configuration methods:
-
-### 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:
+Create `.env` file from `.env.example`:
```bash
-# Application
-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
+cp .env.example .env
```
+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
-WebDrop Bridge includes comprehensive test coverage with unit, integration, and end-to-end tests.
-
```bash
# Run all tests
-pytest tests -v
+pytest
-# Run with coverage report
-pytest tests --cov=src/webdrop_bridge --cov-report=html
+# Run specific test type
+pytest tests/unit/ # Unit tests only
+pytest tests/integration/ # Integration tests only
-# Run specific test categories
-pytest tests/unit -v # Unit tests only
-pytest tests/integration -v # Integration tests only
+# With coverage report
+pytest --cov=src/webdrop_bridge --cov-report=html
-# Run specific test
-pytest tests/unit/test_validator.py -v
-
-# Run tests matching a pattern
-pytest tests -k "config" -v
+# Run on specific platform marker
+pytest -m windows # Windows-specific tests
+pytest -m macos # macOS-specific tests
```
-**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
-### Windows MSI Installer
+### Windows MSI
```bash
-# Simple build (creates standalone .exe)
+pip install pyinstaller
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:
-- 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`
+Output: `build/dist/WebDropBridge.exe`
-### macOS DMG Installer
+### macOS DMG
```bash
-# Build DMG (requires macOS)
+pip install pyinstaller
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:
-- 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
-```
+Output: `build/dist/WebDropBridge.dmg`
## Development Workflow
@@ -340,35 +251,13 @@ MIT License - see [LICENSE](LICENSE) file for details
- Inspired by professional desktop integration practices
- 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
-- [x] Core drag-drop functionality
-- [x] Configuration management with profiles
-- [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.0 - Stable Windows & macOS release
+- [ ] v1.1 - Advanced filtering and logging UI
- [ ] v1.2 - API for custom handlers
- [ ] v2.0 - Plugin architecture
+- [ ] v2.1 - Cloud storage integration (OneDrive, Google Drive)
## 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
diff --git a/UPDATE_FIX_SUMMARY.md b/UPDATE_FIX_SUMMARY.md
deleted file mode 100644
index ef1925b..0000000
--- a/UPDATE_FIX_SUMMARY.md
+++ /dev/null
@@ -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
diff --git a/VERSIONING_SIMPLIFIED.md b/VERSIONING_SIMPLIFIED.md
deleted file mode 100644
index 5282cb5..0000000
--- a/VERSIONING_SIMPLIFIED.md
+++ /dev/null
@@ -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.
diff --git a/build/WebDropBridge.wixobj b/build/WebDropBridge.wixobj
deleted file mode 100644
index 3edff22..0000000
--- a/build/WebDropBridge.wixobj
+++ /dev/null
@@ -1 +0,0 @@
-
"
- f"Version: {self.config.app_version}
"
- f"
"
- f"A professional Qt-based desktop application that converts "
- f"web-based drag-and-drop text paths into native file operations.
"
- f"
"
- f"© 2026 WebDrop Bridge Contributors"
- )
-
- 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:
"""Navigate to the home (start) URL."""
home_url = self.config.webapp_url
@@ -571,540 +375,3 @@ class MainWindow(QMainWindow):
True if drag was initiated successfully
"""
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()
diff --git a/src/webdrop_bridge/ui/restricted_web_view.py b/src/webdrop_bridge/ui/restricted_web_view.py
index d7b28cc..28a5683 100644
--- a/src/webdrop_bridge/ui/restricted_web_view.py
+++ b/src/webdrop_bridge/ui/restricted_web_view.py
@@ -38,20 +38,20 @@ class RestrictedWebEngineView(QWebEngineView):
Args:
request: Navigation request to process
"""
- url = request.url
+ url = request.url
# If no restrictions, allow all URLs
if not self.allowed_urls:
return
# 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)
return
# URL not whitelisted - open in system browser
request.reject()
- QDesktopServices.openUrl(url) # type: ignore[operator]
+ QDesktopServices.openUrl(url)
def _is_url_allowed(self, url: QUrl) -> bool:
"""Check if a URL matches the whitelist.
@@ -98,4 +98,3 @@ class RestrictedWebEngineView(QWebEngineView):
return True
return False
-
diff --git a/src/webdrop_bridge/ui/settings_dialog.py b/src/webdrop_bridge/ui/settings_dialog.py
deleted file mode 100644
index 5705429..0000000
--- a/src/webdrop_bridge/ui/settings_dialog.py
+++ /dev/null
@@ -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)
diff --git a/src/webdrop_bridge/ui/update_manager_ui.py b/src/webdrop_bridge/ui/update_manager_ui.py
deleted file mode 100644
index 1ddd4f0..0000000
--- a/src/webdrop_bridge/ui/update_manager_ui.py
+++ /dev/null
@@ -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()
diff --git a/src/webdrop_bridge/utils/logging.py b/src/webdrop_bridge/utils/logging.py
index dcdc53c..aaafadb 100644
--- a/src/webdrop_bridge/utils/logging.py
+++ b/src/webdrop_bridge/utils/logging.py
@@ -1,74 +1,9 @@
"""Logging configuration and utilities for WebDrop Bridge."""
-import json
import logging
import logging.handlers
-import time
-from datetime import datetime, timedelta
from pathlib import Path
-from typing import Any, Dict, 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)
-
+from typing import Optional
def setup_logging(
@@ -76,7 +11,6 @@ def setup_logging(
level: str = "INFO",
log_file: Optional[Path] = None,
fmt: Optional[str] = None,
- json_format: bool = False,
) -> logging.Logger:
"""Configure application-wide logging.
@@ -90,7 +24,6 @@ def setup_logging(
to this file in addition to console
fmt: Optional custom format string. If None, uses default format.
Default: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
- json_format: If True, use JSON format for logs. Ignores fmt parameter.
Returns:
logging.Logger: Configured logger instance
@@ -105,14 +38,12 @@ def setup_logging(
except AttributeError as e:
raise KeyError(f"Invalid logging level: {level}") from e
- # Create formatter based on format type
- if json_format:
- formatter = JSONFormatter()
- else:
- # Use default format if not provided
- if fmt is None:
- fmt = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
- formatter = logging.Formatter(fmt)
+ # Use default format if not provided
+ if fmt is None:
+ fmt = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+
+ # Create formatter
+ formatter = logging.Formatter(fmt)
# Get or create logger
logger = logging.getLogger(name)
@@ -133,9 +64,6 @@ def setup_logging(
# Create parent directories if needed
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
# Max 10 MB per file, keep 5 backups
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
"""
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
diff --git a/test_output.txt b/test_output.txt
deleted file mode 100644
index 8b4e01c..0000000
Binary files a/test_output.txt and /dev/null differ
diff --git a/test_results.txt b/test_results.txt
deleted file mode 100644
index 06d8d28..0000000
Binary files a/test_results.txt and /dev/null differ
diff --git a/test_timeout_handling.py b/test_timeout_handling.py
deleted file mode 100644
index 6a6d6b2..0000000
--- a/test_timeout_handling.py
+++ /dev/null
@@ -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()
diff --git a/test_update_no_hang.py b/test_update_no_hang.py
deleted file mode 100644
index b98f23a..0000000
--- a/test_update_no_hang.py
+++ /dev/null
@@ -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()
diff --git a/tests/integration/test_update_flow.py b/tests/integration/test_update_flow.py
deleted file mode 100644
index f1c52e0..0000000
--- a/tests/integration/test_update_flow.py
+++ /dev/null
@@ -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
diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py
index c8f569f..10ff76d 100644
--- a/tests/unit/test_config.py
+++ b/tests/unit/test_config.py
@@ -70,9 +70,7 @@ class TestConfigFromEnv:
config = Config.from_env(str(env_file))
assert config.app_name == "WebDrop Bridge"
- # Version should come from __init__.py (dynamic, not hardcoded)
- from webdrop_bridge import __version__
- assert config.app_version == __version__
+ assert config.app_version == "1.0.0"
assert config.log_level == "INFO"
assert config.window_width == 1024
assert config.window_height == 768
diff --git a/tests/unit/test_config_manager.py b/tests/unit/test_config_manager.py
deleted file mode 100644
index 35038d3..0000000
--- a/tests/unit/test_config_manager.py
+++ /dev/null
@@ -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)
diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py
index 1fa8af6..36674d5 100644
--- a/tests/unit/test_logging.py
+++ b/tests/unit/test_logging.py
@@ -1,19 +1,12 @@
"""Unit tests for logging module."""
-import json
import logging
import logging.handlers
-import time
from pathlib import Path
import pytest
-from webdrop_bridge.utils.logging import (
- JSONFormatter,
- PerformanceTracker,
- get_logger,
- setup_logging,
-)
+from webdrop_bridge.utils.logging import get_logger, setup_logging
class TestSetupLogging:
@@ -159,178 +152,3 @@ class TestLogRotation:
# Default: 10 MB max, 5 backups
assert rotating_handler.maxBytes == 10 * 1024 * 1024
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
diff --git a/tests/unit/test_main_window.py b/tests/unit/test_main_window.py
index 75216e0..edc982f 100644
--- a/tests/unit/test_main_window.py
+++ b/tests/unit/test_main_window.py
@@ -32,7 +32,6 @@ def sample_config(tmp_path):
webapp_url=str(webapp_file),
window_width=800,
window_height=600,
- window_title="Test WebDrop v1.0.0",
enable_logging=False,
)
return config
@@ -324,118 +323,6 @@ class TestMainWindowSignals:
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:
"""Test stylesheet application."""
diff --git a/tests/unit/test_settings_dialog.py b/tests/unit/test_settings_dialog.py
deleted file mode 100644
index 332d63d..0000000
--- a/tests/unit/test_settings_dialog.py
+++ /dev/null
@@ -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
diff --git a/tests/unit/test_startup_check.py b/tests/unit/test_startup_check.py
deleted file mode 100644
index dedeaf1..0000000
--- a/tests/unit/test_startup_check.py
+++ /dev/null
@@ -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()
diff --git a/tests/unit/test_update_manager_ui.py b/tests/unit/test_update_manager_ui.py
deleted file mode 100644
index 23f5d3e..0000000
--- a/tests/unit/test_update_manager_ui.py
+++ /dev/null
@@ -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()
diff --git a/tests/unit/test_updater.py b/tests/unit/test_updater.py
deleted file mode 100644
index 40d4a59..0000000
--- a/tests/unit/test_updater.py
+++ /dev/null
@@ -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
diff --git a/verify_fix.py b/verify_fix.py
deleted file mode 100644
index 88b8481..0000000
--- a/verify_fix.py
+++ /dev/null
@@ -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")
diff --git a/verify_timeout_handling.py b/verify_timeout_handling.py
deleted file mode 100644
index 51755d8..0000000
--- a/verify_timeout_handling.py
+++ /dev/null
@@ -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")
diff --git a/webapp/index.html b/webapp/index.html
index ac302bf..e4ace2d 100644
--- a/webapp/index.html
+++ b/webapp/index.html
@@ -163,13 +163,13 @@
Z:\data\test-image.jpg
+Z:\samples\image.psd
Z:\data\API_DOCUMENTATION.pdf
+Z:\samples\document.indd
WebDrop Bridge v1.0.0 | Built with Qt and PySide6