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.) -![Status](https://img.shields.io/badge/Status-Pre--Release%20Phase%204-blue) ![License](https://img.shields.io/badge/License-MIT-blue) ![Python](https://img.shields.io/badge/Python-3.10%2B-blue) +![Status](https://img.shields.io/badge/Status-Development-yellow) ![License](https://img.shields.io/badge/License-MIT-blue) ![Python](https://img.shields.io/badge/Python-3.10%2B-blue) ## 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 @@ -
112522Installation Database3WebDrop Bridge4HIM-Tools5Installer6This installer database contains the logic and data required to install WebDrop Bridge.7Intel;10339*14200152192
MainExecutable*INSTALLFOLDER0WebDropBridgeExeProgramMenuShortcut*ApplicationProgramsFolder4regFD152C6D1C7A935EF206EACE58C8B00A
INSTALLFOLDERProgramFilesFolder-ycdokbp|WebDrop BridgeProgramFilesFolderTARGETDIR.ApplicationProgramsFolderProgramMenuFolderswqvo9yh|WebDrop BridgeProgramMenuFolderTARGETDIR.TARGETDIRSourceDir
ProductFeatureWebDrop Bridge210
WebDropBridgeExeMainExecutablegefzwes7.exe|WebDropBridge.exe0512
10#WebDropBridge.cab
ALLUSERS1
regFD152C6D1C7A935EF206EACE58C8B00A1Software\Microsoft\Windows\CurrentVersion\Uninstall\WebDropBridgeinstalled#1ProgramMenuShortcut
ApplicationProgramsFolderRemoveProgramMenuShortcutApplicationProgramsFolder2
ApplicationStartMenuShortcutApplicationProgramsFolders1qprqrd|WebDrop BridgeProgramMenuShortcut[INSTALLFOLDER]WebDropBridge.exeWeb Drag-and-Drop BridgeINSTALLFOLDER
ProductFeature1MainExecutable10ProductFeature1ProgramMenuShortcut10*5ProductFeature20
WebDropBridgeExeINSTALLFOLDER1C:\Development\VS Code Projects\webdrop_bridge\build\dist\windows\WebDropBridge.exe-110
ProductFeatureFeatureMainExecutableComponentProductFeatureFeatureProgramMenuShortcutComponent*ProductProductFeatureFeature
PropertyManufacturerPropertyProductCodePropertyProductLanguagePropertyProductNamePropertyProductVersionPropertyUpgradeCodeComponentMainExecutableComponentProgramMenuShortcutDirectoryINSTALLFOLDERMedia1DirectoryApplicationProgramsFolder
ManufacturerHIM-Tools
ProductCode*
ProductLanguage1033
ProductNameWebDrop Bridge
ProductVersion0.1.0
UpgradeCode{12345678-1234-1234-1234-123456789012}
\ No newline at end of file diff --git a/build/WebDropBridge.wxs b/build/WebDropBridge.wxs deleted file mode 100644 index 58270a9..0000000 --- a/build/WebDropBridge.wxs +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/build/scripts/build_macos.sh b/build/scripts/build_macos.sh index 661df12..b5fd8fb 100644 --- a/build/scripts/build_macos.sh +++ b/build/scripts/build_macos.sh @@ -11,13 +11,7 @@ # - create-dmg (optional, for custom DMG: brew install create-dmg) # # Usage: -# bash build_macos.sh [--sign] [--notarize] [--env-file PATH] -# -# Options: -# --sign Sign app (requires Apple developer certificate) -# --notarize Notarize app (requires Apple ID) -# --env-file PATH Use custom .env file (default: project root .env) -# Build fails if .env doesn't exist +# bash build_macos.sh [--sign] [--notarize] set -e # Exit on error @@ -33,9 +27,6 @@ APP_NAME="WebDropBridge" DMG_VOLUME_NAME="WebDrop Bridge" VERSION="1.0.0" -# Default .env file -ENV_FILE="$PROJECT_ROOT/.env" - # Parse arguments SIGN_APP=0 NOTARIZE_APP=0 @@ -50,10 +41,6 @@ while [[ $# -gt 0 ]]; do NOTARIZE_APP=1 shift ;; - --env-file) - ENV_FILE="$2" - shift 2 - ;; *) echo "Unknown option: $1" exit 1 @@ -61,15 +48,6 @@ while [[ $# -gt 0 ]]; do esac done -# Validate env file -if [ ! -f "$ENV_FILE" ]; then - echo "❌ Configuration file not found: $ENV_FILE" - echo "Please provide a valid .env file or use --env-file parameter" - exit 1 -fi - -echo "📋 Using configuration: $ENV_FILE" - # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' @@ -176,9 +154,6 @@ build_executable() { log_info "Building macOS executable with PyInstaller..." echo "" - # Export env file for spec file to pick up - export WEBDROP_ENV_FILE="$ENV_FILE" - python3 -m PyInstaller \ --distpath="$DIST_DIR" \ --buildpath="$TEMP_BUILD" \ diff --git a/build/scripts/build_windows.py b/build/scripts/build_windows.py index 53d56ee..87df6e9 100644 --- a/build/scripts/build_windows.py +++ b/build/scripts/build_windows.py @@ -9,27 +9,16 @@ Requirements: - For MSI: WiX Toolset (optional, requires separate installation) Usage: - python build_windows.py [--msi] [--code-sign] [--env-file PATH] - - Options: - --msi Create MSI installer (requires WiX Toolset) - --code-sign Sign executable (requires certificate) - --env-file PATH Use custom .env file (default: project root .env) - If not provided, uses .env from project root - Build fails if .env doesn't exist + python build_windows.py [--msi] [--code-sign] """ import sys import subprocess import os import shutil -import argparse from pathlib import Path from datetime import datetime -# Import shared version utilities -from version_utils import get_current_version - # Fix Unicode output on Windows if sys.platform == "win32": import io @@ -41,43 +30,23 @@ if sys.platform == "win32": class WindowsBuilder: """Build Windows installer using PyInstaller.""" - def __init__(self, env_file: Path | None = None): - """Initialize builder paths. - - Args: - env_file: Path to .env file to bundle. If None, uses project root .env. - If that doesn't exist, raises error. - """ + def __init__(self): + """Initialize builder paths.""" self.project_root = Path(__file__).parent.parent.parent self.build_dir = self.project_root / "build" self.dist_dir = self.build_dir / "dist" / "windows" self.temp_dir = self.build_dir / "temp" / "windows" self.spec_file = self.build_dir / "webdrop_bridge.spec" - self.version = get_current_version() - - # Validate and set env file - if env_file is None: - env_file = self.project_root / ".env" - else: - env_file = Path(env_file).resolve() - - if not env_file.exists(): - raise FileNotFoundError( - f"Configuration file not found: {env_file}\n" - f"Please provide a .env file using --env-file parameter\n" - f"or ensure .env exists in project root" - ) - - self.env_file = env_file - print(f"📋 Using configuration: {self.env_file}") + self.version = self._get_version() def _get_version(self) -> str: - """Get version from __init__.py. - - Note: This method is deprecated. Use get_current_version() from - version_utils.py instead. - """ - return get_current_version() + """Get version from config.py.""" + config_file = self.project_root / "src" / "webdrop_bridge" / "config.py" + for line in config_file.read_text().split("\n"): + if "app_version" in line and "1.0.0" in line: + # Extract default version from config + return "1.0.0" + return "1.0.0" def clean(self): """Clean previous builds.""" @@ -95,7 +64,6 @@ class WindowsBuilder: self.temp_dir.mkdir(parents=True, exist_ok=True) # PyInstaller command using spec file - # Pass env_file path as environment variable for spec to pick up cmd = [ sys.executable, "-m", @@ -108,18 +76,7 @@ class WindowsBuilder: ] print(f" Command: {' '.join(cmd)}") - - # Set environment variable for spec file to use - env = os.environ.copy() - env["WEBDROP_ENV_FILE"] = str(self.env_file) - - result = subprocess.run( - cmd, - cwd=str(self.project_root), - encoding="utf-8", - errors="replace", - env=env - ) + result = subprocess.run(cmd, cwd=str(self.project_root)) if result.returncode != 0: print("❌ PyInstaller build failed") @@ -166,21 +123,13 @@ class WindowsBuilder: """ print("\n📦 Creating MSI installer with WiX...") - # Check if WiX is installed (try PATH first, then default location) + # Check if WiX is installed heat_exe = shutil.which("heat.exe") candle_exe = shutil.which("candle.exe") light_exe = shutil.which("light.exe") - # Fallback to default WiX installation location - if not candle_exe: - default_wix = Path("C:\\Program Files (x86)\\WiX Toolset v3.14\\bin") - if default_wix.exists(): - heat_exe = str(default_wix / "heat.exe") - candle_exe = str(default_wix / "candle.exe") - light_exe = str(default_wix / "light.exe") - if not all([heat_exe, candle_exe, light_exe]): - print("⚠️ WiX Toolset not found in PATH or default location") + print("⚠️ WiX Toolset not found in PATH") print(" Install from: https://wixtoolset.org/releases/") print(" Or use: choco install wixtoolset") return False @@ -193,21 +142,16 @@ class WindowsBuilder: wix_obj = self.build_dir / "WebDropBridge.wixobj" msi_output = self.dist_dir / f"WebDropBridge-{self.version}-Setup.msi" - # Run candle (compiler) - pass preprocessor variables + # Run candle (compiler) candle_cmd = [ str(candle_exe), - f"-dDistDir={self.dist_dir}", "-o", str(wix_obj), str(self.build_dir / "WebDropBridge.wxs"), ] print(f" Compiling WiX source...") - result = subprocess.run( - candle_cmd, - encoding="utf-8", - errors="replace" - ) + result = subprocess.run(candle_cmd) if result.returncode != 0: print("❌ WiX compilation failed") return False @@ -221,11 +165,7 @@ class WindowsBuilder: ] print(f" Linking MSI installer...") - result = subprocess.run( - light_cmd, - encoding="utf-8", - errors="replace" - ) + result = subprocess.run(light_cmd) if result.returncode != 0: print("❌ MSI linking failed") return False @@ -328,11 +268,7 @@ class WindowsBuilder: str(exe_path), ] - result = subprocess.run( - cmd, - encoding="utf-8", - errors="replace" - ) + result = subprocess.run(cmd) if result.returncode != 0: print("❌ Code signing failed") return False @@ -377,10 +313,12 @@ class WindowsBuilder: return True -def main() -> int: - """Build Windows MSI installer.""" +def main(): + """Main entry point.""" + import argparse + parser = argparse.ArgumentParser( - description="Build WebDrop Bridge Windows installer" + description="Build WebDrop Bridge for Windows" ) parser.add_argument( "--msi", @@ -388,29 +326,15 @@ def main() -> int: help="Create MSI installer (requires WiX Toolset)", ) parser.add_argument( - "--code-sign", + "--sign", action="store_true", - help="Sign executable (requires certificate in CODE_SIGN_CERT env var)", + help="Sign executable (requires CODE_SIGN_CERT environment variable)", ) - parser.add_argument( - "--env-file", - type=str, - default=None, - help="Path to .env file to bundle (default: project root .env)", - ) - + args = parser.parse_args() - - print("🔄 Syncing version...") - sync_version() - try: - builder = WindowsBuilder(env_file=args.env_file) - except FileNotFoundError as e: - print(f"❌ Build failed: {e}") - return 1 - - success = builder.build(create_msi=args.msi, sign=args.code_sign) + builder = WindowsBuilder() + success = builder.build(create_msi=args.msi, sign=args.sign) return 0 if success else 1 diff --git a/build/scripts/create_release.ps1 b/build/scripts/create_release.ps1 index 2cec0ab..4564045 100644 --- a/build/scripts/create_release.ps1 +++ b/build/scripts/create_release.ps1 @@ -1,6 +1,5 @@ # Create Forgejo Release with Binary Assets -# Usage: .\create_release.ps1 [-Version 1.0.0] -# If -Version is not provided, it will be read from src/webdrop_bridge/__init__.py +# Usage: .\create_release.ps1 -Version 1.0.0 # Uses your Forgejo credentials (same as git) # First run will prompt for credentials and save them to this session @@ -19,43 +18,11 @@ param( [string]$ForgejoUrl = "https://git.him-tools.de", [string]$Repo = "HIM-public/webdrop-bridge", [string]$ExePath = "build\dist\windows\WebDropBridge.exe", - [string]$ChecksumPath = "build\dist\windows\WebDropBridge.exe.sha256", - [string]$MsiPath = "build\dist\windows\WebDropBridge-1.0.0-Setup.msi" + [string]$ChecksumPath = "build\dist\windows\WebDropBridge.exe.sha256" ) $ErrorActionPreference = "Stop" -# Function to read version from .env or .env.example -function Get-VersionFromEnv { - # PSScriptRoot is build/scripts, go up to project root with ../../ - $projectRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..") - - # Try .env first (runtime config), then .env.example (template) - $envFile = Join-Path $projectRoot ".env" - $envExampleFile = Join-Path $projectRoot ".env.example" - - # Check .env first - if (Test-Path $envFile) { - $content = Get-Content $envFile -Raw - if ($content -match 'APP_VERSION=([^\r\n]+)') { - Write-Host "Version read from .env" -ForegroundColor Gray - return $matches[1].Trim() - } - } - - # Fall back to .env.example - if (Test-Path $envExampleFile) { - $content = Get-Content $envExampleFile -Raw - if ($content -match 'APP_VERSION=([^\r\n]+)') { - Write-Host "Version read from .env.example" -ForegroundColor Gray - return $matches[1].Trim() - } - } - - Write-Host "ERROR: Could not find APP_VERSION in .env or .env.example" -ForegroundColor Red - exit 1 -} - # Handle --ClearCredentials flag if ($ClearCredentials) { Remove-Item env:FORGEJO_USER -ErrorAction SilentlyContinue @@ -93,11 +60,11 @@ if (-not $ForgejoUser -or -not $ForgejoPW) { Write-Host "Tip: Credentials will persist until you close PowerShell or run: .\create_release.ps1 -ClearCredentials" -ForegroundColor Gray } -# Verify Version parameter - if not provided, read from .env.example +# Verify Version parameter if (-not $Version) { - Write-Host "Version not provided, reading from .env.example..." -ForegroundColor Cyan - $Version = Get-VersionFromEnv - Write-Host "Using version: $Version" -ForegroundColor Green + Write-Host "ERROR: Version parameter required" -ForegroundColor Red + Write-Host "Usage: .\create_release.ps1 -Version 1.0.0" -ForegroundColor Yellow + exit 1 } # Verify files exist @@ -111,9 +78,6 @@ if (-not (Test-Path $ChecksumPath)) { exit 1 } -# MSI is optional (only available on Windows after build) -$hasMsi = Test-Path $MsiPath - Write-Host "Creating WebDropBridge $Version release on Forgejo..." -ForegroundColor Cyan # Get file info @@ -121,10 +85,6 @@ $exeSize = (Get-Item $ExePath).Length / 1MB $checksum = Get-Content $ChecksumPath -Raw Write-Host "File: WebDropBridge.exe ($([math]::Round($exeSize, 2)) MB)" -if ($hasMsi) { - $msiSize = (Get-Item $MsiPath).Length / 1MB - Write-Host "File: WebDropBridge-1.0.0-Setup.msi ($([math]::Round($msiSize, 2)) MB)" -} Write-Host "Checksum: $($checksum.Substring(0, 16))..." # Create basic auth header @@ -210,28 +170,5 @@ catch { exit 1 } -# Step 4: Upload MSI as asset (if available) -if ($hasMsi) { - Write-Host "Uploading MSI installer asset..." -ForegroundColor Yellow - - try { - $response = curl.exe -s -X POST ` - -u $curlAuth ` - -F "attachment=@$MsiPath" ` - $uploadUrl - - if ($response -like "*error*" -or $response -like "*404*") { - Write-Host "ERROR uploading MSI: $response" -ForegroundColor Red - exit 1 - } - - Write-Host "[OK] MSI uploaded" -ForegroundColor Green - } - catch { - Write-Host "ERROR uploading MSI: $_" -ForegroundColor Red - exit 1 - } -} - Write-Host "`n[OK] Release complete!" -ForegroundColor Green Write-Host "View at: $ForgejoUrl/$Repo/releases/tag/v$Version" -ForegroundColor Cyan diff --git a/build/scripts/version_utils.py b/build/scripts/version_utils.py deleted file mode 100644 index aa8627b..0000000 --- a/build/scripts/version_utils.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Shared version management utilities for build scripts. - -This module provides a single source of truth for version reading -to avoid duplication between different build scripts. -""" - -import re -from pathlib import Path - - -def get_project_root() -> Path: - """Get the project root directory. - - Returns: - Path to project root (parent of build/scripts) - """ - return Path(__file__).parent.parent.parent - - -def get_current_version() -> str: - """Read version from __init__.py. - - This is the single source of truth for version information. - All build scripts and version management tools use this function. - - Returns: - Current version string from __init__.py - - Raises: - ValueError: If __version__ cannot be found in __init__.py - """ - project_root = get_project_root() - init_file = project_root / "src" / "webdrop_bridge" / "__init__.py" - - if not init_file.exists(): - raise FileNotFoundError( - f"Cannot find __init__.py at {init_file}" - ) - - content = init_file.read_text(encoding="utf-8") - match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content) - - if not match: - raise ValueError( - f"Could not find __version__ in {init_file}. " - "Expected: __version__ = \"X.Y.Z\"" - ) - - return match.group(1) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 48c4636..6a4c398 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -258,19 +258,33 @@ Startup: <1 second - **Paths**: Forward slash `/` (native) - **Permissions**: May require accessibility permissions -## Update Manager +## Monitoring & Debugging -The `UpdateManager` class checks for new releases using the Forgejo API. It caches results and only signals updates for newer versions. See `src/webdrop_bridge/core/updater.py` for implementation. +### Debug Logging -## Release Flow +```python +# Enable debug logging +LOG_LEVEL=DEBUG -- Checks for new releases on startup or user request -- Parses release notes and assets -- Notifies UI if update is available +# Output +2026-01-28 14:32:15 - webdrop_bridge - DEBUG - DragInterceptor: dragEnterEvent triggered +2026-01-28 14:32:15 - webdrop_bridge - DEBUG - PathValidator: Checking Z:\file.psd +2026-01-28 14:32:15 - webdrop_bridge - INFO - File dragged: Z:\file.psd +``` -## Integration Test Strategy +### Performance Profiling -Integration tests verify workflows across modules. The update workflow is covered in [tests/integration/test_update_flow.py](../tests/integration/test_update_flow.py). +```python +import cProfile +import pstats + +profiler = cProfile.Profile() +profiler.enable() +# ... drag operation ... +profiler.disable() +stats = pstats.Stats(profiler) +stats.print_stats() +``` --- diff --git a/docs/CONFIGURATION_BUILD.md b/docs/CONFIGURATION_BUILD.md deleted file mode 100644 index c9d1212..0000000 --- a/docs/CONFIGURATION_BUILD.md +++ /dev/null @@ -1,162 +0,0 @@ -# Configuration Management for Builds - -This document explains how configuration is handled when building executables and installers for WebDrop Bridge. - -## Overview - -WebDrop Bridge uses `.env` files for runtime configuration. When building distributable packages (exe, MSI, or DMG), the `.env` file is **bundled into the application** so that users receive pre-configured settings. - -## Configuration File - -The configuration file must be named `.env` and contains settings like: - -```dotenv -APP_NAME=WebDrop Bridge -APP_VERSION=0.1.0 -WEBAPP_URL=https://example.com -ALLOWED_ROOTS=Z:/,C:/Users/Public -ALLOWED_URLS= -LOG_LEVEL=INFO -LOG_FILE=logs/webdrop_bridge.log -ENABLE_LOGGING=true -WINDOW_WIDTH=1024 -WINDOW_HEIGHT=768 -``` - -See `.env.example` for a template with all available options. - -## Building with Default Configuration - -If you want to use the project's `.env` file (in the project root), simply run: - -### Windows -```bash -python build/scripts/build_windows.py --msi -``` - -### macOS -```bash -bash build/scripts/build_macos.sh -``` - -**Important:** The build will **fail** if `.env` doesn't exist. This prevents accidentally shipping without configuration. - -## Building with Custom Configuration - -For different customers or deployments, you can specify a custom `.env` file: - -### Windows -```bash -python build/scripts/build_windows.py --msi --env-file path/to/customer1.env -``` - -### macOS -```bash -bash build/scripts/build_macos.sh --env-file path/to/customer1.env -``` - -The custom `.env` file will be bundled into the executable and users will receive those pre-configured settings. - -## Example: Multi-Customer Setup - -If you have different customer configurations: - -``` -webdrop_bridge/ -├── .env # Default project configuration -├── .env.example # Template -├── build/ -│ └── scripts/ -│ ├── build_windows.py -│ └── build_macos.sh -├── customer_configs/ # Create this for customer-specific settings -│ ├── acme_corp.env -│ ├── globex_corporation.env -│ └── initech.env -└── ... -``` - -Then build for each customer: - -```bash -# ACME Corp -python build/scripts/build_windows.py --msi --env-file customer_configs/acme_corp.env - -# Globex Corporation -python build/scripts/build_windows.py --msi --env-file customer_configs/globex_corporation.env - -# Initech -python build/scripts/build_windows.py --msi --env-file customer_configs/initech.env -``` - -Each MSI will include that customer's specific configuration (URLs, allowed paths, etc.). - -## What Gets Bundled - -When building, the `.env` file is: -1. ✅ Copied into the PyInstaller bundle -2. ✅ Extracted to the application's working directory when the app starts -3. ✅ Automatically loaded by `Config.from_env()` at startup - -Users **do not** need to create their own `.env` files. - -## After Installation - -When users run the installed application: -1. The embedded `.env` is automatically available -2. Settings are loaded and applied -3. Users can optionally create a custom `.env` in the installation directory to override settings - -This allows: -- **Pre-configured deployments** for your customers -- **Easy customization** by users (just edit the `.env` file) -- **No manual setup** required after installation - -## Build Command Reference - -### Windows -```bash -# Default (.env from project root) -python build/scripts/build_windows.py --msi - -# Custom .env file -python build/scripts/build_windows.py --msi --env-file customer_configs/acme.env - -# Without MSI (just EXE) -python build/scripts/build_windows.py - -# Sign executable (requires CODE_SIGN_CERT env var) -python build/scripts/build_windows.py --msi --code-sign -``` - -### macOS -```bash -# Default (.env from project root) -bash build/scripts/build_macos.sh - -# Custom .env file -bash build/scripts/build_macos.sh --env-file customer_configs/acme.env - -# Sign app (requires Apple developer certificate) -bash build/scripts/build_macos.sh --sign - -# Notarize app (requires Apple ID) -bash build/scripts/build_macos.sh --notarize -``` - -## Configuration Validation - -The build process validates that: -1. ✅ The specified `.env` file exists -2. ✅ All required environment variables are present -3. ✅ Values are valid (LOG_LEVEL is valid, paths exist for ALLOWED_ROOTS, etc.) - -If validation fails, the build stops with a clear error message. - -## Version Management - -The `APP_VERSION` is read from two places (in order): -1. `.env` file (if specified) -2. `src/webdrop_bridge/__init__.py` (as fallback) - -This allows you to override the version per customer if needed. diff --git a/docs/CUSTOMER_BUILD_EXAMPLES.md b/docs/CUSTOMER_BUILD_EXAMPLES.md deleted file mode 100644 index de97cf3..0000000 --- a/docs/CUSTOMER_BUILD_EXAMPLES.md +++ /dev/null @@ -1,299 +0,0 @@ -# Customer-Specific Build Examples - -This document shows practical examples of how to build WebDrop Bridge for different customers or deployment scenarios. - -## Scenario 1: Single Build with Default Configuration - -**Situation:** You have one main configuration for your primary customer or general use. - -**Setup:** -``` -webdrop_bridge/ -├── .env # Your main configuration -└── build/ - └── scripts/ - └── build_windows.py -``` - -**Build Command:** -```bash -python build/scripts/build_windows.py --msi -``` - -**Result:** `WebDropBridge-x.x.x-Setup.msi` with your `.env` configuration bundled. - ---- - -## Scenario 2: Multi-Customer Builds - -**Situation:** You support multiple customers, each with different URLs, allowed paths, etc. - -**Setup:** -``` -webdrop_bridge/ -├── .env # Default project config -├── build/ -│ └── scripts/ -│ └── build_windows.py -└── deploy/ # Create this directory - └── customer_configs/ - ├── README.md - ├── acme_corp.env - ├── globex_corporation.env - ├── initech.env - └── wayne_enterprises.env -``` - -**Customer Config Example:** `deploy/customer_configs/acme_corp.env` -```dotenv -APP_NAME=WebDrop Bridge - ACME Corp Edition -APP_VERSION=1.0.0 -WEBAPP_URL=https://acme-drop.example.com/drop -ALLOWED_ROOTS=Z:/acme_files/,C:/Users/Public/ACME -LOG_LEVEL=INFO -LOG_FILE=logs/webdrop_bridge.log -ENABLE_LOGGING=true -WINDOW_WIDTH=1024 -WINDOW_HEIGHT=768 -``` - -**Build Commands:** -```bash -# Build for ACME Corp -python build/scripts/build_windows.py --msi --env-file deploy/customer_configs/acme_corp.env - -# Build for Globex -python build/scripts/build_windows.py --msi --env-file deploy/customer_configs/globex_corporation.env - -# Build for Initech -python build/scripts/build_windows.py --msi --env-file deploy/customer_configs/initech.env - -# Build for Wayne Enterprises -python build/scripts/build_windows.py --msi --env-file deploy/customer_configs/wayne_enterprises.env -``` - -**Result:** Four separate MSI files: -- `WebDropBridge-1.0.0-Setup.msi` (ACME - says "ACME Corp Edition") -- `WebDropBridge-1.0.0-Setup.msi` (Globex - say "Globex Edition") -- etc. - ---- - -## Scenario 3: Development vs. Production Builds - -**Situation:** You want different settings for internal testing vs. customer releases. - -**Setup:** -``` -webdrop_bridge/ -├── .env # Production config (primary) -├── build/ -│ └── scripts/ -│ └── build_windows.py -└── build_configs/ - ├── development.env # For internal testing - ├── staging.env # Pre-production testing - └── production.env # For customers (same as project .env) -``` - -**Development Config:** `build_configs/development.env` -```dotenv -APP_NAME=WebDrop Bridge DEV -WEBAPP_URL=http://localhost:3000 -LOG_LEVEL=DEBUG -LOG_FILE=logs/webdrop_bridge.log -ENABLE_LOGGING=true -WINDOW_WIDTH=1024 -WINDOW_HEIGHT=768 -``` - -**Build Commands:** -```bash -# Development build (for testing) -python build/scripts/build_windows.py --env-file build_configs/development.env - -# Staging build (pre-release testing) -python build/scripts/build_windows.py --env-file build_configs/staging.env - -# Production build (for customers) -python build/scripts/build_windows.py --msi -# OR explicitly: -python build/scripts/build_windows.py --msi --env-file build_configs/production.env -``` - ---- - -## Scenario 4: Building with Code Signing - -**Situation:** You have a code signing certificate and want to sign releases. - -**Prerequisites:** -- Set environment variable: `CODE_SIGN_CERT=path/to/certificate.pfx` -- Set environment variable: `CODE_SIGN_PASSWORD=your_password` - -**Build Command:** -```bash -python build/scripts/build_windows.py --msi --code-sign --env-file deploy/customer_configs/acme_corp.env -``` - -**Result:** Signed MSI installer ready for enterprise deployment. - ---- - -## Scenario 5: Automated Build Pipeline - -**Situation:** You have multiple customers and want to automate builds. - -**Script:** `build_all_customers.ps1` -```powershell -# Build WebDrop Bridge for all customers - -$PROJECT_ROOT = "C:\Development\VS Code Projects\webdrop_bridge" -$CONFIG_DIR = "$PROJECT_ROOT\deploy\customer_configs" -$BUILD_SCRIPT = "$PROJECT_ROOT\build\scripts\build_windows.py" - -# Get all .env files for customers -$customerConfigs = @( - "acme_corp.env", - "globex_corporation.env", - "initech.env", - "wayne_enterprises.env" -) - -$timestamp = Get-Date -Format "yyyy-MM-dd_HH-mm-ss" -$output_dir = "$PROJECT_ROOT\build\releases\$timestamp" -New-Item -ItemType Directory -Path $output_dir -Force | Out-Null - -Write-Host "🚀 Building WebDrop Bridge for all customers..." -ForegroundColor Cyan -Write-Host "" - -foreach ($config in $customerConfigs) { - $customer_name = $config -replace '\.env$', '' - $config_path = "$CONFIG_DIR\$config" - - Write-Host "Building for $customer_name..." -ForegroundColor Yellow - - # Build - python $BUILD_SCRIPT --msi --env-file "$config_path" - - # Copy to output directory - $msi_file = Get-ChildItem "$PROJECT_ROOT\build\dist\windows\*.msi" | Sort-Object LastWriteTime | Select-Object -Last 1 - if ($msi_file) { - Copy-Item $msi_file.FullName "$output_dir\WebDropBridge-${customer_name}.msi" - Write-Host "✅ Built: WebDropBridge-${customer_name}.msi" -ForegroundColor Green - } - - Write-Host "" -} - -Write-Host "✅ All builds complete!" -ForegroundColor Green -Write-Host "📦 Outputs in: $output_dir" -``` - -**Run:** -```bash -.\build_all_customers.ps1 -``` - -**Result:** All customer builds in a timestamped directory: -``` -build/releases/2024-01-30_14-30-00/ -├── WebDropBridge-acme_corp.msi -├── WebDropBridge-globex_corporation.msi -├── WebDropBridge-initech.msi -└── WebDropBridge-wayne_enterprises.msi -``` - ---- - -## Configuration Best Practices - -### 1. **Version Numbers** -Keep APP_VERSION in sync across all builds. Options: -- Use project `.env` with single source of truth -- Or explicitly set in each customer config - -### 2. **Naming Convention** -Customer configs: -``` -deploy/customer_configs/ -├── {customer_name_lowercase}.env -├── {customer_name_lowercase}-staging.env -└── {customer_name_lowercase}-dev.env -``` - -### 3. **Security** -- Don't commit customer configs to git (if they contain sensitive URLs) -- Use `.gitignore`: `deploy/customer_configs/*.env` (but keep template) -- Store customer configs in secure location (separate backup/version control) - -### 4. **Documentation** -In each customer config, add comments: -```dotenv -# WebDropBridge Configuration - ACME Corp -# Last updated: 2024-01-30 -# Contact: support@acmecorp.com - -# The web application they'll connect to -WEBAPP_URL=https://acme-drop.example.com/drop - -# Directories they can access -ALLOWED_ROOTS=Z:/acme_files/,C:/Users/Public/ACME -``` - -### 5. **Testing** -Before building for a customer: -1. Copy their config to `.env` in project root -2. Run the app: `python src/webdrop_bridge/main.py` -3. Test the configuration loads correctly -4. Then build: `python build/scripts/build_windows.py --msi` - ---- - -## Troubleshooting - -### "Configuration file not found" -**Problem:** `.env` file specified with `--env-file` doesn't exist. - -**Solution:** -```bash -# Check the file exists -ls deploy/customer_configs/acme_corp.env - -# Use full path if relative path doesn't work -python build/scripts/build_windows.py --msi --env-file C:\full\path\to\acme_corp.env -``` - -### Build fails with no --env-file specified -**Problem:** Project root `.env` doesn't exist, but no `--env-file` provided. - -**Solution:** -```bash -# Option 1: Create .env in project root -copy .env.example .env -# Edit .env as needed - -# Option 2: Specify custom location -python build/scripts/build_windows.py --msi --env-file deploy/customer_configs/your_config.env -``` - -### App shows wrong configuration -**Problem:** Built app has old configuration. - -**Solution:** -1. Delete previous build: `rmdir /s build\dist` -2. Verify you're using correct `.env`: - - Check with `python build/scripts/build_windows.py --help` - - Look at the console output during build: "📋 Using configuration: ..." -3. Rebuild - ---- - -## Summary - -With the new configuration bundling system, you can: -- ✅ Build once, configure for different customers -- ✅ Maintain centralized customer configurations -- ✅ Automate multi-customer builds -- ✅ Deploy to different environments (dev/staging/prod) -- ✅ No manual customer setup required after installation diff --git a/full_test.txt b/full_test.txt deleted file mode 100644 index f29edc9..0000000 Binary files a/full_test.txt and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml index 06a2c7e..a65be40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,12 +2,9 @@ requires = ["setuptools>=65.0", "wheel"] build-backend = "setuptools.build_meta" -[tool.setuptools.dynamic] -version = {attr = "webdrop_bridge.__version__"} - [project] name = "webdrop-bridge" -dynamic = ["version"] +version = "1.0.0" description = "Professional Qt-based desktop bridge application converting web drag-and-drop to native file operations for InDesign, Word, and other desktop applications" readme = "README.md" requires-python = ">=3.9" diff --git a/requirements-dev.txt b/requirements-dev.txt index d47efc6..32e5cdb 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,7 +4,6 @@ pytest>=7.4.0 pytest-cov>=4.1.0 pytest-qt>=4.2.0 -pytest-asyncio>=0.21.0 # Code Quality black>=23.0.0 diff --git a/scripts/sync_version.py b/scripts/sync_version.py deleted file mode 100644 index c9c7056..0000000 --- a/scripts/sync_version.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Sync version from __init__.py to changelog. - -This script reads the version from src/webdrop_bridge/__init__.py and -updates the CHANGELOG.md. Config and pyproject.toml automatically read -from __init__.py, so no manual sync needed for those files. - -This script uses shared version utilities (build/scripts/version_utils.py) -to ensure consistent version reading across all build scripts. - -Usage: - python scripts/sync_version.py [--version VERSION] - -Examples: - python scripts/sync_version.py # Use version from __init__.py - python scripts/sync_version.py --version 2.0.0 # Override with new version -""" - -import argparse -import re -import sys -from datetime import datetime -from pathlib import Path - -# Enable UTF-8 output on Windows -if sys.platform == "win32": - import io - sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") - sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8") - -# Import shared version utilities -sys.path.insert(0, str(Path(__file__).parent.parent / "build" / "scripts")) -from version_utils import get_current_version, get_project_root - -PROJECT_ROOT = get_project_root() - - -def get_current_version_from_init() -> str: - """Get version from __init__.py using shared utility. - - Returns: - Current version string from __init__.py - - Raises: - ValueError: If __version__ cannot be found - """ - return get_current_version() - - -def update_init_version(version: str) -> None: - """Update version in __init__.py. - - Args: - version: New version string to set - """ - init_file = PROJECT_ROOT / "src/webdrop_bridge/__init__.py" - content = init_file.read_text() - new_content = re.sub( - r'__version__\s*=\s*["\'][^"\']+["\']', - f'__version__ = "{version}"', - content, - ) - init_file.write_text(new_content) - print(f"✓ Updated src/webdrop_bridge/__init__.py to {version}") - - -def update_env_example(version: str) -> None: - """Update APP_VERSION in .env.example (optional). - - Note: config.py now reads from __init__.py by default. - Only update if .env.example explicitly sets APP_VERSION for testing. - - Args: - version: New version string to set - """ - env_file = PROJECT_ROOT / ".env.example" - if env_file.exists(): - content = env_file.read_text() - # Only update if APP_VERSION is explicitly set - if 'APP_VERSION=' in content: - new_content = re.sub( - r'APP_VERSION=[^\n]+', - f'APP_VERSION={version}', - content, - ) - env_file.write_text(new_content) - print(f"✓ Updated .env.example to {version}") - else: - print( - f"ℹ️ .env.example does not override APP_VERSION " - f"(uses __init__.py)" - ) - - -def update_env_file(version: str) -> None: - """Update APP_VERSION in .env if it exists. - - Args: - version: New version string to set - """ - env_file = PROJECT_ROOT / ".env" - if env_file.exists(): - content = env_file.read_text() - # Update if APP_VERSION is present - if 'APP_VERSION=' in content: - new_content = re.sub( - r'APP_VERSION=[^\n]+', - f'APP_VERSION={version}', - content, - ) - env_file.write_text(new_content) - print(f"✓ Updated .env to {version}") - - -def update_changelog(version: str) -> None: - """Add version header to CHANGELOG.md if not present. - - Args: - version: New version string to add - """ - changelog = PROJECT_ROOT / "CHANGELOG.md" - if changelog.exists(): - content = changelog.read_text() - if f"## [{version}]" not in content and f"## {version}" not in content: - date_str = datetime.now().strftime("%Y-%m-%d") - header = ( - f"## [{version}] - {date_str}\n\n" - "### Added\n\n### Changed\n\n### Fixed\n\n" - ) - new_content = header + content - changelog.write_text(new_content) - print(f"✓ Added version header to CHANGELOG.md for {version}") - - -def main() -> int: - """Sync version across project. - - Updates __init__.py (source of truth) and changelog. - Config and pyproject.toml automatically read from __init__.py. - - Returns: - 0 on success, 1 on error - """ - parser = argparse.ArgumentParser( - description="Sync version from __init__.py to dependent files" - ) - parser.add_argument( - "--version", - type=str, - help="Version to set (if not provided, reads from __init__.py)", - ) - args = parser.parse_args() - - try: - if args.version: - if not re.match(r"^\d+\.\d+\.\d+", args.version): - print( - "❌ Invalid version format. Use semantic versioning" - " (e.g., 1.2.3)" - ) - return 1 - version = args.version - update_init_version(version) - else: - version = get_current_version_from_init() - print(f"📍 Current version from __init__.py: {version}") - - update_env_example(version) - update_env_file(version) - update_changelog(version) - print(f"\n✅ Version sync complete: {version}") - return 0 - - except Exception as e: - print(f"❌ Error: {e}") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/webdrop_bridge/__init__.py b/src/webdrop_bridge/__init__.py index a488cd1..5631cf7 100644 --- a/src/webdrop_bridge/__init__.py +++ b/src/webdrop_bridge/__init__.py @@ -1,6 +1,6 @@ """WebDrop Bridge - Qt-based desktop application for intelligent drag-and-drop file handling.""" -__version__ = "0.1.0" +__version__ = "1.0.0" __author__ = "WebDrop Team" __license__ = "MIT" diff --git a/src/webdrop_bridge/config.py b/src/webdrop_bridge/config.py index ea0c5a6..bb610af 100644 --- a/src/webdrop_bridge/config.py +++ b/src/webdrop_bridge/config.py @@ -31,7 +31,6 @@ class Config: webapp_url: URL to load in embedded web application window_width: Initial window width in pixels window_height: Initial window height in pixels - window_title: Main window title (default: "{app_name} v{app_version}") enable_logging: Whether to write logs to file Raises: @@ -47,7 +46,6 @@ class Config: webapp_url: str window_width: int window_height: int - window_title: str enable_logging: bool @classmethod @@ -71,12 +69,7 @@ class Config: # Extract and validate configuration values app_name = os.getenv("APP_NAME", "WebDrop Bridge") - # Version comes from __init__.py (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") + app_version = os.getenv("APP_VERSION", "1.0.0") log_level = os.getenv("LOG_LEVEL", "INFO").upper() log_file_str = os.getenv("LOG_FILE", "logs/webdrop_bridge.log") allowed_roots_str = os.getenv("ALLOWED_ROOTS", "Z:/,C:/Users/Public") @@ -84,9 +77,6 @@ class Config: webapp_url = os.getenv("WEBAPP_URL", "file:///./webapp/index.html") window_width = int(os.getenv("WINDOW_WIDTH", "1024")) window_height = int(os.getenv("WINDOW_HEIGHT", "768")) - # Window title defaults to app_name + version if not specified - default_title = f"{app_name} v{app_version}" - window_title = os.getenv("WINDOW_TITLE", default_title) enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true" # Validate log level @@ -150,7 +140,6 @@ class Config: webapp_url=webapp_url, window_width=window_width, window_height=window_height, - window_title=window_title, enable_logging=enable_logging, ) diff --git a/src/webdrop_bridge/core/config_manager.py b/src/webdrop_bridge/core/config_manager.py deleted file mode 100644 index 3b0f313..0000000 --- a/src/webdrop_bridge/core/config_manager.py +++ /dev/null @@ -1,263 +0,0 @@ -"""Configuration management with validation, profiles, and import/export.""" - -import json -import logging -from pathlib import Path -from typing import Any, Dict, List, Optional - -from webdrop_bridge.config import Config, ConfigurationError - -logger = logging.getLogger(__name__) - - -class ConfigValidator: - """Validates configuration values against schema. - - Provides detailed error messages for invalid configurations. - """ - - # Schema definition for configuration - SCHEMA = { - "app_name": {"type": str, "min_length": 1, "max_length": 100}, - "app_version": {"type": str, "pattern": r"^\d+\.\d+\.\d+$"}, - "log_level": {"type": str, "allowed": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]}, - "log_file": {"type": (str, type(None)), "optional": True}, - "allowed_roots": {"type": list, "item_type": (str, Path), "min_items": 0}, - "allowed_urls": {"type": list, "item_type": str, "min_items": 0}, - "webapp_url": {"type": str, "min_length": 1}, - "window_width": {"type": int, "min_value": 400, "max_value": 5000}, - "window_height": {"type": int, "min_value": 300, "max_value": 5000}, - "enable_logging": {"type": bool}, - } - - @staticmethod - def validate(config_dict: Dict[str, Any]) -> List[str]: - """Validate configuration dictionary. - - Args: - config_dict: Configuration dictionary to validate - - Returns: - List of validation error messages (empty if valid) - """ - errors = [] - - for field, rules in ConfigValidator.SCHEMA.items(): - if field not in config_dict: - if not rules.get("optional", False): - errors.append(f"Missing required field: {field}") - continue - - value = config_dict[field] - - # Check type - expected_type = rules.get("type") - if expected_type and not isinstance(value, expected_type): - errors.append(f"{field}: expected {expected_type.__name__}, got {type(value).__name__}") - continue - - # Check allowed values - if "allowed" in rules and value not in rules["allowed"]: - errors.append(f"{field}: must be one of {rules['allowed']}, got {value}") - - # Check string length - if isinstance(value, str): - if "min_length" in rules and len(value) < rules["min_length"]: - errors.append(f"{field}: minimum length is {rules['min_length']}") - if "max_length" in rules and len(value) > rules["max_length"]: - errors.append(f"{field}: maximum length is {rules['max_length']}") - - # Check numeric range - if isinstance(value, int): - if "min_value" in rules and value < rules["min_value"]: - errors.append(f"{field}: minimum value is {rules['min_value']}") - if "max_value" in rules and value > rules["max_value"]: - errors.append(f"{field}: maximum value is {rules['max_value']}") - - # Check list items - if isinstance(value, list): - if "min_items" in rules and len(value) < rules["min_items"]: - errors.append(f"{field}: minimum {rules['min_items']} items required") - - return errors - - @staticmethod - def validate_or_raise(config_dict: Dict[str, Any]) -> None: - """Validate configuration and raise error if invalid. - - Args: - config_dict: Configuration dictionary to validate - - Raises: - ConfigurationError: If configuration is invalid - """ - errors = ConfigValidator.validate(config_dict) - if errors: - raise ConfigurationError(f"Configuration validation failed:\n" + "\n".join(errors)) - - -class ConfigProfile: - """Manages named configuration profiles. - - Profiles are stored in ~/.webdrop-bridge/profiles/ directory as JSON files. - """ - - PROFILES_DIR = Path.home() / ".webdrop-bridge" / "profiles" - - def __init__(self): - """Initialize profile manager.""" - self.PROFILES_DIR.mkdir(parents=True, exist_ok=True) - - def save_profile(self, profile_name: str, config: Config) -> Path: - """Save configuration as a named profile. - - Args: - profile_name: Name of the profile (e.g., "work", "personal") - config: Config object to save - - Returns: - Path to the saved profile file - - Raises: - ConfigurationError: If profile name is invalid - """ - if not profile_name or "/" in profile_name or "\\" in profile_name: - raise ConfigurationError(f"Invalid profile name: {profile_name}") - - profile_path = self.PROFILES_DIR / f"{profile_name}.json" - - config_data = { - "app_name": config.app_name, - "app_version": config.app_version, - "log_level": config.log_level, - "log_file": str(config.log_file) if config.log_file else None, - "allowed_roots": [str(p) for p in config.allowed_roots], - "allowed_urls": config.allowed_urls, - "webapp_url": config.webapp_url, - "window_width": config.window_width, - "window_height": config.window_height, - "enable_logging": config.enable_logging, - } - - try: - profile_path.write_text(json.dumps(config_data, indent=2)) - logger.info(f"Profile saved: {profile_name}") - return profile_path - except (OSError, IOError) as e: - raise ConfigurationError(f"Failed to save profile {profile_name}: {e}") - - def load_profile(self, profile_name: str) -> Dict[str, Any]: - """Load configuration from a named profile. - - Args: - profile_name: Name of the profile to load - - Returns: - Configuration dictionary - - Raises: - ConfigurationError: If profile not found or invalid - """ - profile_path = self.PROFILES_DIR / f"{profile_name}.json" - - if not profile_path.exists(): - raise ConfigurationError(f"Profile not found: {profile_name}") - - try: - config_data = json.loads(profile_path.read_text()) - # Validate before returning - ConfigValidator.validate_or_raise(config_data) - return config_data - except json.JSONDecodeError as e: - raise ConfigurationError(f"Invalid JSON in profile {profile_name}: {e}") - - def list_profiles(self) -> List[str]: - """List all available profiles. - - Returns: - List of profile names (without .json extension) - """ - if not self.PROFILES_DIR.exists(): - return [] - - return sorted([p.stem for p in self.PROFILES_DIR.glob("*.json")]) - - def delete_profile(self, profile_name: str) -> None: - """Delete a profile. - - Args: - profile_name: Name of the profile to delete - - Raises: - ConfigurationError: If profile not found - """ - profile_path = self.PROFILES_DIR / f"{profile_name}.json" - - if not profile_path.exists(): - raise ConfigurationError(f"Profile not found: {profile_name}") - - try: - profile_path.unlink() - logger.info(f"Profile deleted: {profile_name}") - except OSError as e: - raise ConfigurationError(f"Failed to delete profile {profile_name}: {e}") - - -class ConfigExporter: - """Handle configuration import and export operations.""" - - @staticmethod - def export_to_json(config: Config, output_path: Path) -> None: - """Export configuration to JSON file. - - Args: - config: Config object to export - output_path: Path to write JSON file - - Raises: - ConfigurationError: If export fails - """ - config_data = { - "app_name": config.app_name, - "app_version": config.app_version, - "log_level": config.log_level, - "log_file": str(config.log_file) if config.log_file else None, - "allowed_roots": [str(p) for p in config.allowed_roots], - "allowed_urls": config.allowed_urls, - "webapp_url": config.webapp_url, - "window_width": config.window_width, - "window_height": config.window_height, - "enable_logging": config.enable_logging, - } - - try: - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(json.dumps(config_data, indent=2)) - logger.info(f"Configuration exported to: {output_path}") - except (OSError, IOError) as e: - raise ConfigurationError(f"Failed to export configuration: {e}") - - @staticmethod - def import_from_json(input_path: Path) -> Dict[str, Any]: - """Import configuration from JSON file. - - Args: - input_path: Path to JSON file to import - - Returns: - Configuration dictionary - - Raises: - ConfigurationError: If import fails or validation fails - """ - if not input_path.exists(): - raise ConfigurationError(f"File not found: {input_path}") - - try: - config_data = json.loads(input_path.read_text()) - # Validate before returning - ConfigValidator.validate_or_raise(config_data) - logger.info(f"Configuration imported from: {input_path}") - return config_data - except json.JSONDecodeError as e: - raise ConfigurationError(f"Invalid JSON file: {e}") diff --git a/src/webdrop_bridge/core/updater.py b/src/webdrop_bridge/core/updater.py deleted file mode 100644 index 3de4b9f..0000000 --- a/src/webdrop_bridge/core/updater.py +++ /dev/null @@ -1,444 +0,0 @@ -"""Auto-update system for WebDrop Bridge using Forgejo releases. - -This module manages checking for updates, downloading installers, and -verifying checksums from Forgejo releases. -""" - -import asyncio -import hashlib -import json -import logging -import socket -from dataclasses import dataclass -from datetime import datetime, timedelta -from pathlib import Path -from typing import Optional -from urllib.error import URLError -from urllib.request import urlopen - -logger = logging.getLogger(__name__) - - -@dataclass -class Release: - """Represents a Forgejo release.""" - - tag_name: str - name: str - version: str # Semantic version (e.g., "1.0.0") - body: str # Release notes/changelog - assets: list[dict] # List of {name, browser_download_url} - published_at: str # ISO format datetime - - -class UpdateManager: - """Manages auto-updates via Forgejo releases API.""" - - def __init__(self, current_version: str, config_dir: Optional[Path] = None): - """Initialize update manager. - - Args: - current_version: Current app version (e.g., "0.0.1") - config_dir: Directory for storing update cache. Defaults to temp. - """ - self.current_version = current_version - self.forgejo_url = "https://git.him-tools.de" - self.repo = "HIM-public/webdrop-bridge" - self.api_endpoint = ( - f"{self.forgejo_url}/api/v1/repos/{self.repo}/releases/latest" - ) - - # Cache management - self.cache_dir = config_dir or Path.home() / ".webdrop-bridge" - self.cache_dir.mkdir(parents=True, exist_ok=True) - self.cache_file = self.cache_dir / "update_check.json" - self.cache_ttl = timedelta(hours=24) - - def _parse_version(self, version_str: str) -> tuple[int, int, int]: - """Parse semantic version string to tuple. - - Args: - version_str: Version string (e.g., "1.0.0" or "v1.0.0") - - Returns: - Tuple of (major, minor, patch) - - Raises: - ValueError: If version format is invalid - """ - # Remove 'v' prefix if present - version_str = version_str.lstrip("v") - - try: - parts = version_str.split(".") - if len(parts) != 3: - raise ValueError(f"Invalid version format: {version_str}") - return tuple(int(p) for p in parts) # type: ignore - except ValueError as e: - logger.error(f"Failed to parse version '{version_str}': {e}") - raise - - def _is_newer_version(self, latest_version: str) -> bool: - """Check if latest version is newer than current. - - Args: - latest_version: Latest version string - - Returns: - True if latest_version > current_version - """ - try: - current = self._parse_version(self.current_version) - latest = self._parse_version(latest_version) - return latest > current - except ValueError: - logger.error("Failed to compare versions") - return False - - def _load_cache(self) -> Optional[dict]: - """Load cached release info if valid. - - Returns: - Cached release dict if cache exists and is fresh, None otherwise - """ - if not self.cache_file.exists(): - return None - - try: - with open(self.cache_file) as f: - cached = json.load(f) - - # Check if cache is still valid - timestamp = datetime.fromisoformat(cached.get("timestamp", "")) - if datetime.now() - timestamp < self.cache_ttl: - logger.debug("Using cached release info") - return cached - else: - logger.debug("Cache expired") - self.cache_file.unlink() - return None - except (json.JSONDecodeError, ValueError) as e: - logger.warning(f"Failed to load cache: {e}") - self.cache_file.unlink() - return None - - def _save_cache(self, release_info: dict) -> None: - """Save release info to cache. - - Args: - release_info: Release information to cache - """ - try: - cache_data = { - "timestamp": datetime.now().isoformat(), - "release": release_info, - } - with open(self.cache_file, "w") as f: - json.dump(cache_data, f) - logger.debug("Cached release info") - except OSError as e: - logger.warning(f"Failed to save cache: {e}") - - async def check_for_updates(self) -> Optional[Release]: - """Check Forgejo API for latest release. - - Returns: - Release object if newer version available, None otherwise - """ - logger.debug(f"check_for_updates() called, current version: {self.current_version}") - - # Try cache first - logger.debug("Checking cache...") - cached = self._load_cache() - if cached: - logger.debug("Found cached release") - release_data = cached.get("release") - if release_data: - version = release_data["tag_name"].lstrip("v") - if not self._is_newer_version(version): - logger.info("No newer version available (cached)") - return None - return Release(**release_data) - - # Fetch from API - logger.debug("Fetching from API...") - try: - logger.info(f"Checking for updates from {self.api_endpoint}") - - # Run in thread pool with aggressive timeout - loop = asyncio.get_event_loop() - response = await asyncio.wait_for( - loop.run_in_executor( - None, self._fetch_release - ), - timeout=8 # Timeout after network call also has timeout - ) - - if not response: - return None - - # Check if newer version - version = response["tag_name"].lstrip("v") - if not self._is_newer_version(version): - logger.info(f"Latest version {version} is not newer than {self.current_version}") - self._save_cache(response) - return None - - logger.info(f"New version available: {version}") - release = Release(**response) - self._save_cache(response) - return release - - except asyncio.TimeoutError: - logger.warning("Update check timed out - API server not responding") - return None - except Exception as e: - logger.error(f"Error checking for updates: {e}") - return None - - def _fetch_release(self) -> Optional[dict]: - """Fetch latest release from Forgejo API (blocking). - - Returns: - Release data dict or None on error - """ - try: - logger.debug(f"Fetching release from {self.api_endpoint}") - - # Set socket timeout to prevent hanging - old_timeout = socket.getdefaulttimeout() - socket.setdefaulttimeout(5) - - try: - logger.debug("Opening URL connection...") - with urlopen(self.api_endpoint, timeout=5) as response: - logger.debug(f"Response status: {response.status}, reading data...") - response_data = response.read() - logger.debug(f"Read {len(response_data)} bytes, parsing JSON...") - data = json.loads(response_data) - logger.info(f"Successfully fetched release: {data.get('tag_name', 'unknown')}") - return { - "tag_name": data["tag_name"], - "name": data["name"], - "version": data["tag_name"].lstrip("v"), - "body": data["body"], - "assets": data.get("assets", []), - "published_at": data.get("published_at", ""), - } - finally: - socket.setdefaulttimeout(old_timeout) - - except socket.timeout as e: - logger.error(f"Socket timeout (5s) connecting to {self.api_endpoint}") - return None - except TimeoutError as e: - logger.error(f"Timeout error: {e}") - return None - except Exception as e: - logger.error(f"Failed to fetch release: {type(e).__name__}: {e}") - import traceback - logger.debug(traceback.format_exc()) - return None - - async def download_update( - self, release: Release, output_dir: Optional[Path] = None - ) -> Optional[Path]: - """Download installer from release assets. - - Args: - release: Release information - output_dir: Directory to save installer. Defaults to cache_dir. - - Returns: - Path to downloaded file or None on error - """ - if not release.assets: - logger.error("No assets found in release") - return None - - # Find .msi or .dmg file - installer_asset = None - for asset in release.assets: - if asset["name"].endswith((".msi", ".dmg")): - installer_asset = asset - break - - if not installer_asset: - logger.error("No installer found in release assets") - return None - - output_dir = output_dir or self.cache_dir - output_dir.mkdir(parents=True, exist_ok=True) - output_file = output_dir / installer_asset["name"] - - try: - logger.info(f"Downloading {installer_asset['name']}") - - # Run in thread pool with 5-minute timeout for large files - loop = asyncio.get_event_loop() - success = await asyncio.wait_for( - loop.run_in_executor( - None, - self._download_file, - installer_asset["browser_download_url"], - output_file, - ), - timeout=300 - ) - - if success: - logger.info(f"Downloaded to {output_file}") - return output_file - return None - - except asyncio.TimeoutError: - logger.error(f"Download timed out: {installer_asset['name']}") - if output_file.exists(): - output_file.unlink() - return None - except Exception as e: - logger.error(f"Error downloading update: {e}") - if output_file.exists(): - output_file.unlink() - return None - - def _download_file(self, url: str, output_path: Path) -> bool: - """Download file from URL (blocking). - - Args: - url: URL to download from - output_path: Path to save file - - Returns: - True if successful, False otherwise - """ - try: - logger.debug(f"Downloading from {url}") - with urlopen(url, timeout=300) as response: # 5 min timeout - with open(output_path, "wb") as f: - f.write(response.read()) - logger.debug(f"Downloaded {output_path.stat().st_size} bytes") - return True - except URLError as e: - logger.error(f"Download failed: {e}") - return False - - async def verify_checksum( - self, file_path: Path, release: Release - ) -> bool: - """Verify file checksum against release checksum file. - - Args: - file_path: Path to downloaded installer - release: Release information - - Returns: - True if checksum matches, False otherwise - """ - # Find .sha256 file in release assets - checksum_asset = None - for asset in release.assets: - if asset["name"].endswith(".sha256"): - checksum_asset = asset - break - - if not checksum_asset: - logger.warning("No checksum file found in release") - return True # Continue anyway - - try: - logger.info("Verifying checksum...") - - # Download checksum file with 30 second timeout - loop = asyncio.get_event_loop() - checksum_content = await asyncio.wait_for( - loop.run_in_executor( - None, - self._download_checksum, - checksum_asset["browser_download_url"], - ), - timeout=30 - ) - - if not checksum_content: - logger.warning("Failed to download checksum") - return False - - # Calculate file checksum - sha256_hash = hashlib.sha256() - with open(file_path, "rb") as f: - for chunk in iter(lambda: f.read(8192), b""): - sha256_hash.update(chunk) - - file_checksum = sha256_hash.hexdigest() - expected_checksum = checksum_content.strip() - - if file_checksum == expected_checksum: - logger.info("Checksum verification passed") - return True - else: - logger.error( - f"Checksum mismatch: {file_checksum} != {expected_checksum}" - ) - return False - - except asyncio.TimeoutError: - logger.error("Checksum verification timed out") - return False - except Exception as e: - logger.error(f"Error verifying checksum: {e}") - return False - - def _download_checksum(self, url: str) -> Optional[str]: - """Download checksum file (blocking). - - Args: - url: URL to checksum file - - Returns: - Checksum content or None on error - """ - try: - with urlopen(url, timeout=10) as response: - return response.read().decode().strip() - except URLError as e: - logger.error(f"Failed to download checksum: {e}") - return None - - def install_update(self, installer_path: Path) -> bool: - """Launch installer for update. - - Args: - installer_path: Path to installer executable - - Returns: - True if installer launched, False otherwise - - Note: - The actual installation and restart are handled by the installer. - """ - if not installer_path.exists(): - logger.error(f"Installer not found: {installer_path}") - return False - - try: - import platform - import subprocess - - if platform.system() == "Windows": - # Windows: Run MSI installer - logger.info(f"Launching installer: {installer_path}") - subprocess.Popen([str(installer_path)]) - return True - elif platform.system() == "Darwin": - # macOS: Mount DMG and run installer - logger.info(f"Launching DMG: {installer_path}") - subprocess.Popen(["open", str(installer_path)]) - return True - else: - logger.error(f"Unsupported platform: {platform.system()}") - return False - - except Exception as e: - logger.error(f"Failed to launch installer: {e}") - return False diff --git a/src/webdrop_bridge/main.py b/src/webdrop_bridge/main.py index 6c33e88..d6dad60 100644 --- a/src/webdrop_bridge/main.py +++ b/src/webdrop_bridge/main.py @@ -53,9 +53,6 @@ def main() -> int: window.show() logger.info("Main window opened successfully") - - # Check for updates on startup (non-blocking, async) - window.check_for_updates_startup() # Run event loop return app.exec() diff --git a/src/webdrop_bridge/ui/bridge_script.js b/src/webdrop_bridge/ui/bridge_script.js deleted file mode 100644 index aa5b8a3..0000000 --- a/src/webdrop_bridge/ui/bridge_script.js +++ /dev/null @@ -1,73 +0,0 @@ -// WebDrop Bridge - Injected Script -// Automatically converts Z:\ path drags to native file drags via QWebChannel bridge - -(function() { - if (window.__webdrop_bridge_injected) return; - window.__webdrop_bridge_injected = true; - - function ensureChannel(cb) { - if (window.bridge) { cb(); return; } - - function init() { - if (window.QWebChannel && window.qt && window.qt.webChannelTransport) { - new QWebChannel(window.qt.webChannelTransport, function(channel) { - window.bridge = channel.objects.bridge; - cb(); - }); - } - } - - if (window.QWebChannel) { - init(); - return; - } - - var s = document.createElement('script'); - s.src = 'qrc:///qtwebchannel/qwebchannel.js'; - s.onload = init; - document.documentElement.appendChild(s); - } - - function hook() { - document.addEventListener('dragstart', function(e) { - var dt = e.dataTransfer; - if (!dt) return; - - // Get path from existing payload or from the card markup. - var path = dt.getData('text/plain'); - if (!path) { - var card = e.target.closest && e.target.closest('.drag-item'); - if (card) { - var pathEl = card.querySelector('p'); - if (pathEl) { - path = (pathEl.textContent || '').trim(); - } - } - } - if (!path) return; - - // Ensure text payload exists for non-file drags and downstream targets. - if (!dt.getData('text/plain')) { - dt.setData('text/plain', path); - } - - // Check if path is Z:\ — if yes, trigger native file drag. Otherwise, stay as text. - var isZDrive = /^z:/i.test(path); - if (!isZDrive) return; - - // Z:\ detected — prevent default browser drag and convert to native file drag - e.preventDefault(); - ensureChannel(function() { - if (window.bridge && typeof window.bridge.start_file_drag === 'function') { - window.bridge.start_file_drag(path); - } - }); - }, false); - } - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', hook); - } else { - hook(); - } -})(); diff --git a/src/webdrop_bridge/ui/main_window.py b/src/webdrop_bridge/ui/main_window.py index 84638a4..039b802 100644 --- a/src/webdrop_bridge/ui/main_window.py +++ b/src/webdrop_bridge/ui/main_window.py @@ -1,34 +1,16 @@ """Main application window with web engine integration.""" -import asyncio -import logging -from datetime import datetime from pathlib import Path from typing import Optional -from PySide6.QtCore import QObject, QPoint, QSize, Qt, QThread, QTimer, QUrl, Signal, Slot -from PySide6.QtGui import QIcon -from PySide6.QtWebChannel import QWebChannel -from PySide6.QtWebEngineCore import QWebEngineScript -from PySide6.QtWidgets import ( - QLabel, - QMainWindow, - QSizePolicy, - QSpacerItem, - QStatusBar, - QToolBar, - QVBoxLayout, - QWidget, - QWidgetAction, -) +from PySide6.QtCore import QSize, Qt, QUrl +from PySide6.QtWidgets import QMainWindow, QToolBar, QVBoxLayout, QWidget from webdrop_bridge.config import Config from webdrop_bridge.core.drag_interceptor import DragInterceptor from webdrop_bridge.core.validator import PathValidator from webdrop_bridge.ui.restricted_web_view import RestrictedWebEngineView -logger = logging.getLogger(__name__) - # Default welcome page HTML when no webapp is configured DEFAULT_WELCOME_PAGE = """ @@ -184,39 +166,6 @@ DEFAULT_WELCOME_PAGE = """ """ -class _DragBridge(QObject): - """JavaScript bridge for drag operations via QWebChannel. - - Exposed to JavaScript as 'bridge' object. - """ - - def __init__(self, window: 'MainWindow', parent: Optional[QObject] = None): - """Initialize the drag bridge. - - Args: - window: MainWindow instance - parent: Parent QObject - """ - super().__init__(parent) - self.window = window - - @Slot(str) - def start_file_drag(self, path_text: str) -> None: - """Start a native file drag for the given path. - - Called from JavaScript when user drags a Z:\ path item. - Defers execution to avoid Qt drag manager state issues. - - Args: - path_text: File path string to drag - """ - logger.debug(f"Bridge: start_file_drag called for {path_text}") - - # Defer to avoid drag manager state issues - # initiate_drag() handles validation internally - QTimer.singleShot(0, lambda: self.window.drag_interceptor.initiate_drag([path_text])) - - class MainWindow(QMainWindow): """Main application window for WebDrop Bridge. @@ -224,10 +173,6 @@ class MainWindow(QMainWindow): integration with the native filesystem. """ - # Signals - check_for_updates = Signal() - update_available = Signal(object) # Emits Release object - def __init__( self, config: Config, @@ -241,13 +186,9 @@ class MainWindow(QMainWindow): """ super().__init__(parent) self.config = config - self._background_threads = [] # Keep references to background threads - self._background_workers = {} # Keep references to background workers - self.checking_dialog = None # Track the checking dialog - self._is_manual_check = False # Track if this is a manual check (for UI feedback) # Set window properties - self.setWindowTitle(config.window_title) + self.setWindowTitle(f"{config.app_name} v{config.app_version}") self.setGeometry( 100, 100, @@ -261,11 +202,9 @@ class MainWindow(QMainWindow): # Create navigation toolbar (Kiosk-mode navigation) self._create_navigation_toolbar() - # Create status bar - self._create_status_bar() - # Create drag interceptor self.drag_interceptor = DragInterceptor() + # Set up path validator validator = PathValidator(config.allowed_roots) self.drag_interceptor.set_validator(validator) @@ -274,15 +213,6 @@ class MainWindow(QMainWindow): self.drag_interceptor.drag_started.connect(self._on_drag_started) self.drag_interceptor.drag_failed.connect(self._on_drag_failed) - # Set up JavaScript bridge with QWebChannel - self._drag_bridge = _DragBridge(self) - web_channel = QWebChannel(self) - web_channel.registerObject("bridge", self._drag_bridge) - self.web_view.page().setWebChannel(web_channel) - - # Install the drag bridge script - self._install_bridge_script() - # Set up central widget with layout central_widget = QWidget() layout = QVBoxLayout() @@ -301,7 +231,6 @@ class MainWindow(QMainWindow): """Load the web application. Loads HTML from the configured webapp URL or from local file. - Injects the WebChannel bridge JavaScript for drag-and-drop. Supports both bundled apps (PyInstaller) and development mode. Falls back to default welcome page if webapp not found. """ @@ -336,55 +265,15 @@ class MainWindow(QMainWindow): self.web_view.setHtml(welcome_html) return - # Load local file - html_content = file_path.read_text(encoding='utf-8') - - # Inject WebChannel bridge JavaScript - injected_html = self._inject_drag_bridge(html_content) - - # Load the modified HTML - self.web_view.setHtml(injected_html, QUrl.fromLocalFile(file_path.parent)) + # Load local file as file:// URL + file_url = file_path.as_uri() + self.web_view.load(QUrl(file_url)) except (OSError, ValueError) as e: # Show welcome page on error welcome_html = DEFAULT_WELCOME_PAGE.format(version=self.config.app_version) self.web_view.setHtml(welcome_html) - def _install_bridge_script(self) -> None: - """Install the drag bridge JavaScript via QWebEngineScript. - - Follows the POC pattern for proper script injection and QWebChannel setup. - """ - script = QWebEngineScript() - script.setName("webdrop-bridge") - script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady) - script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld) - script.setRunsOnSubFrames(False) - - # Load bridge script from file - script_path = Path(__file__).parent / "bridge_script.js" - try: - with open(script_path, 'r', encoding='utf-8') as f: - script.setSourceCode(f.read()) - self.web_view.page().scripts().insert(script) - logger.debug(f"Installed bridge script from {script_path}") - except (OSError, IOError) as e: - logger.warning(f"Failed to load bridge script: {e}") - - def _inject_drag_bridge(self, html_content: str) -> str: - """Return HTML content unmodified. - - The drag bridge script is now injected via QWebEngineScript in _install_bridge_script(). - This method is kept for compatibility but does nothing. - - Args: - html_content: Original HTML content - - Returns: - HTML unchanged - """ - return html_content - def _apply_stylesheet(self) -> None: """Apply application stylesheet if available.""" stylesheet_path = Path(__file__).parent.parent.parent.parent / \ @@ -421,7 +310,6 @@ class MainWindow(QMainWindow): """Create navigation toolbar with Home, Back, Forward, Refresh buttons. In Kiosk-mode, users can navigate history but cannot freely browse. - Help actions are positioned on the right side of the toolbar. """ toolbar = QToolBar("Navigation") toolbar.setMovable(False) @@ -430,13 +318,13 @@ class MainWindow(QMainWindow): # Back button back_action = self.web_view.pageAction( - self.web_view.page().WebAction.Back + self.web_view.page().WebAction.Back # type: ignore ) toolbar.addAction(back_action) # Forward button forward_action = self.web_view.pageAction( - self.web_view.page().WebAction.Forward + self.web_view.page().WebAction.Forward # type: ignore ) toolbar.addAction(forward_action) @@ -444,99 +332,15 @@ class MainWindow(QMainWindow): toolbar.addSeparator() # Home button - home_action = toolbar.addAction(self.style().standardIcon(self.style().StandardPixmap.SP_DirHomeIcon), "") - home_action.setToolTip("Home") + home_action = toolbar.addAction("Home") home_action.triggered.connect(self._navigate_home) # Refresh button refresh_action = self.web_view.pageAction( - self.web_view.page().WebAction.Reload + self.web_view.page().WebAction.Reload # type: ignore ) toolbar.addAction(refresh_action) - # Add stretch spacer to push help buttons to the right - spacer = QWidget() - spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) - toolbar.addWidget(spacer) - - # About button (info icon) on the right - about_action = toolbar.addAction("ℹ️") - about_action.setToolTip("About WebDrop Bridge") - about_action.triggered.connect(self._show_about_dialog) - - # Settings button on the right - settings_action = toolbar.addAction("⚙️") - settings_action.setToolTip("Settings") - settings_action.triggered.connect(self._show_settings_dialog) - - # Check for Updates button on the right - check_updates_action = toolbar.addAction("🔄") - check_updates_action.setToolTip("Check for Updates") - check_updates_action.triggered.connect(self._on_manual_check_for_updates) - - def _create_status_bar(self) -> None: - """Create status bar with update status indicator.""" - self.status_bar = self.statusBar() - - # Update status label - self.update_status_label = QLabel("Ready") - self.update_status_label.setStyleSheet("margin-right: 10px;") - self.status_bar.addPermanentWidget(self.update_status_label) - - def set_update_status(self, status: str, emoji: str = "") -> None: - """Update the status bar with update information. - - Args: - status: Status text to display - emoji: Optional emoji prefix (🔄, ✅, ⬇️, ⚠️) - """ - if emoji: - self.update_status_label.setText(f"{emoji} {status}") - else: - self.update_status_label.setText(status) - - def _on_manual_check_for_updates(self) -> None: - """Handle manual check for updates from menu. - - Triggers an immediate update check (bypass cache) with user feedback dialog. - """ - logger.info("Manual update check requested from menu") - - # Show "Checking for Updates..." dialog - from webdrop_bridge.ui.update_manager_ui import CheckingDialog - - self.checking_dialog = CheckingDialog(self) - self._is_manual_check = True - - # Start the update check - self.check_for_updates_startup() - - # Show the dialog - self.checking_dialog.show() - - def _show_about_dialog(self) -> None: - """Show About dialog with version and information.""" - from PySide6.QtWidgets import QMessageBox - - about_text = ( - f"{self.config.app_name}
" - 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 @@
🖼️

Sample Image

-

Z:\data\test-image.jpg

+

Z:\samples\image.psd

📄

Sample Document

-

Z:\data\API_DOCUMENTATION.pdf

+

Z:\samples\document.indd

@@ -193,5 +193,57 @@

WebDrop Bridge v1.0.0 | Built with Qt and PySide6

+ +