Compare commits
No commits in common. "main" and "v0.6.2" have entirely different histories.
71 changed files with 14988 additions and 8110 deletions
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
# Application
|
# Application
|
||||||
APP_NAME=WebDrop Bridge
|
APP_NAME=WebDrop Bridge
|
||||||
APP_VERSION=0.8.6
|
APP_VERSION=0.6.0
|
||||||
|
|
||||||
# Web App
|
# Web App
|
||||||
WEBAPP_URL=file:///./webapp/index.html
|
WEBAPP_URL=file:///./webapp/index.html
|
||||||
|
|
|
||||||
12
.github/copilot-instructions.md
vendored
12
.github/copilot-instructions.md
vendored
|
|
@ -19,18 +19,16 @@ WebDrop Bridge is a professional Qt-based desktop application (v0.5.0) that conv
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `src/webdrop_bridge/__init__.py` | Package info, version (0.7.1) |
|
| `src/webdrop_bridge/__init__.py` | Package info, version (0.5.0) |
|
||||||
| `src/webdrop_bridge/main.py` | Application entry point, config loading |
|
| `src/webdrop_bridge/main.py` | Application entry point, config loading |
|
||||||
| `src/webdrop_bridge/config.py` | Configuration management (file/env), URL mappings, validation |
|
| `src/webdrop_bridge/config.py` | Configuration management (file/env), URL mappings, validation |
|
||||||
| `src/webdrop_bridge/core/validator.py` | Path validation against whitelist, security checks |
|
| `src/webdrop_bridge/core/validator.py` | Path validation against whitelist, security checks |
|
||||||
| `src/webdrop_bridge/core/drag_interceptor.py` | Drag-and-drop event handling |
|
| `src/webdrop_bridge/core/drag_interceptor.py` | Drag-and-drop event handling |
|
||||||
| `src/webdrop_bridge/core/config_manager.py` | Configuration validation, profiles, import/export |
|
| `src/webdrop_bridge/core/config_manager.py` | File-based config loading and caching |
|
||||||
| `src/webdrop_bridge/core/url_converter.py` | Azure blob URL → local path conversion |
|
| `src/webdrop_bridge/core/url_converter.py` | Azure blob URL → local path conversion |
|
||||||
| `src/webdrop_bridge/core/updater.py` | Update checking via Forgejo API, release management |
|
| `src/webdrop_bridge/core/updater.py` | Update checking via Forgejo API, release management |
|
||||||
| `src/webdrop_bridge/ui/main_window.py` | Main Qt window, config injection, menu bar |
|
| `src/webdrop_bridge/ui/main_window.py` | Main Qt window, config injection, menu bar |
|
||||||
| `src/webdrop_bridge/ui/restricted_web_view.py` | Hardened QWebEngineView with security policies |
|
| `src/webdrop_bridge/ui/restricted_web_view.py` | Hardened QWebEngineView with security policies |
|
||||||
| `src/webdrop_bridge/ui/bridge_script_intercept.js` | JavaScript drag interception and WebChannel bridge |
|
|
||||||
| `src/webdrop_bridge/ui/download_interceptor.js` | Download handling for web content |
|
|
||||||
| `src/webdrop_bridge/ui/settings_dialog.py` | Settings UI, URL mapping configuration |
|
| `src/webdrop_bridge/ui/settings_dialog.py` | Settings UI, URL mapping configuration |
|
||||||
| `src/webdrop_bridge/ui/update_manager_ui.py` | Update check UI and dialogs |
|
| `src/webdrop_bridge/ui/update_manager_ui.py` | Update check UI and dialogs |
|
||||||
| `src/webdrop_bridge/utils/logging.py` | Logging configuration (console + file) |
|
| `src/webdrop_bridge/utils/logging.py` | Logging configuration (console + file) |
|
||||||
|
|
@ -256,6 +254,6 @@ git push origin feature/my-feature
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Current Status**: Phase 4 Complete - Phase 5 (Release Candidates) In Progress
|
**Current Status**: Phase 4 Complete (Jan 29, 2026) - Phase 5 (Release Candidates) Planned
|
||||||
**Version**: 0.7.1
|
**Version**: 0.5.0
|
||||||
**Last Updated**: March 3, 2026
|
**Last Updated**: February 18, 2026
|
||||||
|
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -143,12 +143,6 @@ ehthumbs.db
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
build/dist/
|
build/dist/
|
||||||
build/build_output.log
|
|
||||||
build/test.txt
|
|
||||||
build/*.wixobj
|
|
||||||
build/*.wixpdb
|
|
||||||
build/*_Files.wxs
|
|
||||||
build/*.generated.wxs
|
|
||||||
*.msi
|
*.msi
|
||||||
*.exe
|
*.exe
|
||||||
*.dmg
|
*.dmg
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"timestamp": "2026-03-12T10:57:42.150570", "release": {"tag_name": "v0.8.4", "name": "WebDropBridge v0.8.4", "version": "0.8.4", "body": "Shared branded release for WebDrop Bridge v0.8.4", "assets": [{"id": 49, "name": "AgravityBridge-0.8.4-win-x64.msi", "size": 214445231, "download_count": 2, "created_at": "2026-03-12T08:25:03Z", "uuid": "7ffcd98a-99a9-4100-8e71-3ebe63534b8f", "browser_download_url": "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/AgravityBridge-0.8.4-win-x64.msi", "type": "attachment"}, {"id": 50, "name": "AgravityBridge-0.8.4-win-x64.msi.sha256", "size": 64, "download_count": 2, "created_at": "2026-03-12T08:25:03Z", "uuid": "ddd00072-a5bc-422f-93c0-7cc3bc3408d3", "browser_download_url": "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/AgravityBridge-0.8.4-win-x64.msi.sha256", "type": "attachment"}, {"id": 47, "name": "WebDropBridge-0.8.4-win-x64.msi", "size": 214445229, "download_count": 0, "created_at": "2026-03-12T08:24:20Z", "uuid": "5a20eef9-b77d-4e04-be06-d85c3ebd3f6e", "browser_download_url": "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/WebDropBridge-0.8.4-win-x64.msi", "type": "attachment"}, {"id": 48, "name": "WebDropBridge-0.8.4-win-x64.msi.sha256", "size": 64, "download_count": 0, "created_at": "2026-03-12T08:24:21Z", "uuid": "9972b3bb-7c4b-4b26-951a-5a8dfc1a1f27", "browser_download_url": "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/WebDropBridge-0.8.4-win-x64.msi.sha256", "type": "attachment"}, {"id": 51, "name": "release-manifest.json", "size": 931, "download_count": 0, "created_at": "2026-03-12T08:25:03Z", "uuid": "e3c13ccd-cbc6-4eb1-988e-7f465a75eca6", "browser_download_url": "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/release-manifest.json", "type": "attachment"}], "published_at": "2026-03-12T08:23:40Z"}}
|
|
||||||
232
CHANGELOG.md
Normal file
232
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
## [0.6.0] - 2026-02-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **UI Enhancements**
|
||||||
|
- Web source configuration tab in settings dialog for URL mapping management
|
||||||
|
- Enhanced about dialog with product description and contact information
|
||||||
|
|
||||||
|
- **Build & Distribution**
|
||||||
|
- Executable versioning support for Windows builds
|
||||||
|
- Desktop shortcut creation in WiX installer
|
||||||
|
- Support for 64-bit components in MSI installer (fix)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Refactored logging configuration to use AppData directory (Windows) instead of application root
|
||||||
|
- Enhanced Windows installer with improved UI and error reporting
|
||||||
|
- Improved code structure and readability across multiple modules
|
||||||
|
- Refactored version syncing script with better Unicode handling
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed import order in settings_dialog.py (QTabWidget positioning)
|
||||||
|
- Improved error reporting in Windows installer linking
|
||||||
|
- Enhanced Unicode handling in build scripts
|
||||||
|
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to WebDrop Bridge will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-01-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Core Features**
|
||||||
|
- Qt6-based desktop application for web-to-file drag-and-drop
|
||||||
|
- PySide6 integration with WebEngine for embedded browser
|
||||||
|
- Path validation and security with whitelist-based access control
|
||||||
|
- Drag-and-drop event interception and handling
|
||||||
|
- Real-time drag state monitoring
|
||||||
|
|
||||||
|
- **UI/UX**
|
||||||
|
- Professional main window with toolbar navigation
|
||||||
|
- Restricted web view with URL whitelist enforcement
|
||||||
|
- Kiosk-mode support (restricted browsing)
|
||||||
|
- Beautiful default welcome page for unconfigured instances
|
||||||
|
- Responsive layout with proper window management
|
||||||
|
|
||||||
|
- **Configuration**
|
||||||
|
- Environment-based configuration system (.env file support)
|
||||||
|
- Configurable allowed root directories for file access
|
||||||
|
- URL whitelist with wildcard support (*.example.com)
|
||||||
|
- Window size and appearance settings
|
||||||
|
- Logging level and output control
|
||||||
|
|
||||||
|
- **Logging & Monitoring**
|
||||||
|
- Structured logging with INFO, DEBUG, ERROR levels
|
||||||
|
- Optional file-based logging
|
||||||
|
- Comprehensive error messages and diagnostics
|
||||||
|
- Application startup and shutdown logging
|
||||||
|
|
||||||
|
- **Build & Distribution**
|
||||||
|
- PyInstaller configuration for Windows and macOS
|
||||||
|
- Standalone executable generation (195.7 MB for Windows)
|
||||||
|
- Dependency bundling (PySide6, Qt6, Chromium)
|
||||||
|
- Resource embedding (webapp, icons, stylesheets)
|
||||||
|
- Cross-platform support (Windows .exe, macOS .dmg)
|
||||||
|
|
||||||
|
- **Testing & Quality**
|
||||||
|
- 99 unit and integration tests
|
||||||
|
- 84% code coverage
|
||||||
|
- Ruff linting and Black code formatting
|
||||||
|
- mypy type checking
|
||||||
|
- Comprehensive test fixtures and mocking
|
||||||
|
|
||||||
|
- **CI/CD**
|
||||||
|
- Build automation scripts for Windows and macOS
|
||||||
|
- Forgejo Packages support for distribution
|
||||||
|
- SHA256 checksum generation for release files
|
||||||
|
- Release documentation on Forgejo
|
||||||
|
|
||||||
|
- **Documentation**
|
||||||
|
- Comprehensive API documentation with docstrings
|
||||||
|
- Architecture documentation (ARCHITECTURE.md)
|
||||||
|
- Development plan (DEVELOPMENT_PLAN.md)
|
||||||
|
- Setup and quickstart guides
|
||||||
|
- Contributing guidelines
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
- **Language**: Python 3.13
|
||||||
|
- **Framework**: PySide6 6.10.1 (Qt6)
|
||||||
|
- **Web Engine**: Qt6 WebEngine with Chromium
|
||||||
|
- **Build Tool**: PyInstaller 6.18.0
|
||||||
|
- **Testing**: pytest with coverage
|
||||||
|
- **Linting**: Ruff + Black
|
||||||
|
|
||||||
|
### Known Limitations
|
||||||
|
- Requires configuration for custom web applications
|
||||||
|
- Manual release builds needed (no CI/CD runners in Forgejo at this time)
|
||||||
|
|
||||||
|
## [0.5.0] - 2026-02-18
|
||||||
|
|
||||||
|
### Added - Phase 4 Professional Features
|
||||||
|
|
||||||
|
#### Phase 4.1: Auto-Update System
|
||||||
|
- **Auto-update Manager** (`core/updater.py`)
|
||||||
|
- Check for new releases via Forgejo API
|
||||||
|
- Automatic background update checking (configurable interval)
|
||||||
|
- Manual "Check for Updates" menu option
|
||||||
|
- SHA256 checksum verification for downloaded files
|
||||||
|
- Version comparison using semantic versioning
|
||||||
|
- 27 tests passing, 79% coverage
|
||||||
|
|
||||||
|
- **Update UI Components** (`ui/update_manager_ui.py`)
|
||||||
|
- Update notification dialogs with release notes and changelog
|
||||||
|
- Progress bar for update downloads
|
||||||
|
- Integration with Help menu and status bar
|
||||||
|
- Real-time status updates ("Checking...", "Downloading...", "Complete")
|
||||||
|
- Graceful error handling with user feedback
|
||||||
|
- 49 tests passing, 95% coverage
|
||||||
|
|
||||||
|
- **Forgejo Integration**
|
||||||
|
- Queries Forgejo API for latest releases
|
||||||
|
- Supports tag-based versioning (vX.Y.Z)
|
||||||
|
- Release notes parsing and display
|
||||||
|
- Asset/checksum management
|
||||||
|
|
||||||
|
#### Phase 4.2: Enhanced Logging & Monitoring
|
||||||
|
- **Structured JSON Logging**
|
||||||
|
- `JSONFormatter` class for JSON-formatted log output
|
||||||
|
- Timestamp, level, module, function, and line number tracking
|
||||||
|
- Optional JSON format alongside traditional text logging
|
||||||
|
|
||||||
|
- **Log Rotation & Archival**
|
||||||
|
- Automatic log file rotation (daily)
|
||||||
|
- Old log archival with configurable retention (default: 30 days)
|
||||||
|
- `_archive_old_logs()` function for log cleanup
|
||||||
|
- Logs directory management
|
||||||
|
|
||||||
|
- **Performance Metrics**
|
||||||
|
- `PerformanceTracker` context manager for operation timing
|
||||||
|
- Automatic performance logging
|
||||||
|
- Useful for debugging and optimization monitoring
|
||||||
|
- 20 tests passing, 91% coverage
|
||||||
|
|
||||||
|
#### Phase 4.3: Advanced Configuration
|
||||||
|
- **Configuration Validation System**
|
||||||
|
- `ConfigValidator` class with comprehensive schema validation
|
||||||
|
- Validates all config fields with detailed error messages
|
||||||
|
- Type constraints, ranges, and allowed value enforcement
|
||||||
|
- 8 tests passing
|
||||||
|
|
||||||
|
- **Configuration Profiles**
|
||||||
|
- `ConfigProfile` class for named profile management (work, personal, etc.)
|
||||||
|
- Profile storage in `~/.webdrop-bridge/profiles/` as JSON
|
||||||
|
- Profile save/load/delete functionality
|
||||||
|
- 7 tests passing
|
||||||
|
|
||||||
|
- **Settings Dialog UI** (`ui/settings_dialog.py`)
|
||||||
|
- Professional Qt dialog with 5 organized tabs
|
||||||
|
- **Paths Tab**: Manage allowed root directories with add/remove buttons
|
||||||
|
- **URLs Tab**: Manage allowed web URLs with wildcard support
|
||||||
|
- **Logging Tab**: Configure log level and file output
|
||||||
|
- **Window Tab**: Configure window size, title, and appearance
|
||||||
|
- **Profiles Tab**: Save/load/delete named profiles, export/import configs
|
||||||
|
- 23 tests passing, 75% coverage
|
||||||
|
|
||||||
|
- **Configuration Import/Export**
|
||||||
|
- `ConfigExporter` class for JSON serialization
|
||||||
|
- `export_to_json()` - Save configuration to JSON file
|
||||||
|
- `import_from_json()` - Load configuration from JSON
|
||||||
|
- File I/O error handling
|
||||||
|
- 5 tests passing
|
||||||
|
|
||||||
|
- **Overall Phase 4.3 Stats**
|
||||||
|
- 43 tests passing total
|
||||||
|
- 87% coverage on `config_manager.py`
|
||||||
|
- 75% coverage on `settings_dialog.py`
|
||||||
|
|
||||||
|
### Technical Improvements
|
||||||
|
- **Test Coverage**: Increased from 84% (v1.0.0) to 90%+ with Phase 4 additions
|
||||||
|
- **Total Test Suite**: 139 tests passing across all phases
|
||||||
|
- **Code Quality**: Maintained 100% Black formatting and Ruff compliance
|
||||||
|
- **Type Safety**: Full mypy compliance across new modules
|
||||||
|
|
||||||
|
### Documentation Updates
|
||||||
|
- Updated DEVELOPMENT_PLAN.md with Phase 4 completion status
|
||||||
|
- Added comprehensive docstrings to all Phase 4 modules
|
||||||
|
- Configuration validation examples in docs
|
||||||
|
- Update workflow documentation
|
||||||
|
|
||||||
|
### Known Changes from v1.0.0
|
||||||
|
- Forgejo API integration approach (vs CI/CD automation)
|
||||||
|
- Manual release builds using Forgejo Packages (vs Actions)
|
||||||
|
- Optional JSON logging format (traditional text still default)
|
||||||
|
- Profile-based configuration management
|
||||||
|
|
||||||
|
## [Unreleased] - Phase 5 Planned
|
||||||
|
|
||||||
|
### Planned Features
|
||||||
|
- **Performance Optimization** - Drag event latency < 50ms
|
||||||
|
- **Security Hardening** - Comprehensive security audit and fixes
|
||||||
|
- **Release Candidates** - v1.0.1-rc1, rc2, rc3 testing
|
||||||
|
- **Final Releases** - Stable Windows & macOS builds
|
||||||
|
- **Analytics** (Optional post-release)
|
||||||
|
- **Community Support** - GitHub/Forgejo discussion forums
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version Numbering
|
||||||
|
|
||||||
|
- **MAJOR**: Significant feature additions or breaking changes
|
||||||
|
- **MINOR**: New features, backward compatible
|
||||||
|
- **PATCH**: Bug fixes, improvements
|
||||||
|
|
||||||
|
Example: `1.0.0` = Version 1, Release 0, Patch 0
|
||||||
|
|
||||||
|
## Release Process
|
||||||
|
|
||||||
|
1. Update version in `src/webdrop_bridge/__init__.py` (__version__)
|
||||||
|
2. Update CHANGELOG.md with new features/fixes
|
||||||
|
3. Commit: `git commit -m "chore: Bump version to X.Y.Z"`
|
||||||
|
4. Build on Windows: `python build/scripts/build_windows.py`
|
||||||
|
5. Build on macOS: `bash build/scripts/build_macos.sh`
|
||||||
|
6. Tag: `git tag -a vX.Y.Z -m "Release version X.Y.Z"`
|
||||||
|
7. Push: `git push upstream vX.Y.Z`
|
||||||
|
8. (Optional) Upload to Forgejo Packages using provided upload scripts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Current Version**: 1.0.0 (Released 2026-01-28)
|
||||||
|
**Last Updated**: 2026-02-18 with v1.0.1 Phase 4 features
|
||||||
|
**Next Version**: 1.1.0 (Planned for Phase 5 release candidates)
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# WebDrop Bridge - Professional Development Plan
|
# WebDrop Bridge - Professional Development Plan
|
||||||
|
|
||||||
**Version**: 1.0
|
**Version**: 1.0
|
||||||
**Last Updated**: March 3, 2026
|
**Last Updated**: February 18, 2026
|
||||||
**Status**: Phase 4 Complete - Phase 5 (Release Candidates) In Progress
|
**Status**: Phase 4 Complete - Phase 5 (Release Candidates) Planned
|
||||||
|
|
||||||
## Executive Summary
|
## Executive Summary
|
||||||
|
|
||||||
|
|
@ -131,9 +131,9 @@ def setup_logging(
|
||||||
```
|
```
|
||||||
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
- [x] `src/webdrop_bridge/utils/logging.py` - Logging utilities
|
- [ ] `src/webdrop_bridge/utils/logging.py` - Logging utilities
|
||||||
- [x] Logs directory with `.gitkeep`
|
- [ ] Logs directory with `.gitkeep`
|
||||||
- [x] Log rotation policy
|
- [ ] Log rotation policy
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
**Acceptance Criteria:**
|
||||||
- Logs written to `logs/webdrop_bridge.log`
|
- Logs written to `logs/webdrop_bridge.log`
|
||||||
|
|
@ -189,9 +189,9 @@ class PathValidator:
|
||||||
```
|
```
|
||||||
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
- [x] `src/webdrop_bridge/core/validator.py` - Path validation
|
- [ ] `src/webdrop_bridge/core/validator.py` - Path validation
|
||||||
- [x] Unit tests for `PathValidator`
|
- [ ] Unit tests for `PathValidator`
|
||||||
- [x] Security documentation
|
- [ ] Security documentation
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
**Acceptance Criteria:**
|
||||||
- All paths resolved to absolute
|
- All paths resolved to absolute
|
||||||
|
|
@ -251,9 +251,9 @@ class DragInterceptor(QWidget):
|
||||||
```
|
```
|
||||||
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
- [x] `src/webdrop_bridge/core/drag_interceptor.py` - Drag handling
|
- [ ] `src/webdrop_bridge/core/drag_interceptor.py` - Drag handling
|
||||||
- [x] Unit tests with mocking
|
- [ ] Unit tests with mocking
|
||||||
- [x] Platform-specific tests (Windows/macOS)
|
- [ ] Platform-specific tests (Windows/macOS)
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
**Acceptance Criteria:**
|
||||||
- Drag events properly intercepted
|
- Drag events properly intercepted
|
||||||
|
|
@ -510,8 +510,7 @@ if __name__ == "__main__":
|
||||||
|
|
||||||
### 2.2 Integration Tests
|
### 2.2 Integration Tests
|
||||||
|
|
||||||
**Files created:**
|
**Files to create:**
|
||||||
- [x] `tests/integration/test_update_flow.py`
|
|
||||||
- [ ] `tests/integration/test_drag_workflow.py`
|
- [ ] `tests/integration/test_drag_workflow.py`
|
||||||
- [ ] `tests/integration/test_webapp_loading.py`
|
- [ ] `tests/integration/test_webapp_loading.py`
|
||||||
- [ ] `tests/integration/test_end_to_end.py`
|
- [ ] `tests/integration/test_end_to_end.py`
|
||||||
|
|
@ -627,8 +626,8 @@ export APPLE_TEAM_ID="XXXXXXXXXX"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
**Acceptance Criteria:**
|
||||||
- [x] .app bundle builds successfully
|
- [ ] .app bundle builds successfully
|
||||||
- [x] DMG image creates without errors
|
- [ ] DMG image creates without errors
|
||||||
- [ ] DMG mounts and shows contents properly
|
- [ ] DMG mounts and shows contents properly
|
||||||
- [ ] Code signing works
|
- [ ] Code signing works
|
||||||
- [ ] Notarization passes
|
- [ ] Notarization passes
|
||||||
|
|
@ -1197,27 +1196,6 @@ February 2026
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Decision: Package Manager Support (Phase 5)
|
|
||||||
|
|
||||||
**Options:**
|
|
||||||
1. Only direct downloads
|
|
||||||
2. Single package manager (Chocolatey OR Homebrew)
|
|
||||||
3. Multiple package managers (Chocolatey AND Homebrew) with custom taps
|
|
||||||
|
|
||||||
**Decision**: **Multi-channel distribution via package managers**
|
|
||||||
- **Windows**: Chocolatey community repository or internal NuGet
|
|
||||||
- **macOS**: Custom Homebrew tap on Forgejo (HIM-public/homebrew-webdrop-bridge)
|
|
||||||
- **Fallback**: Direct wget downloads + built-in auto-update system
|
|
||||||
- **Implementation**: Supports both official repos and internal/private hosting
|
|
||||||
|
|
||||||
**Implementation Details:**
|
|
||||||
- Chocolatey: `build/chocolatey/` with .nuspec manifest
|
|
||||||
- Homebrew: `build/homebrew/` with Ruby formula
|
|
||||||
- Auto-download checksums from Forgejo releases
|
|
||||||
- Documentation in `docs/PACKAGE_MANAGER_SUPPORT.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Decision: Telemetry
|
### Decision: Telemetry
|
||||||
|
|
||||||
**Options:**
|
**Options:**
|
||||||
|
|
@ -1234,14 +1212,13 @@ February 2026
|
||||||
|
|
||||||
## Current Phase
|
## Current Phase
|
||||||
|
|
||||||
Phase 4 Complete - Professional Features & Auto-Update system fully implemented.
|
Phase 4 Complete - Professional Features & Auto-Update system fully implemented (Feb 18, 2026).
|
||||||
**Current Status**: Phase 5 (Release Candidates) In Progress (as of March 3, 2026)
|
|
||||||
|
|
||||||
**Phase 4 Completion Summary:**
|
**Phase 4 Completion Summary:**
|
||||||
- ✅ Phase 4.1: Auto-Update System with Forgejo integration (76 tests)
|
- ✅ Phase 4.1: Auto-Update System with Forgejo integration (76 tests)
|
||||||
- ✅ Phase 4.2: Enhanced Logging & Monitoring (20 tests)
|
- ✅ Phase 4.2: Enhanced Logging & Monitoring (20 tests)
|
||||||
- ✅ Phase 4.3: Advanced Configuration & Settings UI (43 tests)
|
- ✅ Phase 4.3: Advanced Configuration & Settings UI (43 tests)
|
||||||
- ✅ Total Phase 4: 139 tests passing, 85%+ code coverage
|
- ✅ Total Phase 4: 139 tests passing, 90%+ coverage
|
||||||
|
|
||||||
**MSI Update Support (Feb 20, 2026):**
|
**MSI Update Support (Feb 20, 2026):**
|
||||||
- ✅ Added `<MajorUpgrade />` element to WiX configuration (build/WebDropBridge.wxs)
|
- ✅ Added `<MajorUpgrade />` element to WiX configuration (build/WebDropBridge.wxs)
|
||||||
|
|
@ -1249,50 +1226,32 @@ Phase 4 Complete - Professional Features & Auto-Update system fully implemented.
|
||||||
- ✅ Implemented EXE version information setting in build script (build/scripts/build_windows.py)
|
- ✅ Implemented EXE version information setting in build script (build/scripts/build_windows.py)
|
||||||
- ✅ Added pefile dependency for version injection
|
- ✅ Added pefile dependency for version injection
|
||||||
- Impact: MSI installer now properly detects and applies version updates
|
- Impact: MSI installer now properly detects and applies version updates
|
||||||
|
- Status: Ready for Phase 5 release candidate builds
|
||||||
**Documentation Updates (March 3, 2026):**
|
|
||||||
- ✅ Updated ARCHITECTURE.md to reflect actual implementation (WebChannel bridge, URLConverter, config_manager)
|
|
||||||
- ✅ Updated DRAG_DROP_PROBLEM_ANALYSIS.md with Phase 1 implementation status
|
|
||||||
- ✅ Fixed copilot-instructions.md version (0.5.0 → 0.7.1)
|
|
||||||
- ✅ Updated CONFIGURATION_BUILD.md with correct version examples
|
|
||||||
- ✅ Verified CUSTOMER_BUILD_EXAMPLES.md accuracy
|
|
||||||
|
|
||||||
**Application Status:**
|
**Application Status:**
|
||||||
- **Version**: 0.7.1 (current development version)
|
- Version: 1.0.0 (released Jan 28, 2026)
|
||||||
- **Phase 1-3**: Complete (core features, testing, build system)
|
- Phase 1-3: Complete (core features, testing, build system)
|
||||||
- **Phase 4**: Complete (auto-update, logging, configuration)
|
- Phase 4: Complete (auto-update, logging, configuration)
|
||||||
- **Phase 5**: In Progress (Release candidates & final polish)
|
- Phase 5: Ready to begin (Release candidates & final polish)
|
||||||
|
|
||||||
**Code Quality Metrics:**
|
|
||||||
- Test Count: 99+ passing unit tests
|
|
||||||
- Code Coverage: 85% overall
|
|
||||||
- Type Hints: Complete for core modules
|
|
||||||
- Documentation: 100% up-to-date with actual code
|
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
1. **Phase 5 - Release Candidates** (Current):
|
1. **Phase 5 - Release Candidates**:
|
||||||
- Build release candidates (v1.0.0 or higher)
|
- Build release candidates (v1.0.0-rc1, rc2, rc3)
|
||||||
- Cross-platform testing on Windows 10/11, macOS 12-14
|
- Cross-platform testing on Windows 10/11, macOS 12-14
|
||||||
- Security hardening and final audit
|
- Security hardening and final audit
|
||||||
- Performance optimization (drag latency < 50ms)
|
- Performance optimization (drag latency < 50ms)
|
||||||
- **Package Manager Setup** (NEW):
|
|
||||||
- Chocolatey packaging and publishing workflow
|
|
||||||
- Homebrew tap creation for custom distribution
|
|
||||||
- Documentation for package manager support
|
|
||||||
|
|
||||||
2. **Testing & Validation**:
|
2. **Testing & Validation**:
|
||||||
- Run full test suite on both platforms
|
- Run full test suite on both platforms
|
||||||
- User acceptance testing with real-world scenarios
|
- User acceptance testing
|
||||||
- Package manager installation testing
|
- Documentation review
|
||||||
- Documentation review and finalization
|
|
||||||
|
|
||||||
3. **Finalization**:
|
3. **Finalization**:
|
||||||
- Announce stable release v1.0.0
|
- Code signing for Windows MSI (optional)
|
||||||
- Publish installers to Forgejo Packages
|
- Apple notarization for macOS DMG (future)
|
||||||
- Publish to Chocolatey (community or internal)
|
- Create stable v1.0.0 release
|
||||||
- Create and publish Homebrew tap
|
- Publish to Forgejo Packages
|
||||||
- Enable auto-update system for users
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
168
QUICKSTART.md
168
QUICKSTART.md
|
|
@ -70,6 +70,46 @@ webdrop-bridge/
|
||||||
└── Makefile ← Convenience commands
|
└── Makefile ← Convenience commands
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
**Phase 4 is COMPLETE** - All core features and professional features implemented!
|
||||||
|
|
||||||
|
### What's Already Implemented
|
||||||
|
|
||||||
|
**Phase 1-3 (Core Features):**
|
||||||
|
- ✅ Configuration system with JSON file support & profiles
|
||||||
|
- ✅ Path validator with whitelist-based security
|
||||||
|
- ✅ Drag interceptor for web-to-file conversion
|
||||||
|
- ✅ Main window with toolbar and WebEngine integration
|
||||||
|
- ✅ Windows MSIX and macOS DMG build automation
|
||||||
|
- ✅ 99+ unit tests with 85%+ coverage
|
||||||
|
|
||||||
|
**Phase 4.1 (Auto-Update System - Feb 2026):**
|
||||||
|
- ✅ Update manager with Forgejo API integration
|
||||||
|
- ✅ Update UI dialogs and status bar integration
|
||||||
|
- ✅ Automatic background update checking
|
||||||
|
- ✅ 76 tests, 79% coverage
|
||||||
|
|
||||||
|
**Phase 4.2 (Enhanced Logging - Feb 2026):**
|
||||||
|
- ✅ Structured JSON logging with rotation
|
||||||
|
- ✅ Performance metrics tracking
|
||||||
|
- ✅ Log archival with 30-day retention
|
||||||
|
- ✅ 20 tests, 91% coverage
|
||||||
|
|
||||||
|
**Phase 4.3 (Advanced Configuration - Feb 2026):**
|
||||||
|
- ✅ Configuration profiles (work, personal, etc.)
|
||||||
|
- ✅ Settings dialog with 5 organized tabs
|
||||||
|
- ✅ Configuration validation & import/export
|
||||||
|
- ✅ 43 tests, 87% coverage
|
||||||
|
|
||||||
|
### Next Steps (Phase 5)
|
||||||
|
|
||||||
|
See [DEVELOPMENT_PLAN.md Phase 5](DEVELOPMENT_PLAN.md#phase-5-post-release-months-2-3) for:
|
||||||
|
- Release candidate testing
|
||||||
|
- Cross-platform validation
|
||||||
|
- Performance optimization
|
||||||
|
- Final packaging and deployment
|
||||||
|
|
||||||
## Common Tasks
|
## Common Tasks
|
||||||
|
|
||||||
### Running Tests
|
### Running Tests
|
||||||
|
|
@ -108,112 +148,11 @@ tox -e type
|
||||||
tox
|
tox
|
||||||
```
|
```
|
||||||
|
|
||||||
### Installing from Release (wget)
|
### Building
|
||||||
|
|
||||||
Download pre-built installers from Forgejo releases using **wget**, **package managers**, or **automated scripts** (useful for enterprise deployments, automated scripts, or initial setup before the built-in update mechanism):
|
|
||||||
|
|
||||||
#### Package Manager (Easiest)
|
|
||||||
|
|
||||||
**Windows (Chocolatey)**
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Install
|
|
||||||
choco install webdrop-bridge
|
|
||||||
|
|
||||||
# Upgrade to latest
|
|
||||||
choco upgrade webdrop-bridge
|
|
||||||
|
|
||||||
# Uninstall
|
|
||||||
choco uninstall webdrop-bridge
|
|
||||||
```
|
|
||||||
|
|
||||||
**macOS (Homebrew with custom tap)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Add tap (one-time setup)
|
|
||||||
brew tap HIM-public/webdrop-bridge https://git.him-tools.de/HIM-public/homebrew-webdrop-bridge.git
|
|
||||||
|
|
||||||
# Install
|
|
||||||
brew install webdrop-bridge
|
|
||||||
|
|
||||||
# Upgrade
|
|
||||||
brew upgrade webdrop-bridge
|
|
||||||
|
|
||||||
# Uninstall
|
|
||||||
brew uninstall webdrop-bridge
|
|
||||||
```
|
|
||||||
|
|
||||||
For more package manager details and internal hosting options, see [docs/PACKAGE_MANAGER_SUPPORT.md](../docs/PACKAGE_MANAGER_SUPPORT.md)
|
|
||||||
|
|
||||||
#### Simplest: Direct wget (if you know the version)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Replace VERSION with release tag (e.g., v0.8.0)
|
|
||||||
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/VERSION/WebDropBridge_Setup.msi
|
|
||||||
|
|
||||||
# Real example - download v0.8.0 MSI
|
|
||||||
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0/WebDropBridge_Setup.msi
|
|
||||||
|
|
||||||
# macOS - download v0.8.0 DMG
|
|
||||||
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0/WebDropBridge_Setup.dmg
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Windows (PowerShell) - Full Control Script
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Download latest release
|
|
||||||
.\build\scripts\download_release.ps1
|
|
||||||
|
|
||||||
# Download to specific directory
|
|
||||||
.\build\scripts\download_release.ps1 -OutputDir "C:\Installers"
|
|
||||||
|
|
||||||
# Download specific version
|
|
||||||
.\build\scripts\download_release.ps1 -Version "0.8.0"
|
|
||||||
|
|
||||||
# Skip checksum verification
|
|
||||||
.\build\scripts\download_release.ps1 -Verify $false
|
|
||||||
```
|
|
||||||
|
|
||||||
**Prerequisites**: `wget` (install via `choco install wget` or `winget install GNU.Wget`)
|
|
||||||
|
|
||||||
#### macOS / Linux (Bash) - Full Control Script
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Download latest release to current directory
|
|
||||||
./build/scripts/download_release.sh
|
|
||||||
|
|
||||||
# Download to specific directory
|
|
||||||
./build/scripts/download_release.sh latest ~/Downloads
|
|
||||||
|
|
||||||
# Download specific version
|
|
||||||
./build/scripts/download_release.sh 0.8.0
|
|
||||||
|
|
||||||
# Skip checksum verification
|
|
||||||
./build/scripts/download_release.sh latest --no-verify
|
|
||||||
```
|
|
||||||
|
|
||||||
**Prerequisites**: `wget` (install via `brew install wget` on macOS or `apt-get install wget` on Linux)
|
|
||||||
|
|
||||||
#### Alternative Methods
|
|
||||||
|
|
||||||
**With checksum verification (grep/cut, no jq required):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Get latest and download with automatic checksum verification
|
|
||||||
wget -qO- https://git.him-tools.de/api/v1/repos/HIM-public/webdrop-bridge/releases/latest | \
|
|
||||||
grep -o '"browser_download_url":"[^"]*\.\(msi\|dmg\)"' | head -1 | cut -d'"' -f4 | \
|
|
||||||
xargs wget -O installer.msi
|
|
||||||
```
|
|
||||||
|
|
||||||
**Via web browser:**
|
|
||||||
|
|
||||||
Simply visit https://git.him-tools.de/HIM-public/webdrop-bridge/releases and download directly
|
|
||||||
|
|
||||||
### Building from Source
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Windows MSI
|
# Windows MSI
|
||||||
python build/scripts/build_windows.py --msi
|
python build/scripts/build_windows.py
|
||||||
|
|
||||||
# macOS DMG
|
# macOS DMG
|
||||||
bash build/scripts/build_macos.sh
|
bash build/scripts/build_macos.sh
|
||||||
|
|
@ -296,6 +235,8 @@ Edit as needed:
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
|
**Phase 4 is complete!** Here's what you can do:
|
||||||
|
|
||||||
### To Run the Application
|
### To Run the Application
|
||||||
```bash
|
```bash
|
||||||
# Run the full application (requires config)
|
# Run the full application (requires config)
|
||||||
|
|
@ -314,12 +255,31 @@ pytest --cov=src/webdrop_bridge tests
|
||||||
pytest tests/unit/test_config.py -v
|
pytest tests/unit/test_config.py -v
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### To Explore Phase 4 Features
|
||||||
|
1. **Auto-Update System** → See `src/webdrop_bridge/core/updater.py`
|
||||||
|
2. **Enhanced Logging** → See `src/webdrop_bridge/utils/logging.py`
|
||||||
|
3. **Configuration Profiles** → See `src/webdrop_bridge/core/config_manager.py`
|
||||||
|
4. **Settings Dialog** → See `src/webdrop_bridge/ui/settings_dialog.py`
|
||||||
|
|
||||||
|
### To Prepare for Phase 5
|
||||||
|
1. **Read** [DEVELOPMENT_PLAN.md Phase 5](DEVELOPMENT_PLAN.md#phase-5-post-release-months-2-3)
|
||||||
|
2. **Review** [CHANGELOG.md](CHANGELOG.md) for v1.0.0 Phase 4 additions
|
||||||
|
3. **Test on multiple platforms** - Windows, macOS
|
||||||
|
4. **Report issues** via GitHub/Forgejo issues
|
||||||
|
|
||||||
### To Contribute
|
### To Contribute
|
||||||
**Review** [CONTRIBUTING.md](CONTRIBUTING.md)
|
1. **Review** [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
|
2. **Choose a Phase 5 task** or bug fix
|
||||||
|
3. **Follow TDD** - write tests first
|
||||||
|
4. **Run quality checks** → `tox`
|
||||||
|
|
||||||
## Getting Help
|
## Getting Help
|
||||||
|
|
||||||
- 📖 **Documentation**: See README.md, DEVELOPMENT_PLAN.md, docs/
|
- 📖 **Documentation**: See README.md, DEVELOPMENT_PLAN.md, docs/
|
||||||
|
- 🐛 **Issues**: GitHub Issues tracker
|
||||||
|
- 💬 **Questions**: GitHub Discussions
|
||||||
|
- 🤝 **Contributing**: See CONTRIBUTING.md
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
**Phase 4 Complete!** → Next: [DEVELOPMENT_PLAN.md Phase 5](DEVELOPMENT_PLAN.md#phase-5-post-release-months-2-3) Release Candidates
|
||||||
|
|
|
||||||
83
README.md
83
README.md
|
|
@ -38,47 +38,6 @@ WebDrop Bridge embeds a web application in a Qt container with full filesystem a
|
||||||
- Windows 10/11
|
- Windows 10/11
|
||||||
- 200 MB disk space (includes Chromium from PyInstaller)
|
- 200 MB disk space (includes Chromium from PyInstaller)
|
||||||
|
|
||||||
### Installation from Pre-Built Release (Recommended)
|
|
||||||
|
|
||||||
**Option 1: Package Manager (Recommended for most users)**
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Windows - Chocolatey
|
|
||||||
choco install webdrop-bridge
|
|
||||||
choco upgrade webdrop-bridge # Update when new version available
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# macOS - Homebrew (with custom tap)
|
|
||||||
brew tap HIM-public/webdrop-bridge https://git.him-tools.de/HIM-public/homebrew-webdrop-bridge.git
|
|
||||||
brew install webdrop-bridge
|
|
||||||
brew upgrade webdrop-bridge # Update to latest version
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option 2: Direct wget (if you know the version)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Replace VERSION with release tag (e.g., v0.8.0)
|
|
||||||
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/VERSION/WebDropBridge_Setup.msi
|
|
||||||
|
|
||||||
# Example for v0.8.0:
|
|
||||||
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0/WebDropBridge_Setup.msi
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option 3: Automated script (auto-detects platform)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Windows (PowerShell)
|
|
||||||
.\build\scripts\download_release.ps1
|
|
||||||
|
|
||||||
# macOS / Linux
|
|
||||||
./build/scripts/download_release.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
For more installation options and details, see [QUICKSTART.md](QUICKSTART.md#installing-from-release-wget) and [PACKAGE_MANAGER_SUPPORT.md](docs/PACKAGE_MANAGER_SUPPORT.md)
|
|
||||||
|
|
||||||
For multi-brand packaging and release workflows, see [BRANDING_AND_RELEASES.md](docs/BRANDING_AND_RELEASES.md).
|
|
||||||
|
|
||||||
### Installation from Source
|
### Installation from Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -143,11 +102,6 @@ webdrop-bridge/
|
||||||
└── README.md # This file
|
└── README.md # This file
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- [Architecture Guide](docs/ARCHITECTURE.md)
|
|
||||||
- [Translations Guide (i18n)](docs/TRANSLATIONS_GUIDE.md)
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -188,7 +142,7 @@ Launch the application and access the Settings menu to configure:
|
||||||
- **Window Tab** - Configure window dimensions
|
- **Window Tab** - Configure window dimensions
|
||||||
- **Profiles Tab** - Save/load/export-import configuration profiles
|
- **Profiles Tab** - Save/load/export-import configuration profiles
|
||||||
|
|
||||||
Profiles are saved in `~/.webdrop_bridge/profiles/`
|
Profiles are saved in `~/.webdrop-bridge/profiles/`
|
||||||
|
|
||||||
### 2. Environment Variables
|
### 2. Environment Variables
|
||||||
Create a `.env` file in the project root. Available settings:
|
Create a `.env` file in the project root. Available settings:
|
||||||
|
|
@ -259,6 +213,10 @@ The update system is fully integrated with the application and runs in the backg
|
||||||
|
|
||||||
For technical details, see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#update-system).
|
For technical details, see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#update-system).
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
See [CHANGELOG.md](CHANGELOG.md) for release notes.
|
||||||
|
|
||||||
## Building Installers
|
## Building Installers
|
||||||
|
|
||||||
### Windows MSI Installer
|
### Windows MSI Installer
|
||||||
|
|
@ -359,6 +317,37 @@ MIT License - see [LICENSE](LICENSE) file for details
|
||||||
- Inspired by professional desktop integration practices
|
- Inspired by professional desktop integration practices
|
||||||
- Special thanks to the Qt community
|
- Special thanks to the Qt community
|
||||||
|
|
||||||
|
## Development Status
|
||||||
|
|
||||||
|
**Current Phase**: Phase 4 Complete - Phase 5 (Release Candidates) Planned
|
||||||
|
|
||||||
|
**Completed**:
|
||||||
|
- ✅ Phase 1: Core Components (Validator, Config, Drag Interceptor, Main Window)
|
||||||
|
- ✅ Phase 2: Testing & Quality (99 tests, 85%+ coverage)
|
||||||
|
- ✅ Phase 3: Build & Distribution (Windows MSI, macOS DMG, Release Scripts)
|
||||||
|
- ✅ Phase 4.1: Auto-Update System (Forgejo API integration, 76 tests)
|
||||||
|
- ✅ Phase 4.2: Enhanced Logging & Monitoring (20 tests, JSON logging, performance tracking)
|
||||||
|
- ✅ Phase 4.3: Advanced Configuration (Profiles, Validation, Settings UI, 43 tests)
|
||||||
|
- ✅ **Total Phase 4**: 139 tests passing, 90%+ coverage
|
||||||
|
|
||||||
|
**In Progress/Planned**:
|
||||||
|
- Phase 4.4: User Documentation (manuals, tutorials, guides)
|
||||||
|
- Phase 5: Release Candidates & Final Testing (v1.0.0 stable release)
|
||||||
|
- Post-Release: Analytics, Community Support
|
||||||
|
|
||||||
|
## 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.2 - API for custom handlers
|
||||||
|
- [ ] v2.0 - Plugin architecture
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
- 📖 [Documentation](https://git.him-tools.de/HIM-public/webdrop-bridge/wiki)
|
- 📖 [Documentation](https://git.him-tools.de/HIM-public/webdrop-bridge/wiki)
|
||||||
|
|
|
||||||
543
START_HERE.md
Normal file
543
START_HERE.md
Normal file
|
|
@ -0,0 +1,543 @@
|
||||||
|
# 🎉 WebDrop Bridge - Professional Phase 4 Complete
|
||||||
|
|
||||||
|
**Initial Setup**: January 28, 2026
|
||||||
|
**Last Updated**: February 18, 2026
|
||||||
|
**Status**: ✅ **PHASE 4 COMPLETE - PHASE 5 READY**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Executive Summary
|
||||||
|
|
||||||
|
WebDrop Bridge has been **fully implemented through Phase 4** with production-quality architecture, comprehensive features, professional testing (139 tests, 90%+ coverage), and is now ready for Phase 5 (Release Candidates & Final Testing).
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ WebDrop Bridge - v0.5.0 Release │
|
||||||
|
│ │
|
||||||
|
│ ✅ Phase 1-3: Core features & build system │
|
||||||
|
│ ✅ Phase 4.1: Auto-Update System (76 tests) │
|
||||||
|
│ ✅ Phase 4.2: Enhanced Logging (20 tests) │
|
||||||
|
│ ✅ Phase 4.3: Advanced Configuration (43 tests) │
|
||||||
|
│ ✅ Total: 139 tests, 90%+ coverage │
|
||||||
|
│ ✅ Production-ready functionality │
|
||||||
|
│ │
|
||||||
|
│ Ready for Phase 5: Release Candidates │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 What Has Been Delivered
|
||||||
|
|
||||||
|
### 1. Complete Project Infrastructure ✅
|
||||||
|
|
||||||
|
```
|
||||||
|
📁 webdrop-bridge/
|
||||||
|
├── 📂 src/webdrop_bridge/ (COMPLETE: All 4 phases implemented)
|
||||||
|
│ ├── core/ (Config, Validator, Drag Interceptor, Updater)
|
||||||
|
│ ├── ui/ (Main Window, Settings Dialog, Update UI, WebView)
|
||||||
|
│ └── utils/ (Logging, URL Converter)
|
||||||
|
├── 📂 tests/ (139 tests passing, 90%+ coverage)
|
||||||
|
│ ├── unit/ (14 test files, ~100 tests)
|
||||||
|
│ ├── integration/ (test_update_flow.py)
|
||||||
|
│ └── fixtures/ (Test data & mocks)
|
||||||
|
├── 📂 build/ (Build automation - COMPLETE)
|
||||||
|
│ ├── windows/ (PyInstaller spec, Windows build scripts)
|
||||||
|
│ ├── macos/ (macOS build automation)
|
||||||
|
│ └── scripts/ (build_windows.py, build_macos.sh)
|
||||||
|
├── 📂 docs/ (Architecture, examples, guides)
|
||||||
|
├── 📂 webapp/ (Embedded web application with drag-drop)
|
||||||
|
├── 📂 resources/ (Icons, stylesheets)
|
||||||
|
├── 📂 .github/workflows/ (GitHub Actions test automation)
|
||||||
|
└── 📂 .vscode/ (Debug & task automation)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Complete Core Features (Phase 1-3) ✅
|
||||||
|
|
||||||
|
| Component | Status | Tests | Coverage |
|
||||||
|
|-----------|--------|-------|----------|
|
||||||
|
| Configuration Management | ✅ Complete with profiles & validation | 15+ | 95%+ |
|
||||||
|
| Path Validator | ✅ Complete with whitelist security | 16+ | 94% |
|
||||||
|
| Drag Interceptor | ✅ Complete with file conversion | 25+ | 96% |
|
||||||
|
| Main Window & UI | ✅ Complete with toolbar & settings | 38+ | 88% |
|
||||||
|
| Restricted Web View | ✅ Complete with URL whitelist | 15+ | 95% |
|
||||||
|
|
||||||
|
### 3. Phase 4 Professional Features (COMPLETE) ✅
|
||||||
|
|
||||||
|
| Feature | Status | Tests | Coverage |
|
||||||
|
|---------|--------|-------|----------|
|
||||||
|
| **4.1: Auto-Update System** | ✅ Forgejo API integration | 76 | 79% |
|
||||||
|
| **4.2: Enhanced Logging** | ✅ JSON logging, rotation, archival | 20 | 91% |
|
||||||
|
| **4.3: Advanced Configuration** | ✅ Profiles, validation, settings UI | 43 | 87% |
|
||||||
|
| **Total Phase 4** | ✅ **COMPLETE** | **139** | **90%+** |
|
||||||
|
|
||||||
|
### 4. Documentation & Configuration (Complete) ✅
|
||||||
|
|
||||||
|
```
|
||||||
|
README.md User overview & setup
|
||||||
|
DEVELOPMENT_PLAN.md Phase 1-5 roadmap with implementation details
|
||||||
|
CHANGELOG.md v1.0.0 release notes + v1.0.1 Phase 4 features
|
||||||
|
QUICKSTART.md 5-minute setup guide
|
||||||
|
CONTRIBUTING.md Development workflow & guidelines
|
||||||
|
docs/ARCHITECTURE.md Technical deep-dive
|
||||||
|
.github/copilot-instructions.md AI assistant guidelines
|
||||||
|
pyproject.toml PEP 517 modern packaging (v1.0.0 dynamic)
|
||||||
|
.env.example Environment configuration template
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Build & Distribution ✅
|
||||||
|
|
||||||
|
```
|
||||||
|
.github/workflows/tests.yml GitHub Actions CI/CD
|
||||||
|
build/scripts/build_windows.py PyInstaller → MSI (Windows)
|
||||||
|
build/scripts/build_macos.sh PyInstaller → DMG (macOS)
|
||||||
|
Makefile Convenience commands
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Code Quality Setup ✅
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ Black formatter (configured)
|
||||||
|
✅ Ruff linter (configured)
|
||||||
|
✅ isort import sorter (configured)
|
||||||
|
✅ mypy type checker (configured)
|
||||||
|
✅ pytest test framework (configured)
|
||||||
|
✅ Coverage reporting (configured)
|
||||||
|
✅ Tox automation (6 test environments)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. VS Code Integration ✅
|
||||||
|
|
||||||
|
```
|
||||||
|
.vscode/settings.json Editor & Python config
|
||||||
|
.vscode/launch.json Debug configurations
|
||||||
|
.vscode/tasks.json Build & test tasks
|
||||||
|
webdrop_bridge.code-workspace Workspace file
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Project Statistics
|
||||||
|
|
||||||
|
```
|
||||||
|
Total Files: 44
|
||||||
|
Documentation: 9 files, 4100+ lines
|
||||||
|
Configuration: 8 files
|
||||||
|
Source Code Stubs: 8 files (ready for Phase 1)
|
||||||
|
Test Framework: 5 files (starter structure)
|
||||||
|
Build & CI/CD: 5 files
|
||||||
|
VS Code Config: 4 files
|
||||||
|
Resources: 2 directories
|
||||||
|
|
||||||
|
Code Quality Tools: 7 (Black, Ruff, isort, mypy, pytest, tox, coverage)
|
||||||
|
Supported Platforms: 3 (Windows, macOS, Linux)
|
||||||
|
Development Phases: 5 (12-week roadmap)
|
||||||
|
Test Coverage Target: 80%+
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Quick Start (5 Minutes)
|
||||||
|
|
||||||
|
### Step 1: Open Project
|
||||||
|
```bash
|
||||||
|
code webdrop_bridge.code-workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Setup Environment
|
||||||
|
```bash
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # macOS/Linux
|
||||||
|
# venv\Scripts\activate # Windows
|
||||||
|
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Verify Setup
|
||||||
|
```bash
|
||||||
|
pytest tests/unit/test_project_structure.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Read Documentation
|
||||||
|
- **Quick overview**: `QUICKSTART.md` (5 min)
|
||||||
|
- **Full roadmap**: `DEVELOPMENT_PLAN.md` (20 min)
|
||||||
|
- **Architecture**: `docs/ARCHITECTURE.md` (15 min)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Development Status & Roadmap
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ PHASE 1: Foundation (COMPLETE - Jan 2026)
|
||||||
|
├─ Configuration system
|
||||||
|
├─ Path validator with security
|
||||||
|
├─ Drag interceptor with file conversion
|
||||||
|
├─ Main window with WebEngine
|
||||||
|
└─ Professional logging system
|
||||||
|
|
||||||
|
✅ PHASE 2: Testing & Quality (COMPLETE - Jan 2026)
|
||||||
|
├─ 99+ unit tests
|
||||||
|
├─ 85%+ code coverage
|
||||||
|
├─ Ruff linting & Black formatting
|
||||||
|
└─ mypy type checking
|
||||||
|
|
||||||
|
✅ PHASE 3: Build & Distribution (COMPLETE - Jan 2026)
|
||||||
|
├─ Windows executable via PyInstaller
|
||||||
|
├─ macOS DMG package
|
||||||
|
└─ Forgejo Packages distribution
|
||||||
|
|
||||||
|
✅ PHASE 4.1: Auto-Update System (COMPLETE - Feb 2026)
|
||||||
|
├─ Forgejo API integration
|
||||||
|
├─ Update dialogs & notifications
|
||||||
|
├─ Background update checking
|
||||||
|
└─ 76 tests, 79% coverage
|
||||||
|
|
||||||
|
✅ PHASE 4.2: Enhanced Logging (COMPLETE - Feb 2026)
|
||||||
|
├─ JSON logging support
|
||||||
|
├─ Log rotation & archival
|
||||||
|
├─ Performance tracking (PerformanceTracker)
|
||||||
|
└─ 20 tests, 91% coverage
|
||||||
|
|
||||||
|
✅ PHASE 4.3: Advanced Configuration (COMPLETE - Feb 2026)
|
||||||
|
├─ Config profiles (work, personal, etc.)
|
||||||
|
├─ Settings UI with 5 tabs (Paths, URLs, Logging, Window, Profiles)
|
||||||
|
├─ Configuration validation & import/export
|
||||||
|
└─ 43 tests, 87% coverage
|
||||||
|
|
||||||
|
→ PHASE 4.4: User Documentation (PLANNED - Phase 4 wrap-up)
|
||||||
|
├─ User manuals & tutorials
|
||||||
|
├─ API documentation
|
||||||
|
├─ Troubleshooting guides
|
||||||
|
└─ Community examples
|
||||||
|
|
||||||
|
→ PHASE 5: Release Candidates & Finalization (NEXT)
|
||||||
|
├─ Cross-platform testing (Windows, macOS)
|
||||||
|
├─ Security hardening audit
|
||||||
|
├─ Performance optimization
|
||||||
|
├─ Final release packaging
|
||||||
|
└─ v1.0.0 Stable Release
|
||||||
|
```
|
||||||
|
|
||||||
|
**Completion**: Phase 4 - 100% | **Phase 5 Ready**: Yes | **Version**: 1.0.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Key Highlights
|
||||||
|
|
||||||
|
### Professional Architecture
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Presentation Layer (Qt/PySide6) │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ Business Logic Layer (core/) │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ Utility Layer (utils/) │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ Platform Layer (OS Integration) │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security & Validation
|
||||||
|
- ✅ Whitelist-based path validation
|
||||||
|
- ✅ Absolute path resolution
|
||||||
|
- ✅ Symlink attack prevention
|
||||||
|
- ✅ Web engine sandboxing
|
||||||
|
- ✅ Environment-based secrets
|
||||||
|
|
||||||
|
### Cross-Platform Support
|
||||||
|
- ✅ Windows 10/11 (x64)
|
||||||
|
- ✅ macOS 12-14 (Intel & ARM64)
|
||||||
|
- ✅ Linux (experimental)
|
||||||
|
|
||||||
|
### Quality Assurance
|
||||||
|
- ✅ Unit tests (structure ready)
|
||||||
|
- ✅ Integration tests (structure ready)
|
||||||
|
- ✅ End-to-end tests (structure ready)
|
||||||
|
- ✅ Code coverage tracking
|
||||||
|
- ✅ Automated CI/CD
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Documentation Map
|
||||||
|
|
||||||
|
```
|
||||||
|
QUICKSTART.md ← Start here (5 min)
|
||||||
|
↓
|
||||||
|
README.md ← Overview (10 min)
|
||||||
|
↓
|
||||||
|
DEVELOPMENT_PLAN.md ← Roadmap (20 min)
|
||||||
|
↓
|
||||||
|
docs/ARCHITECTURE.md ← Technical deep-dive (15 min)
|
||||||
|
↓
|
||||||
|
CONTRIBUTING.md ← Guidelines (10 min)
|
||||||
|
↓
|
||||||
|
IMPLEMENTATION_CHECKLIST.md ← Phase 1 tasks (reference)
|
||||||
|
↓
|
||||||
|
Source Code ← Docstrings & comments
|
||||||
|
```
|
||||||
|
|
||||||
|
**Total Reading Time**: ~60-90 minutes to fully understand
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Convenience Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# One-command setup
|
||||||
|
make install-dev && pytest tests/unit/test_project_structure.py
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
make test # All tests with coverage
|
||||||
|
make test-quick # Fast test run
|
||||||
|
make lint # Code style check
|
||||||
|
make format # Auto-fix formatting
|
||||||
|
|
||||||
|
# Building
|
||||||
|
make build-windows # Build Windows MSI
|
||||||
|
make build-macos # Build macOS DMG
|
||||||
|
make clean # Clean build artifacts
|
||||||
|
|
||||||
|
# Help
|
||||||
|
make help # List all commands
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎓 Learning Path
|
||||||
|
|
||||||
|
### For New Team Members
|
||||||
|
1. **Day 1**: Read QUICKSTART.md + README.md (30 min)
|
||||||
|
2. **Day 2**: Read DEVELOPMENT_PLAN.md Phase 1 (45 min)
|
||||||
|
3. **Day 3**: Study docs/ARCHITECTURE.md (30 min)
|
||||||
|
4. **Day 4**: Setup environment & run tests (15 min)
|
||||||
|
5. **Day 5**: Begin Phase 1 implementation
|
||||||
|
|
||||||
|
### For Architects
|
||||||
|
1. Read docs/ARCHITECTURE.md (30 min)
|
||||||
|
2. Review DEVELOPMENT_PLAN.md (45 min)
|
||||||
|
3. Study existing POC structure (20 min)
|
||||||
|
4. Validate design decisions (20 min)
|
||||||
|
|
||||||
|
### For DevOps/Build
|
||||||
|
1. Review build/scripts/ (15 min)
|
||||||
|
2. Review .github/workflows/tests.yml (15 min)
|
||||||
|
3. Study tox.ini & pytest.ini (10 min)
|
||||||
|
4. Test builds locally (30 min)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Project Verification
|
||||||
|
|
||||||
|
### Structure Validation
|
||||||
|
```bash
|
||||||
|
pytest tests/unit/test_project_structure.py -v
|
||||||
|
# Expected: All 3 tests pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### File Count
|
||||||
|
```bash
|
||||||
|
find . -type f -name "*.py" -o -name "*.md" -o -name "*.toml" | wc -l
|
||||||
|
# Expected: 44 files
|
||||||
|
```
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
```bash
|
||||||
|
find . -name "*.md" -exec wc -l {} + | tail -1
|
||||||
|
# Expected: 4100+ lines
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎁 Bonus Features
|
||||||
|
|
||||||
|
### Included
|
||||||
|
- ✅ Beautiful test webapp (drag-drop demo)
|
||||||
|
- ✅ Makefile with 10+ commands
|
||||||
|
- ✅ VS Code workspace configuration
|
||||||
|
- ✅ GitHub Actions auto-testing
|
||||||
|
- ✅ PyInstaller build scripts
|
||||||
|
- ✅ Comprehensive .gitignore
|
||||||
|
- ✅ MIT License
|
||||||
|
- ✅ Professional README
|
||||||
|
|
||||||
|
### Optional (For Later)
|
||||||
|
- WiX Toolset for advanced MSI features
|
||||||
|
- Auto-update system (Phase 5)
|
||||||
|
- Analytics & monitoring (Phase 5)
|
||||||
|
- Plugin architecture (Future)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Support Resources
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- **Setup Issues**: → QUICKSTART.md
|
||||||
|
- **Project Overview**: → README.md
|
||||||
|
- **Development Plan**: → DEVELOPMENT_PLAN.md
|
||||||
|
- **Technical Design**: → docs/ARCHITECTURE.md
|
||||||
|
- **Contributing**: → CONTRIBUTING.md
|
||||||
|
- **Implementation Tasks**: → IMPLEMENTATION_CHECKLIST.md
|
||||||
|
|
||||||
|
### Internal References
|
||||||
|
- **File Listing**: → FILE_LISTING.md
|
||||||
|
- **Project Summary**: → PROJECT_SETUP_SUMMARY.md
|
||||||
|
- **AI Guidelines**: → .github/copilot-instructions.md
|
||||||
|
|
||||||
|
### External Resources
|
||||||
|
- PySide6 Docs: https://doc.qt.io/qtforpython/
|
||||||
|
- pytest Docs: https://docs.pytest.org/
|
||||||
|
- GitHub Actions: https://docs.github.com/actions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Completion Checklist
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
- ✅ All directories created
|
||||||
|
- ✅ All configuration files present
|
||||||
|
- ✅ All documentation files present
|
||||||
|
- ✅ Build scripts ready
|
||||||
|
- ✅ CI/CD pipeline configured
|
||||||
|
- ✅ Test framework set up
|
||||||
|
- ✅ VS Code integration complete
|
||||||
|
|
||||||
|
### Quality & Standards
|
||||||
|
- ✅ Code style tools configured (Black, Ruff)
|
||||||
|
- ✅ Type checking configured (mypy)
|
||||||
|
- ✅ Testing framework configured (pytest, tox)
|
||||||
|
- ✅ Coverage tracking configured
|
||||||
|
- ✅ Git workflow documented
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- ✅ User documentation complete
|
||||||
|
- ✅ Developer documentation complete
|
||||||
|
- ✅ Architecture documentation complete
|
||||||
|
- ✅ Contributing guidelines complete
|
||||||
|
- ✅ 12-week roadmap documented
|
||||||
|
- ✅ Implementation checklist created
|
||||||
|
|
||||||
|
### Ready for Development
|
||||||
|
- ✅ Project scaffolding complete
|
||||||
|
- ✅ All dependencies specified
|
||||||
|
- ✅ Build automation ready
|
||||||
|
- ✅ CI/CD pipeline ready
|
||||||
|
- ✅ Phase 1 specifications documented
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Next Actions
|
||||||
|
|
||||||
|
### Phase 4.4: User Documentation (This Week)
|
||||||
|
1. Write user manual & setup guides
|
||||||
|
2. Create video tutorials
|
||||||
|
3. Document configuration examples
|
||||||
|
4. Add API reference documentation
|
||||||
|
5. Create troubleshooting guide
|
||||||
|
|
||||||
|
See [DEVELOPMENT_PLAN.md Phase 4.4](DEVELOPMENT_PLAN.md#44-user-documentation) for details.
|
||||||
|
|
||||||
|
### Phase 5: Release Candidates (Next)
|
||||||
|
1. **Build & Test on Windows 10/11**
|
||||||
|
- Run full test suite
|
||||||
|
- Manual UAT (User Acceptance Testing)
|
||||||
|
- Performance benchmarking
|
||||||
|
|
||||||
|
2. **Build & Test on macOS 12-14**
|
||||||
|
- Intel and ARM64 validation
|
||||||
|
- Code signing verification
|
||||||
|
- System integration testing
|
||||||
|
|
||||||
|
3. **Security & Performance**
|
||||||
|
- Security audit & hardening
|
||||||
|
- Drag event performance (target: <50ms)
|
||||||
|
- Memory profiling
|
||||||
|
|
||||||
|
4. **Release Candidate Builds**
|
||||||
|
- v1.0.0-rc1: Community testing
|
||||||
|
- v1.0.0-rc2: Issue fixes
|
||||||
|
- v1.0.0-rc3: Final polish
|
||||||
|
- v1.0.0: Stable release
|
||||||
|
|
||||||
|
### Post-Release (Future)
|
||||||
|
1. Community support & forums
|
||||||
|
2. Analytics & monitoring
|
||||||
|
3. Feature requests for v1.1
|
||||||
|
4. Long-term maintenance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Success Metrics
|
||||||
|
|
||||||
|
| Metric | Target | Timeline |
|
||||||
|
|--------|--------|----------|
|
||||||
|
| Code Coverage | 80%+ | Week 6 |
|
||||||
|
| Test Pass Rate | 100% | Continuous |
|
||||||
|
| Build Time | <2 min | Week 8 |
|
||||||
|
| App Startup | <1 sec | Week 8 |
|
||||||
|
| Installer Size | <150 MB | Week 8 |
|
||||||
|
| Documentation | 100% | Week 12 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎓 Key Design Decisions
|
||||||
|
|
||||||
|
### 1. PySide6 (vs PyQt5, Tkinter, PySimpleGUI)
|
||||||
|
✅ Modern, LGPL licensed, excellent macOS support
|
||||||
|
|
||||||
|
### 2. PyInstaller (vs Briefcase, Nuitka, py2exe)
|
||||||
|
✅ Mature, stable, excellent one-file executable
|
||||||
|
|
||||||
|
### 3. pytest (vs unittest, nose2)
|
||||||
|
✅ Modern, expressive, great CI/CD integration
|
||||||
|
|
||||||
|
### 4. GitHub Actions (vs Jenkins, GitLab CI, Travis)
|
||||||
|
✅ Free, integrated, simple workflow
|
||||||
|
|
||||||
|
### 5. Whitelist Validation (vs Blacklist)
|
||||||
|
✅ Secure by default, explicit permissions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Security Notes
|
||||||
|
|
||||||
|
### Implemented
|
||||||
|
- ✅ Path validation (whitelist)
|
||||||
|
- ✅ File existence checks
|
||||||
|
- ✅ Web engine sandboxing
|
||||||
|
- ✅ Environment-based secrets
|
||||||
|
|
||||||
|
### Recommended (Phase 4+)
|
||||||
|
- [ ] Encrypted configuration
|
||||||
|
- [ ] Audit logging
|
||||||
|
- [ ] Rate limiting
|
||||||
|
- [ ] Signed releases
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 Conclusion
|
||||||
|
|
||||||
|
**WebDrop Bridge has successfully completed Phase 4** with:
|
||||||
|
|
||||||
|
- ✅ **Phase 1-3**: Core features, comprehensive testing, build automation
|
||||||
|
- ✅ **Phase 4**: Auto-Update System, Enhanced Logging, Advanced Configuration
|
||||||
|
- ✅ **139 tests passing** (90%+ coverage)
|
||||||
|
- ✅ **Production-ready features** - v1.0.0 released
|
||||||
|
- ✅ **Enterprise-level architecture**
|
||||||
|
- ✅ **Cross-platform support** (Windows, macOS)
|
||||||
|
|
||||||
|
**Current Status**: Phase 4 Complete - Phase 5 Release Candidates Ready
|
||||||
|
**Version**: 1.0.0
|
||||||
|
**Next Phase**: Release Candidate Testing & Final Packaging
|
||||||
|
**Team Size**: 1-2 developers
|
||||||
|
**Complexity**: Intermediate (Qt + Python knowledge helpful)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Ready to continue?** → Open [DEVELOPMENT_PLAN.md Phase 5](DEVELOPMENT_PLAN.md#phase-5-post-release-months-2-3) or [QUICKSTART.md](QUICKSTART.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Created: January 28, 2026*
|
||||||
|
*Updated: February 18, 2026*
|
||||||
|
*Project: WebDrop Bridge - Professional Edition*
|
||||||
|
*Status: ✅ Phase 4 Complete - Phase 5 Ready*
|
||||||
1
build/WebDropBridge.wixobj
Normal file
1
build/WebDropBridge.wixobj
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -1,37 +1,29 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"
|
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"
|
||||||
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui"
|
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui">
|
||||||
xmlns:util="http://schemas.microsoft.com/wix/UtilExtension">
|
<Product Id="*" Name="WebDrop Bridge" Language="1033" Version="0.6.0"
|
||||||
<Product Id="*" Name="{product_name_with_version}" Language="1033" Version="{version}"
|
Manufacturer="HIM-Tools"
|
||||||
Manufacturer="{manufacturer}"
|
UpgradeCode="12345678-1234-1234-1234-123456789012">
|
||||||
UpgradeCode="{upgrade_code}">
|
|
||||||
|
|
||||||
<Package InstallerVersion="200" Compressed="yes" InstallScope="perMachine" Platform="x64" />
|
<Package InstallerVersion="200" Compressed="yes" InstallScope="perMachine" Platform="x64" />
|
||||||
<Media Id="1" Cabinet="{asset_prefix}.cab" EmbedCab="yes" />
|
<Media Id="1" Cabinet="WebDropBridge.cab" EmbedCab="yes" />
|
||||||
|
|
||||||
<!-- Required property for WixUI_InstallDir dialog set -->
|
<!-- Required property for WixUI_InstallDir dialog set -->
|
||||||
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />
|
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />
|
||||||
|
|
||||||
<!-- Application Icon -->
|
<!-- Application Icon -->
|
||||||
<Icon Id="AppIcon.ico" SourceFile="{icon_ico}" />
|
<Icon Id="AppIcon.ico" SourceFile="$(var.ResourcesDir)\icons\app.ico" />
|
||||||
|
|
||||||
<!-- Custom branding for InstallDir dialog set -->
|
<!-- Custom branding for InstallDir dialog set -->
|
||||||
<WixVariable Id="WixUIDialogBmp" Value="{dialog_bmp}" />
|
<WixVariable Id="WixUIDialogBmp" Value="$(var.ResourcesDir)\icons\background.bmp" />
|
||||||
<WixVariable Id="WixUIBannerBmp" Value="{banner_bmp}" />
|
<WixVariable Id="WixUIBannerBmp" Value="$(var.ResourcesDir)\icons\banner.bmp" />
|
||||||
<WixVariable Id="WixUILicenseRtf" Value="{license_rtf}" />
|
<WixVariable Id="WixUILicenseRtf" Value="$(var.ResourcesDir)\license.rtf" />
|
||||||
|
|
||||||
<!-- Installation UI dialogs -->
|
<!-- Installation UI dialogs -->
|
||||||
<UIRef Id="WixUI_InstallDir" />
|
<UIRef Id="WixUI_InstallDir" />
|
||||||
<UIRef Id="WixUI_ErrorProgressText" />
|
<UIRef Id="WixUI_ErrorProgressText" />
|
||||||
|
|
||||||
<!-- Close running application before installation -->
|
<Feature Id="ProductFeature" Title="WebDrop Bridge" Level="1">
|
||||||
<util:CloseApplication
|
|
||||||
Target="{exe_name}.exe"
|
|
||||||
CloseMessage="yes"
|
|
||||||
RebootPrompt="no"
|
|
||||||
ElevatedCloseMessage="no" />
|
|
||||||
|
|
||||||
<Feature Id="ProductFeature" Title="{product_name}" Level="1">
|
|
||||||
<ComponentGroupRef Id="AppFiles" />
|
<ComponentGroupRef Id="AppFiles" />
|
||||||
<ComponentRef Id="ProgramMenuShortcut" />
|
<ComponentRef Id="ProgramMenuShortcut" />
|
||||||
<ComponentRef Id="DesktopShortcut" />
|
<ComponentRef Id="DesktopShortcut" />
|
||||||
|
|
@ -39,10 +31,10 @@
|
||||||
|
|
||||||
<Directory Id="TARGETDIR" Name="SourceDir">
|
<Directory Id="TARGETDIR" Name="SourceDir">
|
||||||
<Directory Id="ProgramFiles64Folder">
|
<Directory Id="ProgramFiles64Folder">
|
||||||
<Directory Id="INSTALLFOLDER" Name="{install_dir_name}" />
|
<Directory Id="INSTALLFOLDER" Name="WebDrop Bridge" />
|
||||||
</Directory>
|
</Directory>
|
||||||
<Directory Id="ProgramMenuFolder">
|
<Directory Id="ProgramMenuFolder">
|
||||||
<Directory Id="ApplicationProgramsFolder" Name="{product_name}"/>
|
<Directory Id="ApplicationProgramsFolder" Name="WebDrop Bridge"/>
|
||||||
</Directory>
|
</Directory>
|
||||||
<Directory Id="DesktopFolder" />
|
<Directory Id="DesktopFolder" />
|
||||||
</Directory>
|
</Directory>
|
||||||
|
|
@ -50,16 +42,16 @@
|
||||||
<DirectoryRef Id="ApplicationProgramsFolder">
|
<DirectoryRef Id="ApplicationProgramsFolder">
|
||||||
<Component Id="ProgramMenuShortcut" Guid="*">
|
<Component Id="ProgramMenuShortcut" Guid="*">
|
||||||
<Shortcut Id="ApplicationStartMenuShortcut"
|
<Shortcut Id="ApplicationStartMenuShortcut"
|
||||||
Name="{product_name}"
|
Name="WebDrop Bridge"
|
||||||
Description="{shortcut_description}"
|
Description="Web Drag-and-Drop Bridge"
|
||||||
Target="[INSTALLFOLDER]{exe_name}.exe"
|
Target="[INSTALLFOLDER]WebDropBridge.exe"
|
||||||
Icon="AppIcon.ico"
|
Icon="AppIcon.ico"
|
||||||
IconIndex="0"
|
IconIndex="0"
|
||||||
WorkingDirectory="INSTALLFOLDER" />
|
WorkingDirectory="INSTALLFOLDER" />
|
||||||
<RemoveFolder Id="ApplicationProgramsFolderRemove"
|
<RemoveFolder Id="ApplicationProgramsFolderRemove"
|
||||||
On="uninstall" />
|
On="uninstall" />
|
||||||
<RegistryValue Root="HKCU"
|
<RegistryValue Root="HKCU"
|
||||||
Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\{exe_name}"
|
Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\WebDropBridge"
|
||||||
Name="installed"
|
Name="installed"
|
||||||
Type="integer"
|
Type="integer"
|
||||||
Value="1"
|
Value="1"
|
||||||
|
|
@ -70,14 +62,14 @@
|
||||||
<DirectoryRef Id="DesktopFolder">
|
<DirectoryRef Id="DesktopFolder">
|
||||||
<Component Id="DesktopShortcut" Guid="*">
|
<Component Id="DesktopShortcut" Guid="*">
|
||||||
<Shortcut Id="DesktopApplicationShortcut"
|
<Shortcut Id="DesktopApplicationShortcut"
|
||||||
Name="{product_name}"
|
Name="WebDrop Bridge"
|
||||||
Description="{shortcut_description}"
|
Description="Web Drag-and-Drop Bridge"
|
||||||
Target="[INSTALLFOLDER]{exe_name}.exe"
|
Target="[INSTALLFOLDER]WebDropBridge.exe"
|
||||||
Icon="AppIcon.ico"
|
Icon="AppIcon.ico"
|
||||||
IconIndex="0"
|
IconIndex="0"
|
||||||
WorkingDirectory="INSTALLFOLDER" />
|
WorkingDirectory="INSTALLFOLDER" />
|
||||||
<RegistryValue Root="HKCU"
|
<RegistryValue Root="HKCU"
|
||||||
Key="Software\{exe_name}"
|
Key="Software\WebDropBridge"
|
||||||
Name="DesktopShortcut"
|
Name="DesktopShortcut"
|
||||||
Type="integer"
|
Type="integer"
|
||||||
Value="1"
|
Value="1"
|
||||||
|
|
|
||||||
1
build/WebDropBridge_Files.wixobj
Normal file
1
build/WebDropBridge_Files.wixobj
Normal file
File diff suppressed because one or more lines are too long
11833
build/WebDropBridge_Files.wxs
Normal file
11833
build/WebDropBridge_Files.wxs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,22 +0,0 @@
|
||||||
{
|
|
||||||
"brand_id": "agravity",
|
|
||||||
"display_name": "Agravity Bridge",
|
|
||||||
"asset_prefix": "AgravityBridge",
|
|
||||||
"exe_name": "AgravityBridge",
|
|
||||||
"manufacturer": "agravity",
|
|
||||||
"install_dir_name": "Agravity Bridge",
|
|
||||||
"shortcut_description": "Agravity drag-and-drop bridge",
|
|
||||||
"bundle_identifier": "io.agravity.bridge",
|
|
||||||
"config_dir_name": "agravity_bridge",
|
|
||||||
"msi_upgrade_code": "4a7c80da-6170-4d88-8efc-3f30636f6392",
|
|
||||||
"update_channel": "stable",
|
|
||||||
"icon_ico": "resources/icons/app.ico",
|
|
||||||
"icon_icns": "resources/icons/app.icns",
|
|
||||||
"dialog_bmp": "resources/icons/background.bmp",
|
|
||||||
"banner_bmp": "resources/icons/banner.bmp",
|
|
||||||
"license_rtf": "resources/license.rtf",
|
|
||||||
"toolbar_icon_home": "resources/icons/home.ico",
|
|
||||||
"toolbar_icon_reload": "resources/icons/reload.ico",
|
|
||||||
"toolbar_icon_open": "resources/icons/open.ico",
|
|
||||||
"toolbar_icon_openwith": "resources/icons/openwith.ico"
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
{
|
|
||||||
// Copy this file to build/brands/<your-brand>.json (without comments)
|
|
||||||
// and replace values.
|
|
||||||
"brand_id": "your_brand_id",
|
|
||||||
"display_name": "Your Brand Bridge",
|
|
||||||
"asset_prefix": "YourBrandBridge",
|
|
||||||
"exe_name": "YourBrandBridge",
|
|
||||||
"manufacturer": "Your Company",
|
|
||||||
"install_dir_name": "Your Brand Bridge",
|
|
||||||
"shortcut_description": "Your brand drag-and-drop bridge",
|
|
||||||
"bundle_identifier": "com.yourcompany.bridge",
|
|
||||||
"config_dir_name": "your_brand_bridge",
|
|
||||||
"msi_upgrade_code": "00000000-0000-0000-0000-000000000000",
|
|
||||||
"update_channel": "stable",
|
|
||||||
"icon_ico": "resources/icons/app.ico",
|
|
||||||
"icon_icns": "resources/icons/app.icns",
|
|
||||||
"dialog_bmp": "resources/icons/background.bmp",
|
|
||||||
"banner_bmp": "resources/icons/banner.bmp",
|
|
||||||
"license_rtf": "resources/license.rtf",
|
|
||||||
"toolbar_icon_home": "resources/icons/home.ico",
|
|
||||||
"toolbar_icon_reload": "resources/icons/reload.ico",
|
|
||||||
"toolbar_icon_open": "resources/icons/open.ico",
|
|
||||||
"toolbar_icon_openwith": "resources/icons/openwith.ico"
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
|
|
||||||
$PackageName = 'webdrop-bridge'
|
|
||||||
$Version = '0.8.0'
|
|
||||||
$Url = "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v$Version/WebDropBridge_Setup.msi"
|
|
||||||
$Checksum = '' # Update with actual SHA256 checksum from release
|
|
||||||
$ChecksumType = 'sha256'
|
|
||||||
|
|
||||||
# Create temporary directory for download
|
|
||||||
$TempDir = Join-Path $env:TEMP "webdrop-bridge-$Version"
|
|
||||||
New-Item -ItemType Directory -Path $TempDir -Force | Out-Null
|
|
||||||
|
|
||||||
try {
|
|
||||||
# Download MSI installer
|
|
||||||
Write-Host "Downloading WebDropBridge $Version MSI installer..."
|
|
||||||
$InstallerPath = Join-Path $TempDir "WebDropBridge_Setup.msi"
|
|
||||||
|
|
||||||
Get-ChocolateyWebFile -PackageName $PackageName `
|
|
||||||
-FileFullPath $InstallerPath `
|
|
||||||
-Url $Url `
|
|
||||||
-Checksum $Checksum `
|
|
||||||
-ChecksumType $ChecksumType
|
|
||||||
|
|
||||||
# Install MSI
|
|
||||||
Write-Host "Installing WebDropBridge..."
|
|
||||||
$InstallArgs = @(
|
|
||||||
'/i'
|
|
||||||
"`"$InstallerPath`""
|
|
||||||
'/quiet' # Silent installation
|
|
||||||
'/norestart' # Don't restart immediately
|
|
||||||
)
|
|
||||||
|
|
||||||
Invoke-ChocolateyInstall -PackageName $PackageName `
|
|
||||||
-File 'msiexec.exe' `
|
|
||||||
-FileArgs $InstallArgs `
|
|
||||||
-ValidExitCodes @(0, 3010) # 0=success, 3010=restart needed
|
|
||||||
|
|
||||||
Write-Host "WebDropBridge installed successfully"
|
|
||||||
} catch {
|
|
||||||
Write-Error "Installation failed: $_"
|
|
||||||
exit 1
|
|
||||||
} finally {
|
|
||||||
# Cleanup
|
|
||||||
if (Test-Path $TempDir) {
|
|
||||||
Remove-Item $TempDir -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
|
|
||||||
$PackageName = 'webdrop-bridge'
|
|
||||||
|
|
||||||
try {
|
|
||||||
# Find installed version
|
|
||||||
$UninstallPath = Get-ChildItem HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall |
|
|
||||||
Where-Object { $_.GetValue('DisplayName') -like '*WebDropBridge*' } |
|
|
||||||
Select-Object -First 1
|
|
||||||
|
|
||||||
if ($UninstallPath) {
|
|
||||||
$UninstallString = $UninstallPath.GetValue('UninstallString')
|
|
||||||
|
|
||||||
# Extract MSI Product ID from uninstall string
|
|
||||||
if ($UninstallString -match '{[A-F0-9-]+}') {
|
|
||||||
$ProductId = $matches[0]
|
|
||||||
|
|
||||||
Write-Host "Uninstalling WebDropBridge (Product ID: $ProductId)..."
|
|
||||||
|
|
||||||
$UninstallArgs = @(
|
|
||||||
'/x'
|
|
||||||
$ProductId
|
|
||||||
'/quiet'
|
|
||||||
'/norestart'
|
|
||||||
)
|
|
||||||
|
|
||||||
& 'msiexec.exe' @UninstallArgs
|
|
||||||
|
|
||||||
Write-Host "WebDropBridge uninstalled successfully"
|
|
||||||
} else {
|
|
||||||
Write-Warning "Could not extract Product ID from uninstall string"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Write-Warning "WebDropBridge is not installed"
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
Write-Error "Uninstall failed: $_"
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
|
|
||||||
<metadata>
|
|
||||||
<id>webdrop-bridge</id>
|
|
||||||
<version>0.8.0</version>
|
|
||||||
<packageSourceUrl>https://git.him-tools.de/HIM-public/webdrop-bridge</packageSourceUrl>
|
|
||||||
<owners>HIM-public</owners>
|
|
||||||
<title>WebDrop Bridge</title>
|
|
||||||
<authors>HIM-public</authors>
|
|
||||||
<licenseUrl>https://git.him-tools.de/HIM-public/webdrop-bridge/blob/main/LICENSE</licenseUrl>
|
|
||||||
<projectUrl>https://git.him-tools.de/HIM-public/webdrop-bridge</projectUrl>
|
|
||||||
<bugTrackerUrl>https://git.him-tools.de/HIM-public/webdrop-bridge/issues</bugTrackerUrl>
|
|
||||||
<description>
|
|
||||||
Professional Qt-based desktop application for intelligent drag-and-drop file handling between web applications and desktop clients (InDesign, Word, Notepad++, etc.)
|
|
||||||
|
|
||||||
Converts text-based drag-and-drop operations from embedded web applications into native file operations recognized by professional desktop applications.
|
|
||||||
</description>
|
|
||||||
<summary>Intelligent drag-and-drop file bridge for web to desktop applications</summary>
|
|
||||||
<releaseNotes>https://git.him-tools.de/HIM-public/webdrop-bridge/releases/tag/v0.8.0</releaseNotes>
|
|
||||||
<tags>drag-drop file-transfer qt pyside6 desktop automation</tags>
|
|
||||||
<dependencies>
|
|
||||||
<dependency id="chocolatey" version="0.10.8" />
|
|
||||||
</dependencies>
|
|
||||||
</metadata>
|
|
||||||
<files>
|
|
||||||
<file src="tools\**" target="tools" />
|
|
||||||
</files>
|
|
||||||
</package>
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
class WebdropBridge < Formula
|
|
||||||
desc "Intelligent drag-and-drop file bridge for web to desktop applications"
|
|
||||||
homepage "https://git.him-tools.de/HIM-public/webdrop-bridge"
|
|
||||||
version "0.8.0"
|
|
||||||
|
|
||||||
# ARM64 (Apple Silicon)
|
|
||||||
on_arm do
|
|
||||||
url "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0/WebDropBridge_Setup.dmg"
|
|
||||||
sha256 "" # Update with actual checksum
|
|
||||||
end
|
|
||||||
|
|
||||||
# Intel x86_64
|
|
||||||
on_intel do
|
|
||||||
url "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0/WebDropBridge_Setup.dmg"
|
|
||||||
sha256 "" # Update with actual checksum (may be same as ARM64 if universal binary)
|
|
||||||
end
|
|
||||||
|
|
||||||
license "MIT"
|
|
||||||
|
|
||||||
livecheck do
|
|
||||||
url "https://git.him-tools.de/api/v1/repos/HIM-public/webdrop-bridge/releases/latest"
|
|
||||||
strategy :json do |json|
|
|
||||||
json["tag_name"]&.strip&.sub(/^v/, "")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
app "WebDropBridge.app"
|
|
||||||
|
|
||||||
post_install do
|
|
||||||
# Create user defaults directory if needed
|
|
||||||
system "mkdir", "-p", "#{Dir.home}/.webdrop-bridge"
|
|
||||||
end
|
|
||||||
|
|
||||||
def caveats
|
|
||||||
<<~EOS
|
|
||||||
WebDropBridge has been installed.
|
|
||||||
|
|
||||||
Configuration files are stored in: ~/.webdrop-bridge/
|
|
||||||
Logs are written to: ~/.webdrop-bridge/logs/
|
|
||||||
|
|
||||||
To start the application:
|
|
||||||
- Open Applications > WebDropBridge
|
|
||||||
- Or run: open /Applications/WebDropBridge.app
|
|
||||||
|
|
||||||
For documentation: https://git.him-tools.de/HIM-public/webdrop-bridge
|
|
||||||
EOS
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
# Package Manager Distributions
|
|
||||||
|
|
||||||
This directory contains package manager configurations for distributing WebDropBridge across different platforms.
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
build/
|
|
||||||
├── chocolatey/ # Windows - Chocolatey/NuGet package
|
|
||||||
│ ├── webdrop-bridge.nuspec # Package manifest
|
|
||||||
│ └── tools/
|
|
||||||
│ ├── chocolateyInstall.ps1 # Installation script
|
|
||||||
│ └── chocolateyUninstall.ps1 # Uninstallation script
|
|
||||||
│
|
|
||||||
└── homebrew/ # macOS - Homebrew formula
|
|
||||||
└── webdrop-bridge.rb # Homebrew formula
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Chocolatey Package (Windows)
|
|
||||||
|
|
||||||
1. **Build MSI installer**:
|
|
||||||
```bash
|
|
||||||
python build/scripts/build_windows.py --msi
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Get SHA256 checksum**:
|
|
||||||
```powershell
|
|
||||||
certutil -hashfile build/dist/windows/WebDropBridge_Setup.msi SHA256
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Update package files**:
|
|
||||||
- `build/chocolatey/webdrop-bridge.nuspec` - update `<version>`
|
|
||||||
- `build/chocolatey/tools/chocolateyInstall.ps1` - update `$Version` and `$Checksum`
|
|
||||||
|
|
||||||
4. **Package it** (requires Chocolatey CLI):
|
|
||||||
```powershell
|
|
||||||
cd build/chocolatey
|
|
||||||
choco pack webdrop-bridge.nuspec
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Publish** (requires Chocolatey API key):
|
|
||||||
```powershell
|
|
||||||
choco push webdrop-bridge.0.8.0.nupkg --api-key YOUR_KEY
|
|
||||||
```
|
|
||||||
|
|
||||||
### Homebrew Formula (macOS)
|
|
||||||
|
|
||||||
1. **Build DMG installer**:
|
|
||||||
```bash
|
|
||||||
bash build/scripts/build_macos.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Get SHA256 checksum**:
|
|
||||||
```bash
|
|
||||||
shasum -a 256 build/dist/macos/WebDropBridge_Setup.dmg
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Update formula**:
|
|
||||||
- `build/homebrew/webdrop-bridge.rb` - update `version` and `sha256`
|
|
||||||
|
|
||||||
4. **Test locally**:
|
|
||||||
```bash
|
|
||||||
brew audit --formula build/homebrew/webdrop-bridge.rb
|
|
||||||
brew install build/homebrew/webdrop-bridge.rb
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Publish** (create Forgejo tap or submit to official Homebrew):
|
|
||||||
- Option A: Create `homebrew-webdrop-bridge` tap on Forgejo
|
|
||||||
- Option B: Submit to `homebrew/casks` on GitHub
|
|
||||||
|
|
||||||
## Publishing Strategy
|
|
||||||
|
|
||||||
### Recommended Approach for HIM
|
|
||||||
|
|
||||||
1. **Chocolatey**:
|
|
||||||
- Host in internal Artifactory/Azure Artifacts NuGet repository
|
|
||||||
- OR submit to Chocolatey community (chocolatey.org)
|
|
||||||
- Users: `choco install webdrop-bridge`
|
|
||||||
|
|
||||||
2. **Homebrew**:
|
|
||||||
- Create custom tap: `HIM-public/homebrew-webdrop-bridge` on Forgejo
|
|
||||||
- Users add tap: `brew tap HIM-public/webdrop-bridge https://git.him-tools.de/...`
|
|
||||||
- Users: `brew install webdrop-bridge`
|
|
||||||
|
|
||||||
3. **Fallback**:
|
|
||||||
- Direct wget/downloads from Forgejo releases
|
|
||||||
- Built-in auto-update system in app
|
|
||||||
|
|
||||||
## Release Checklist
|
|
||||||
|
|
||||||
When releasing version X.Y.Z:
|
|
||||||
|
|
||||||
- [ ] Build Windows MSI: `python build/scripts/build_windows.py --msi`
|
|
||||||
- [ ] Build macOS DMG: `bash build/scripts/build_macos.sh`
|
|
||||||
- [ ] Calculate checksums (certutil / shasum)
|
|
||||||
- [ ] Create Forgejo release with installers
|
|
||||||
- [ ] Update `build/chocolatey/webdrop-bridge.nuspec` version
|
|
||||||
- [ ] Update `build/chocolatey/tools/chocolateyInstall.ps1` version & checksum
|
|
||||||
- [ ] Update `build/homebrew/webdrop-bridge.rb` version & checksum
|
|
||||||
- [ ] Test Chocolatey package locally
|
|
||||||
- [ ] Test Homebrew formula locally
|
|
||||||
- [ ] Publish to package managers
|
|
||||||
|
|
||||||
## User Installation Commands
|
|
||||||
|
|
||||||
After publishing:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Windows
|
|
||||||
choco install webdrop-bridge
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# macOS
|
|
||||||
brew tap HIM-public/webdrop-bridge https://git.him-tools.de/HIM-public/homebrew-webdrop-bridge.git
|
|
||||||
brew install webdrop-bridge
|
|
||||||
```
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [Full Documentation](../../docs/PACKAGE_MANAGER_SUPPORT.md)
|
|
||||||
- [Chocolatey Docs](https://docs.chocolatey.org/)
|
|
||||||
- [Homebrew Docs](https://docs.brew.sh/)
|
|
||||||
- [Forgejo API](https://docs.gitea.com/api/1.22/)
|
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
# Build Scripts
|
|
||||||
|
|
||||||
Automation scripts for building, releasing, and downloading WebDrop Bridge.
|
|
||||||
|
|
||||||
## Scripts Overview
|
|
||||||
|
|
||||||
| Script | Purpose | OS |
|
|
||||||
|--------|---------|-----|
|
|
||||||
| `download_release.ps1` | Download installer from Forgejo via wget | Windows |
|
|
||||||
| `download_release.sh` | Download installer from Forgejo via wget | macOS/Linux |
|
|
||||||
| `build_windows.py` | Build Windows MSI installer | Windows |
|
|
||||||
| `build_macos.sh` | Build macOS DMG installer | macOS |
|
|
||||||
| `create_release.ps1` | Create GitHub/Forgejo release | Windows |
|
|
||||||
| `create_release.sh` | Create GitHub/Forgejo release | macOS/Linux |
|
|
||||||
| `sync_remotes.ps1` | Sync git remotes | Windows |
|
|
||||||
| `sync_version.py` | Manage version synchronization | All |
|
|
||||||
|
|
||||||
## Download Scripts
|
|
||||||
|
|
||||||
### Purpose
|
|
||||||
|
|
||||||
The `download_release.ps1` (Windows) and `download_release.sh` (macOS/Linux) scripts download pre-built WebDrop Bridge installers from the Forgejo repository using **wget**. This is the recommended way to:
|
|
||||||
|
|
||||||
- **Initial Installation**: First-time users can bootstrap without building from source
|
|
||||||
- **Enterprise Deployments**: Automated setup scripts in larger organizations
|
|
||||||
- **Offline/Air-Gapped Systems**: Download on one machine, transfer to another
|
|
||||||
- **Proxy Environments**: Works with corporate proxies (via wget)
|
|
||||||
- **CI/CD Automation**: Internal deployment pipelines
|
|
||||||
- **Command-Line Preference**: Admins who prefer CLI tools over GUIs
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
✅ **Automatic platform detection** - Prefers .dmg on macOS, .msi on Windows
|
|
||||||
✅ **SHA256 checksum verification** - Ensures integrity of downloaded files
|
|
||||||
✅ **Progress indication** - Shows download progress with wget
|
|
||||||
✅ **Error handling** - Clear error messages for common issues
|
|
||||||
✅ **Version selection** - Download specific releases or latest
|
|
||||||
✅ **Offline-friendly** - Works in environments with limited connectivity
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- **wget** (required)
|
|
||||||
- Windows: `choco install wget` or `winget install GNU.Wget`
|
|
||||||
- macOS: `brew install wget`
|
|
||||||
- Linux: `apt-get install wget` (Ubuntu/Debian) or equivalent
|
|
||||||
|
|
||||||
### Direct wget Commands (No Script Needed)
|
|
||||||
|
|
||||||
**Simplest: If you know the version**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Download directly by version tag
|
|
||||||
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0/WebDropBridge_Setup.msi
|
|
||||||
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0/WebDropBridge_Setup.dmg
|
|
||||||
```
|
|
||||||
|
|
||||||
**If you need to auto-detect latest (with grep/cut, no jq needed)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Get latest release and download MSI/DMG
|
|
||||||
wget -qO- https://git.him-tools.de/api/v1/repos/HIM-public/webdrop-bridge/releases/latest | \
|
|
||||||
grep -o '"browser_download_url":"[^"]*\.\(msi\|dmg\)"' | head -1 | cut -d'"' -f4 | \
|
|
||||||
xargs wget -O installer.msi
|
|
||||||
```
|
|
||||||
|
|
||||||
**With checksum verification**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Download installer and checksum
|
|
||||||
INSTALLER=$(wget -qO- https://git.him-tools.de/api/v1/repos/HIM-public/webdrop-bridge/releases/latest | \
|
|
||||||
grep -o '"browser_download_url":"[^"]*\.\(msi\|dmg\)"' | head -1 | cut -d'"' -f4)
|
|
||||||
|
|
||||||
wget -O installer.msi "$INSTALLER"
|
|
||||||
wget -O installer.sha256 "${INSTALLER}.sha256"
|
|
||||||
|
|
||||||
# Verify (macOS: shasum -a 256 -c installer.sha256)
|
|
||||||
sha256sum -c installer.sha256
|
|
||||||
```
|
|
||||||
|
|
||||||
### Script-Based Usage (Recommended for Automation)
|
|
||||||
|
|
||||||
#### Windows PowerShell
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Latest release to current directory
|
|
||||||
.\download_release.ps1
|
|
||||||
|
|
||||||
# Specific version to Downloads folder
|
|
||||||
.\download_release.ps1 -Version "0.8.0" -OutputDir "$env:USERPROFILE\Downloads"
|
|
||||||
|
|
||||||
# Skip checksum verification
|
|
||||||
.\download_release.ps1 -Verify $false
|
|
||||||
```
|
|
||||||
|
|
||||||
#### macOS / Linux Bash
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Latest release
|
|
||||||
./build/scripts/download_release.sh
|
|
||||||
|
|
||||||
# Specific version to Downloads
|
|
||||||
./build/scripts/download_release.sh 0.8.0 ~/Downloads
|
|
||||||
|
|
||||||
# Skip checksum verification
|
|
||||||
./build/scripts/download_release.sh latest --no-verify
|
|
||||||
```
|
|
||||||
|
|
||||||
## Build Scripts
|
|
||||||
|
|
||||||
### build_windows.py
|
|
||||||
Builds Windows MSI installer using PyInstaller and WIX toolset.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python build/scripts/build_windows.py --msi
|
|
||||||
```
|
|
||||||
|
|
||||||
### build_macos.sh
|
|
||||||
Builds macOS DMG installer with code signing and notarization.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash build/scripts/build_macos.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Release Scripts
|
|
||||||
|
|
||||||
### create_release.ps1 / create_release.sh
|
|
||||||
Automated release creation with versioning and asset uploads.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Windows
|
|
||||||
.\build\scripts\create_release.ps1
|
|
||||||
|
|
||||||
# macOS/Linux
|
|
||||||
./build/scripts/create_release.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Version Management
|
|
||||||
|
|
||||||
### sync_version.py
|
|
||||||
Manages consistent versioning across the project.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python build/scripts/sync_version.py --version 0.8.0
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integration Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
download_release.ps1/sh
|
|
||||||
↓
|
|
||||||
Fetches release from Forgejo API
|
|
||||||
↓
|
|
||||||
Downloads installer (.msi or .dmg)
|
|
||||||
↓
|
|
||||||
Verifies SHA256 checksum
|
|
||||||
↓
|
|
||||||
Installer ready for execution
|
|
||||||
↓
|
|
||||||
(Application auto-update handles future updates)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing Scripts Locally
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Test download script (dry-run)
|
|
||||||
.\build\scripts\download_release.ps1 -Version "0.7.1"
|
|
||||||
|
|
||||||
# Test with different output directory
|
|
||||||
.\build\scripts\download_release.ps1 -OutputDir ".\test_download"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### wget not found
|
|
||||||
- **Windows**: Install via `winget install GNU.Wget` or Chocolatey
|
|
||||||
- **macOS**: `brew install wget`
|
|
||||||
- **Linux**: `apt-get install wget` (or equivalent)
|
|
||||||
|
|
||||||
### Checksum verification failed
|
|
||||||
- File may be corrupted in transit
|
|
||||||
- Retry download: `.\download_release.ps1 -Verify $false` then manually verify
|
|
||||||
- Report issue with download URL and Forgejo release info
|
|
||||||
|
|
||||||
### Network timeouts
|
|
||||||
- Check connectivity to `https://git.him-tools.de`
|
|
||||||
- May indicate temporary Forgejo API unavailability
|
|
||||||
- Retry after a few minutes
|
|
||||||
|
|
||||||
### Permission denied (macOS/Linux)
|
|
||||||
```bash
|
|
||||||
chmod +x build/scripts/download_release.sh
|
|
||||||
chmod +x build/scripts/build_macos.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
For user-facing documentation, see [QUICKSTART.md](../../QUICKSTART.md) and [README.md](../../README.md)
|
|
||||||
|
|
@ -1,397 +0,0 @@
|
||||||
"""Brand-aware build configuration helpers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class BrandConfig:
|
|
||||||
"""Packaging metadata for a branded build."""
|
|
||||||
|
|
||||||
brand_id: str
|
|
||||||
display_name: str
|
|
||||||
asset_prefix: str
|
|
||||||
exe_name: str
|
|
||||||
manufacturer: str
|
|
||||||
install_dir_name: str
|
|
||||||
shortcut_description: str
|
|
||||||
bundle_identifier: str
|
|
||||||
config_dir_name: str
|
|
||||||
msi_upgrade_code: str
|
|
||||||
update_channel: str
|
|
||||||
icon_ico: Path
|
|
||||||
icon_icns: Path
|
|
||||||
dialog_bmp: Path
|
|
||||||
banner_bmp: Path
|
|
||||||
license_rtf: Path
|
|
||||||
toolbar_icon_home: str
|
|
||||||
toolbar_icon_reload: str
|
|
||||||
toolbar_icon_open: str
|
|
||||||
toolbar_icon_openwith: str
|
|
||||||
|
|
||||||
def windows_installer_name(self, version: str) -> str:
|
|
||||||
return f"{self.asset_prefix}-{version}-win-x64.msi"
|
|
||||||
|
|
||||||
def macos_installer_name(self, version: str) -> str:
|
|
||||||
return f"{self.asset_prefix}-{version}-macos-universal.dmg"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def app_bundle_name(self) -> str:
|
|
||||||
return f"{self.asset_prefix}.app"
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_BRAND_VALUES: dict[str, Any] = {
|
|
||||||
"brand_id": "webdrop_bridge",
|
|
||||||
"display_name": "WebDrop Bridge",
|
|
||||||
"asset_prefix": "WebDropBridge",
|
|
||||||
"exe_name": "WebDropBridge",
|
|
||||||
"manufacturer": "HIM-Tools",
|
|
||||||
"install_dir_name": "WebDrop Bridge",
|
|
||||||
"shortcut_description": "Web Drag-and-Drop Bridge",
|
|
||||||
"bundle_identifier": "de.him_tools.webdrop-bridge",
|
|
||||||
"config_dir_name": "webdrop_bridge",
|
|
||||||
"msi_upgrade_code": "12345678-1234-1234-1234-123456789012",
|
|
||||||
"update_channel": "stable",
|
|
||||||
"icon_ico": "resources/icons/app.ico",
|
|
||||||
"icon_icns": "resources/icons/app.icns",
|
|
||||||
"dialog_bmp": "resources/icons/background.bmp",
|
|
||||||
"banner_bmp": "resources/icons/banner.bmp",
|
|
||||||
"license_rtf": "resources/license.rtf",
|
|
||||||
"toolbar_icon_home": "resources/icons/home.ico",
|
|
||||||
"toolbar_icon_reload": "resources/icons/reload.ico",
|
|
||||||
"toolbar_icon_open": "resources/icons/open.ico",
|
|
||||||
"toolbar_icon_openwith": "resources/icons/openwith.ico",
|
|
||||||
}
|
|
||||||
|
|
||||||
DEFAULT_BRAND_ID = str(DEFAULT_BRAND_VALUES["brand_id"])
|
|
||||||
|
|
||||||
|
|
||||||
def project_root() -> Path:
|
|
||||||
return Path(__file__).resolve().parents[2]
|
|
||||||
|
|
||||||
|
|
||||||
def brands_dir(root: Path | None = None) -> Path:
|
|
||||||
base = root or project_root()
|
|
||||||
return base / "build" / "brands"
|
|
||||||
|
|
||||||
|
|
||||||
def available_brand_names(root: Path | None = None) -> list[str]:
|
|
||||||
"""Return all supported brand names, including the default build."""
|
|
||||||
base = root or project_root()
|
|
||||||
names = [DEFAULT_BRAND_ID]
|
|
||||||
manifest_dir = brands_dir(base)
|
|
||||||
if manifest_dir.exists():
|
|
||||||
for manifest in sorted(manifest_dir.glob("*.json")):
|
|
||||||
if manifest.stem not in names:
|
|
||||||
names.append(manifest.stem)
|
|
||||||
return names
|
|
||||||
|
|
||||||
|
|
||||||
def load_brand_config(
|
|
||||||
brand: str | None = None,
|
|
||||||
*,
|
|
||||||
root: Path | None = None,
|
|
||||||
manifest_path: Path | None = None,
|
|
||||||
) -> BrandConfig:
|
|
||||||
"""Load a brand manifest with defaults and asset fallbacks."""
|
|
||||||
base = root or project_root()
|
|
||||||
values = dict(DEFAULT_BRAND_VALUES)
|
|
||||||
|
|
||||||
if manifest_path is None and brand and brand != DEFAULT_BRAND_ID:
|
|
||||||
manifest_path = brands_dir(base) / f"{brand}.json"
|
|
||||||
|
|
||||||
if manifest_path and manifest_path.exists():
|
|
||||||
values.update(json.loads(manifest_path.read_text(encoding="utf-8")))
|
|
||||||
elif manifest_path and not manifest_path.exists():
|
|
||||||
raise FileNotFoundError(f"Brand manifest not found: {manifest_path}")
|
|
||||||
|
|
||||||
def resolve_asset(key: str) -> Path:
|
|
||||||
candidate = base / str(values.get(key, DEFAULT_BRAND_VALUES[key]))
|
|
||||||
if candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return base / str(DEFAULT_BRAND_VALUES[key])
|
|
||||||
|
|
||||||
return BrandConfig(
|
|
||||||
brand_id=str(values["brand_id"]),
|
|
||||||
display_name=str(values["display_name"]),
|
|
||||||
asset_prefix=str(values["asset_prefix"]),
|
|
||||||
exe_name=str(values["exe_name"]),
|
|
||||||
manufacturer=str(values["manufacturer"]),
|
|
||||||
install_dir_name=str(values["install_dir_name"]),
|
|
||||||
shortcut_description=str(values["shortcut_description"]),
|
|
||||||
bundle_identifier=str(values["bundle_identifier"]),
|
|
||||||
config_dir_name=str(values["config_dir_name"]),
|
|
||||||
msi_upgrade_code=str(values["msi_upgrade_code"]),
|
|
||||||
update_channel=str(values.get("update_channel", "stable")),
|
|
||||||
icon_ico=resolve_asset("icon_ico"),
|
|
||||||
icon_icns=resolve_asset("icon_icns"),
|
|
||||||
dialog_bmp=resolve_asset("dialog_bmp"),
|
|
||||||
banner_bmp=resolve_asset("banner_bmp"),
|
|
||||||
license_rtf=resolve_asset("license_rtf"),
|
|
||||||
toolbar_icon_home=str(
|
|
||||||
values.get("toolbar_icon_home", DEFAULT_BRAND_VALUES["toolbar_icon_home"])
|
|
||||||
),
|
|
||||||
toolbar_icon_reload=str(
|
|
||||||
values.get("toolbar_icon_reload", DEFAULT_BRAND_VALUES["toolbar_icon_reload"])
|
|
||||||
),
|
|
||||||
toolbar_icon_open=str(
|
|
||||||
values.get("toolbar_icon_open", DEFAULT_BRAND_VALUES["toolbar_icon_open"])
|
|
||||||
),
|
|
||||||
toolbar_icon_openwith=str(
|
|
||||||
values.get("toolbar_icon_openwith", DEFAULT_BRAND_VALUES["toolbar_icon_openwith"])
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_release_manifest(
|
|
||||||
version: str,
|
|
||||||
brands: list[str],
|
|
||||||
*,
|
|
||||||
output_path: Path,
|
|
||||||
root: Path | None = None,
|
|
||||||
) -> Path:
|
|
||||||
"""Generate a shared release-manifest.json from local build outputs."""
|
|
||||||
base = root or project_root()
|
|
||||||
manifest: dict[str, Any] = {
|
|
||||||
"version": version,
|
|
||||||
"channel": "stable",
|
|
||||||
"brands": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
for brand_name in brands:
|
|
||||||
brand = load_brand_config(brand_name, root=base)
|
|
||||||
manifest["channel"] = brand.update_channel
|
|
||||||
entries: dict[str, dict[str, str]] = {}
|
|
||||||
|
|
||||||
windows_dir = base / "build" / "dist" / "windows" / brand.brand_id
|
|
||||||
windows_installer = windows_dir / brand.windows_installer_name(version)
|
|
||||||
windows_checksum = windows_dir / f"{windows_installer.name}.sha256"
|
|
||||||
if windows_installer.exists():
|
|
||||||
entries["windows-x64"] = {
|
|
||||||
"installer": windows_installer.name,
|
|
||||||
"checksum": windows_checksum.name if windows_checksum.exists() else "",
|
|
||||||
}
|
|
||||||
|
|
||||||
macos_dir = base / "build" / "dist" / "macos" / brand.brand_id
|
|
||||||
macos_installer = macos_dir / brand.macos_installer_name(version)
|
|
||||||
macos_checksum = macos_dir / f"{macos_installer.name}.sha256"
|
|
||||||
if macos_installer.exists():
|
|
||||||
entries["macos-universal"] = {
|
|
||||||
"installer": macos_installer.name,
|
|
||||||
"checksum": macos_checksum.name if macos_checksum.exists() else "",
|
|
||||||
}
|
|
||||||
|
|
||||||
if entries:
|
|
||||||
manifest["brands"][brand.brand_id] = entries
|
|
||||||
|
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
output_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
|
||||||
return output_path
|
|
||||||
|
|
||||||
|
|
||||||
def merge_release_manifests(
|
|
||||||
base_manifest: dict[str, Any], overlay_manifest: dict[str, Any]
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Merge two release manifests, preserving previously uploaded platforms."""
|
|
||||||
merged: dict[str, Any] = {
|
|
||||||
"version": overlay_manifest.get("version") or base_manifest.get("version", ""),
|
|
||||||
"channel": overlay_manifest.get("channel") or base_manifest.get("channel", "stable"),
|
|
||||||
"brands": dict(base_manifest.get("brands", {})),
|
|
||||||
}
|
|
||||||
|
|
||||||
for brand_id, entries in overlay_manifest.get("brands", {}).items():
|
|
||||||
brand_entry = dict(merged["brands"].get(brand_id, {}))
|
|
||||||
for platform_key, platform_value in entries.items():
|
|
||||||
if platform_value:
|
|
||||||
brand_entry[platform_key] = platform_value
|
|
||||||
merged["brands"][brand_id] = brand_entry
|
|
||||||
|
|
||||||
return merged
|
|
||||||
|
|
||||||
|
|
||||||
def collect_local_release_data(
|
|
||||||
version: str,
|
|
||||||
*,
|
|
||||||
platform: str,
|
|
||||||
root: Path | None = None,
|
|
||||||
brands: list[str] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Collect local artifacts and manifest entries for the requested platform."""
|
|
||||||
base = root or project_root()
|
|
||||||
selected_brands = brands or available_brand_names(base)
|
|
||||||
release_manifest: dict[str, Any] = {
|
|
||||||
"version": version,
|
|
||||||
"channel": "stable",
|
|
||||||
"brands": {},
|
|
||||||
}
|
|
||||||
artifacts: list[str] = []
|
|
||||||
found_brands: list[str] = []
|
|
||||||
|
|
||||||
for brand_name in selected_brands:
|
|
||||||
brand = load_brand_config(brand_name, root=base)
|
|
||||||
release_manifest["channel"] = brand.update_channel
|
|
||||||
|
|
||||||
if platform == "windows":
|
|
||||||
artifact_dir = base / "build" / "dist" / "windows" / brand.brand_id
|
|
||||||
installer = artifact_dir / brand.windows_installer_name(version)
|
|
||||||
checksum = artifact_dir / f"{installer.name}.sha256"
|
|
||||||
platform_key = "windows-x64"
|
|
||||||
elif platform == "macos":
|
|
||||||
artifact_dir = base / "build" / "dist" / "macos" / brand.brand_id
|
|
||||||
installer = artifact_dir / brand.macos_installer_name(version)
|
|
||||||
checksum = artifact_dir / f"{installer.name}.sha256"
|
|
||||||
platform_key = "macos-universal"
|
|
||||||
|
|
||||||
if not installer.exists() and brand.brand_id == DEFAULT_BRAND_ID:
|
|
||||||
legacy_installer = (base / "build" / "dist" / "macos") / brand.macos_installer_name(
|
|
||||||
version
|
|
||||||
)
|
|
||||||
legacy_checksum = legacy_installer.parent / f"{legacy_installer.name}.sha256"
|
|
||||||
if legacy_installer.exists():
|
|
||||||
installer = legacy_installer
|
|
||||||
checksum = legacy_checksum
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unsupported platform: {platform}")
|
|
||||||
|
|
||||||
if not installer.exists():
|
|
||||||
continue
|
|
||||||
|
|
||||||
found_brands.append(brand.brand_id)
|
|
||||||
artifacts.append(str(installer))
|
|
||||||
if checksum.exists():
|
|
||||||
artifacts.append(str(checksum))
|
|
||||||
|
|
||||||
release_manifest["brands"].setdefault(brand.brand_id, {})[platform_key] = {
|
|
||||||
"installer": installer.name,
|
|
||||||
"checksum": checksum.name if checksum.exists() else "",
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"version": version,
|
|
||||||
"platform": platform,
|
|
||||||
"brands": found_brands,
|
|
||||||
"artifacts": artifacts,
|
|
||||||
"manifest": release_manifest,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def cli_env(args: argparse.Namespace) -> int:
|
|
||||||
brand = load_brand_config(args.brand)
|
|
||||||
assignments = {
|
|
||||||
"WEBDROP_BRAND_ID": brand.brand_id,
|
|
||||||
"WEBDROP_APP_DISPLAY_NAME": brand.display_name,
|
|
||||||
"WEBDROP_ASSET_PREFIX": brand.asset_prefix,
|
|
||||||
"WEBDROP_EXE_NAME": brand.exe_name,
|
|
||||||
"WEBDROP_BUNDLE_ID": brand.bundle_identifier,
|
|
||||||
"WEBDROP_CONFIG_DIR_NAME": brand.config_dir_name,
|
|
||||||
"WEBDROP_UPDATE_CHANNEL": brand.update_channel,
|
|
||||||
"WEBDROP_ICON_ICO": str(brand.icon_ico),
|
|
||||||
"WEBDROP_ICON_ICNS": str(brand.icon_icns),
|
|
||||||
"WEBDROP_TOOLBAR_ICON_HOME": brand.toolbar_icon_home,
|
|
||||||
"WEBDROP_TOOLBAR_ICON_RELOAD": brand.toolbar_icon_reload,
|
|
||||||
"WEBDROP_TOOLBAR_ICON_OPEN": brand.toolbar_icon_open,
|
|
||||||
"WEBDROP_TOOLBAR_ICON_OPENWITH": brand.toolbar_icon_openwith,
|
|
||||||
}
|
|
||||||
for key, value in assignments.items():
|
|
||||||
print(f'export {key}="{value}"')
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cli_manifest(args: argparse.Namespace) -> int:
|
|
||||||
output = generate_release_manifest(
|
|
||||||
args.version,
|
|
||||||
args.brands,
|
|
||||||
output_path=Path(args.output).resolve(),
|
|
||||||
)
|
|
||||||
print(output)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cli_local_release_data(args: argparse.Namespace) -> int:
|
|
||||||
data = collect_local_release_data(
|
|
||||||
args.version,
|
|
||||||
platform=args.platform,
|
|
||||||
brands=args.brands,
|
|
||||||
)
|
|
||||||
print(json.dumps(data, indent=2))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cli_merge_manifests(args: argparse.Namespace) -> int:
|
|
||||||
base_manifest = json.loads(Path(args.base).read_text(encoding="utf-8"))
|
|
||||||
overlay_manifest = json.loads(Path(args.overlay).read_text(encoding="utf-8"))
|
|
||||||
merged = merge_release_manifests(base_manifest, overlay_manifest)
|
|
||||||
output_path = Path(args.output)
|
|
||||||
output_path.write_text(json.dumps(merged, indent=2), encoding="utf-8")
|
|
||||||
print(output_path)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cli_show(args: argparse.Namespace) -> int:
|
|
||||||
brand = load_brand_config(args.brand)
|
|
||||||
print(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"brand_id": brand.brand_id,
|
|
||||||
"display_name": brand.display_name,
|
|
||||||
"asset_prefix": brand.asset_prefix,
|
|
||||||
"exe_name": brand.exe_name,
|
|
||||||
"manufacturer": brand.manufacturer,
|
|
||||||
"install_dir_name": brand.install_dir_name,
|
|
||||||
"shortcut_description": brand.shortcut_description,
|
|
||||||
"bundle_identifier": brand.bundle_identifier,
|
|
||||||
"config_dir_name": brand.config_dir_name,
|
|
||||||
"msi_upgrade_code": brand.msi_upgrade_code,
|
|
||||||
"update_channel": brand.update_channel,
|
|
||||||
"toolbar_icon_home": brand.toolbar_icon_home,
|
|
||||||
"toolbar_icon_reload": brand.toolbar_icon_reload,
|
|
||||||
"toolbar_icon_open": brand.toolbar_icon_open,
|
|
||||||
"toolbar_icon_openwith": brand.toolbar_icon_openwith,
|
|
||||||
},
|
|
||||||
indent=2,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(description="Brand-aware build configuration")
|
|
||||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
||||||
|
|
||||||
env_parser = subparsers.add_parser("env")
|
|
||||||
env_parser.add_argument("--brand", required=True)
|
|
||||||
env_parser.set_defaults(func=cli_env)
|
|
||||||
|
|
||||||
manifest_parser = subparsers.add_parser("release-manifest")
|
|
||||||
manifest_parser.add_argument("--version", required=True)
|
|
||||||
manifest_parser.add_argument("--output", required=True)
|
|
||||||
manifest_parser.add_argument("--brands", nargs="+", required=True)
|
|
||||||
manifest_parser.set_defaults(func=cli_manifest)
|
|
||||||
|
|
||||||
local_parser = subparsers.add_parser("local-release-data")
|
|
||||||
local_parser.add_argument("--version", required=True)
|
|
||||||
local_parser.add_argument("--platform", choices=["windows", "macos"], required=True)
|
|
||||||
local_parser.add_argument("--brands", nargs="+")
|
|
||||||
local_parser.set_defaults(func=cli_local_release_data)
|
|
||||||
|
|
||||||
merge_parser = subparsers.add_parser("merge-manifests")
|
|
||||||
merge_parser.add_argument("--base", required=True)
|
|
||||||
merge_parser.add_argument("--overlay", required=True)
|
|
||||||
merge_parser.add_argument("--output", required=True)
|
|
||||||
merge_parser.set_defaults(func=cli_merge_manifests)
|
|
||||||
|
|
||||||
show_parser = subparsers.add_parser("show")
|
|
||||||
show_parser.add_argument("--brand", required=True)
|
|
||||||
show_parser.set_defaults(func=cli_show)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
return args.func(args)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
|
|
@ -28,13 +28,10 @@ DIST_DIR="$BUILD_DIR/dist/macos"
|
||||||
TEMP_BUILD="$BUILD_DIR/temp/macos"
|
TEMP_BUILD="$BUILD_DIR/temp/macos"
|
||||||
SPECS_DIR="$BUILD_DIR/specs"
|
SPECS_DIR="$BUILD_DIR/specs"
|
||||||
SPEC_FILE="$BUILD_DIR/webdrop_bridge.spec"
|
SPEC_FILE="$BUILD_DIR/webdrop_bridge.spec"
|
||||||
BRAND_HELPER="$BUILD_DIR/scripts/brand_config.py"
|
|
||||||
|
|
||||||
BRAND=""
|
|
||||||
APP_NAME="WebDropBridge"
|
APP_NAME="WebDropBridge"
|
||||||
DMG_VOLUME_NAME="WebDrop Bridge"
|
DMG_VOLUME_NAME="WebDrop Bridge"
|
||||||
BUNDLE_IDENTIFIER="de.him_tools.webdrop-bridge"
|
VERSION="1.0.0"
|
||||||
VERSION=""
|
|
||||||
|
|
||||||
# Default .env file
|
# Default .env file
|
||||||
ENV_FILE="$PROJECT_ROOT/.env"
|
ENV_FILE="$PROJECT_ROOT/.env"
|
||||||
|
|
@ -57,10 +54,6 @@ while [[ $# -gt 0 ]]; do
|
||||||
ENV_FILE="$2"
|
ENV_FILE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--brand)
|
|
||||||
BRAND="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1"
|
echo "Unknown option: $1"
|
||||||
exit 1
|
exit 1
|
||||||
|
|
@ -77,23 +70,6 @@ fi
|
||||||
|
|
||||||
echo "📋 Using configuration: $ENV_FILE"
|
echo "📋 Using configuration: $ENV_FILE"
|
||||||
|
|
||||||
if [ -z "$BRAND" ]; then
|
|
||||||
BRAND="webdrop_bridge"
|
|
||||||
fi
|
|
||||||
|
|
||||||
eval "$(python3 "$BRAND_HELPER" env --brand "$BRAND")"
|
|
||||||
APP_NAME="$WEBDROP_ASSET_PREFIX"
|
|
||||||
DMG_VOLUME_NAME="$WEBDROP_APP_DISPLAY_NAME"
|
|
||||||
BUNDLE_IDENTIFIER="$WEBDROP_BUNDLE_ID"
|
|
||||||
DIST_DIR="$BUILD_DIR/dist/macos/$WEBDROP_BRAND_ID"
|
|
||||||
TEMP_BUILD="$BUILD_DIR/temp/macos/$WEBDROP_BRAND_ID"
|
|
||||||
|
|
||||||
if [ -n "$WEBDROP_APP_DISPLAY_NAME" ]; then
|
|
||||||
echo "🏷️ Building brand: $WEBDROP_APP_DISPLAY_NAME ($WEBDROP_BRAND_ID)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
VERSION="$(python3 -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$BUILD_DIR/scripts').resolve())); from version_utils import get_current_version; print(get_current_version())")"
|
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
|
|
@ -200,27 +176,8 @@ build_executable() {
|
||||||
log_info "Building macOS executable with PyInstaller..."
|
log_info "Building macOS executable with PyInstaller..."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Create bundled runtime .env with brand defaults so first launch
|
|
||||||
# uses brand-specific app name and config directory.
|
|
||||||
BUNDLED_ENV_FILE="$TEMP_BUILD/.env"
|
|
||||||
cp "$ENV_FILE" "$BUNDLED_ENV_FILE"
|
|
||||||
{
|
|
||||||
echo ""
|
|
||||||
echo "# Brand-specific defaults added during packaging"
|
|
||||||
echo "APP_NAME=\"$WEBDROP_APP_DISPLAY_NAME\""
|
|
||||||
echo "BRAND_ID=\"$WEBDROP_BRAND_ID\""
|
|
||||||
echo "APP_CONFIG_DIR_NAME=\"$WEBDROP_CONFIG_DIR_NAME\""
|
|
||||||
echo "UPDATE_CHANNEL=\"$WEBDROP_UPDATE_CHANNEL\""
|
|
||||||
echo "TOOLBAR_ICON_HOME=\"$WEBDROP_TOOLBAR_ICON_HOME\""
|
|
||||||
echo "TOOLBAR_ICON_RELOAD=\"$WEBDROP_TOOLBAR_ICON_RELOAD\""
|
|
||||||
echo "TOOLBAR_ICON_OPEN=\"$WEBDROP_TOOLBAR_ICON_OPEN\""
|
|
||||||
echo "TOOLBAR_ICON_OPENWITH=\"$WEBDROP_TOOLBAR_ICON_OPENWITH\""
|
|
||||||
} >> "$BUNDLED_ENV_FILE"
|
|
||||||
|
|
||||||
# Export env file for spec file to pick up
|
# Export env file for spec file to pick up
|
||||||
export WEBDROP_ENV_FILE="$BUNDLED_ENV_FILE"
|
export WEBDROP_ENV_FILE="$ENV_FILE"
|
||||||
export WEBDROP_VERSION="$VERSION"
|
|
||||||
export WEBDROP_BUNDLE_ID="$BUNDLE_IDENTIFIER"
|
|
||||||
|
|
||||||
python3 -m PyInstaller \
|
python3 -m PyInstaller \
|
||||||
--distpath="$DIST_DIR" \
|
--distpath="$DIST_DIR" \
|
||||||
|
|
@ -242,7 +199,7 @@ create_dmg() {
|
||||||
log_info "Creating DMG package..."
|
log_info "Creating DMG package..."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
DMG_FILE="$DIST_DIR/${APP_NAME}-${VERSION}-macos-universal.dmg"
|
DMG_FILE="$DIST_DIR/${APP_NAME}-${VERSION}.dmg"
|
||||||
|
|
||||||
# Remove existing DMG
|
# Remove existing DMG
|
||||||
if [ -f "$DMG_FILE" ]; then
|
if [ -f "$DMG_FILE" ]; then
|
||||||
|
|
@ -295,8 +252,6 @@ create_dmg() {
|
||||||
SIZE=$(du -h "$DMG_FILE" | cut -f1)
|
SIZE=$(du -h "$DMG_FILE" | cut -f1)
|
||||||
log_success "DMG created successfully"
|
log_success "DMG created successfully"
|
||||||
log_info "Output: $DMG_FILE (Size: $SIZE)"
|
log_info "Output: $DMG_FILE (Size: $SIZE)"
|
||||||
shasum -a 256 "$DMG_FILE" | awk '{print $1}' > "$DMG_FILE.sha256"
|
|
||||||
log_info "Checksum: $DMG_FILE.sha256"
|
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,13 @@ from typing import Optional
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
os.environ["PYTHONIOENCODING"] = "utf-8"
|
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||||
import io
|
import io
|
||||||
|
|
||||||
# Reconfigure stdout/stderr for UTF-8 output
|
# Reconfigure stdout/stderr for UTF-8 output
|
||||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
sys.stdout = io.TextIOWrapper(
|
||||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
sys.stdout.buffer, encoding="utf-8", errors="replace"
|
||||||
|
)
|
||||||
|
sys.stderr = io.TextIOWrapper(
|
||||||
|
sys.stderr.buffer, encoding="utf-8", errors="replace"
|
||||||
|
)
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import shutil
|
import shutil
|
||||||
|
|
@ -38,17 +41,14 @@ import argparse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from dotenv import dotenv_values
|
|
||||||
|
|
||||||
# Import shared version utilities
|
# Import shared version utilities
|
||||||
from brand_config import load_brand_config
|
|
||||||
from sync_version import get_current_version, do_sync_version
|
from sync_version import get_current_version, do_sync_version
|
||||||
|
|
||||||
|
|
||||||
class WindowsBuilder:
|
class WindowsBuilder:
|
||||||
"""Build Windows installer using PyInstaller."""
|
"""Build Windows installer using PyInstaller."""
|
||||||
|
|
||||||
def __init__(self, env_file: Path | None = None, brand: str | None = None):
|
def __init__(self, env_file: Path | None = None):
|
||||||
"""Initialize builder paths.
|
"""Initialize builder paths.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -56,12 +56,10 @@ class WindowsBuilder:
|
||||||
If that doesn't exist, raises error.
|
If that doesn't exist, raises error.
|
||||||
"""
|
"""
|
||||||
self.project_root = Path(__file__).parent.parent.parent
|
self.project_root = Path(__file__).parent.parent.parent
|
||||||
self.brand = load_brand_config(brand, root=self.project_root)
|
|
||||||
self.build_dir = self.project_root / "build"
|
self.build_dir = self.project_root / "build"
|
||||||
self.dist_dir = self.build_dir / "dist" / "windows" / self.brand.brand_id
|
self.dist_dir = self.build_dir / "dist" / "windows"
|
||||||
self.temp_dir = self.build_dir / "temp" / "windows" / self.brand.brand_id
|
self.temp_dir = self.build_dir / "temp" / "windows"
|
||||||
self.spec_file = self.build_dir / "webdrop_bridge.spec"
|
self.spec_file = self.build_dir / "webdrop_bridge.spec"
|
||||||
self.wix_template = self.build_dir / "WebDropBridge.wxs"
|
|
||||||
self.version = get_current_version()
|
self.version = get_current_version()
|
||||||
|
|
||||||
# Validate and set env file
|
# Validate and set env file
|
||||||
|
|
@ -79,7 +77,6 @@ class WindowsBuilder:
|
||||||
|
|
||||||
self.env_file = env_file
|
self.env_file = env_file
|
||||||
print(f"📋 Using configuration: {self.env_file}")
|
print(f"📋 Using configuration: {self.env_file}")
|
||||||
print(f"🏷️ Building brand: {self.brand.display_name} ({self.brand.brand_id})")
|
|
||||||
|
|
||||||
def _get_version(self) -> str:
|
def _get_version(self) -> str:
|
||||||
"""Get version from __init__.py.
|
"""Get version from __init__.py.
|
||||||
|
|
@ -97,48 +94,6 @@ class WindowsBuilder:
|
||||||
shutil.rmtree(path)
|
shutil.rmtree(path)
|
||||||
print(f" Removed {path}")
|
print(f" Removed {path}")
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _format_env_value(value: str) -> str:
|
|
||||||
"""Format env values safely for .env files."""
|
|
||||||
if any(ch in value for ch in [" ", "#", '"', "'", "\t"]):
|
|
||||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
||||||
return f'"{escaped}"'
|
|
||||||
return value
|
|
||||||
|
|
||||||
def _create_bundled_env_file(self) -> Path:
|
|
||||||
"""Create a bundled .env file with brand-specific runtime defaults."""
|
|
||||||
values = dotenv_values(self.env_file)
|
|
||||||
overrides = {
|
|
||||||
"APP_NAME": self.brand.display_name,
|
|
||||||
"BRAND_ID": self.brand.brand_id,
|
|
||||||
"APP_CONFIG_DIR_NAME": self.brand.config_dir_name,
|
|
||||||
"UPDATE_CHANNEL": self.brand.update_channel,
|
|
||||||
"TOOLBAR_ICON_HOME": self.brand.toolbar_icon_home,
|
|
||||||
"TOOLBAR_ICON_RELOAD": self.brand.toolbar_icon_reload,
|
|
||||||
"TOOLBAR_ICON_OPEN": self.brand.toolbar_icon_open,
|
|
||||||
"TOOLBAR_ICON_OPENWITH": self.brand.toolbar_icon_openwith,
|
|
||||||
}
|
|
||||||
|
|
||||||
output_env = self.temp_dir / ".env"
|
|
||||||
output_env.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
lines: list[str] = []
|
|
||||||
for key, raw_value in values.items():
|
|
||||||
if key in overrides:
|
|
||||||
continue
|
|
||||||
if raw_value is None:
|
|
||||||
lines.append(key)
|
|
||||||
else:
|
|
||||||
lines.append(f"{key}={self._format_env_value(str(raw_value))}")
|
|
||||||
|
|
||||||
lines.append("")
|
|
||||||
lines.append("# Brand-specific defaults added during packaging")
|
|
||||||
for key, value in overrides.items():
|
|
||||||
lines.append(f"{key}={self._format_env_value(value)}")
|
|
||||||
|
|
||||||
output_env.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
||||||
return output_env
|
|
||||||
|
|
||||||
def build_executable(self) -> bool:
|
def build_executable(self) -> bool:
|
||||||
"""Build executable using PyInstaller."""
|
"""Build executable using PyInstaller."""
|
||||||
print("\n🔨 Building Windows executable with PyInstaller...")
|
print("\n🔨 Building Windows executable with PyInstaller...")
|
||||||
|
|
@ -163,25 +118,21 @@ class WindowsBuilder:
|
||||||
|
|
||||||
# Set environment variable for spec file to use
|
# Set environment variable for spec file to use
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env["WEBDROP_ENV_FILE"] = str(self._create_bundled_env_file())
|
env["WEBDROP_ENV_FILE"] = str(self.env_file)
|
||||||
env["WEBDROP_BRAND_ID"] = self.brand.brand_id
|
|
||||||
env["WEBDROP_APP_DISPLAY_NAME"] = self.brand.display_name
|
|
||||||
env["WEBDROP_ASSET_PREFIX"] = self.brand.asset_prefix
|
|
||||||
env["WEBDROP_EXE_NAME"] = self.brand.exe_name
|
|
||||||
env["WEBDROP_BUNDLE_ID"] = self.brand.bundle_identifier
|
|
||||||
env["WEBDROP_CONFIG_DIR_NAME"] = self.brand.config_dir_name
|
|
||||||
env["WEBDROP_ICON_ICO"] = str(self.brand.icon_ico)
|
|
||||||
env["WEBDROP_ICON_ICNS"] = str(self.brand.icon_icns)
|
|
||||||
env["WEBDROP_VERSION"] = self.version
|
|
||||||
|
|
||||||
result = subprocess.run(cmd, cwd=str(self.project_root), text=True, env=env)
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=str(self.project_root),
|
||||||
|
text=True,
|
||||||
|
env=env
|
||||||
|
)
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print("❌ PyInstaller build failed")
|
print("❌ PyInstaller build failed")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check if executable exists (inside the COLLECT directory)
|
# Check if executable exists (now in WebDropBridge/WebDropBridge.exe due to COLLECT)
|
||||||
exe_path = self.dist_dir / self.brand.exe_name / f"{self.brand.exe_name}.exe"
|
exe_path = self.dist_dir / "WebDropBridge" / "WebDropBridge.exe"
|
||||||
if not exe_path.exists():
|
if not exe_path.exists():
|
||||||
print(f"❌ Executable not found at {exe_path}")
|
print(f"❌ Executable not found at {exe_path}")
|
||||||
return False
|
return False
|
||||||
|
|
@ -190,11 +141,7 @@ class WindowsBuilder:
|
||||||
print(f"📦 Output: {exe_path}")
|
print(f"📦 Output: {exe_path}")
|
||||||
|
|
||||||
# Calculate total dist size
|
# Calculate total dist size
|
||||||
total_size = sum(
|
total_size = sum(f.stat().st_size for f in self.dist_dir.glob("WebDropBridge/**/*") if f.is_file())
|
||||||
f.stat().st_size
|
|
||||||
for f in self.dist_dir.glob(f"{self.brand.exe_name}/**/*")
|
|
||||||
if f.is_file()
|
|
||||||
)
|
|
||||||
if total_size > 0:
|
if total_size > 0:
|
||||||
print(f" Total size: {total_size / 1024 / 1024:.1f} MB")
|
print(f" Total size: {total_size / 1024 / 1024:.1f} MB")
|
||||||
|
|
||||||
|
|
@ -302,13 +249,9 @@ class WindowsBuilder:
|
||||||
if not self._create_wix_source():
|
if not self._create_wix_source():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Ensure toolbar icons are present in bundled resources before harvesting.
|
|
||||||
if not self._ensure_toolbar_icons_in_bundle():
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Harvest application files using Heat
|
# Harvest application files using Heat
|
||||||
print(f" Harvesting application files...")
|
print(f" Harvesting application files...")
|
||||||
dist_folder = self.dist_dir / self.brand.exe_name
|
dist_folder = self.dist_dir / "WebDropBridge"
|
||||||
if not dist_folder.exists():
|
if not dist_folder.exists():
|
||||||
print(f"❌ Distribution folder not found: {dist_folder}")
|
print(f"❌ Distribution folder not found: {dist_folder}")
|
||||||
return False
|
return False
|
||||||
|
|
@ -320,15 +263,12 @@ class WindowsBuilder:
|
||||||
str(heat_exe),
|
str(heat_exe),
|
||||||
"dir",
|
"dir",
|
||||||
str(dist_folder),
|
str(dist_folder),
|
||||||
"-cg",
|
"-cg", "AppFiles",
|
||||||
"AppFiles",
|
"-dr", "INSTALLFOLDER",
|
||||||
"-dr",
|
|
||||||
"INSTALLFOLDER",
|
|
||||||
"-sfrag",
|
"-sfrag",
|
||||||
"-srd",
|
"-srd",
|
||||||
"-gg",
|
"-gg",
|
||||||
"-o",
|
"-o", str(harvest_file),
|
||||||
str(harvest_file),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
result = subprocess.run(heat_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
result = subprocess.run(heat_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
|
@ -343,28 +283,27 @@ class WindowsBuilder:
|
||||||
if harvest_file.exists():
|
if harvest_file.exists():
|
||||||
content = harvest_file.read_text()
|
content = harvest_file.read_text()
|
||||||
# Add Win64="yes" to all Component tags
|
# Add Win64="yes" to all Component tags
|
||||||
content = content.replace("<Component ", '<Component Win64="yes" ')
|
content = content.replace(
|
||||||
|
'<Component ',
|
||||||
|
'<Component Win64="yes" '
|
||||||
|
)
|
||||||
harvest_file.write_text(content)
|
harvest_file.write_text(content)
|
||||||
print(f" ✓ Marked components as 64-bit")
|
print(f" ✓ Marked components as 64-bit")
|
||||||
|
|
||||||
# Compile both WiX files
|
# Compile both WiX files
|
||||||
wix_obj = self.build_dir / "WebDropBridge.generated.wixobj"
|
wix_obj = self.build_dir / "WebDropBridge.wixobj"
|
||||||
wix_files_obj = self.build_dir / "WebDropBridge_Files.wixobj"
|
wix_files_obj = self.build_dir / "WebDropBridge_Files.wixobj"
|
||||||
msi_output = self.dist_dir / self.brand.windows_installer_name(self.version)
|
msi_output = self.dist_dir / f"WebDropBridge-{self.version}-Setup.msi"
|
||||||
|
|
||||||
# Run candle compiler - make sure to use correct source directory
|
# Run candle compiler - make sure to use correct source directory
|
||||||
candle_cmd = [
|
candle_cmd = [
|
||||||
str(candle_exe),
|
str(candle_exe),
|
||||||
"-ext",
|
"-ext", "WixUIExtension",
|
||||||
"WixUIExtension",
|
|
||||||
"-ext",
|
|
||||||
"WixUtilExtension",
|
|
||||||
f"-dDistDir={self.dist_dir}",
|
f"-dDistDir={self.dist_dir}",
|
||||||
f"-dSourceDir={self.dist_dir / self.brand.exe_name}", # Set SourceDir for Heat-generated files
|
f"-dSourceDir={self.dist_dir}\\WebDropBridge", # Set SourceDir for Heat-generated files
|
||||||
f"-dResourcesDir={self.project_root}\\resources", # Set ResourcesDir for branding assets
|
f"-dResourcesDir={self.project_root}\\resources", # Set ResourcesDir for branding assets
|
||||||
"-o",
|
"-o", str(self.build_dir) + "\\",
|
||||||
str(self.build_dir) + "\\",
|
str(self.build_dir / "WebDropBridge.wxs"),
|
||||||
str(self.build_dir / "WebDropBridge.generated.wxs"),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if harvest_file.exists():
|
if harvest_file.exists():
|
||||||
|
|
@ -379,14 +318,9 @@ class WindowsBuilder:
|
||||||
# Link MSI - include both obj files if harvest was successful
|
# Link MSI - include both obj files if harvest was successful
|
||||||
light_cmd = [
|
light_cmd = [
|
||||||
str(light_exe),
|
str(light_exe),
|
||||||
"-ext",
|
"-ext", "WixUIExtension",
|
||||||
"WixUIExtension",
|
"-b", str(self.dist_dir / "WebDropBridge"), # Base path for source files
|
||||||
"-ext",
|
"-o", str(msi_output),
|
||||||
"WixUtilExtension",
|
|
||||||
"-b",
|
|
||||||
str(self.dist_dir / self.brand.exe_name), # Base path for source files
|
|
||||||
"-o",
|
|
||||||
str(msi_output),
|
|
||||||
str(wix_obj),
|
str(wix_obj),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -394,9 +328,7 @@ class WindowsBuilder:
|
||||||
light_cmd.append(str(wix_files_obj))
|
light_cmd.append(str(wix_files_obj))
|
||||||
|
|
||||||
print(f" Linking MSI installer...")
|
print(f" Linking MSI installer...")
|
||||||
result = subprocess.run(
|
result = subprocess.run(light_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
light_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print("❌ MSI linking failed")
|
print("❌ MSI linking failed")
|
||||||
if result.stdout:
|
if result.stdout:
|
||||||
|
|
@ -412,76 +344,103 @@ class WindowsBuilder:
|
||||||
print("✅ MSI installer created successfully")
|
print("✅ MSI installer created successfully")
|
||||||
print(f"📦 Output: {msi_output}")
|
print(f"📦 Output: {msi_output}")
|
||||||
print(f" Size: {msi_output.stat().st_size / 1024 / 1024:.1f} MB")
|
print(f" Size: {msi_output.stat().st_size / 1024 / 1024:.1f} MB")
|
||||||
self.generate_checksum(msi_output)
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _ensure_toolbar_icons_in_bundle(self) -> bool:
|
|
||||||
"""Ensure toolbar icon files exist in the bundled app folder.
|
|
||||||
|
|
||||||
This guarantees WiX Heat harvest includes these icons in the MSI,
|
|
||||||
even if a previous PyInstaller run omitted them.
|
|
||||||
"""
|
|
||||||
src_icons_dir = self.project_root / "resources" / "icons"
|
|
||||||
bundle_icons_dir = self.dist_dir / self.brand.exe_name / "_internal" / "resources" / "icons"
|
|
||||||
required_icons = ["home.ico", "reload.ico", "open.ico", "openwith.ico"]
|
|
||||||
|
|
||||||
try:
|
|
||||||
bundle_icons_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
for icon_name in required_icons:
|
|
||||||
src = src_icons_dir / icon_name
|
|
||||||
dst = bundle_icons_dir / icon_name
|
|
||||||
|
|
||||||
if not src.exists():
|
|
||||||
print(f"❌ Required icon not found: {src}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not dst.exists() or src.stat().st_mtime > dst.stat().st_mtime:
|
|
||||||
shutil.copy2(src, dst)
|
|
||||||
print(f" ✓ Ensured toolbar icon in bundle: {icon_name}")
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Failed to ensure toolbar icons in bundle: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _create_wix_source(self) -> bool:
|
def _create_wix_source(self) -> bool:
|
||||||
"""Create WiX source file for MSI generation.
|
"""Create WiX source file for MSI generation.
|
||||||
|
|
||||||
Creates per-machine installation (Program Files).
|
Creates per-machine installation (Program Files).
|
||||||
Installation requires admin rights, but the app does not.
|
Installation requires admin rights, but the app does not.
|
||||||
"""
|
"""
|
||||||
wix_template = self.wix_template.read_text(encoding="utf-8")
|
wix_content = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
wix_content = wix_template.format(
|
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"
|
||||||
product_name=self.brand.display_name,
|
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui">
|
||||||
product_name_with_version=f"{self.brand.display_name} v{self.version}",
|
<Product Id="*" Name="WebDrop Bridge" Language="1033" Version="{self.version}"
|
||||||
version=self.version,
|
Manufacturer="HIM-Tools"
|
||||||
manufacturer=self.brand.manufacturer,
|
UpgradeCode="12345678-1234-1234-1234-123456789012">
|
||||||
upgrade_code=self.brand.msi_upgrade_code,
|
|
||||||
asset_prefix=self.brand.asset_prefix,
|
|
||||||
icon_ico=str(self.brand.icon_ico),
|
|
||||||
dialog_bmp=str(self.brand.dialog_bmp),
|
|
||||||
banner_bmp=str(self.brand.banner_bmp),
|
|
||||||
license_rtf=str(self.brand.license_rtf),
|
|
||||||
exe_name=self.brand.exe_name,
|
|
||||||
install_dir_name=self.brand.install_dir_name,
|
|
||||||
shortcut_description=self.brand.shortcut_description,
|
|
||||||
)
|
|
||||||
|
|
||||||
wix_file = self.build_dir / "WebDropBridge.generated.wxs"
|
<Package InstallerVersion="200" Compressed="yes" InstallScope="perMachine" Platform="x64" />
|
||||||
|
<Media Id="1" Cabinet="WebDropBridge.cab" EmbedCab="yes" />
|
||||||
|
|
||||||
|
<!-- Required property for WixUI_InstallDir dialog set -->
|
||||||
|
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />
|
||||||
|
|
||||||
|
<!-- Application Icon -->
|
||||||
|
<Icon Id="AppIcon.ico" SourceFile="$(var.ResourcesDir)\\icons\\app.ico" />
|
||||||
|
|
||||||
|
<!-- Custom branding for InstallDir dialog set -->
|
||||||
|
<WixVariable Id="WixUIDialogBmp" Value="$(var.ResourcesDir)\\icons\\background.bmp" />
|
||||||
|
<WixVariable Id="WixUIBannerBmp" Value="$(var.ResourcesDir)\\icons\\banner.bmp" />
|
||||||
|
<WixVariable Id="WixUILicenseRtf" Value="$(var.ResourcesDir)\\license.rtf" />
|
||||||
|
|
||||||
|
<!-- Installation UI dialogs -->
|
||||||
|
<UIRef Id="WixUI_InstallDir" />
|
||||||
|
<UIRef Id="WixUI_ErrorProgressText" />
|
||||||
|
|
||||||
|
<Feature Id="ProductFeature" Title="WebDrop Bridge" Level="1">
|
||||||
|
<ComponentGroupRef Id="AppFiles" />
|
||||||
|
<ComponentRef Id="ProgramMenuShortcut" />
|
||||||
|
<ComponentRef Id="DesktopShortcut" />
|
||||||
|
</Feature>
|
||||||
|
|
||||||
|
<Directory Id="TARGETDIR" Name="SourceDir">
|
||||||
|
<Directory Id="ProgramFiles64Folder">
|
||||||
|
<Directory Id="INSTALLFOLDER" Name="WebDrop Bridge" />
|
||||||
|
</Directory>
|
||||||
|
<Directory Id="ProgramMenuFolder">
|
||||||
|
<Directory Id="ApplicationProgramsFolder" Name="WebDrop Bridge"/>
|
||||||
|
</Directory>
|
||||||
|
<Directory Id="DesktopFolder" />
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
<DirectoryRef Id="ApplicationProgramsFolder">
|
||||||
|
<Component Id="ProgramMenuShortcut" Guid="*">
|
||||||
|
<Shortcut Id="ApplicationStartMenuShortcut"
|
||||||
|
Name="WebDrop Bridge"
|
||||||
|
Description="Web Drag-and-Drop Bridge"
|
||||||
|
Target="[INSTALLFOLDER]WebDropBridge.exe"
|
||||||
|
Icon="AppIcon.ico"
|
||||||
|
IconIndex="0"
|
||||||
|
WorkingDirectory="INSTALLFOLDER" />
|
||||||
|
<RemoveFolder Id="ApplicationProgramsFolderRemove"
|
||||||
|
On="uninstall" />
|
||||||
|
<RegistryValue Root="HKCU"
|
||||||
|
Key="Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\WebDropBridge"
|
||||||
|
Name="installed"
|
||||||
|
Type="integer"
|
||||||
|
Value="1"
|
||||||
|
KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
</DirectoryRef>
|
||||||
|
|
||||||
|
<DirectoryRef Id="DesktopFolder">
|
||||||
|
<Component Id="DesktopShortcut" Guid="*">
|
||||||
|
<Shortcut Id="DesktopApplicationShortcut"
|
||||||
|
Name="WebDrop Bridge"
|
||||||
|
Description="Web Drag-and-Drop Bridge"
|
||||||
|
Target="[INSTALLFOLDER]WebDropBridge.exe"
|
||||||
|
Icon="AppIcon.ico"
|
||||||
|
IconIndex="0"
|
||||||
|
WorkingDirectory="INSTALLFOLDER" />
|
||||||
|
<RegistryValue Root="HKCU"
|
||||||
|
Key="Software\\WebDropBridge"
|
||||||
|
Name="DesktopShortcut"
|
||||||
|
Type="integer"
|
||||||
|
Value="1"
|
||||||
|
KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
</DirectoryRef>
|
||||||
|
</Product>
|
||||||
|
</Wix>
|
||||||
|
'''
|
||||||
|
|
||||||
|
wix_file = self.build_dir / "WebDropBridge.wxs"
|
||||||
wix_file.write_text(wix_content)
|
wix_file.write_text(wix_content)
|
||||||
print(f" Created WiX source: {wix_file}")
|
print(f" Created WiX source: {wix_file}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _generate_file_elements(
|
def _generate_file_elements(self, folder: Path, parent_dir_ref: str, parent_rel_path: str, indent: int = 8, file_counter: Optional[dict] = None) -> str:
|
||||||
self,
|
|
||||||
folder: Path,
|
|
||||||
parent_dir_ref: str,
|
|
||||||
parent_rel_path: str,
|
|
||||||
indent: int = 8,
|
|
||||||
file_counter: Optional[dict] = None,
|
|
||||||
) -> str:
|
|
||||||
"""Generate WiX File elements for all files in a folder.
|
"""Generate WiX File elements for all files in a folder.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -506,7 +465,6 @@ class WindowsBuilder:
|
||||||
if item.is_file():
|
if item.is_file():
|
||||||
# Create unique File element ID using hash of full path
|
# Create unique File element ID using hash of full path
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
path_hash = hashlib.md5(str(item).encode()).hexdigest()[:8]
|
path_hash = hashlib.md5(str(item).encode()).hexdigest()[:8]
|
||||||
file_id = f"File_{path_hash}"
|
file_id = f"File_{path_hash}"
|
||||||
file_path = str(item)
|
file_path = str(item)
|
||||||
|
|
@ -514,7 +472,10 @@ class WindowsBuilder:
|
||||||
elif item.is_dir() and item.name != "__pycache__":
|
elif item.is_dir() and item.name != "__pycache__":
|
||||||
# Recursively add files from subdirectories
|
# Recursively add files from subdirectories
|
||||||
sub_elements = self._generate_file_elements(
|
sub_elements = self._generate_file_elements(
|
||||||
item, parent_dir_ref, f"{parent_rel_path}/{item.name}", indent, file_counter
|
item, parent_dir_ref,
|
||||||
|
f"{parent_rel_path}/{item.name}",
|
||||||
|
indent,
|
||||||
|
file_counter
|
||||||
)
|
)
|
||||||
if sub_elements:
|
if sub_elements:
|
||||||
elements.append(sub_elements)
|
elements.append(sub_elements)
|
||||||
|
|
@ -560,7 +521,7 @@ class WindowsBuilder:
|
||||||
print(" Skipping code signing")
|
print(" Skipping code signing")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
exe_path = self.dist_dir / self.brand.exe_name / f"{self.brand.exe_name}.exe"
|
exe_path = self.dist_dir / "WebDropBridge.exe"
|
||||||
cmd = [
|
cmd = [
|
||||||
signtool,
|
signtool,
|
||||||
"sign",
|
"sign",
|
||||||
|
|
@ -573,7 +534,10 @@ class WindowsBuilder:
|
||||||
str(exe_path),
|
str(exe_path),
|
||||||
]
|
]
|
||||||
|
|
||||||
result = subprocess.run(cmd, text=True)
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print("❌ Code signing failed")
|
print("❌ Code signing failed")
|
||||||
return False
|
return False
|
||||||
|
|
@ -593,7 +557,7 @@ class WindowsBuilder:
|
||||||
"""
|
"""
|
||||||
start_time = datetime.now()
|
start_time = datetime.now()
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print(f"🚀 {self.brand.display_name} Windows Build")
|
print("🚀 WebDrop Bridge Windows Build")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
self.clean()
|
self.clean()
|
||||||
|
|
@ -620,7 +584,9 @@ class WindowsBuilder:
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
"""Build Windows MSI installer."""
|
"""Build Windows MSI installer."""
|
||||||
parser = argparse.ArgumentParser(description="Build WebDrop Bridge Windows installer")
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Build WebDrop Bridge Windows installer"
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--msi",
|
"--msi",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
|
@ -637,12 +603,6 @@ def main() -> int:
|
||||||
default=None,
|
default=None,
|
||||||
help="Path to .env file to bundle (default: project root .env)",
|
help="Path to .env file to bundle (default: project root .env)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"--brand",
|
|
||||||
type=str,
|
|
||||||
default=None,
|
|
||||||
help="Brand manifest name from build/brands (e.g. agravity)",
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|
@ -650,7 +610,7 @@ def main() -> int:
|
||||||
do_sync_version()
|
do_sync_version()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
builder = WindowsBuilder(env_file=args.env_file, brand=args.brand)
|
builder = WindowsBuilder(env_file=args.env_file)
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
print(f"❌ Build failed: {e}")
|
print(f"❌ Build failed: {e}")
|
||||||
return 1
|
return 1
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
|
# 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
|
||||||
|
# Uses your Forgejo credentials (same as git)
|
||||||
|
# First run will prompt for credentials and save them to this session
|
||||||
|
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[string]$Version,
|
[string]$Version,
|
||||||
|
|
||||||
[Parameter(Mandatory = $false)]
|
|
||||||
[string[]]$Brands,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[string]$ForgejoUser,
|
[string]$ForgejoUser,
|
||||||
|
|
||||||
|
|
@ -12,47 +15,56 @@ param(
|
||||||
[string]$ForgejoPW,
|
[string]$ForgejoPW,
|
||||||
|
|
||||||
[switch]$ClearCredentials,
|
[switch]$ClearCredentials,
|
||||||
[switch]$DryRun,
|
|
||||||
|
[switch]$SkipExe,
|
||||||
|
|
||||||
[string]$ForgejoUrl = "https://git.him-tools.de",
|
[string]$ForgejoUrl = "https://git.him-tools.de",
|
||||||
[string]$Repo = "HIM-public/webdrop-bridge"
|
[string]$Repo = "HIM-public/webdrop-bridge",
|
||||||
|
[string]$ExePath = "build\dist\windows\WebDropBridge\WebDropBridge.exe",
|
||||||
|
[string]$ChecksumPath = "build\dist\windows\WebDropBridge\WebDropBridge.exe.sha256"
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# Get project root (PSScriptRoot is build/scripts, go up to project root with ..\..)
|
||||||
$projectRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..")
|
$projectRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..")
|
||||||
$pythonExe = Join-Path $projectRoot ".venv\Scripts\python.exe"
|
|
||||||
if (-not (Test-Path $pythonExe)) {
|
# Resolve file paths relative to project root
|
||||||
$pythonExe = "python"
|
$ExePath = Join-Path $projectRoot $ExePath
|
||||||
|
$ChecksumPath = Join-Path $projectRoot $ChecksumPath
|
||||||
|
$MsiPath = Join-Path $projectRoot $MsiPath
|
||||||
|
|
||||||
|
# Function to read version from .env or .env.example
|
||||||
|
function Get-VersionFromEnv {
|
||||||
|
# Use already resolved project root
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$brandHelper = Join-Path $projectRoot "build\scripts\brand_config.py"
|
# Fall back to .env.example
|
||||||
$manifestOutput = Join-Path $projectRoot "build\dist\release-manifest.json"
|
if (Test-Path $envExampleFile) {
|
||||||
$localManifestPath = Join-Path $projectRoot "build\dist\release-manifest.local.json"
|
$content = Get-Content $envExampleFile -Raw
|
||||||
$existingManifestPath = Join-Path $projectRoot "build\dist\release-manifest.existing.json"
|
if ($content -match 'APP_VERSION=([^\r\n]+)') {
|
||||||
|
Write-Host "Version read from .env.example" -ForegroundColor Gray
|
||||||
function Get-CurrentVersion {
|
return $matches[1].Trim()
|
||||||
return (& $pythonExe -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$projectRoot/build/scripts').resolve())); from version_utils import get_current_version; print(get_current_version())").Trim()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-LocalReleaseData {
|
Write-Host "ERROR: Could not find APP_VERSION in .env or .env.example" -ForegroundColor Red
|
||||||
$arguments = @($brandHelper, "local-release-data", "--platform", "windows", "--version", $Version)
|
exit 1
|
||||||
if ($Brands) {
|
|
||||||
$arguments += "--brands"
|
|
||||||
$arguments += $Brands
|
|
||||||
}
|
|
||||||
return (& $pythonExe @arguments | ConvertFrom-Json)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-AssetMap {
|
|
||||||
param([object[]]$Assets)
|
|
||||||
|
|
||||||
$map = @{}
|
|
||||||
foreach ($asset in ($Assets | Where-Object { $_ })) {
|
|
||||||
$map[$asset.name] = $asset
|
|
||||||
}
|
|
||||||
return $map
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Handle --ClearCredentials flag
|
||||||
if ($ClearCredentials) {
|
if ($ClearCredentials) {
|
||||||
Remove-Item env:FORGEJO_USER -ErrorAction SilentlyContinue
|
Remove-Item env:FORGEJO_USER -ErrorAction SilentlyContinue
|
||||||
Remove-Item env:FORGEJO_PASS -ErrorAction SilentlyContinue
|
Remove-Item env:FORGEJO_PASS -ErrorAction SilentlyContinue
|
||||||
|
|
@ -60,228 +72,191 @@ if ($ClearCredentials) {
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not $Version) {
|
# Get credentials from sources (in order of priority)
|
||||||
$Version = Get-CurrentVersion
|
|
||||||
}
|
|
||||||
|
|
||||||
$localData = Get-LocalReleaseData
|
|
||||||
$artifactPaths = New-Object System.Collections.Generic.List[string]
|
|
||||||
|
|
||||||
foreach ($artifact in $localData.artifacts) {
|
|
||||||
$artifactPaths.Add([string]$artifact)
|
|
||||||
if ((Test-Path $artifact) -and ((Get-Item $artifact).Extension -eq ".msi")) {
|
|
||||||
$msiSize = (Get-Item $artifact).Length / 1MB
|
|
||||||
Write-Host "Windows artifact: $([System.IO.Path]::GetFileName($artifact)) ($([math]::Round($msiSize, 2)) MB)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($artifactPaths.Count -eq 0) {
|
|
||||||
Write-Host "ERROR: No local Windows artifacts found" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
$localManifestJson = $localData.manifest | ConvertTo-Json -Depth 10
|
|
||||||
[System.IO.File]::WriteAllText($localManifestPath, $localManifestJson, (New-Object System.Text.UTF8Encoding($false)))
|
|
||||||
|
|
||||||
if ($DryRun) {
|
|
||||||
Copy-Item $localManifestPath $manifestOutput -Force
|
|
||||||
$brandsText = if ($localData.brands.Count -gt 0) { $localData.brands -join ", " } else { "<none>" }
|
|
||||||
|
|
||||||
Write-Host "[DRY RUN] No network requests or uploads will be performed." -ForegroundColor Yellow
|
|
||||||
Write-Host "[DRY RUN] Release tag: v$Version"
|
|
||||||
Write-Host "[DRY RUN] Release URL: $ForgejoUrl/$Repo/releases/tag/v$Version"
|
|
||||||
Write-Host "[DRY RUN] Discovered brands: $brandsText"
|
|
||||||
Write-Host "[DRY RUN] Artifacts that would be uploaded:"
|
|
||||||
foreach ($artifact in $artifactPaths) {
|
|
||||||
Write-Host " - $artifact"
|
|
||||||
}
|
|
||||||
Write-Host "[DRY RUN] Local manifest preview: $manifestOutput"
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $ForgejoUser) {
|
if (-not $ForgejoUser) {
|
||||||
$ForgejoUser = $env:FORGEJO_USER
|
$ForgejoUser = $env:FORGEJO_USER
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not $ForgejoPW) {
|
if (-not $ForgejoPW) {
|
||||||
$ForgejoPW = $env:FORGEJO_PASS
|
$ForgejoPW = $env:FORGEJO_PASS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# If still no credentials, prompt user interactively
|
||||||
if (-not $ForgejoUser -or -not $ForgejoPW) {
|
if (-not $ForgejoUser -or -not $ForgejoPW) {
|
||||||
Write-Host "Forgejo credentials not found. Enter your credentials:" -ForegroundColor Yellow
|
Write-Host "Forgejo credentials not found. Enter your credentials:" -ForegroundColor Yellow
|
||||||
|
|
||||||
if (-not $ForgejoUser) {
|
if (-not $ForgejoUser) {
|
||||||
$ForgejoUser = Read-Host "Username"
|
$ForgejoUser = Read-Host "Username"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not $ForgejoPW) {
|
if (-not $ForgejoPW) {
|
||||||
$securePass = Read-Host "Password" -AsSecureString
|
$securePass = Read-Host "Password" -AsSecureString
|
||||||
$ForgejoPW = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToCoTaskMemUnicode($securePass))
|
$ForgejoPW = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToCoTaskMemUnicode($securePass))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Save credentials to environment for this session
|
||||||
$env:FORGEJO_USER = $ForgejoUser
|
$env:FORGEJO_USER = $ForgejoUser
|
||||||
$env:FORGEJO_PASS = $ForgejoPW
|
$env:FORGEJO_PASS = $ForgejoPW
|
||||||
|
Write-Host "[OK] Credentials saved to this PowerShell session" -ForegroundColor Green
|
||||||
|
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
|
||||||
|
if (-not $Version) {
|
||||||
|
Write-Host "Version not provided, reading from .env.example..." -ForegroundColor Cyan
|
||||||
|
$Version = Get-VersionFromEnv
|
||||||
|
Write-Host "Using version: $Version" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# Define MSI path with resolved version
|
||||||
|
$MsiPath = Join-Path $projectRoot "build\dist\windows\WebDropBridge-$Version-Setup.msi"
|
||||||
|
|
||||||
|
# Verify files exist (exe/checksum optional, MSI required)
|
||||||
|
if (-not $SkipExe) {
|
||||||
|
if (-not (Test-Path $ExePath)) {
|
||||||
|
Write-Host "WARNING: Executable not found at $ExePath" -ForegroundColor Yellow
|
||||||
|
Write-Host " Use -SkipExe flag to skip exe upload" -ForegroundColor Gray
|
||||||
|
$SkipExe = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $SkipExe -and -not (Test-Path $ChecksumPath)) {
|
||||||
|
Write-Host "WARNING: Checksum file not found at $ChecksumPath" -ForegroundColor Yellow
|
||||||
|
Write-Host " Exe will not be uploaded" -ForegroundColor Gray
|
||||||
|
$SkipExe = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# MSI is the primary release artifact
|
||||||
|
if (-not (Test-Path $MsiPath)) {
|
||||||
|
Write-Host "ERROR: MSI installer not found at $MsiPath" -ForegroundColor Red
|
||||||
|
Write-Host "Please build with MSI support:" -ForegroundColor Yellow
|
||||||
|
Write-Host " python build\scripts\build_windows.py --msi" -ForegroundColor Cyan
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Creating WebDropBridge $Version release on Forgejo..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Get file info
|
||||||
|
$msiSize = (Get-Item $MsiPath).Length / 1MB
|
||||||
|
Write-Host "Primary Artifact: WebDropBridge-$Version-Setup.msi ($([math]::Round($msiSize, 2)) MB)"
|
||||||
|
|
||||||
|
if (-not $SkipExe) {
|
||||||
|
$exeSize = (Get-Item $ExePath).Length / 1MB
|
||||||
|
$checksum = Get-Content $ChecksumPath -Raw
|
||||||
|
Write-Host "Optional Artifact: WebDropBridge.exe ($([math]::Round($exeSize, 2)) MB)"
|
||||||
|
Write-Host " Checksum: $($checksum.Substring(0, 16))..."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create basic auth header
|
||||||
$auth = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes("${ForgejoUser}:${ForgejoPW}"))
|
$auth = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes("${ForgejoUser}:${ForgejoPW}"))
|
||||||
|
|
||||||
$headers = @{
|
$headers = @{
|
||||||
"Authorization" = "Basic $auth"
|
"Authorization" = "Basic $auth"
|
||||||
"Content-Type" = "application/json"
|
"Content-Type" = "application/json"
|
||||||
}
|
}
|
||||||
|
|
||||||
$releaseLookupUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/tags/v$Version"
|
# Step 1: Create release
|
||||||
|
Write-Host "`nCreating release v$Version..." -ForegroundColor Yellow
|
||||||
$releaseUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases"
|
$releaseUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases"
|
||||||
|
|
||||||
|
# Build release body with checksum info if exe is being uploaded
|
||||||
|
$releaseBody = "WebDropBridge v$Version`n`n**Release Artifacts:**`n- MSI Installer (Windows Setup)`n"
|
||||||
|
if (-not $SkipExe) {
|
||||||
|
$checksum = Get-Content $ChecksumPath -Raw
|
||||||
|
$releaseBody += "- Portable Executable`n`n**Checksum:**`n$checksum`n"
|
||||||
|
}
|
||||||
|
|
||||||
$releaseData = @{
|
$releaseData = @{
|
||||||
tag_name = "v$Version"
|
tag_name = "v$Version"
|
||||||
name = "WebDropBridge v$Version"
|
name = "WebDropBridge v$Version"
|
||||||
body = "Shared branded release for WebDrop Bridge v$Version"
|
body = $releaseBody
|
||||||
draft = $false
|
draft = $false
|
||||||
prerelease = $false
|
prerelease = $false
|
||||||
} | ConvertTo-Json
|
} | ConvertTo-Json
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$releaseInfo = Invoke-RestMethod -Uri $releaseLookupUrl -Method GET -Headers $headers -TimeoutSec 30 -ErrorAction Stop
|
$response = Invoke-WebRequest -Uri $releaseUrl `
|
||||||
$releaseId = $releaseInfo.id
|
-Method POST `
|
||||||
Write-Host "[OK] Using existing release (ID: $releaseId)" -ForegroundColor Green
|
-Headers $headers `
|
||||||
}
|
-Body $releaseData `
|
||||||
catch {
|
-TimeoutSec 30 `
|
||||||
$releaseInfo = Invoke-RestMethod -Uri $releaseUrl -Method POST -Headers $headers -Body $releaseData -TimeoutSec 30 -ErrorAction Stop
|
-UseBasicParsing `
|
||||||
|
-ErrorAction Stop
|
||||||
|
|
||||||
|
$releaseInfo = $response.Content | ConvertFrom-Json
|
||||||
$releaseId = $releaseInfo.id
|
$releaseId = $releaseInfo.id
|
||||||
Write-Host "[OK] Release created (ID: $releaseId)" -ForegroundColor Green
|
Write-Host "[OK] Release created (ID: $releaseId)" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
catch {
|
||||||
$assetMap = Get-AssetMap -Assets $releaseInfo.assets
|
Write-Host "ERROR creating release: $_" -ForegroundColor Red
|
||||||
if ($assetMap.ContainsKey("release-manifest.json")) {
|
|
||||||
Invoke-WebRequest -Uri $assetMap["release-manifest.json"].browser_download_url -Method GET -Headers $headers -TimeoutSec 30 -OutFile $existingManifestPath | Out-Null
|
|
||||||
|
|
||||||
& $pythonExe $brandHelper merge-manifests --base $existingManifestPath --overlay $localManifestPath --output $manifestOutput | Out-Null
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Copy-Item $localManifestPath $manifestOutput -Force
|
|
||||||
}
|
|
||||||
|
|
||||||
# Ensure uploaded manifest is UTF-8 without BOM (for strict JSON parsers)
|
|
||||||
if (Test-Path $manifestOutput) {
|
|
||||||
$manifestText = Get-Content -Raw -Path $manifestOutput
|
|
||||||
[System.IO.File]::WriteAllText($manifestOutput, $manifestText, (New-Object System.Text.UTF8Encoding($false)))
|
|
||||||
}
|
|
||||||
|
|
||||||
$artifactPaths.Add($manifestOutput)
|
|
||||||
$assetMap = Get-AssetMap -Assets $releaseInfo.assets
|
|
||||||
|
|
||||||
$artifactsToUpload = New-Object System.Collections.Generic.List[string]
|
|
||||||
foreach ($artifact in $artifactPaths) {
|
|
||||||
$assetName = [System.IO.Path]::GetFileName($artifact)
|
|
||||||
$extension = [System.IO.Path]::GetExtension($artifact).ToLowerInvariant()
|
|
||||||
|
|
||||||
if ($extension -eq ".msi" -and $assetMap.ContainsKey($assetName)) {
|
|
||||||
$localSize = (Get-Item $artifact).Length
|
|
||||||
$remoteSize = [int64]$assetMap[$assetName].size
|
|
||||||
if ($localSize -eq $remoteSize) {
|
|
||||||
Write-Host "[OK] Skipping already uploaded MSI $assetName ($([math]::Round($localSize / 1MB, 2)) MB)" -ForegroundColor Cyan
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$artifactsToUpload.Add($artifact)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($artifactsToUpload.Count -eq 0) {
|
|
||||||
Write-Host "[OK] All release assets already uploaded." -ForegroundColor Green
|
|
||||||
Write-Host "View at: $ForgejoUrl/$Repo/releases/tag/v$Version" -ForegroundColor Cyan
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Use Python requests library for more reliable large file uploads
|
|
||||||
$pythonUploadScript = @"
|
|
||||||
import sys
|
|
||||||
import requests
|
|
||||||
from requests.auth import HTTPBasicAuth
|
|
||||||
from pathlib import Path
|
|
||||||
import time
|
|
||||||
|
|
||||||
upload_url = sys.argv[1]
|
|
||||||
artifacts = sys.argv[2:]
|
|
||||||
username = '$ForgejoUser'
|
|
||||||
password = '$ForgejoPW'
|
|
||||||
delete_url_template = '${ForgejoUrl}/api/v1/repos/${Repo}/releases/$releaseId/assets/{}'
|
|
||||||
release_info_url = '${ForgejoUrl}/api/v1/repos/${Repo}/releases/$releaseId'
|
|
||||||
|
|
||||||
session = requests.Session()
|
|
||||||
session.auth = HTTPBasicAuth(username, password)
|
|
||||||
session.headers.update({'Connection': 'close'})
|
|
||||||
|
|
||||||
def upload_with_retry(artifact_path, max_retries=3):
|
|
||||||
asset_name = Path(artifact_path).name
|
|
||||||
|
|
||||||
# Check if asset already exists and delete it
|
|
||||||
try:
|
|
||||||
release_response = session.get(release_info_url, timeout=30)
|
|
||||||
release_response.raise_for_status()
|
|
||||||
for asset in release_response.json().get('assets', []):
|
|
||||||
if asset['name'] == asset_name:
|
|
||||||
delete_resp = session.delete(delete_url_template.format(asset['id']), timeout=30)
|
|
||||||
delete_resp.raise_for_status()
|
|
||||||
print(f'[OK] Replaced existing asset {asset_name}', file=sys.stderr)
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
print(f'Warning checking existing assets: {e}', file=sys.stderr)
|
|
||||||
|
|
||||||
# Upload file with streaming and retries
|
|
||||||
retryable_status_codes = {429, 502, 503, 504}
|
|
||||||
for attempt in range(max_retries):
|
|
||||||
try:
|
|
||||||
if attempt > 0:
|
|
||||||
print(f' Retry {attempt} of {max_retries}...', file=sys.stderr)
|
|
||||||
time.sleep(min(10, 2 * attempt))
|
|
||||||
|
|
||||||
with open(artifact_path, 'rb') as f:
|
|
||||||
files = {'attachment': (asset_name, f)}
|
|
||||||
response = session.post(
|
|
||||||
upload_url,
|
|
||||||
files=files,
|
|
||||||
timeout=900, # 15 minute timeout
|
|
||||||
stream=False
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code in [200, 201]:
|
|
||||||
print(f'[OK] Uploaded {asset_name}')
|
|
||||||
return True
|
|
||||||
|
|
||||||
if response.status_code in retryable_status_codes:
|
|
||||||
if attempt >= max_retries - 1:
|
|
||||||
print(f'ERROR uploading {asset_name} (HTTP {response.status_code} after {max_retries} retries)')
|
|
||||||
print(response.text)
|
|
||||||
sys.exit(1)
|
|
||||||
continue
|
|
||||||
|
|
||||||
print(f'ERROR uploading {asset_name} (HTTP {response.status_code})')
|
|
||||||
print(response.text)
|
|
||||||
sys.exit(1)
|
|
||||||
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:
|
|
||||||
if attempt >= max_retries - 1:
|
|
||||||
print(f'ERROR uploading {asset_name}: Connection failed after {max_retries} retries')
|
|
||||||
print(str(e))
|
|
||||||
sys.exit(1)
|
|
||||||
time.sleep(min(10, 2 * (attempt + 1)))
|
|
||||||
except Exception as e:
|
|
||||||
print(f'ERROR uploading {asset_name}: {e}')
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
for artifact_path in artifacts:
|
|
||||||
upload_with_retry(artifact_path)
|
|
||||||
|
|
||||||
print(f'[OK] All files uploaded successfully')
|
|
||||||
"@
|
|
||||||
|
|
||||||
$uploadScriptPath = ([System.IO.Path]::GetTempFileName() -replace 'tmp$', 'py')
|
|
||||||
Set-Content -Path $uploadScriptPath -Value $pythonUploadScript -Encoding UTF8
|
|
||||||
|
|
||||||
try {
|
|
||||||
$uploadUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/$releaseId/assets"
|
|
||||||
& $pythonExe $uploadScriptPath $uploadUrl @artifactsToUpload
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Setup curl authentication
|
||||||
|
$curlAuth = "$ForgejoUser`:$ForgejoPW"
|
||||||
|
$uploadUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/$releaseId/assets"
|
||||||
|
|
||||||
|
# Step 2: Upload MSI installer as primary artifact
|
||||||
|
Write-Host "`nUploading MSI installer (primary artifact)..." -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 installer uploaded" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host "ERROR uploading MSI: $_" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 3: Upload executable as optional artifact (if available)
|
||||||
|
if (-not $SkipExe) {
|
||||||
|
Write-Host "`nUploading executable (optional portable version)..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = curl.exe -s -X POST `
|
||||||
|
-u $curlAuth `
|
||||||
|
-F "attachment=@$ExePath" `
|
||||||
|
$uploadUrl
|
||||||
|
|
||||||
|
if ($response -like "*error*" -or $response -like "*404*") {
|
||||||
|
Write-Host "WARNING: Could not upload executable: $response" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "[OK] Executable uploaded" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host "WARNING: Could not upload executable: $_" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 4: Upload checksum as asset
|
||||||
|
Write-Host "Uploading checksum..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = curl.exe -s -X POST `
|
||||||
|
-u $curlAuth `
|
||||||
|
-F "attachment=@$ChecksumPath" `
|
||||||
|
$uploadUrl
|
||||||
|
|
||||||
|
if ($response -like "*error*" -or $response -like "*404*") {
|
||||||
|
Write-Host "WARNING: Could not upload checksum: $response" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "[OK] Checksum uploaded" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host "WARNING: Could not upload checksum: $_" -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
finally {
|
|
||||||
Remove-Item $uploadScriptPath -ErrorAction SilentlyContinue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "`n[OK] Release complete!" -ForegroundColor Green
|
Write-Host "`n[OK] Release complete!" -ForegroundColor Green
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,31 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Create or update a shared Forgejo release with branded macOS assets.
|
# Create Forgejo Release with Binary Assets
|
||||||
|
# Usage: ./create_release.sh -v 1.0.0
|
||||||
|
# Uses your Forgejo credentials (same as git)
|
||||||
|
# First run will prompt for credentials and save them to this session
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
VERSION=""
|
VERSION=""
|
||||||
BRANDS=()
|
FORGEJO_USER=""
|
||||||
FORGEJO_USER="${FORGEJO_USER}"
|
FORGEJO_PASS=""
|
||||||
FORGEJO_PASS="${FORGEJO_PASS}"
|
|
||||||
FORGEJO_URL="https://git.him-tools.de"
|
FORGEJO_URL="https://git.him-tools.de"
|
||||||
REPO="HIM-public/webdrop-bridge"
|
REPO="HIM-public/webdrop-bridge"
|
||||||
|
DMG_PATH="build/dist/macos/WebDropBridge.dmg"
|
||||||
|
CHECKSUM_PATH="build/dist/macos/WebDropBridge.dmg.sha256"
|
||||||
CLEAR_CREDS=false
|
CLEAR_CREDS=false
|
||||||
DRY_RUN=false
|
|
||||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
||||||
BRAND_HELPER="$PROJECT_ROOT/build/scripts/brand_config.py"
|
|
||||||
MANIFEST_OUTPUT="$PROJECT_ROOT/build/dist/release-manifest.json"
|
|
||||||
LOCAL_MANIFEST_OUTPUT="$PROJECT_ROOT/build/dist/release-manifest.local.json"
|
|
||||||
EXISTING_MANIFEST_OUTPUT="$PROJECT_ROOT/build/dist/release-manifest.existing.json"
|
|
||||||
LOCAL_DATA_OUTPUT="$PROJECT_ROOT/build/dist/release-data.local.json"
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-v|--version) VERSION="$2"; shift 2;;
|
-v|--version) VERSION="$2"; shift 2;;
|
||||||
-u|--url) FORGEJO_URL="$2"; shift 2;;
|
-u|--url) FORGEJO_URL="$2"; shift 2;;
|
||||||
--brand) BRANDS+=("$2"); shift 2 ;;
|
|
||||||
--clear-credentials) CLEAR_CREDS=true; shift;;
|
--clear-credentials) CLEAR_CREDS=true; shift;;
|
||||||
--dry-run) DRY_RUN=true; shift ;;
|
|
||||||
*) echo "Unknown option: $1"; exit 1;;
|
*) echo "Unknown option: $1"; exit 1;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Handle --clear-credentials flag
|
||||||
if [ "$CLEAR_CREDS" = true ]; then
|
if [ "$CLEAR_CREDS" = true ]; then
|
||||||
unset FORGEJO_USER
|
unset FORGEJO_USER
|
||||||
unset FORGEJO_PASS
|
unset FORGEJO_PASS
|
||||||
|
|
@ -36,193 +33,127 @@ if [ "$CLEAR_CREDS" = true ]; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Load credentials from environment
|
||||||
|
FORGEJO_USER="${FORGEJO_USER}"
|
||||||
|
FORGEJO_PASS="${FORGEJO_PASS}"
|
||||||
|
|
||||||
|
# Verify required parameters
|
||||||
if [ -z "$VERSION" ]; then
|
if [ -z "$VERSION" ]; then
|
||||||
VERSION="$(python3 -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$PROJECT_ROOT/build/scripts').resolve())); from version_utils import get_current_version; print(get_current_version())")"
|
echo "ERROR: Version parameter required" >&2
|
||||||
fi
|
echo "Usage: $0 -v VERSION [-u FORGEJO_URL]" >&2
|
||||||
|
echo "Example: $0 -v 1.0.0" >&2
|
||||||
LOCAL_ARGS=("$BRAND_HELPER" "local-release-data" "--platform" "macos" "--version" "$VERSION")
|
|
||||||
if [ ${#BRANDS[@]} -gt 0 ]; then
|
|
||||||
LOCAL_ARGS+=("--brands" "${BRANDS[@]}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
python3 "${LOCAL_ARGS[@]}" > "$LOCAL_DATA_OUTPUT"
|
|
||||||
|
|
||||||
mapfile -t ARTIFACTS < <(python3 - "$LOCAL_DATA_OUTPUT" "$LOCAL_MANIFEST_OUTPUT" <<'PY'
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
|
||||||
Path(sys.argv[2]).write_text(json.dumps(data["manifest"], indent=2), encoding="utf-8")
|
|
||||||
for artifact in data["artifacts"]:
|
|
||||||
print(artifact)
|
|
||||||
PY
|
|
||||||
)
|
|
||||||
|
|
||||||
for ARTIFACT in "${ARTIFACTS[@]}"; do
|
|
||||||
if [ -f "$ARTIFACT" ] && [ "${ARTIFACT##*.}" = "dmg" ]; then
|
|
||||||
DMG_SIZE=$(du -m "$ARTIFACT" | cut -f1)
|
|
||||||
echo "macOS artifact: $(basename "$ARTIFACT") ($DMG_SIZE MB)"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ ${#ARTIFACTS[@]} -eq 0 ]; then
|
|
||||||
echo "ERROR: No local macOS artifacts found"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$DRY_RUN" = true ]; then
|
# If no credentials, prompt user interactively
|
||||||
cp "$LOCAL_MANIFEST_OUTPUT" "$MANIFEST_OUTPUT"
|
|
||||||
DISCOVERED_BRANDS=$(python3 - "$LOCAL_DATA_OUTPUT" <<'PY'
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
|
||||||
print(", ".join(data.get("brands", [])) or "<none>")
|
|
||||||
PY
|
|
||||||
)
|
|
||||||
|
|
||||||
echo "[DRY RUN] No network requests or uploads will be performed."
|
|
||||||
echo "[DRY RUN] Release tag: v$VERSION"
|
|
||||||
echo "[DRY RUN] Release URL: $FORGEJO_URL/$REPO/releases/tag/v$VERSION"
|
|
||||||
echo "[DRY RUN] Discovered brands: $DISCOVERED_BRANDS"
|
|
||||||
echo "[DRY RUN] Artifacts that would be uploaded:"
|
|
||||||
for ARTIFACT in "${ARTIFACTS[@]}"; do
|
|
||||||
echo " - $ARTIFACT"
|
|
||||||
done
|
|
||||||
echo "[DRY RUN] Local manifest preview: $MANIFEST_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$FORGEJO_USER" ] || [ -z "$FORGEJO_PASS" ]; then
|
if [ -z "$FORGEJO_USER" ] || [ -z "$FORGEJO_PASS" ]; then
|
||||||
echo "Forgejo credentials not found. Enter your credentials:"
|
echo "Forgejo credentials not found. Enter your credentials:"
|
||||||
|
|
||||||
if [ -z "$FORGEJO_USER" ]; then
|
if [ -z "$FORGEJO_USER" ]; then
|
||||||
read -r -p "Username: " FORGEJO_USER
|
read -p "Username: " FORGEJO_USER
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$FORGEJO_PASS" ]; then
|
if [ -z "$FORGEJO_PASS" ]; then
|
||||||
read -r -s -p "Password: " FORGEJO_PASS
|
read -sp "Password: " FORGEJO_PASS
|
||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Export for this session
|
||||||
export FORGEJO_USER
|
export FORGEJO_USER
|
||||||
export FORGEJO_PASS
|
export FORGEJO_PASS
|
||||||
|
echo "[OK] Credentials saved to this shell session"
|
||||||
|
echo "Tip: Credentials will persist until you close the terminal or run: $0 --clear-credentials"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Verify files exist
|
||||||
|
if [ ! -f "$DMG_PATH" ]; then
|
||||||
|
echo "ERROR: DMG file not found at $DMG_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$CHECKSUM_PATH" ]; then
|
||||||
|
echo "ERROR: Checksum file not found at $CHECKSUM_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Creating WebDropBridge $VERSION release on Forgejo..."
|
||||||
|
|
||||||
|
# Get file info
|
||||||
|
DMG_SIZE=$(du -m "$DMG_PATH" | cut -f1)
|
||||||
|
CHECKSUM=$(cat "$CHECKSUM_PATH")
|
||||||
|
|
||||||
|
echo "File: WebDropBridge.dmg ($DMG_SIZE MB)"
|
||||||
|
echo "Checksum: ${CHECKSUM:0:16}..."
|
||||||
|
|
||||||
|
# Create basic auth
|
||||||
BASIC_AUTH=$(echo -n "${FORGEJO_USER}:${FORGEJO_PASS}" | base64)
|
BASIC_AUTH=$(echo -n "${FORGEJO_USER}:${FORGEJO_PASS}" | base64)
|
||||||
|
|
||||||
|
# Step 1: Create release
|
||||||
|
echo ""
|
||||||
|
echo "Creating release v$VERSION..."
|
||||||
RELEASE_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases"
|
RELEASE_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases"
|
||||||
RELEASE_LOOKUP_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases/tags/v$VERSION"
|
|
||||||
|
|
||||||
RELEASE_RESPONSE_FILE=$(mktemp)
|
|
||||||
HTTP_CODE=$(curl -s -o "$RELEASE_RESPONSE_FILE" -w "%{http_code}" -H "Authorization: Basic $BASIC_AUTH" "$RELEASE_LOOKUP_URL")
|
|
||||||
if [ "$HTTP_CODE" = "200" ]; then
|
|
||||||
RELEASE_ID=$(python3 - "$RELEASE_RESPONSE_FILE" <<'PY'
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
|
||||||
print(payload.get("id", ""))
|
|
||||||
PY
|
|
||||||
)
|
|
||||||
else
|
|
||||||
RELEASE_ID=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
|
||||||
RELEASE_DATA=$(cat <<EOF
|
RELEASE_DATA=$(cat <<EOF
|
||||||
{
|
{
|
||||||
"tag_name": "v$VERSION",
|
"tag_name": "v$VERSION",
|
||||||
"name": "WebDropBridge v$VERSION",
|
"name": "WebDropBridge v$VERSION",
|
||||||
"body": "Shared branded release for WebDrop Bridge v$VERSION",
|
"body": "WebDropBridge v$VERSION\n\nChecksum: $CHECKSUM",
|
||||||
"draft": false,
|
"draft": false,
|
||||||
"prerelease": false
|
"prerelease": false
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
)
|
)
|
||||||
HTTP_CODE=$(curl -s -o "$RELEASE_RESPONSE_FILE" -w "%{http_code}" -X POST \
|
|
||||||
|
RESPONSE=$(curl -s -X POST \
|
||||||
-H "Authorization: Basic $BASIC_AUTH" \
|
-H "Authorization: Basic $BASIC_AUTH" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$RELEASE_DATA" \
|
-d "$RELEASE_DATA" \
|
||||||
"$RELEASE_URL")
|
"$RELEASE_URL")
|
||||||
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "201" ]; then
|
|
||||||
RELEASE_ID=$(python3 - "$RELEASE_RESPONSE_FILE" <<'PY'
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
|
||||||
print(payload.get("id", ""))
|
|
||||||
PY
|
|
||||||
)
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
echo "ERROR creating or finding release"
|
echo "ERROR creating release:"
|
||||||
cat "$RELEASE_RESPONSE_FILE"
|
echo "$RESPONSE"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
MANIFEST_URL=$(python3 - "$RELEASE_RESPONSE_FILE" <<'PY'
|
echo "[OK] Release created (ID: $RELEASE_ID)"
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
|
||||||
for asset in payload.get("assets", []):
|
|
||||||
if asset.get("name") == "release-manifest.json":
|
|
||||||
print(asset.get("browser_download_url", ""))
|
|
||||||
break
|
|
||||||
PY
|
|
||||||
)
|
|
||||||
|
|
||||||
if [ -n "$MANIFEST_URL" ]; then
|
|
||||||
curl -s -H "Authorization: Basic $BASIC_AUTH" "$MANIFEST_URL" -o "$EXISTING_MANIFEST_OUTPUT"
|
|
||||||
python3 "$BRAND_HELPER" merge-manifests --base "$EXISTING_MANIFEST_OUTPUT" --overlay "$LOCAL_MANIFEST_OUTPUT" --output "$MANIFEST_OUTPUT" >/dev/null
|
|
||||||
else
|
|
||||||
cp "$LOCAL_MANIFEST_OUTPUT" "$MANIFEST_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
ARTIFACTS+=("$MANIFEST_OUTPUT")
|
|
||||||
|
|
||||||
|
# Step 2: Upload DMG as asset
|
||||||
|
echo "Uploading executable asset..."
|
||||||
UPLOAD_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets"
|
UPLOAD_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets"
|
||||||
for ARTIFACT in "${ARTIFACTS[@]}"; do
|
|
||||||
ASSET_NAME="$(basename "$ARTIFACT")"
|
|
||||||
EXISTING_ASSET_ID=$(python3 - "$RELEASE_RESPONSE_FILE" "$ASSET_NAME" <<'PY'
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
|
||||||
asset_name = sys.argv[2]
|
|
||||||
for asset in payload.get("assets", []):
|
|
||||||
if asset.get("name") == asset_name:
|
|
||||||
print(asset.get("id", ""))
|
|
||||||
break
|
|
||||||
PY
|
|
||||||
)
|
|
||||||
|
|
||||||
if [ -n "$EXISTING_ASSET_ID" ]; then
|
|
||||||
curl -s -X DELETE \
|
|
||||||
-H "Authorization: Basic $BASIC_AUTH" \
|
|
||||||
"$FORGEJO_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets/$EXISTING_ASSET_ID" >/dev/null
|
|
||||||
echo "[OK] Replaced existing asset $ASSET_NAME"
|
|
||||||
fi
|
|
||||||
|
|
||||||
HTTP_CODE=$(curl -s -w "%{http_code}" -X POST \
|
HTTP_CODE=$(curl -s -w "%{http_code}" -X POST \
|
||||||
-H "Authorization: Basic $BASIC_AUTH" \
|
-H "Authorization: Basic $BASIC_AUTH" \
|
||||||
-F "attachment=@$ARTIFACT" \
|
-F "attachment=@$DMG_PATH" \
|
||||||
"$UPLOAD_URL" \
|
"$UPLOAD_URL" \
|
||||||
-o /tmp/curl_response.txt)
|
-o /tmp/curl_response.txt)
|
||||||
|
|
||||||
if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 200 ]; then
|
if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 200 ]; then
|
||||||
echo "[OK] Uploaded $ASSET_NAME"
|
echo "[OK] DMG uploaded"
|
||||||
else
|
else
|
||||||
echo "ERROR uploading $ASSET_NAME (HTTP $HTTP_CODE)"
|
echo "ERROR uploading DMG (HTTP $HTTP_CODE)"
|
||||||
|
cat /tmp/curl_response.txt
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 3: Upload checksum as asset
|
||||||
|
echo "Uploading checksum asset..."
|
||||||
|
|
||||||
|
HTTP_CODE=$(curl -s -w "%{http_code}" -X POST \
|
||||||
|
-H "Authorization: Basic $BASIC_AUTH" \
|
||||||
|
-F "attachment=@$CHECKSUM_PATH" \
|
||||||
|
"$UPLOAD_URL" \
|
||||||
|
-o /tmp/curl_response.txt)
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 200 ]; then
|
||||||
|
echo "[OK] Checksum uploaded"
|
||||||
|
else
|
||||||
|
echo "ERROR uploading checksum (HTTP $HTTP_CODE)"
|
||||||
cat /tmp/curl_response.txt
|
cat /tmp/curl_response.txt
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "[OK] Release complete!"
|
echo "[OK] Release complete!"
|
||||||
|
|
|
||||||
|
|
@ -1,192 +0,0 @@
|
||||||
#!/usr/bin/env pwsh
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Download WebDrop Bridge release installer from Forgejo via wget.
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
Fetches the latest (or specified) WebDrop Bridge release from the Forgejo repository
|
|
||||||
and downloads the appropriate installer (MSI for Windows, DMG for macOS) using wget.
|
|
||||||
|
|
||||||
This script is useful for:
|
|
||||||
- Enterprise deployments with proxy requirements
|
|
||||||
- Automated deployment scripts
|
|
||||||
- Initial setup before the built-in update mechanism kicks in
|
|
||||||
- Admins preferring command-line tools for infrastructure automation
|
|
||||||
|
|
||||||
.PARAMETER Version
|
|
||||||
Semantic version to download (e.g., "0.8.0").
|
|
||||||
If not specified, downloads the latest release.
|
|
||||||
Default: "latest"
|
|
||||||
|
|
||||||
.PARAMETER OutputDir
|
|
||||||
Directory to save the downloaded installer.
|
|
||||||
Default: Current directory
|
|
||||||
|
|
||||||
.PARAMETER Verify
|
|
||||||
If $true, verify checksum against .sha256 file from release.
|
|
||||||
Default: $true
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
# Download latest release to current directory
|
|
||||||
.\download_release.ps1
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
# Download specific version to Downloads folder
|
|
||||||
.\download_release.ps1 -Version "0.8.0" -OutputDir "$env:USERPROFILE\Downloads"
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
# Download without checksum verification
|
|
||||||
.\download_release.ps1 -Verify $false
|
|
||||||
|
|
||||||
.NOTES
|
|
||||||
Requires wget to be installed and available in PATH.
|
|
||||||
Install via: choco install wget (Chocolatey) or winget install GNU.Wget
|
|
||||||
#>
|
|
||||||
|
|
||||||
param(
|
|
||||||
[string]$Version = "latest",
|
|
||||||
[string]$OutputDir = ".",
|
|
||||||
[bool]$Verify = $true
|
|
||||||
)
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
$ForgejoUrl = "https://git.him-tools.de"
|
|
||||||
$Repo = "HIM-public/webdrop-bridge"
|
|
||||||
$ApiEndpoint = "$ForgejoUrl/api/v1/repos/$Repo/releases/$Version"
|
|
||||||
|
|
||||||
# Ensure output directory exists
|
|
||||||
if (-not (Test-Path $OutputDir)) {
|
|
||||||
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
|
|
||||||
}
|
|
||||||
|
|
||||||
# Resolve to absolute path
|
|
||||||
$OutputDirAbs = (Resolve-Path $OutputDir).Path
|
|
||||||
|
|
||||||
Write-Host "🚀 WebDrop Bridge Download Script" -ForegroundColor Cyan
|
|
||||||
Write-Host "Version: $Version"
|
|
||||||
Write-Host "Output: $OutputDirAbs"
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# Check if wget is installed
|
|
||||||
try {
|
|
||||||
$wgetVersion = (wget --version 2>&1 | Select-Object -First 1)
|
|
||||||
Write-Host "✓ wget found: $wgetVersion" -ForegroundColor Green
|
|
||||||
} catch {
|
|
||||||
Write-Host "❌ wget not found. Install via:" -ForegroundColor Red
|
|
||||||
Write-Host " choco install wget" -ForegroundColor Yellow
|
|
||||||
Write-Host " or" -ForegroundColor Yellow
|
|
||||||
Write-Host " winget install GNU.Wget" -ForegroundColor Yellow
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Fetch release info from Forgejo API
|
|
||||||
Write-Host "📥 Fetching release information from Forgejo..." -ForegroundColor Cyan
|
|
||||||
try {
|
|
||||||
$response = Invoke-WebRequest -Uri $ApiEndpoint -UseBasicParsing -ErrorAction Stop
|
|
||||||
$releaseData = ConvertFrom-Json $response.Content
|
|
||||||
} catch {
|
|
||||||
Write-Host "❌ Failed to fetch release info: $_" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $releaseData) {
|
|
||||||
Write-Host "❌ Release not found: $Version" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
$TagName = $releaseData.tag_name
|
|
||||||
$ReleaseName = $releaseData.name
|
|
||||||
Write-Host "📦 Found release: $ReleaseName ($TagName)" -ForegroundColor Green
|
|
||||||
|
|
||||||
# Find installer asset (.msi for Windows, .dmg for macOS)
|
|
||||||
$InstallerAsset = $null
|
|
||||||
$Sha256Asset = $null
|
|
||||||
|
|
||||||
foreach ($asset in $releaseData.assets) {
|
|
||||||
$assetName = $asset.name
|
|
||||||
if ($assetName -match '\.(msi|dmg)$') {
|
|
||||||
$InstallerAsset = $asset
|
|
||||||
}
|
|
||||||
if ($assetName -match '\.sha256$') {
|
|
||||||
$Sha256Asset = $asset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $InstallerAsset) {
|
|
||||||
Write-Host "❌ No installer found in release (looking for .msi or .dmg)" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
$InstallerName = $InstallerAsset.name
|
|
||||||
$InstallerUrl = $InstallerAsset.browser_download_url
|
|
||||||
$InstallerPath = Join-Path $OutputDirAbs $InstallerName
|
|
||||||
|
|
||||||
Write-Host "💾 Downloading: $InstallerName" -ForegroundColor Cyan
|
|
||||||
Write-Host " URL: $InstallerUrl" -ForegroundColor Gray
|
|
||||||
|
|
||||||
# Download using wget
|
|
||||||
try {
|
|
||||||
& wget -O $InstallerPath $InstallerUrl -q --show-progress
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
throw "wget exited with code $LASTEXITCODE"
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
Write-Host "❌ Download failed: $_" -ForegroundColor Red
|
|
||||||
if (Test-Path $InstallerPath) {
|
|
||||||
Remove-Item $InstallerPath -Force
|
|
||||||
}
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "✓ Downloaded: $InstallerPath" -ForegroundColor Green
|
|
||||||
|
|
||||||
# Verify checksum if requested
|
|
||||||
if ($Verify -and $Sha256Asset) {
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "🔍 Verifying checksum..." -ForegroundColor Cyan
|
|
||||||
|
|
||||||
$Sha256Url = $Sha256Asset.browser_download_url
|
|
||||||
$Sha256Path = Join-Path $OutputDirAbs "$InstallerName.sha256"
|
|
||||||
|
|
||||||
try {
|
|
||||||
& wget -O $Sha256Path $Sha256Url -q
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
throw "Failed to download checksum"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Read checksum file (format: "hash filename")
|
|
||||||
$checksumContent = Get-Content $Sha256Path
|
|
||||||
$expectedHash = ($checksumContent -split '\s+')[0]
|
|
||||||
|
|
||||||
# Calculate SHA256 of downloaded file
|
|
||||||
$actualHash = (Get-FileHash -Path $InstallerPath -Algorithm SHA256).Hash.ToLower()
|
|
||||||
|
|
||||||
if ($actualHash -eq $expectedHash.ToLower()) {
|
|
||||||
Write-Host "✓ Checksum verified" -ForegroundColor Green
|
|
||||||
} else {
|
|
||||||
Write-Host "❌ Checksum mismatch!" -ForegroundColor Red
|
|
||||||
Write-Host " Expected: $expectedHash" -ForegroundColor Yellow
|
|
||||||
Write-Host " Actual: $actualHash" -ForegroundColor Yellow
|
|
||||||
Remove-Item $InstallerPath -Force
|
|
||||||
Remove-Item $Sha256Path -Force
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Clean up checksum file
|
|
||||||
Remove-Item $Sha256Path -Force
|
|
||||||
} catch {
|
|
||||||
Write-Host "⚠ Checksum verification failed: $_" -ForegroundColor Yellow
|
|
||||||
Write-Host " Installer downloaded but not verified" -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
} elseif ($Verify -and -not $Sha256Asset) {
|
|
||||||
Write-Host "⚠ No checksum file in release, skipping verification" -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "✅ Download complete!" -ForegroundColor Green
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "Next steps:" -ForegroundColor Cyan
|
|
||||||
Write-Host " 1. Review: $InstallerPath"
|
|
||||||
Write-Host " 2. Execute installer to install WebDrop Bridge"
|
|
||||||
Write-Host " 3. Launch application and configure paths/URLs in settings"
|
|
||||||
Write-Host ""
|
|
||||||
|
|
@ -1,208 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
#
|
|
||||||
# WebDrop Bridge Release Downloader
|
|
||||||
#
|
|
||||||
# Download WebDrop Bridge release installer from Forgejo via wget.
|
|
||||||
# Useful for enterprise deployments, automated scripts, and initial setup.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./download_release.sh # Download latest to current dir
|
|
||||||
# ./download_release.sh 0.8.0 # Download specific version
|
|
||||||
# ./download_release.sh latest ~/Downloads # Download to specific directory
|
|
||||||
# ./download_release.sh --no-verify # Skip checksum verification
|
|
||||||
#
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
FORGEJO_URL="https://git.him-tools.de"
|
|
||||||
REPO="HIM-public/webdrop-bridge"
|
|
||||||
VERSION="${1:-latest}"
|
|
||||||
OUTPUT_DIR="${2:-.}"
|
|
||||||
VERIFY_CHECKSUM=true
|
|
||||||
|
|
||||||
# Handle flags
|
|
||||||
if [[ "$VERSION" == "--no-verify" ]]; then
|
|
||||||
VERIFY_CHECKSUM=false
|
|
||||||
VERSION="latest"
|
|
||||||
OUTPUT_DIR="${2:-.}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$VERSION" == "--no-verify" ]]; then
|
|
||||||
VERIFY_CHECKSUM=false
|
|
||||||
VERSION="latest"
|
|
||||||
OUTPUT_DIR="${2:-.}"
|
|
||||||
elif [[ ! "$VERSION" =~ ^[0-9\.a-z-]+$ ]] && [[ "$VERSION" != "latest" ]]; then
|
|
||||||
# Treat any non-version argument as output dir
|
|
||||||
OUTPUT_DIR="$VERSION"
|
|
||||||
VERSION="latest"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Colors
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
CYAN='\033[0;36m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
# Create output directory
|
|
||||||
mkdir -p "$OUTPUT_DIR"
|
|
||||||
OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)"
|
|
||||||
|
|
||||||
echo -e "${CYAN}🚀 WebDrop Bridge Download Script${NC}"
|
|
||||||
echo -e "Version: $VERSION"
|
|
||||||
echo -e "Output: $OUTPUT_DIR"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check if wget is installed
|
|
||||||
if ! command -v wget &> /dev/null; then
|
|
||||||
echo -e "${RED}❌ wget not found. Install via:${NC}"
|
|
||||||
echo -e "${YELLOW} macOS: brew install wget${NC}"
|
|
||||||
echo -e "${YELLOW} Linux: apt-get install wget (Ubuntu/Debian) or equivalent${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
WGET_VERSION=$(wget --version | head -n1)
|
|
||||||
echo -e "${GREEN}✓ wget found: $WGET_VERSION${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Fetch release info from Forgejo API
|
|
||||||
API_ENDPOINT="$FORGEJO_URL/api/v1/repos/$REPO/releases/$VERSION"
|
|
||||||
echo -e "${CYAN}📥 Fetching release information from Forgejo...${NC}"
|
|
||||||
|
|
||||||
RELEASE_JSON=$(wget -q -O - "$API_ENDPOINT" 2>/dev/null || {
|
|
||||||
echo -e "${RED}❌ Failed to fetch release info from $API_ENDPOINT${NC}"
|
|
||||||
exit 1
|
|
||||||
})
|
|
||||||
|
|
||||||
if [[ -z "$RELEASE_JSON" ]]; then
|
|
||||||
echo -e "${RED}❌ Release not found: $VERSION${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Parse JSON (basic shell parsing, suitable for our use case)
|
|
||||||
TAG_NAME=$(echo "$RELEASE_JSON" | grep -o '"tag_name":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
||||||
RELEASE_NAME=$(echo "$RELEASE_JSON" | grep -o '"name":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
||||||
|
|
||||||
if [[ -z "$TAG_NAME" ]]; then
|
|
||||||
echo -e "${RED}❌ Failed to parse release information${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -e "${GREEN}📦 Found release: $RELEASE_NAME ($TAG_NAME)${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Find installer asset (.msi for Windows, .dmg for macOS)
|
|
||||||
# Extract all asset names and URLs
|
|
||||||
INSTALLER_NAME=""
|
|
||||||
INSTALLER_URL=""
|
|
||||||
CHECKSUM_URL=""
|
|
||||||
|
|
||||||
# macOS systems prefer .dmg, Windows/.msi
|
|
||||||
SYSTEM=$(uname -s)
|
|
||||||
if [[ "$SYSTEM" == "Darwin" ]]; then
|
|
||||||
# macOS: prefer .dmg
|
|
||||||
INSTALLER_NAME=$(echo "$RELEASE_JSON" | grep -o '"name":"[^"]*\.dmg"' | head -1 | cut -d'"' -f4)
|
|
||||||
if [[ -z "$INSTALLER_NAME" ]]; then
|
|
||||||
# Fallback to .msi if no .dmg
|
|
||||||
INSTALLER_NAME=$(echo "$RELEASE_JSON" | grep -o '"name":"[^"]*\.msi"' | head -1 | cut -d'"' -f4)
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
# Linux/Other: prefer .msi, fallback to .dmg
|
|
||||||
INSTALLER_NAME=$(echo "$RELEASE_JSON" | grep -o '"name":"[^"]*\.msi"' | head -1 | cut -d'"' -f4)
|
|
||||||
if [[ -z "$INSTALLER_NAME" ]]; then
|
|
||||||
INSTALLER_NAME=$(echo "$RELEASE_JSON" | grep -o '"name":"[^"]*\.dmg"' | head -1 | cut -d'"' -f4)
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "$INSTALLER_NAME" ]]; then
|
|
||||||
echo -e "${RED}❌ No installer found in release (looking for .msi or .dmg)${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Extract browser_download_url for installer
|
|
||||||
# This is a bit hacky but works for JSON without a full JSON parser
|
|
||||||
INSTALLER_URL=$(echo "$RELEASE_JSON" | \
|
|
||||||
grep -B2 "\"name\":\"$INSTALLER_NAME\"" | \
|
|
||||||
grep -o '"browser_download_url":"[^"]*"' | \
|
|
||||||
cut -d'"' -f4)
|
|
||||||
|
|
||||||
if [[ -z "$INSTALLER_URL" ]]; then
|
|
||||||
echo -e "${RED}❌ Could not extract download URL for $INSTALLER_NAME${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Find checksum URL if verification is enabled
|
|
||||||
if [[ "$VERIFY_CHECKSUM" == "true" ]]; then
|
|
||||||
CHECKSUM_FILENAME="${INSTALLER_NAME}.sha256"
|
|
||||||
CHECKSUM_URL=$(echo "$RELEASE_JSON" | \
|
|
||||||
grep -B2 "\"name\":\"$CHECKSUM_FILENAME\"" | \
|
|
||||||
grep -o '"browser_download_url":"[^"]*"' | \
|
|
||||||
cut -d'"' -f4 || echo "")
|
|
||||||
fi
|
|
||||||
|
|
||||||
INSTALLER_PATH="$OUTPUT_DIR/$INSTALLER_NAME"
|
|
||||||
|
|
||||||
echo -e "${CYAN}💾 Downloading: $INSTALLER_NAME${NC}"
|
|
||||||
echo -e "${CYAN} URL: $INSTALLER_URL${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Download using wget with progress
|
|
||||||
if ! wget -O "$INSTALLER_PATH" "$INSTALLER_URL" --show-progress 2>&1; then
|
|
||||||
echo -e "${RED}❌ Download failed${NC}"
|
|
||||||
[[ -f "$INSTALLER_PATH" ]] && rm -f "$INSTALLER_PATH"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${GREEN}✓ Downloaded: $INSTALLER_PATH${NC}"
|
|
||||||
|
|
||||||
# Verify checksum if requested and available
|
|
||||||
if [[ "$VERIFY_CHECKSUM" == "true" ]] && [[ -n "$CHECKSUM_URL" ]]; then
|
|
||||||
echo ""
|
|
||||||
echo -e "${CYAN}🔍 Verifying checksum...${NC}"
|
|
||||||
|
|
||||||
CHECKSUM_PATH="$OUTPUT_DIR/${INSTALLER_NAME}.sha256"
|
|
||||||
|
|
||||||
if wget -O "$CHECKSUM_PATH" "$CHECKSUM_URL" -q 2>/dev/null; then
|
|
||||||
# Read checksum from file (format: "hash filename")
|
|
||||||
EXPECTED_HASH=$(cut -d' ' -f1 "$CHECKSUM_PATH")
|
|
||||||
|
|
||||||
# Calculate SHA256
|
|
||||||
if command -v sha256sum &> /dev/null; then
|
|
||||||
ACTUAL_HASH=$(sha256sum "$INSTALLER_PATH" | cut -d' ' -f1)
|
|
||||||
elif command -v shasum &> /dev/null; then
|
|
||||||
ACTUAL_HASH=$(shasum -a 256 "$INSTALLER_PATH" | cut -d' ' -f1)
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW}⚠ No SHA256 tool available, skipping verification${NC}"
|
|
||||||
ACTUAL_HASH=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$ACTUAL_HASH" ]]; then
|
|
||||||
if [[ "${EXPECTED_HASH,,}" == "${ACTUAL_HASH,,}" ]]; then
|
|
||||||
echo -e "${GREEN}✓ Checksum verified${NC}"
|
|
||||||
else
|
|
||||||
echo -e "${RED}❌ Checksum mismatch!${NC}"
|
|
||||||
echo -e "${YELLOW} Expected: $EXPECTED_HASH${NC}"
|
|
||||||
echo -e "${YELLOW} Actual: $ACTUAL_HASH${NC}"
|
|
||||||
rm -f "$INSTALLER_PATH" "$CHECKSUM_PATH"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -f "$CHECKSUM_PATH"
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW}⚠ Could not download checksum file, skipping verification${NC}"
|
|
||||||
fi
|
|
||||||
elif [[ "$VERIFY_CHECKSUM" == "true" ]]; then
|
|
||||||
echo -e "${YELLOW}⚠ No checksum file in release, skipping verification${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${GREEN}✅ Download complete!${NC}"
|
|
||||||
echo ""
|
|
||||||
echo -e "${CYAN}Next steps:${NC}"
|
|
||||||
echo -e " 1. Review: $INSTALLER_PATH"
|
|
||||||
echo -e " 2. Execute installer to install WebDrop Bridge"
|
|
||||||
echo -e " 3. Launch application and configure paths/URLs in settings"
|
|
||||||
echo ""
|
|
||||||
1
build/test.txt
Normal file
1
build/test.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
WebDropBridge.wxs
|
||||||
|
|
@ -1,12 +1,6 @@
|
||||||
{
|
{
|
||||||
"brand_id": "agravity",
|
"app_name": "WebDrop Bridge",
|
||||||
"config_dir_name": "agravity_bridge",
|
|
||||||
"app_name": "Agravity Bridge",
|
|
||||||
"webapp_url": "https://dev.agravity.io/",
|
"webapp_url": "https://dev.agravity.io/",
|
||||||
"update_base_url": "https://git.him-tools.de",
|
|
||||||
"update_repo": "HIM-public/webdrop-bridge",
|
|
||||||
"update_channel": "stable",
|
|
||||||
"update_manifest_name": "release-manifest.json",
|
|
||||||
"url_mappings": [
|
"url_mappings": [
|
||||||
{
|
{
|
||||||
"url_prefix": "https://devagravitystg.file.core.windows.net/devagravitysync/",
|
"url_prefix": "https://devagravitystg.file.core.windows.net/devagravitysync/",
|
||||||
|
|
@ -24,6 +18,5 @@
|
||||||
"log_file": null,
|
"log_file": null,
|
||||||
"window_width": 1024,
|
"window_width": 1024,
|
||||||
"window_height": 768,
|
"window_height": 768,
|
||||||
"enable_logging": true,
|
"enable_logging": true
|
||||||
"enable_checkout": false
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
268
docs/ANGULAR_CDK_ANALYSIS.md
Normal file
268
docs/ANGULAR_CDK_ANALYSIS.md
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
# Angular CDK Drag & Drop Analysis - GlobalDAM
|
||||||
|
|
||||||
|
## Framework Detection
|
||||||
|
|
||||||
|
**Web Application:** Agravity GlobalDAM
|
||||||
|
**Framework:** Angular 19.2.14
|
||||||
|
**Drag & Drop:** Angular CDK (Component Dev Kit)
|
||||||
|
**Styling:** TailwindCSS
|
||||||
|
|
||||||
|
## Technical Findings
|
||||||
|
|
||||||
|
### 1. Angular CDK Implementation
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Drag Group (oberste Ebene) -->
|
||||||
|
<div cdkdroplistgroup="" aydnd="" class="flex h-full flex-col">
|
||||||
|
|
||||||
|
<!-- Drop Zone (Collections) -->
|
||||||
|
<div cdkdroplist="" class="cdk-drop-list" id="collectioncsuaaDVNokl0...">
|
||||||
|
|
||||||
|
<!-- Draggable Element (Asset Card) -->
|
||||||
|
<li cdkdrag="" class="cdk-drag asset-list-item" draggable="false">
|
||||||
|
<img src="./GlobalDAM JRI_files/anPGZszKzgKaSz1SIx2HFgduy"
|
||||||
|
alt="weiss_ORIGINAL">
|
||||||
|
</li>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Key Observations
|
||||||
|
|
||||||
|
#### Native HTML5 Drag ist DEAKTIVIERT
|
||||||
|
```html
|
||||||
|
draggable="false"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bedeutung:**
|
||||||
|
- Kein Zugriff auf native `dragstart`, `drag`, `dragend` Events
|
||||||
|
- Kein `event.dataTransfer` API verfügbar
|
||||||
|
- Angular CDK simuliert Drag & Drop komplett in JavaScript
|
||||||
|
- Daten werden NICHT über natives Clipboard/DataTransfer übertragen
|
||||||
|
|
||||||
|
#### Angular CDK Direktiven
|
||||||
|
- `cdkdroplistgroup` - Gruppiert mehrere Drop-Zonen
|
||||||
|
- `cdkdroplist` - Markiert Drop-Bereiche (Collections, Clipboard)
|
||||||
|
- `cdkdrag` - Markiert draggbare Elemente (Assets)
|
||||||
|
- `cdkdroplistsortingdisabled` - Sortierung deaktiviert
|
||||||
|
|
||||||
|
#### Asset Identifikation
|
||||||
|
```html
|
||||||
|
<!-- Asset ID im Element-ID -->
|
||||||
|
<div id="anPGZszKzgKaSz1SIx2HFgduy">
|
||||||
|
|
||||||
|
<!-- Asset ID in der Bild-URL -->
|
||||||
|
<img src="./GlobalDAM JRI_files/anPGZszKzgKaSz1SIx2HFgduy">
|
||||||
|
|
||||||
|
<!-- Asset Name im alt-Attribut -->
|
||||||
|
<img alt="weiss_ORIGINAL">
|
||||||
|
```
|
||||||
|
|
||||||
|
## Impact on WebDrop Bridge
|
||||||
|
|
||||||
|
### ❌ Bisheriger Ansatz funktioniert NICHT
|
||||||
|
|
||||||
|
Unser aktueller Ansatz basiert auf:
|
||||||
|
1. Interception von nativen Drag-Events
|
||||||
|
2. Manipulation von `event.dataTransfer.effectAllowed` und `.dropEffect`
|
||||||
|
3. Setzen von URLs im DataTransfer
|
||||||
|
|
||||||
|
**Das funktioniert NICHT mit Angular CDK**, da:
|
||||||
|
- Angular CDK das native Drag & Drop komplett umgeht
|
||||||
|
- Keine nativen Events gefeuert werden
|
||||||
|
- DataTransfer API nicht verwendet wird
|
||||||
|
|
||||||
|
### ✅ Mögliche Lösungsansätze
|
||||||
|
|
||||||
|
#### Ansatz 1: JavaScript Injection zur Laufzeit
|
||||||
|
Injiziere JavaScript-Code, der Angular CDK Events abfängt:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Überwache Angular CDK Event-Handler
|
||||||
|
document.addEventListener('cdkDragStarted', (event) => {
|
||||||
|
const assetId = event.source.element.nativeElement.id;
|
||||||
|
const assetName = event.source.element.nativeElement.querySelector('img')?.alt;
|
||||||
|
|
||||||
|
// Sende an Qt WebChannel
|
||||||
|
bridge.handleDragStart(assetId, assetName);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('cdkDragDropped', (event) => {
|
||||||
|
// Verhindere das Standard-Verhalten
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// Starte nativen Drag von Qt aus
|
||||||
|
bridge.initNativeDrag();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vorteile:**
|
||||||
|
- ✅ Direkter Zugriff auf Angular CDK Events
|
||||||
|
- ✅ Kann Asset-Informationen extrahieren
|
||||||
|
- ✅ Kann Drag-Operationen abfangen
|
||||||
|
|
||||||
|
**Nachteile:**
|
||||||
|
- ⚠️ Erfordert genaue Kenntnis der Angular CDK Internals
|
||||||
|
- ⚠️ Könnte bei Angular CDK Updates brechen
|
||||||
|
- ⚠️ Komplexer zu implementieren
|
||||||
|
|
||||||
|
#### Ansatz 2: DOM Mutation Observer
|
||||||
|
Überwache DOM-Änderungen während des Drags:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
// Suche nach CDK Drag-Elementen mit bestimmten Klassen
|
||||||
|
const dragElement = document.querySelector('.cdk-drag-preview');
|
||||||
|
if (dragElement) {
|
||||||
|
const assetId = dragElement.querySelector('[id^="a"]')?.id;
|
||||||
|
bridge.handleDrag(assetId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class']
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vorteile:**
|
||||||
|
- ✅ Robuster gegenüber Framework-Updates
|
||||||
|
- ✅ Funktioniert mit beliebigen Frameworks
|
||||||
|
|
||||||
|
**Nachteile:**
|
||||||
|
- ⚠️ Performance-Overhead
|
||||||
|
- ⚠️ Kann falsche Positive erzeugen
|
||||||
|
|
||||||
|
#### Ansatz 3: Qt WebChannel Bridge mit Custom Events
|
||||||
|
Nutze Qt WebChannel, um mit der Angular-Anwendung zu kommunizieren:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Python-Seite (Qt)
|
||||||
|
class DragBridge(QObject):
|
||||||
|
@Slot(str, str)
|
||||||
|
def onAssetDragStart(self, asset_id: str, asset_name: str):
|
||||||
|
"""Called from JavaScript when Angular CDK drag starts."""
|
||||||
|
logger.info(f"Asset drag started: {asset_id} ({asset_name})")
|
||||||
|
self.convert_and_drag(asset_id, asset_name)
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// JavaScript-Seite (injiziert via QWebEngineScript)
|
||||||
|
new QWebChannel(qt.webChannelTransport, (channel) => {
|
||||||
|
const dragBridge = channel.objects.dragBridge;
|
||||||
|
|
||||||
|
// Monkey-patch Angular CDK's DragRef
|
||||||
|
const originalStartDraggingSequence = CdkDrag.prototype._startDraggingSequence;
|
||||||
|
CdkDrag.prototype._startDraggingSequence = function(event) {
|
||||||
|
const assetElement = this.element.nativeElement;
|
||||||
|
const assetId = assetElement.id;
|
||||||
|
const assetName = assetElement.querySelector('img')?.alt;
|
||||||
|
|
||||||
|
// Benachrichtige Qt
|
||||||
|
dragBridge.onAssetDragStart(assetId, assetName);
|
||||||
|
|
||||||
|
// Rufe original Angular CDK Methode auf
|
||||||
|
return originalStartDraggingSequence.call(this, event);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vorteile:**
|
||||||
|
- ✅ Saubere Kommunikation zwischen Qt und Web
|
||||||
|
- ✅ Kann Asset-Informationen zuverlässig extrahieren
|
||||||
|
- ✅ Typensicher (Qt Signals/Slots)
|
||||||
|
|
||||||
|
**Nachteile:**
|
||||||
|
- ⚠️ Erfordert Monkey-Patching von Angular CDK
|
||||||
|
- ⚠️ Kann bei CDK Updates brechen
|
||||||
|
|
||||||
|
#### Ansatz 4: Browser DevTools Protocol (Chrome DevTools)
|
||||||
|
Nutze Chrome DevTools Protocol für tiefere Integration:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from PySide6.QtWebEngineCore import QWebEngineProfile
|
||||||
|
|
||||||
|
profile = QWebEngineProfile.defaultProfile()
|
||||||
|
profile.setRequestInterceptor(...)
|
||||||
|
|
||||||
|
# Intercepte Netzwerk-Requests und injiziere Header
|
||||||
|
# Überwache JavaScript-Execution via CDP
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vorteile:**
|
||||||
|
- ✅ Sehr mächtig, kann JavaScript-Execution überwachen
|
||||||
|
- ✅ Kann Events auf niedrigerer Ebene abfangen
|
||||||
|
|
||||||
|
**Nachteile:**
|
||||||
|
- ⚠️ Sehr komplex
|
||||||
|
- ⚠️ Erfordert Chrome DevTools Protocol Kenntnisse
|
||||||
|
- ⚠️ Performance-Overhead
|
||||||
|
|
||||||
|
## Empfohlener Ansatz
|
||||||
|
|
||||||
|
### **Ansatz 3: Qt WebChannel Bridge** (BEVORZUGT)
|
||||||
|
|
||||||
|
**Begründung:**
|
||||||
|
1. ✅ Saubere Architektur mit klarer Trennung
|
||||||
|
2. ✅ Typsicher durch Qt Signals/Slots
|
||||||
|
3. ✅ Kann Asset-IDs und -Namen zuverlässig extrahieren
|
||||||
|
4. ✅ Funktioniert auch wenn Angular CDK interne Änderungen hat
|
||||||
|
5. ✅ Ermöglicht bidirektionale Kommunikation
|
||||||
|
|
||||||
|
**Implementierungsschritte:**
|
||||||
|
|
||||||
|
### Phase 1: Asset-Informationen extrahieren
|
||||||
|
1. JavaScript via QWebEngineScript injizieren
|
||||||
|
2. Qt WebChannel setuppen
|
||||||
|
3. Angular CDK Events überwachen (ohne Monkey-Patching als Test)
|
||||||
|
4. Asset-IDs und Namen an Qt senden
|
||||||
|
|
||||||
|
### Phase 2: Native Drag initiieren
|
||||||
|
1. Bei CDK Drag-Start: Extrahiere Asset-Informationen
|
||||||
|
2. Sende Asset-ID an Backend/API
|
||||||
|
3. Erhalte lokalen Dateipfad oder Azure Blob URL
|
||||||
|
4. Konvertiere zu lokalem Pfad (wie aktuell)
|
||||||
|
5. Initiiere nativen Drag mit QDrag
|
||||||
|
|
||||||
|
### Phase 3: Drag-Feedback
|
||||||
|
1. Zeige Drag-Preview in Qt (optional)
|
||||||
|
2. Update Cursor während Drag
|
||||||
|
3. Cleanup nach Drag-Ende
|
||||||
|
|
||||||
|
## Asset-ID zu Dateipfad Mapping
|
||||||
|
|
||||||
|
Die Anwendung verwendet Asset-IDs in mehreren Formaten:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Asset-ID: anPGZszKzgKaSz1SIx2HFgduy
|
||||||
|
|
||||||
|
// Mögliche URL-Konstruktion:
|
||||||
|
const assetUrl = `https://dev.agravity.io/api/assets/${assetId}`;
|
||||||
|
const downloadUrl = `https://dev.agravity.io/api/assets/${assetId}/download`;
|
||||||
|
const blobUrl = `https://static.agravity.io/${workspaceId}/${assetId}/${filename}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Für WebDrop Bridge:**
|
||||||
|
- Asset-ID aus DOM extrahieren
|
||||||
|
- Asset-Metadaten via API abrufen (falls verfügbar)
|
||||||
|
- Blob-URL konstruieren
|
||||||
|
- URL Converter nutzen (bereits implementiert!)
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Proof of Concept**: Qt WebChannel mit einfachem Event-Logger
|
||||||
|
2. **Asset-ID Extraction**: JavaScript Injection testen
|
||||||
|
3. **API Research**: GlobalDAM API untersuchen (Asset-Metadaten)
|
||||||
|
4. **Integration**: Mit bestehendem URLConverter verbinden
|
||||||
|
5. **Testing**: Mit echten Assets testen
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
|
||||||
|
- Angular CDK Version kann sich unterscheiden - Code muss robust sein
|
||||||
|
- Asset-IDs scheinen eindeutig zu sein (Base64-ähnlich)
|
||||||
|
- Die Anwendung nutzt Azure Blob Storage (basierend auf bisherigen URLs)
|
||||||
|
- Custom Components (`ay-*`) deuten auf eine eigene Component Library hin
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
# Architecture Guide
|
# Architecture Guide
|
||||||
|
|
||||||
## Related Docs
|
|
||||||
|
|
||||||
- [Translations Guide (i18n)](TRANSLATIONS_GUIDE.md)
|
|
||||||
|
|
||||||
## High-Level Design
|
## High-Level Design
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -40,11 +36,10 @@
|
||||||
|
|
||||||
**Key Components:**
|
**Key Components:**
|
||||||
|
|
||||||
- `validator.py`: Path validation against whitelist with security checks
|
- `validator.py`: Path validation against whitelist
|
||||||
- `drag_interceptor.py`: Drag event handling and native drag operations
|
- `drag_interceptor.py`: Drag event handling and conversion
|
||||||
- `config_manager.py`: Configuration loading from files and caching
|
- `config.py`: Configuration management
|
||||||
- `url_converter.py`: Azure Blob Storage URL → local path conversion
|
- `errors.py`: Custom exception classes
|
||||||
- `updater.py`: Update checking via Forgejo API
|
|
||||||
|
|
||||||
**Dependencies**: None (only stdlib + pathlib)
|
**Dependencies**: None (only stdlib + pathlib)
|
||||||
|
|
||||||
|
|
@ -54,12 +49,9 @@
|
||||||
|
|
||||||
**Key Components:**
|
**Key Components:**
|
||||||
|
|
||||||
- `main_window.py`: Main application window with web engine integration
|
- `main_window.py`: Main application window
|
||||||
- `restricted_web_view.py`: Hardened QWebEngineView with security policies
|
- `widgets.py`: Reusable custom widgets
|
||||||
- `settings_dialog.py`: Settings UI for configuration
|
- `styles.py`: UI styling and themes
|
||||||
- `update_manager_ui.py`: Update checking and notification UI
|
|
||||||
- `bridge_script_intercept.js`: JavaScript drag interception and WebChannel bridge for Qt communication
|
|
||||||
- `download_interceptor.js`: Download handling for web content
|
|
||||||
|
|
||||||
**Dependencies**: PySide6, core/
|
**Dependencies**: PySide6, core/
|
||||||
|
|
||||||
|
|
@ -69,7 +61,9 @@
|
||||||
|
|
||||||
**Key Components:**
|
**Key Components:**
|
||||||
|
|
||||||
- `logging.py`: Logging configuration (console + file with rotation)
|
- `logging.py`: Logging configuration
|
||||||
|
- `constants.py`: Application constants
|
||||||
|
- `helpers.py`: General-purpose helper functions
|
||||||
|
|
||||||
**Dependencies**: stdlib only
|
**Dependencies**: stdlib only
|
||||||
|
|
||||||
|
|
@ -78,57 +72,34 @@
|
||||||
### Drag-and-Drop Operation
|
### Drag-and-Drop Operation
|
||||||
|
|
||||||
```
|
```
|
||||||
User in Web App (browser)
|
User in Web App
|
||||||
↓
|
↓
|
||||||
[dragstart event] → bridge_script_intercept.js detects drag
|
[dragstart event] → JavaScript sets dataTransfer.text = "Z:\path\file.txt"
|
||||||
├─ Checks if content is convertible (file path or Azure URL)
|
|
||||||
├─ Calls window.bridge.start_file_drag(url)
|
|
||||||
└─ preventDefault() → Blocks normal browser drag
|
|
||||||
|
|
||||||
↓
|
↓
|
||||||
JavaScript → QWebChannel Bridge
|
[dragend event] → Drag leaves WebEngine widget
|
||||||
↓
|
↓
|
||||||
_DragBridge.start_file_drag(path_text) [main_window.py]
|
DragInterceptor.dragEnterEvent() triggered
|
||||||
├─ Defers execution via QTimer (drag manager safety)
|
|
||||||
└─ Calls DragInterceptor.handle_drag()
|
|
||||||
|
|
||||||
↓
|
↓
|
||||||
DragInterceptor.handle_drag() [core/drag_interceptor.py]
|
Extract text from QMimeData
|
||||||
├─ Check if Azure URL: Use URLConverter → local path
|
|
||||||
├─ Else: Treat as direct file path
|
|
||||||
└─ Validate with PathValidator
|
|
||||||
|
|
||||||
↓
|
↓
|
||||||
PathValidator.validate(path)
|
PathValidator.is_valid_file(path)
|
||||||
├─ Resolve to absolute path
|
├─ is_allowed(path) → Check whitelist
|
||||||
├─ Check file exists (if configured)
|
└─ path.exists() and path.is_file() → File system check
|
||||||
├─ Check is regular file (not directory)
|
|
||||||
└─ Check path within allowed_roots (whitelist)
|
|
||||||
|
|
||||||
↓
|
↓
|
||||||
If valid:
|
If valid:
|
||||||
→ Create QUrl.fromLocalFile(path)
|
→ Create QUrl.fromLocalFile(path)
|
||||||
→ Create QMimeData with file URL
|
→ Create new QMimeData with URLs
|
||||||
→ QDrag.exec(Qt.CopyAction) → Native file drag
|
→ QDrag.exec() → Native file drag
|
||||||
→ Emit drag_started signal
|
|
||||||
↓
|
↓
|
||||||
If invalid:
|
If invalid:
|
||||||
→ Emit drag_failed signal with error
|
→ event.ignore()
|
||||||
→ Log validation error
|
→ Log warning
|
||||||
↓
|
↓
|
||||||
OS receives native file drag
|
OS receives native file drag
|
||||||
↓
|
↓
|
||||||
Target application (InDesign/Word) receives file handle
|
InDesign/Word receives file handle
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key Components in Data Flow:**
|
|
||||||
|
|
||||||
1. **bridge_script_intercept.js**: Opens a WebChannel to Qt's _DragBridge
|
|
||||||
2. **_DragBridge**: Exposes `start_file_drag()` slot to JavaScript
|
|
||||||
3. **DragInterceptor**: Handles validation and native drag creation
|
|
||||||
4. **URLConverter**: Maps Azure Blob Storage URLs to local paths via config
|
|
||||||
5. **PathValidator**: Security-critical validation against whitelist
|
|
||||||
|
|
||||||
## Security Model
|
## Security Model
|
||||||
|
|
||||||
### Path Validation Strategy
|
### Path Validation Strategy
|
||||||
|
|
|
||||||
|
|
@ -1,488 +0,0 @@
|
||||||
# Branding, Builds, and Releases
|
|
||||||
|
|
||||||
This document describes how branded builds work in this repository, how to add or edit a brand, how to build the default and branded variants, and how to publish releases.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The project supports one default product and any number of branded variants from the same codebase.
|
|
||||||
|
|
||||||
- The default product is defined by built-in defaults in `build/scripts/brand_config.py`.
|
|
||||||
- The default product identifier is `webdrop_bridge`.
|
|
||||||
- Additional brands are defined by JSON manifests in `build/brands/`.
|
|
||||||
- Runtime behavior can also be branded through application config values such as `brand_id`, `config_dir_name`, `app_name`, and update settings.
|
|
||||||
- Windows and macOS installers are built as separate artifacts per brand.
|
|
||||||
- Releases are shared by version. A single Forgejo release can contain installers for the default product and multiple brands.
|
|
||||||
|
|
||||||
## Branding Model
|
|
||||||
|
|
||||||
There are two layers to branding:
|
|
||||||
|
|
||||||
1. Packaging identity
|
|
||||||
Controls installer name, executable/app bundle name, product display name, bundle identifier, MSI upgrade code, installer artwork, and related metadata.
|
|
||||||
|
|
||||||
2. Runtime configuration
|
|
||||||
Controls app name shown in the UI, config directory name, update feed settings, URL mappings, allowed roots, and similar application behavior.
|
|
||||||
|
|
||||||
Packaging identity lives in `build/brands/<brand>.json`.
|
|
||||||
|
|
||||||
Runtime configuration lives in app config files loaded by the application. See `config.example.json` for the current branded example.
|
|
||||||
|
|
||||||
## Important Files
|
|
||||||
|
|
||||||
- `build/scripts/brand_config.py`: central helper for brand metadata, artifact naming, and release manifest generation
|
|
||||||
- `build/brands/agravity.json`: example branded manifest
|
|
||||||
- `build/scripts/build_windows.py`: Windows build entrypoint
|
|
||||||
- `build/scripts/build_macos.sh`: macOS build entrypoint
|
|
||||||
- `build/scripts/create_release.ps1`: Windows release uploader
|
|
||||||
- `build/scripts/create_release.sh`: macOS release uploader
|
|
||||||
- `config.example.json`: example runtime branding config
|
|
||||||
|
|
||||||
## Create a New Brand
|
|
||||||
|
|
||||||
To create a new brand, add a new manifest file under `build/brands/`.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
1. Copy `build/brands/template.jsonc` to `build/brands/<new-brand>.json`.
|
|
||||||
2. Update the values for the new brand.
|
|
||||||
3. Add any brand-specific assets if you do not want to reuse the default icons/license assets.
|
|
||||||
|
|
||||||
Minimal example:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"brand_id": "customerx",
|
|
||||||
"display_name": "Customer X Bridge",
|
|
||||||
"asset_prefix": "CustomerXBridge",
|
|
||||||
"exe_name": "CustomerXBridge",
|
|
||||||
"manufacturer": "Customer X",
|
|
||||||
"install_dir_name": "Customer X Bridge",
|
|
||||||
"shortcut_description": "Customer X drag-and-drop bridge",
|
|
||||||
"bundle_identifier": "com.customerx.bridge",
|
|
||||||
"config_dir_name": "customerx_bridge",
|
|
||||||
"msi_upgrade_code": "PUT-A-NEW-GUID-HERE",
|
|
||||||
"update_channel": "stable",
|
|
||||||
"icon_ico": "resources/icons/app.ico",
|
|
||||||
"icon_icns": "resources/icons/app.icns",
|
|
||||||
"dialog_bmp": "resources/icons/background.bmp",
|
|
||||||
"banner_bmp": "resources/icons/banner.bmp",
|
|
||||||
"license_rtf": "resources/license.rtf"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Required Fields
|
|
||||||
|
|
||||||
- `brand_id`: internal identifier used for build output folders and release manifest entries
|
|
||||||
- `display_name`: user-facing product name
|
|
||||||
- `asset_prefix`: base name for installer artifacts and app bundle name
|
|
||||||
- `exe_name`: executable name for Windows and app bundle name base for macOS
|
|
||||||
- `manufacturer`: MSI manufacturer string
|
|
||||||
- `install_dir_name`: installation directory name shown to the OS
|
|
||||||
- `shortcut_description`: Windows shortcut description
|
|
||||||
- `bundle_identifier`: macOS bundle identifier
|
|
||||||
- `config_dir_name`: local app config/log/cache directory name
|
|
||||||
- `msi_upgrade_code`: stable GUID for Windows upgrades
|
|
||||||
- `update_channel`: currently typically `stable`
|
|
||||||
|
|
||||||
Generate a new `msi_upgrade_code` for a new brand once and keep it stable afterwards.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
New-Guid
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uuidgen
|
|
||||||
```
|
|
||||||
|
|
||||||
### Asset Fields
|
|
||||||
|
|
||||||
These can point at brand-specific files or default shared files:
|
|
||||||
|
|
||||||
- `icon_ico`
|
|
||||||
- `icon_icns`
|
|
||||||
- `dialog_bmp`
|
|
||||||
- `banner_bmp`
|
|
||||||
- `license_rtf`
|
|
||||||
|
|
||||||
Optional toolbar icon overrides:
|
|
||||||
|
|
||||||
- `toolbar_icon_home`
|
|
||||||
- `toolbar_icon_reload`
|
|
||||||
- `toolbar_icon_open`
|
|
||||||
- `toolbar_icon_openwith`
|
|
||||||
|
|
||||||
If a referenced asset path does not exist, the helper falls back to the default asset defined in `build/scripts/brand_config.py`.
|
|
||||||
|
|
||||||
For toolbar icons, the runtime looks for the configured paths in packaged and development layouts. If an icon is missing:
|
|
||||||
|
|
||||||
- Home falls back to a standard Qt home icon
|
|
||||||
- Reload/Open/OpenWith keep their existing icon behavior
|
|
||||||
|
|
||||||
### Identity Rules
|
|
||||||
|
|
||||||
Treat these values as long-lived product identity once a brand has shipped:
|
|
||||||
|
|
||||||
- `brand_id`
|
|
||||||
- `asset_prefix`
|
|
||||||
- `exe_name`
|
|
||||||
- `bundle_identifier`
|
|
||||||
- `config_dir_name`
|
|
||||||
- `msi_upgrade_code`
|
|
||||||
|
|
||||||
Changing them later can break one or more of the following:
|
|
||||||
|
|
||||||
- Windows upgrade behavior
|
|
||||||
- macOS app identity
|
|
||||||
- auto-update asset selection
|
|
||||||
- local config/log/cache continuity
|
|
||||||
- installer and artifact naming consistency
|
|
||||||
|
|
||||||
If the product is already in use, only change these values deliberately and with migration planning.
|
|
||||||
|
|
||||||
## Edit an Existing Brand
|
|
||||||
|
|
||||||
To edit a shipped or in-progress brand:
|
|
||||||
|
|
||||||
1. Update the brand manifest in `build/brands/<brand>.json`.
|
|
||||||
2. If needed, update brand-specific assets referenced by that manifest.
|
|
||||||
3. If runtime behavior should also change, update the relevant application config values.
|
|
||||||
4. Rebuild the affected platform artifacts.
|
|
||||||
5. Validate the result with a dry-run release before publishing.
|
|
||||||
|
|
||||||
Safe edits after release usually include:
|
|
||||||
|
|
||||||
- `display_name`
|
|
||||||
- `shortcut_description`
|
|
||||||
- artwork paths
|
|
||||||
- license text
|
|
||||||
- update channel, if release policy changes
|
|
||||||
|
|
||||||
High-risk edits after release are the identity fields listed above.
|
|
||||||
|
|
||||||
## Runtime Branding Configuration
|
|
||||||
|
|
||||||
Packaging branding alone is not enough if the app should also present a different name, use different local storage, or point to different update settings.
|
|
||||||
|
|
||||||
Relevant runtime config keys include:
|
|
||||||
|
|
||||||
- `brand_id`
|
|
||||||
- `config_dir_name`
|
|
||||||
- `app_name`
|
|
||||||
- `update_base_url`
|
|
||||||
- `update_repo`
|
|
||||||
- `update_channel`
|
|
||||||
- `update_manifest_name`
|
|
||||||
|
|
||||||
Toolbar icon env overrides (useful for packaged branding):
|
|
||||||
|
|
||||||
- `TOOLBAR_ICON_HOME`
|
|
||||||
- `TOOLBAR_ICON_RELOAD`
|
|
||||||
- `TOOLBAR_ICON_OPEN`
|
|
||||||
- `TOOLBAR_ICON_OPENWITH`
|
|
||||||
|
|
||||||
The current example in `config.example.json` shows the Agravity runtime setup.
|
|
||||||
|
|
||||||
When adding a new brand, make sure the runtime config matches the packaging manifest at least for:
|
|
||||||
|
|
||||||
- `brand_id`
|
|
||||||
- `config_dir_name`
|
|
||||||
- `app_name`
|
|
||||||
|
|
||||||
## Build the Default Product
|
|
||||||
|
|
||||||
### Windows
|
|
||||||
|
|
||||||
Build the default executable only:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python .\build\scripts\build_windows.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Build the default Windows MSI:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python .\build\scripts\build_windows.py --msi
|
|
||||||
```
|
|
||||||
|
|
||||||
Build with a specific `.env` file:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python .\build\scripts\build_windows.py --msi --env-file .\.env
|
|
||||||
```
|
|
||||||
|
|
||||||
### macOS
|
|
||||||
|
|
||||||
Build the default macOS app and DMG:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash build/scripts/build_macos.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Build with a specific `.env` file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash build/scripts/build_macos.sh --env-file .env
|
|
||||||
```
|
|
||||||
|
|
||||||
## Build a Brand
|
|
||||||
|
|
||||||
### Windows
|
|
||||||
|
|
||||||
Build a branded executable only:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python .\build\scripts\build_windows.py --brand agravity
|
|
||||||
```
|
|
||||||
|
|
||||||
Build a branded MSI:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python .\build\scripts\build_windows.py --brand agravity --msi
|
|
||||||
```
|
|
||||||
|
|
||||||
### macOS
|
|
||||||
|
|
||||||
Build a branded macOS app and DMG:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash build/scripts/build_macos.sh --brand agravity
|
|
||||||
```
|
|
||||||
|
|
||||||
## Build Output Locations
|
|
||||||
|
|
||||||
Windows artifacts are written to:
|
|
||||||
|
|
||||||
- `build/dist/windows/webdrop_bridge/` for the default product
|
|
||||||
- `build/dist/windows/<brand_id>/` for branded products
|
|
||||||
|
|
||||||
macOS artifacts are written to:
|
|
||||||
|
|
||||||
- `build/dist/macos/webdrop_bridge/` for the default product
|
|
||||||
- `build/dist/macos/<brand_id>/` for branded products
|
|
||||||
|
|
||||||
Typical artifact names:
|
|
||||||
|
|
||||||
- Windows MSI: `<asset_prefix>-<version>-win-x64.msi`
|
|
||||||
- Windows checksum: `<asset_prefix>-<version>-win-x64.msi.sha256`
|
|
||||||
- macOS DMG: `<asset_prefix>-<version>-macos-universal.dmg`
|
|
||||||
- macOS checksum: `<asset_prefix>-<version>-macos-universal.dmg.sha256`
|
|
||||||
|
|
||||||
## Create a Release
|
|
||||||
|
|
||||||
Releases are shared by version. The release scripts scan local build outputs on the current machine and upload every artifact they find for that platform.
|
|
||||||
|
|
||||||
This means:
|
|
||||||
|
|
||||||
- a Windows machine can upload all locally built MSIs for the current version
|
|
||||||
- a macOS machine can later upload all locally built DMGs for the same version
|
|
||||||
- both runs contribute to the same Forgejo release tag
|
|
||||||
- `release-manifest.json` is merged so later runs do not wipe earlier platform entries
|
|
||||||
|
|
||||||
### Windows Release
|
|
||||||
|
|
||||||
Dry run first:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\build\scripts\create_release.ps1 -DryRun
|
|
||||||
```
|
|
||||||
|
|
||||||
Publish all locally built Windows variants for the current version:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\build\scripts\create_release.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
Publish only selected brands:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\build\scripts\create_release.ps1 -Brands agravity
|
|
||||||
```
|
|
||||||
|
|
||||||
Publish only the default product:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\build\scripts\create_release.ps1 -Brands webdrop_bridge
|
|
||||||
```
|
|
||||||
|
|
||||||
Publish a specific version:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\build\scripts\create_release.ps1 -Version 0.8.4
|
|
||||||
```
|
|
||||||
|
|
||||||
### macOS Release
|
|
||||||
|
|
||||||
Dry run first:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash build/scripts/create_release.sh --dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
Publish all locally built macOS variants for the current version:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash build/scripts/create_release.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Publish only selected brands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash build/scripts/create_release.sh --brand agravity
|
|
||||||
```
|
|
||||||
|
|
||||||
Publish only the default product:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash build/scripts/create_release.sh --brand webdrop_bridge
|
|
||||||
```
|
|
||||||
|
|
||||||
Publish a specific version:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash build/scripts/create_release.sh --version 0.8.4
|
|
||||||
```
|
|
||||||
|
|
||||||
### Credentials
|
|
||||||
|
|
||||||
Both release scripts use Forgejo credentials from environment variables when available:
|
|
||||||
|
|
||||||
- `FORGEJO_USER`
|
|
||||||
- `FORGEJO_PASS`
|
|
||||||
|
|
||||||
If they are not set and you are not in dry-run mode, the script prompts for them.
|
|
||||||
|
|
||||||
Both scripts also support clearing credentials from the current shell session:
|
|
||||||
|
|
||||||
- Windows: `-ClearCredentials`
|
|
||||||
- macOS: `--clear-credentials`
|
|
||||||
|
|
||||||
## Dry Run Behavior
|
|
||||||
|
|
||||||
Dry-run mode is the preferred validation step before publishing.
|
|
||||||
|
|
||||||
Dry-run mode:
|
|
||||||
|
|
||||||
- discovers the local artifacts exactly like a real release run
|
|
||||||
- prints the release tag and target release URL
|
|
||||||
- prints the brands that were discovered locally
|
|
||||||
- prints the artifact paths that would be uploaded
|
|
||||||
- writes a local manifest preview to `build/dist/release-manifest.json`
|
|
||||||
- does not prompt for credentials
|
|
||||||
- does not perform network requests
|
|
||||||
- does not delete or upload assets
|
|
||||||
|
|
||||||
## Release Manifest
|
|
||||||
|
|
||||||
The release scripts generate and upload `release-manifest.json`.
|
|
||||||
|
|
||||||
This file is used by the updater to select the correct installer and checksum for a given brand and platform.
|
|
||||||
|
|
||||||
Current platform keys are:
|
|
||||||
|
|
||||||
- `windows-x64`
|
|
||||||
- `macos-universal`
|
|
||||||
|
|
||||||
The manifest is built from local artifacts and merged with any existing manifest already attached to the release.
|
|
||||||
|
|
||||||
## First Manual Download (Before Auto-Update)
|
|
||||||
|
|
||||||
After creating a release, a user can manually download the first installer directly from Forgejo. Once installed, auto-update handles later versions.
|
|
||||||
|
|
||||||
Base repository URL:
|
|
||||||
|
|
||||||
- `https://git.him-tools.de/HIM-public/webdrop-bridge`
|
|
||||||
|
|
||||||
Release page pattern:
|
|
||||||
|
|
||||||
- `https://git.him-tools.de/HIM-public/webdrop-bridge/releases/tag/v<version>`
|
|
||||||
|
|
||||||
Direct asset download pattern:
|
|
||||||
|
|
||||||
- `https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v<version>/<asset-file-name>`
|
|
||||||
|
|
||||||
Example asset names:
|
|
||||||
|
|
||||||
- `WebDropBridge-0.8.4-win-x64.msi`
|
|
||||||
- `WebDropBridge-0.8.4-macos-universal.dmg`
|
|
||||||
- `AgravityBridge-0.8.4-win-x64.msi`
|
|
||||||
- `AgravityBridge-0.8.4-macos-universal.dmg`
|
|
||||||
|
|
||||||
### wget Examples
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Default Windows installer
|
|
||||||
wget "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/WebDropBridge-0.8.4-win-x64.msi"
|
|
||||||
|
|
||||||
# Agravity macOS installer
|
|
||||||
wget "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/AgravityBridge-0.8.4-macos-universal.dmg"
|
|
||||||
```
|
|
||||||
|
|
||||||
### curl Examples
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Default macOS installer
|
|
||||||
curl -L -o WebDropBridge-0.8.4-macos-universal.dmg \
|
|
||||||
"https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/WebDropBridge-0.8.4-macos-universal.dmg"
|
|
||||||
|
|
||||||
# Agravity Windows installer
|
|
||||||
curl -L -o AgravityBridge-0.8.4-win-x64.msi \
|
|
||||||
"https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/AgravityBridge-0.8.4-win-x64.msi"
|
|
||||||
```
|
|
||||||
|
|
||||||
### PowerShell Example
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Invoke-WebRequest `
|
|
||||||
-Uri "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/WebDropBridge-0.8.4-win-x64.msi" `
|
|
||||||
-OutFile "WebDropBridge-0.8.4-win-x64.msi"
|
|
||||||
```
|
|
||||||
|
|
||||||
You can inspect `release-manifest.json` on the release to see the exact file names for each brand and platform.
|
|
||||||
|
|
||||||
## Recommended Workflow for a New Brand
|
|
||||||
|
|
||||||
1. Create `build/brands/<brand>.json`.
|
|
||||||
2. Add or update brand-specific assets if needed.
|
|
||||||
3. Prepare matching runtime config values.
|
|
||||||
4. Build the brand on Windows and/or macOS.
|
|
||||||
5. Run the release script in dry-run mode.
|
|
||||||
6. Verify artifact names, discovered brands, and manifest contents.
|
|
||||||
7. Run the actual release script.
|
|
||||||
8. Validate update behavior against the shared release.
|
|
||||||
|
|
||||||
## Troubleshooting Notes
|
|
||||||
|
|
||||||
### Brand not discovered by release script
|
|
||||||
|
|
||||||
Check that:
|
|
||||||
|
|
||||||
- the build completed successfully
|
|
||||||
- the artifact is under the expected platform folder
|
|
||||||
- the artifact name matches the `asset_prefix` and current version
|
|
||||||
- the version used by the release script matches the built artifact version
|
|
||||||
|
|
||||||
### Windows upgrade behavior is wrong
|
|
||||||
|
|
||||||
Check that the brand has its own stable `msi_upgrade_code`. Reusing or changing it incorrectly will break expected MSI upgrade semantics.
|
|
||||||
|
|
||||||
### App uses the wrong local config folder
|
|
||||||
|
|
||||||
Check that runtime config uses the intended `config_dir_name`, and that it matches the packaging brand you expect.
|
|
||||||
|
|
||||||
### Auto-update downloads the wrong installer
|
|
||||||
|
|
||||||
Check that:
|
|
||||||
|
|
||||||
- the release contains the correct installer files
|
|
||||||
- `release-manifest.json` includes the correct brand and platform entry
|
|
||||||
- runtime update settings point to the expected repo/channel/manifest
|
|
||||||
|
|
||||||
## Current Example Brand
|
|
||||||
|
|
||||||
The first branded variant currently in the repository is:
|
|
||||||
|
|
||||||
- `build/brands/agravity.json`
|
|
||||||
|
|
||||||
Use it as the template for future branded variants.
|
|
||||||
|
|
@ -12,7 +12,7 @@ The configuration file must be named `.env` and contains settings like:
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
APP_NAME=WebDrop Bridge
|
APP_NAME=WebDrop Bridge
|
||||||
APP_VERSION=0.7.1
|
APP_VERSION=0.1.0
|
||||||
WEBAPP_URL=https://example.com
|
WEBAPP_URL=https://example.com
|
||||||
ALLOWED_ROOTS=Z:/,C:/Users/Public
|
ALLOWED_ROOTS=Z:/,C:/Users/Public
|
||||||
ALLOWED_URLS=
|
ALLOWED_URLS=
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,8 @@
|
||||||
# Drag & Drop Problem Analysis - File Drop + Web App Popup
|
# Drag & Drop Problem Analysis - File Drop + Web App Popup
|
||||||
|
|
||||||
**Status**: Phase 1 (File Drop) ✅ Implemented | Phase 2 (Popup Trigger) ⏸️ Planned
|
|
||||||
**Last Updated**: March 3, 2026
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
### Current Implementation Status
|
|
||||||
|
|
||||||
✅ **Phase 1 - File Drop (IMPLEMENTED)**
|
|
||||||
- JavaScript in `bridge_script_intercept.js` intercepts drag events
|
|
||||||
- Calls `window.bridge.start_file_drag(url)` via QWebChannel to Qt
|
|
||||||
- Validates path against whitelist via `PathValidator`
|
|
||||||
- Converts Azure Storage URLs to local paths via `URLConverter`
|
|
||||||
- Creates and executes native Qt file drag operation
|
|
||||||
- Target application (InDesign, Word, etc.) successfully receives file
|
|
||||||
|
|
||||||
⏸️ **Phase 2 - Programmatic Popup Trigger (PLANNED)**
|
|
||||||
- Would require reverse-engineering the web app's popup trigger mechanism
|
|
||||||
- Could be implemented by calling JavaScript function after successful drop
|
|
||||||
- Currently: Applications handle popups manually or separately from file drops
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Das Kernproblem
|
## Das Kernproblem
|
||||||
|
|
||||||
|
**Ziel**: Bei ALT-Drag soll:
|
||||||
1. ✅ File gedroppt werden (Z:\ Laufwerk) → Native File-Drop
|
1. ✅ File gedroppt werden (Z:\ Laufwerk) → Native File-Drop
|
||||||
2. ✅ Web-App Popup erscheinen (Auschecken-Dialog) → Web-App Drop-Event
|
2. ✅ Web-App Popup erscheinen (Auschecken-Dialog) → Web-App Drop-Event
|
||||||
|
|
||||||
|
|
@ -270,39 +250,28 @@ Object.defineProperty(DataTransfer.prototype, 'types', {
|
||||||
|
|
||||||
## 📝 Empfehlung
|
## 📝 Empfehlung
|
||||||
|
|
||||||
### Current Status (as of March 2026)
|
**Sofortige Maßnahmen:**
|
||||||
|
|
||||||
✅ **Phase 1 Complete:**
|
1. ✅ **Lösung A Phase 1 ist bereits implementiert** (File-Drop funktioniert)
|
||||||
- File-drop via native Qt drag operations is fully functional
|
|
||||||
- JavaScript bridge (`bridge_script_intercept.js`) successfully intercepts and converts drags
|
|
||||||
- Path validation and Azure URL mapping working
|
|
||||||
- Tested with real applications (InDesign, Word, etc.)
|
|
||||||
|
|
||||||
### For Future Enhancement (Phase 2 - Popup Trigger)
|
2. 🔍 **Reverse-Engineering durchführen:**
|
||||||
|
- GlobalDAM JRI im Browser öffnen
|
||||||
|
- DevTools öffnen (F12)
|
||||||
|
- ALT-Drag+Drop durchführen
|
||||||
|
- Beobachten:
|
||||||
|
- Network-Tab → API-Calls?
|
||||||
|
- Console → Fehler/Logs?
|
||||||
|
- Angular DevTools → Component-Events?
|
||||||
|
|
||||||
**If popup trigger integration is needed:**
|
3. 🛠️ **Popup-Trigger implementieren:**
|
||||||
|
- Sobald bekannt WIE Popup ausgelöst wird
|
||||||
|
- JavaScript-Funktion `trigger_checkout_popup()` erstellen
|
||||||
|
- Von Qt aus nach erfolgreichem Drop aufrufen
|
||||||
|
|
||||||
1. 🔍 **Reverse-Engineering the Target Web App:**
|
4. 🧪 **Testen:**
|
||||||
- Identify how popups are triggered (API call, component method, event, etc.)
|
- ALT-Drag eines Assets
|
||||||
- Use browser DevTools:
|
- File-Drop sollte funktionieren
|
||||||
- Network tab → Monitor API calls
|
- Popup sollte erscheinen
|
||||||
- Console → Check for JavaScript errors/logs
|
|
||||||
- Elements → Inspect component structure
|
|
||||||
- Angular/Vue DevTools if applicable
|
|
||||||
|
|
||||||
2. 🛠️ **Implement Popup Trigger:**
|
**Fallback:**
|
||||||
- Create JavaScript hook function (e.g., `window.trigger_popup(assetId)`)
|
Falls Reverse-Engineering zu komplex ist → **Lösung B** verwenden (Kein Drag, nur Copy nach Popup-Bestätigung)
|
||||||
- Connect to drop success signal from Qt
|
|
||||||
- Call popup trigger after successful file drop
|
|
||||||
|
|
||||||
3. 🧪 **Test Integration:**
|
|
||||||
- Verify file drops successfully
|
|
||||||
- Verify popup appears after drop
|
|
||||||
- Test with real assets/files
|
|
||||||
|
|
||||||
**Alternative Approaches:**
|
|
||||||
|
|
||||||
- **Lösung B (Manual)**: Keep file drop and popup as separate user actions
|
|
||||||
- **Lösung C (Complex)**: Use overlay window approach (more involved)
|
|
||||||
|
|
||||||
Current implementation uses **Phase 1 of Lösung A** and is production-ready.
|
|
||||||
|
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
# Hover Effects Analysis - Qt WebEngineView Limitation
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
**Status**: Hover effects partially functional in Qt WebEngineView, with a clear Qt limitation identified.
|
|
||||||
|
|
||||||
- ✅ **Checkbox hover**: Works correctly
|
|
||||||
- ✅ **Event detection**: Polling-based detection functional
|
|
||||||
- ❌ **Menu expansion via :hover**: Does NOT work (Qt limitation)
|
|
||||||
- ❌ **Tailwind CSS :hover-based effects**: Do NOT work in Qt
|
|
||||||
|
|
||||||
## Investigation Results
|
|
||||||
|
|
||||||
### Test Environment
|
|
||||||
- **Framework**: PySide6 QWebEngineView
|
|
||||||
- **Web App**: Angular + Tailwind CSS
|
|
||||||
- **Browser Test**: Google Chrome (reference)
|
|
||||||
- **Test Date**: March 4, 2026
|
|
||||||
|
|
||||||
### Chrome Browser Results
|
|
||||||
Both menu expansion and checkbox hover work perfectly in Chrome browser. This confirms the issue is **Qt-specific**, not a web application problem.
|
|
||||||
|
|
||||||
### Qt WebEngineView Results
|
|
||||||
|
|
||||||
#### What Works ✅
|
|
||||||
1. **Checkbox hover effects**
|
|
||||||
- Checkboxes appear on hover
|
|
||||||
- CSS-based simulation via `.__mouse_hover` class works correctly
|
|
||||||
- `input[type="checkbox"].__mouse_hover` CSS selector successfully applied
|
|
||||||
|
|
||||||
2. **Event detection**
|
|
||||||
- Mouse position tracking: Working
|
|
||||||
- `document.elementFromPoint()` polling: Working (50ms interval)
|
|
||||||
- `mouseover`, `mouseenter`, `mouseleave`, `mousemove` event dispatching: Working
|
|
||||||
- Angular event listeners: Receiving dispatched events correctly
|
|
||||||
|
|
||||||
3. **DOM element access**
|
|
||||||
- Menu element found with `querySelectorAll()`
|
|
||||||
- Event listeners identified: `{click: Array(1)}`
|
|
||||||
- Not in Shadow DOM (accessible from JavaScript)
|
|
||||||
|
|
||||||
#### What Doesn't Work ❌
|
|
||||||
1. **Menu expansion via Tailwind :hover**
|
|
||||||
- Menu element: `.group` class with `hover:bg-neutral-300`
|
|
||||||
- Menu children have: `.group-hover:w-full` (Tailwind pattern)
|
|
||||||
- Expected behavior: `.group:hover > .group-hover:w-full` triggers on hover
|
|
||||||
- Actual behavior: No expansion (`:hover` pseudo-selector not activated)
|
|
||||||
|
|
||||||
2. **Tailwind CSS :hover-based styles**
|
|
||||||
- Pattern: `.group:hover > .group-hover:*` (Tailwind generated)
|
|
||||||
- Root cause: Qt doesn't properly set `:hover` pseudo-selector state for dispatched events
|
|
||||||
- Impact: Any CSS rule depending on `:hover` pseudo-selector won't work
|
|
||||||
|
|
||||||
## Technical Analysis
|
|
||||||
|
|
||||||
### The Core Issue
|
|
||||||
|
|
||||||
Qt WebEngineView doesn't forward native mouse events to JavaScript in a way that properly triggers the CSS `:hover` pseudo-selector. When we dispatch synthetic events:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
element.dispatchEvent(new MouseEvent("mouseover", {...}));
|
|
||||||
element.dispatchEvent(new MouseEvent("mouseenter", {...}));
|
|
||||||
```
|
|
||||||
|
|
||||||
The browser's CSS engine **does not** update the `:hover` pseudo-selector state. This is different from a native browser, where:
|
|
||||||
|
|
||||||
1. User moves mouse
|
|
||||||
2. Browser kernel detects native hover
|
|
||||||
3. `:hover` pseudo-selector activates
|
|
||||||
4. CSS rules matching `:hover` are applied
|
|
||||||
|
|
||||||
### Evidence
|
|
||||||
|
|
||||||
**Chrome DevTools inspection** revealed:
|
|
||||||
```
|
|
||||||
Event Listeners: {click: Array(1)} // Only CLICK handler, NO hover handlers
|
|
||||||
Menu element className: "flex h-14 w-full items-center p-2 transition-colors hover:bg-neutral-300 ... group"
|
|
||||||
```
|
|
||||||
|
|
||||||
The Angular app handles UI in two ways:
|
|
||||||
1. **Click events**: Directly handled by JavaScript listeners → Works
|
|
||||||
2. **Hover effects**: Rely on CSS `:hover` pseudo-selector → Doesn't work in Qt
|
|
||||||
|
|
||||||
### Why This Is a Limitation
|
|
||||||
|
|
||||||
This is not fixable by JavaScript injection because:
|
|
||||||
|
|
||||||
1. **JavaScript can't activate CSS `:hover`**: The `:hover` pseudo-selector is a browser-native feature that only CSS engines can modify. JavaScript can't directly trigger it.
|
|
||||||
|
|
||||||
2. **Tailwind CSS is static**: Tailwind generates CSS rules like `.group:hover > .group-hover:w-full { width: 11rem; }`. These rules expect the `:hover` pseudo-selector to be active—JavaScript can't force them to apply.
|
|
||||||
|
|
||||||
3. **Qt engine limitation**: Qt WebEngineView's Chromium engine doesn't properly handle `:hover` for non-native events.
|
|
||||||
|
|
||||||
### What We Tried
|
|
||||||
|
|
||||||
| Approach | Result | Notes |
|
|
||||||
|----------|--------|-------|
|
|
||||||
| Direct CSS class injection | ❌ Failed | `.group.__mouse_hover` doesn't trigger Tailwind rules |
|
|
||||||
| PointerEvent dispatch | ❌ Failed | Modern API didn't help |
|
|
||||||
| JavaScript style manipulation | ❌ Failed | Can't force Tailwind CSS rules via JS |
|
|
||||||
| Polling + synthetic mouse events | ⚠️ Partial | Works for custom handlers, not for `:hover` |
|
|
||||||
|
|
||||||
## Implementation Status
|
|
||||||
|
|
||||||
### Current Solution
|
|
||||||
File: [mouse_event_emulator.js](../src/webdrop_bridge/ui/mouse_event_emulator.js)
|
|
||||||
|
|
||||||
**What it does:**
|
|
||||||
1. Polls `document.elementFromPoint()` every 50ms to detect element changes
|
|
||||||
2. Dispatches `mouseover`, `mouseenter`, `mouseleave`, `mousemove` events
|
|
||||||
3. Applies `.__mouse_hover` CSS class for custom hover simulation
|
|
||||||
4. Works for elements with JavaScript event handlers
|
|
||||||
|
|
||||||
**What it doesn't do:**
|
|
||||||
1. Cannot activate `:hover` pseudo-selector
|
|
||||||
2. Cannot trigger Tailwind CSS hover-based rules
|
|
||||||
3. Cannot fix Qt's limitation
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
- CPU overhead: Minimal (polling every 50ms on idle)
|
|
||||||
- Startup impact: Negligible
|
|
||||||
- Memory footprint: ~2KB script size
|
|
||||||
|
|
||||||
## Verification Steps
|
|
||||||
|
|
||||||
To verify this limitation exists in your Qt environment:
|
|
||||||
|
|
||||||
### Chrome Test
|
|
||||||
1. Open web app in Chrome
|
|
||||||
2. Hover over menu → Menu expands ✅
|
|
||||||
3. Hover over checkbox → Checkbox appears ✅
|
|
||||||
|
|
||||||
### Qt Test
|
|
||||||
1. Run application in Qt
|
|
||||||
2. Hover over menu → Menu does NOT expand ❌ (known limitation)
|
|
||||||
3. Hover over checkbox → Checkbox appears ✅ (works via CSS class)
|
|
||||||
|
|
||||||
### Debug Verification (if needed)
|
|
||||||
In Chrome DevTools console:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Find menu element
|
|
||||||
const menuGroup = document.querySelector('[class*="group"]');
|
|
||||||
console.log("Menu group:", menuGroup?.className);
|
|
||||||
|
|
||||||
// Check for Shadow DOM
|
|
||||||
const inShadow = menuGroup?.getRootNode() !== document;
|
|
||||||
console.log("In Shadow DOM:", inShadow); // Should be false
|
|
||||||
|
|
||||||
// Check event listeners
|
|
||||||
console.log("Event Listeners:", getEventListeners(menuGroup)); // Shows if handlers exist
|
|
||||||
```
|
|
||||||
|
|
||||||
Results:
|
|
||||||
- Menu element: Found
|
|
||||||
- Shadow DOM: No
|
|
||||||
- Event listeners: `{click: Array(1)}` (only click, no hover handlers)
|
|
||||||
|
|
||||||
## Recommendations
|
|
||||||
|
|
||||||
### What Developers Should Know
|
|
||||||
1. **Don't expect :hover effects to work in Qt WebEngineView**
|
|
||||||
- This is a known limitation, not a bug in WebDrop Bridge
|
|
||||||
- The application itself works correctly in Chrome
|
|
||||||
|
|
||||||
2. **Workarounds for your web app**
|
|
||||||
- Replace `:hover` with JavaScript click handlers
|
|
||||||
- Add click-to-toggle functionality instead of hover
|
|
||||||
- This is outside the scope of WebDrop Bridge
|
|
||||||
|
|
||||||
3. **For similar Qt projects**
|
|
||||||
- Be aware of this `:hover` pseudo-selector limitation when embedding web content
|
|
||||||
- Consider detecting Qt environment and serving alternative UI
|
|
||||||
- Test web apps in actual Chrome browser before embedding in Qt
|
|
||||||
|
|
||||||
### Future Improvements (Not Feasible)
|
|
||||||
The following would require Qt framework modifications:
|
|
||||||
- Improving QWebEngineView's `:hover` pseudo-selector support
|
|
||||||
- Better mouse event forwarding to browser CSS engine
|
|
||||||
- Custom CSS selector handling in embedded browser
|
|
||||||
|
|
||||||
None of these are achievable through application-level code.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
WebDrop Bridge successfully emulates hover behavior for elements with JavaScript event handlers (like checkboxes). However, Tailwind CSS and other frameworks that rely on the CSS `:hover` pseudo-selector will not work fully in Qt WebEngineView due to an inherent limitation in how Qt forwards mouse events to the browser's CSS engine.
|
|
||||||
|
|
||||||
This is not a defect in WebDrop Bridge, but rather a limitation of embedding web content in Qt applications. The web application works perfectly in standard browsers like Chrome.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Status**: Issue Closed - Limitation Documented
|
|
||||||
**Last Updated**: March 4, 2026
|
|
||||||
**Severity**: Low (UI-only, core functionality unaffected)
|
|
||||||
|
|
@ -1,391 +0,0 @@
|
||||||
# Package Manager Support for WebDropBridge
|
|
||||||
|
|
||||||
This document explains how to build and publish WebDropBridge to package managers like Chocolatey (Windows) and Homebrew (macOS).
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
WebDropBridge supports installation via package managers, making it easier for users to install, update, and manage the application.
|
|
||||||
|
|
||||||
| Package Manager | OS | Status | Directory |
|
|
||||||
|-----------------|-----|--------|-----------|
|
|
||||||
| **Chocolatey** | Windows | Supported | `build/chocolatey/` |
|
|
||||||
| **Homebrew** | macOS | Supported | `build/homebrew/` |
|
|
||||||
| **Winget** | Windows | Optional | Future |
|
|
||||||
|
|
||||||
## Quick Start: Simplest Approach (Direct Distribution)
|
|
||||||
|
|
||||||
**No infrastructure or accounts needed** - just build once and share:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# 1. Build the Chocolatey package
|
|
||||||
cd build/chocolatey
|
|
||||||
python ../../build/scripts/build_windows.py --msi
|
|
||||||
certutil -hashfile "../../build/dist/windows/WebDropBridge_Setup.msi" SHA256
|
|
||||||
# Update checksum in tools/chocolateyInstall.ps1
|
|
||||||
choco pack webdrop-bridge.nuspec
|
|
||||||
|
|
||||||
# 2. Share webdrop-bridge.0.8.0.nupkg
|
|
||||||
# File share: \\server\packages\
|
|
||||||
# USB drive, email, Forgejo releases, etc.
|
|
||||||
|
|
||||||
# 3. Users install it
|
|
||||||
# choco install webdrop-bridge.0.8.0.nupkg -s "\\server\packages"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Advantages:**
|
|
||||||
- ✅ No accounts or external infrastructure
|
|
||||||
- ✅ Works in air-gapped/offline environments
|
|
||||||
- ✅ Simple one-time setup
|
|
||||||
- ✅ Version management through file shares
|
|
||||||
|
|
||||||
**For centralized distribution**, see Options 1-3 below.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chocolatey (Windows)
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Chocolatey installed: https://chocolatey.org/install
|
|
||||||
- (Only for public community repo) Chocolatey maintainer account at chocolatey.org
|
|
||||||
|
|
||||||
### Building the Chocolatey Package
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Build MSI installer first
|
|
||||||
python build/scripts/build_windows.py --msi
|
|
||||||
|
|
||||||
# 2. Calculate SHA256 checksum of the MSI
|
|
||||||
certutil -hashfile "build/dist/windows/WebDropBridge_Setup.msi" SHA256
|
|
||||||
|
|
||||||
# 3. Update the checksum in build/chocolatey/tools/chocolateyInstall.ps1
|
|
||||||
# Replace: $Checksum = ''
|
|
||||||
# With: $Checksum = 'YOUR_SHA256_HASH'
|
|
||||||
|
|
||||||
# 4. Update version in chocolatey/webdrop-bridge.nuspec
|
|
||||||
# <version>0.8.0</version>
|
|
||||||
|
|
||||||
# 5. Create the package
|
|
||||||
cd build/chocolatey
|
|
||||||
choco pack webdrop-bridge.nuspec
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates `webdrop-bridge.0.8.0.nupkg`
|
|
||||||
|
|
||||||
### Publishing to Chocolatey
|
|
||||||
|
|
||||||
**Option 1: Internal NuGet Repository (Recommended for HIM)**
|
|
||||||
|
|
||||||
Host on your own NuGet server (Azure Artifacts, Artifactory, ProGet, etc.):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Configure Chocolatey to use internal repository
|
|
||||||
choco source add -n=internal-repo -s "https://your-artifactory.internal/nuget/chocolatey/"
|
|
||||||
|
|
||||||
# Push package to internal repo
|
|
||||||
nuget push webdrop-bridge.0.8.0.nupkg -Source https://your-artifactory.internal/nuget/chocolatey/ -ApiKey YOUR_API_KEY
|
|
||||||
|
|
||||||
# Users install from internal repo (already configured)
|
|
||||||
choco install webdrop-bridge
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option 2: Community Repository (chocolatey.org)**
|
|
||||||
|
|
||||||
If you want public distribution (requires community maintainer account):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Push to community repo
|
|
||||||
choco push webdrop-bridge.0.8.0.nupkg --api-key YOUR_CHOCOLATEY_API_KEY
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option 3: No Repository (Direct Distribution)**
|
|
||||||
|
|
||||||
Share the `.nupkg` file directly, users install locally:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# User downloads webdrop-bridge.0.8.0.nupkg and runs:
|
|
||||||
choco install webdrop-bridge.0.8.0.nupkg -s C:\path\to\package\folder
|
|
||||||
```
|
|
||||||
|
|
||||||
### User Installation
|
|
||||||
|
|
||||||
Depending on your chosen distribution:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# If using internal repository
|
|
||||||
choco install webdrop-bridge
|
|
||||||
|
|
||||||
# If using community repo (chocolatey.org)
|
|
||||||
choco install webdrop-bridge
|
|
||||||
|
|
||||||
# If distributing directly
|
|
||||||
choco install webdrop-bridge.0.8.0.nupkg -s "\\network\share\packages"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Homebrew (macOS)
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Homebrew installed: https://brew.sh
|
|
||||||
- GitHub or Gitea account for hosting tap repository
|
|
||||||
|
|
||||||
### Two Approaches
|
|
||||||
|
|
||||||
#### Option A: Local Tap (Recommended for HIM)
|
|
||||||
|
|
||||||
Create a custom tap repository to distribute your formula without submitting to official Homebrew.
|
|
||||||
|
|
||||||
**Setup:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create tap repository
|
|
||||||
mkdir homebrew-webdrop-bridge
|
|
||||||
cd homebrew-webdrop-bridge
|
|
||||||
|
|
||||||
# Create structure
|
|
||||||
mkdir -p Formula
|
|
||||||
cp ../build/homebrew/webdrop-bridge.rb Formula/
|
|
||||||
|
|
||||||
# Initialize git repo and push to Forgejo
|
|
||||||
git init
|
|
||||||
git add .
|
|
||||||
git commit -m "Add webdrop-bridge formula"
|
|
||||||
git remote add origin https://git.him-tools.de/HIM-public/homebrew-webdrop-bridge.git
|
|
||||||
git push -u origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
**User Installation:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Add tap
|
|
||||||
brew tap HIM-public/webdrop-bridge https://git.him-tools.de/HIM-public/homebrew-webdrop-bridge.git
|
|
||||||
|
|
||||||
# Install
|
|
||||||
brew install webdrop-bridge
|
|
||||||
|
|
||||||
# Upgrade
|
|
||||||
brew upgrade webdrop-bridge
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Option B: Official Homebrew Repository
|
|
||||||
|
|
||||||
Submit to `homebrew/casks` (requires more maintenance but no separate tap):
|
|
||||||
|
|
||||||
1. Fork https://github.com/Homebrew/homebrew-casks
|
|
||||||
2. Create pull request with Cask file
|
|
||||||
3. Homebrew maintainers review and merge
|
|
||||||
4. Users install via `brew install --cask webdrop-bridge`
|
|
||||||
|
|
||||||
### Building the Homebrew Package (Locally)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Build DMG installer
|
|
||||||
bash build/scripts/build_macos.sh
|
|
||||||
|
|
||||||
# 2. Calculate SHA256 checksum
|
|
||||||
shasum -a 256 "build/dist/macos/WebDropBridge_Setup.dmg"
|
|
||||||
|
|
||||||
# 3. Update formula with checksum and URL
|
|
||||||
# build/homebrew/webdrop-bridge.rb
|
|
||||||
# - url: https://git.him-tools.de/...releases/download/vX.X.X/WebDropBridge_Setup.dmg
|
|
||||||
# - sha256: YOUR_SHA256_HASH
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing Homebrew Formula Locally
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Validate formula syntax
|
|
||||||
brew audit --formula build/homebrew/webdrop-bridge.rb
|
|
||||||
|
|
||||||
# Install from local formula
|
|
||||||
brew install build/homebrew/webdrop-bridge.rb
|
|
||||||
|
|
||||||
# Verify installation
|
|
||||||
brew list webdrop-bridge
|
|
||||||
webdrop-bridge --version # If CLI exists, or check Applications folder
|
|
||||||
```
|
|
||||||
|
|
||||||
## Publishing Workflow
|
|
||||||
|
|
||||||
### Step 1: Build Release
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Release v0.8.0
|
|
||||||
|
|
||||||
# Windows MSI
|
|
||||||
python build/scripts/build_windows.py --msi
|
|
||||||
|
|
||||||
# macOS DMG
|
|
||||||
bash build/scripts/build_macos.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Create Forgejo Release
|
|
||||||
|
|
||||||
Tag and upload installers to Forgejo:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git tag -a v0.8.0 -m "Release 0.8.0"
|
|
||||||
git push upstream v0.8.0
|
|
||||||
|
|
||||||
# Upload MSI and DMG to Forgejo release page
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Calculate Checksums
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Windows
|
|
||||||
certutil -hashfile WebDropBridge_Setup.msi SHA256
|
|
||||||
|
|
||||||
# macOS
|
|
||||||
shasum -a 256 WebDropBridge_Setup.dmg
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Update Package Manager Files
|
|
||||||
|
|
||||||
**Chocolatey** (`build/chocolatey/tools/chocolateyInstall.ps1`):
|
|
||||||
```powershell
|
|
||||||
$Checksum = 'WINDOWS_SHA256_HASH'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Homebrew** (`build/homebrew/webdrop-bridge.rb`):
|
|
||||||
```ruby
|
|
||||||
sha256 "MACOS_SHA256_HASH"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5: Test Package Installation
|
|
||||||
|
|
||||||
**Chocolatey:**
|
|
||||||
```powershell
|
|
||||||
cd build/chocolatey
|
|
||||||
choco pack
|
|
||||||
choco install webdrop-bridge.0.8.0.nupkg -s .
|
|
||||||
```
|
|
||||||
|
|
||||||
**Homebrew (with tap):**
|
|
||||||
```bash
|
|
||||||
brew install ./build/homebrew/webdrop-bridge.rb
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 6: Publish
|
|
||||||
|
|
||||||
**Chocolatey:**
|
|
||||||
```powershell
|
|
||||||
choco push webdrop-bridge.0.8.0.nupkg --api-key YOUR_KEY
|
|
||||||
```
|
|
||||||
|
|
||||||
**Homebrew:**
|
|
||||||
- If using local tap: Push to Forgejo repository
|
|
||||||
- If using official: Submit pull request to homebrew-casks
|
|
||||||
|
|
||||||
## Update Workflow
|
|
||||||
|
|
||||||
### For Subsequent Releases (e.g., v0.9.0)
|
|
||||||
|
|
||||||
1. Build new installers (MSI/DMG)
|
|
||||||
2. Create Forgejo release with new version
|
|
||||||
3. Calculate new checksums
|
|
||||||
4. Update version and checksums in:
|
|
||||||
- `build/chocolatey/webdrop-bridge.nuspec`
|
|
||||||
- `build/chocolatey/tools/chocolateyInstall.ps1`
|
|
||||||
- `build/homebrew/webdrop-bridge.rb`
|
|
||||||
5. Test locally
|
|
||||||
6. Publish to package managers
|
|
||||||
|
|
||||||
## Configuration in Package Managers
|
|
||||||
|
|
||||||
### Chocolatey
|
|
||||||
|
|
||||||
Located in: `build/chocolatey/tools/chocolateyInstall.ps1`
|
|
||||||
|
|
||||||
Key variables to update per release:
|
|
||||||
- `$Version` - Application version
|
|
||||||
- `$Url` - Download URL (Forgejo release)
|
|
||||||
- `$Checksum` - SHA256 hash of MSI
|
|
||||||
- `$ChecksumType` - Type of hash (sha256)
|
|
||||||
|
|
||||||
### Homebrew
|
|
||||||
|
|
||||||
Located in: `build/homebrew/webdrop-bridge.rb`
|
|
||||||
|
|
||||||
Key variables to update per release:
|
|
||||||
- `version` - Application version
|
|
||||||
- `url` - Download URL (Forgejo release)
|
|
||||||
- `sha256` - SHA256 hash of DMG
|
|
||||||
|
|
||||||
## Automatic Updates
|
|
||||||
|
|
||||||
### Via Package Managers
|
|
||||||
|
|
||||||
When users install via Chocolatey/Homebrew, they receive updates through:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Chocolatey
|
|
||||||
choco upgrade webdrop-bridge
|
|
||||||
|
|
||||||
# Homebrew
|
|
||||||
brew upgrade webdrop-bridge
|
|
||||||
```
|
|
||||||
|
|
||||||
### Built-in Auto-Update (Fallback)
|
|
||||||
|
|
||||||
WebDropBridge also includes built-in auto-update mechanism that:
|
|
||||||
1. Checks Forgejo releases API on startup
|
|
||||||
2. Notifies user of available updates
|
|
||||||
3. Downloads and installs directly (bypasses package manager)
|
|
||||||
|
|
||||||
This works for:
|
|
||||||
- Direct downloads via wget
|
|
||||||
- Standalone installer use
|
|
||||||
- Users who skip package manager route
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Chocolatey Issues
|
|
||||||
|
|
||||||
**Package won't install:**
|
|
||||||
- Verify checksum: `certutil -hashfile WebDropBridge_Setup.msi SHA256`
|
|
||||||
- Check MSI exists at URL: `wget URL`
|
|
||||||
- Verify SHA256 matches in `chocolateyInstall.ps1`
|
|
||||||
|
|
||||||
**Uninstall fails:**
|
|
||||||
- Try manual uninstall first
|
|
||||||
- Then recreate the Chocolatey package
|
|
||||||
|
|
||||||
### Homebrew Issues
|
|
||||||
|
|
||||||
**Formula won't install:**
|
|
||||||
- Validate syntax: `brew audit --formula webdrop-bridge.rb`
|
|
||||||
- Check URL is accessible: `curl -I URL`
|
|
||||||
- Verify SHA256: `shasum -a 256 WebDropBridge_Setup.dmg`
|
|
||||||
|
|
||||||
**Upgrade fails:**
|
|
||||||
- Remove old version: `brew uninstall webdrop-bridge`
|
|
||||||
- Reinstall: `brew install webdrop-bridge`
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- **Chocolatey Documentation**: https://docs.chocolatey.org/
|
|
||||||
- **Homebrew Formula Reference**: https://docs.brew.sh/Formula-Cookbook
|
|
||||||
- **Homebrew Cask**: https://docs.brew.sh/Cask-Cookbook
|
|
||||||
- **Forgejo Releases**: https://git.him-tools.de/HIM-public/webdrop-bridge/releases
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Distribution Strategy Options for HIM:**
|
|
||||||
|
|
||||||
1. **Easiest: Direct Distribution** ✅
|
|
||||||
- Share `.nupkg` file via file share or email
|
|
||||||
- Users: `choco install webdrop-bridge.0.8.0.nupkg -s "\\share\packages"`
|
|
||||||
- No infrastructure needed
|
|
||||||
- No maintainer account required
|
|
||||||
|
|
||||||
2. **Better: Internal NuGet Repository** ✅ (Recommended)
|
|
||||||
- Host on Azure Artifacts or Artifactory
|
|
||||||
- Professional package management
|
|
||||||
- Automatic updates with `choco upgrade`
|
|
||||||
- Users: `choco install webdrop-bridge` (pre-configured)
|
|
||||||
|
|
||||||
3. **Public: Chocolatey Community** (Optional)
|
|
||||||
- Publish to chocolatey.org (requires maintainer account + vetting)
|
|
||||||
- Widest distribution
|
|
||||||
- Public users: `choco install webdrop-bridge`
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
# Translations Guide (i18n)
|
|
||||||
|
|
||||||
This document explains how to:
|
|
||||||
- add a new language
|
|
||||||
- edit an existing language
|
|
||||||
- update translations when new text is added in the app
|
|
||||||
|
|
||||||
The app uses JSON-based translations loaded from:
|
|
||||||
- resources/translations/
|
|
||||||
|
|
||||||
## 1. Translation System Overview
|
|
||||||
|
|
||||||
Main components:
|
|
||||||
- src/webdrop_bridge/utils/i18n.py
|
|
||||||
- Loads language JSON files
|
|
||||||
- Provides tr("key", **kwargs)
|
|
||||||
- Falls back to English if a key is missing
|
|
||||||
- src/webdrop_bridge/main.py
|
|
||||||
- Initializes i18n at app startup
|
|
||||||
- src/webdrop_bridge/config.py
|
|
||||||
- Stores selected language in config (language field)
|
|
||||||
- src/webdrop_bridge/ui/settings_dialog.py
|
|
||||||
- Language selector in Settings -> General
|
|
||||||
|
|
||||||
Current language files:
|
|
||||||
- resources/translations/en.json
|
|
||||||
- resources/translations/de.json
|
|
||||||
- resources/translations/fr.json
|
|
||||||
- resources/translations/it.json
|
|
||||||
- resources/translations/ru.json
|
|
||||||
- resources/translations/zh.json
|
|
||||||
|
|
||||||
## 2. Add a New Language
|
|
||||||
|
|
||||||
Example: add Spanish (es).
|
|
||||||
|
|
||||||
1. Create a new file:
|
|
||||||
- resources/translations/es.json
|
|
||||||
|
|
||||||
2. Copy the full structure from English:
|
|
||||||
- Copy resources/translations/en.json to resources/translations/es.json
|
|
||||||
|
|
||||||
3. Translate all values in es.json:
|
|
||||||
- Keep all keys exactly the same
|
|
||||||
- Only change text values
|
|
||||||
- Keep placeholders unchanged, for example:
|
|
||||||
- {name}
|
|
||||||
- {version}
|
|
||||||
- {error}
|
|
||||||
|
|
||||||
4. Add language display name in i18n helper:
|
|
||||||
- Edit src/webdrop_bridge/utils/i18n.py
|
|
||||||
- In Translator.BUILTIN_LANGUAGES add:
|
|
||||||
- "es": "Español"
|
|
||||||
|
|
||||||
5. Start app and test:
|
|
||||||
- Choose language in Settings -> General
|
|
||||||
- Restart app when prompted
|
|
||||||
- Verify tooltips, dialogs, status texts, settings labels, update dialogs
|
|
||||||
|
|
||||||
## 3. Edit an Existing Language
|
|
||||||
|
|
||||||
1. Open the language file, for example:
|
|
||||||
- resources/translations/de.json
|
|
||||||
|
|
||||||
2. Update only text values.
|
|
||||||
|
|
||||||
3. Do not:
|
|
||||||
- remove keys
|
|
||||||
- rename keys
|
|
||||||
- change placeholder names
|
|
||||||
|
|
||||||
4. Validate JSON formatting:
|
|
||||||
- Must be valid JSON
|
|
||||||
- Keep UTF-8 encoding
|
|
||||||
|
|
||||||
5. Test in app:
|
|
||||||
- Select language in Settings
|
|
||||||
- Restart and verify changed text appears
|
|
||||||
|
|
||||||
## 4. When New Text Is Added in the App
|
|
||||||
|
|
||||||
Whenever new UI text is introduced in code, follow this process.
|
|
||||||
|
|
||||||
### Step A: Add a new translation key in code
|
|
||||||
|
|
||||||
Instead of hardcoded text, use tr("...") with a key.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
- Before: QLabel("Check for Updates")
|
|
||||||
- After: QLabel(tr("toolbar.tooltip.check_updates"))
|
|
||||||
|
|
||||||
If dynamic text is needed:
|
|
||||||
- tr("update.status.available", version=release.version)
|
|
||||||
|
|
||||||
### Step B: Add the key to English first
|
|
||||||
|
|
||||||
1. Add the new key in:
|
|
||||||
- resources/translations/en.json
|
|
||||||
|
|
||||||
2. Use clear key naming by area, for example:
|
|
||||||
- toolbar.tooltip.*
|
|
||||||
- dialog.*
|
|
||||||
- settings.*
|
|
||||||
- update.*
|
|
||||||
- status.*
|
|
||||||
- worker.*
|
|
||||||
|
|
||||||
### Step C: Add the same key to all other language files
|
|
||||||
|
|
||||||
Update each file in resources/translations:
|
|
||||||
- de.json
|
|
||||||
- fr.json
|
|
||||||
- it.json
|
|
||||||
- ru.json
|
|
||||||
- zh.json
|
|
||||||
- and any new language file
|
|
||||||
|
|
||||||
If translation is not ready yet, copy English temporarily (better than missing key text in UI).
|
|
||||||
|
|
||||||
### Step D: Test fallback and real translations
|
|
||||||
|
|
||||||
1. Run app in English and verify new text.
|
|
||||||
2. Run app in other languages and verify translated text.
|
|
||||||
3. Confirm no raw key appears in UI (for example: dialog.my_new_key).
|
|
||||||
|
|
||||||
## 5. Placeholder Rules
|
|
||||||
|
|
||||||
Placeholders must match exactly between code and translation values.
|
|
||||||
|
|
||||||
If code uses:
|
|
||||||
- tr("status.opened", name=file_name)
|
|
||||||
|
|
||||||
Then translation must contain:
|
|
||||||
- "status.opened": "Opened: {name}"
|
|
||||||
|
|
||||||
Common mistakes:
|
|
||||||
- wrong placeholder name ({filename} vs {name})
|
|
||||||
- missing placeholder
|
|
||||||
- extra placeholder not passed by code
|
|
||||||
|
|
||||||
## 6. Recommended Workflow for Translation Updates
|
|
||||||
|
|
||||||
1. Implement UI text with tr("key") in code.
|
|
||||||
2. Add key to en.json.
|
|
||||||
3. Copy key to all language files.
|
|
||||||
4. Run tests.
|
|
||||||
5. Smoke test manually in app.
|
|
||||||
|
|
||||||
Useful test command:
|
|
||||||
- python -m pytest tests/unit/test_i18n.py -q
|
|
||||||
|
|
||||||
Recommended additional checks when UI changed:
|
|
||||||
- python -m pytest tests/unit/test_settings_dialog.py tests/unit/test_update_manager_ui.py tests/unit/test_startup_check.py -q
|
|
||||||
|
|
||||||
## 7. Troubleshooting
|
|
||||||
|
|
||||||
### Problem: Language changed in settings but UI language did not change
|
|
||||||
|
|
||||||
Expected behavior:
|
|
||||||
- language is applied after restart
|
|
||||||
|
|
||||||
Check:
|
|
||||||
- language value saved in config file
|
|
||||||
- restart prompt appears after changing language
|
|
||||||
- selected language JSON file exists and is valid
|
|
||||||
|
|
||||||
### Problem: UI shows translation key text instead of real text
|
|
||||||
|
|
||||||
Example shown in UI:
|
|
||||||
- settings.title
|
|
||||||
|
|
||||||
Cause:
|
|
||||||
- key missing in selected language and missing in en.json fallback
|
|
||||||
|
|
||||||
Fix:
|
|
||||||
- add key to en.json
|
|
||||||
- add key to selected language file
|
|
||||||
|
|
||||||
### Problem: Text formatting errors
|
|
||||||
|
|
||||||
Cause:
|
|
||||||
- placeholder mismatch
|
|
||||||
|
|
||||||
Fix:
|
|
||||||
- compare tr(...) arguments in code with placeholders in translation string
|
|
||||||
|
|
||||||
## 8. Best Practices
|
|
||||||
|
|
||||||
- Keep en.json as complete source of truth.
|
|
||||||
- Keep key names stable once released.
|
|
||||||
- Group keys by feature area.
|
|
||||||
- Prefer short, user-friendly text in UI.
|
|
||||||
- Use formal, consistent tone per language.
|
|
||||||
- Review non-Latin languages (RU/ZH) with a native speaker when possible.
|
|
||||||
|
|
||||||
## 9. Quick Checklist
|
|
||||||
|
|
||||||
When adding new text:
|
|
||||||
- Add tr("new.key") in code
|
|
||||||
- Add key in en.json
|
|
||||||
- Add key in all other language files
|
|
||||||
- Verify placeholders
|
|
||||||
- Run i18n and impacted UI tests
|
|
||||||
- Manual in-app check with at least one non-English language
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 135 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 124 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 146 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 144 KiB |
|
|
@ -1,172 +0,0 @@
|
||||||
{
|
|
||||||
"toolbar.tooltip.open_drop": "Datei hier ablegen, um sie mit der Standardanwendung zu \u00f6ffnen",
|
|
||||||
"toolbar.tooltip.open_with_drop": "Datei hier ablegen, um die \u00d6ffnen-mit-App auszuw\u00e4hlen",
|
|
||||||
"toolbar.tooltip.home": "Startseite",
|
|
||||||
"toolbar.tooltip.about": "\u00dcber WebDrop Bridge",
|
|
||||||
"toolbar.tooltip.settings": "Einstellungen",
|
|
||||||
"toolbar.tooltip.check_updates": "Nach Updates suchen",
|
|
||||||
"toolbar.tooltip.clear_cache": "Cache und Cookies l\u00f6schen",
|
|
||||||
"toolbar.tooltip.open_log": "Protokolldatei \u00f6ffnen",
|
|
||||||
"toolbar.tooltip.dev_tools": "Entwicklerwerkzeuge (F12)",
|
|
||||||
|
|
||||||
"status.ready": "Bereit",
|
|
||||||
"status.opened": "Ge\u00f6ffnet: {name}",
|
|
||||||
"status.choose_app": "App ausw\u00e4hlen f\u00fcr: {name}",
|
|
||||||
"status.download_started": "\ud83d\udce5 Download: {filename}",
|
|
||||||
"status.download_completed": "Download abgeschlossen: {name}",
|
|
||||||
"status.download_cancelled": "\u26a0\ufe0f Download abgebrochen: {name}",
|
|
||||||
"status.download_failed": "\u274c Download fehlgeschlagen: {name}",
|
|
||||||
"status.download_error": "Downloadfehler: {error}",
|
|
||||||
|
|
||||||
"update.status.checking": "Suche nach Updates",
|
|
||||||
"update.status.ready": "Bereit",
|
|
||||||
"update.status.available": "Update verf\u00fcgbar: v{version}",
|
|
||||||
"update.status.deferred": "Update verschoben",
|
|
||||||
"update.status.downloading": "Lade v{version} herunter",
|
|
||||||
"update.status.verifying": "Pr\u00fcfe Download",
|
|
||||||
"update.status.download_failed": "Download fehlgeschlagen",
|
|
||||||
"update.status.verification_failed": "Pr\u00fcfung fehlgeschlagen",
|
|
||||||
"update.status.timed_out": "Zeitüberschreitung",
|
|
||||||
"update.status.ready_to_install": "Bereit zur Installation",
|
|
||||||
"update.status.installation_started": "Installation gestartet",
|
|
||||||
"update.status.installation_failed": "Installation fehlgeschlagen",
|
|
||||||
"update.status.check_timed_out": "Zeitüberschreitung \u2013 keine Serverantwort",
|
|
||||||
"update.status.check_failed": "Fehler: {error}",
|
|
||||||
"update.status.download_timed_out": "Zeitüberschreitung beim Download",
|
|
||||||
|
|
||||||
"dialog.error.title": "Fehler",
|
|
||||||
"dialog.log_not_found.title": "Protokolldatei nicht gefunden",
|
|
||||||
"dialog.log_not_found.msg": "Keine Protokolldatei gefunden unter:\n{log_file}",
|
|
||||||
"dialog.cache_cleared.title": "Cache geleert",
|
|
||||||
"dialog.cache_cleared.msg": "Browser-Cache und Cookies wurden erfolgreich geleert.\n\nBitte laden Sie die Seite neu oder starten Sie die Anwendung neu, damit die \u00c4nderungen wirksam werden.",
|
|
||||||
"dialog.cache_clear_failed.title": "Fehler",
|
|
||||||
"dialog.cache_clear_failed.msg": "Fehler beim Leeren von Cache und Cookies: {error}",
|
|
||||||
"dialog.drag_error.title": "Drag-and-Drop-Fehler",
|
|
||||||
"dialog.drag_error.msg": "Der Drag-and-Drop-Vorgang konnte nicht abgeschlossen werden.\n\nFehler: {error}",
|
|
||||||
"dialog.open_file_error.title": "Fehler beim \u00d6ffnen",
|
|
||||||
"dialog.open_file_error.msg": "Die Datei konnte nicht mit der Standardanwendung ge\u00f6ffnet werden.\n\nDatei: {file_path}\nFehler: {error}",
|
|
||||||
"dialog.open_with_error.title": "\u00d6ffnen mit \u2013 Fehler",
|
|
||||||
"dialog.open_with_error.msg": "Auf dieser Plattform konnte kein Anwendungsauswahldialog ge\u00f6ffnet werden.",
|
|
||||||
"dialog.dev_tools.window_title": "\ud83d\udd27 Entwicklerwerkzeuge",
|
|
||||||
"dialog.dev_tools.error_title": "Entwicklerwerkzeuge",
|
|
||||||
"dialog.dev_tools.error_msg": "Entwicklerwerkzeuge konnten nicht ge\u00f6ffnet werden:\n{error}",
|
|
||||||
"dialog.domain_changed.title": "Domain ge\u00e4ndert \u2013 Neustart empfohlen",
|
|
||||||
"dialog.domain_changed.msg": "Die Web-Anwendungs-Domain wurde ge\u00e4ndert\n\nSie haben zu einer anderen Domain gewechselt. F\u00fcr maximale Stabilit\u00e4t und korrekte Authentifizierung sollte die Anwendung neu gestartet werden.\n\nProfil und Cache wurden geleert, aber ein Neustart wird empfohlen.",
|
|
||||||
"dialog.domain_changed.restart_now": "Jetzt neu starten",
|
|
||||||
"dialog.domain_changed.restart_later": "Sp\u00e4ter neu starten",
|
|
||||||
"dialog.language_changed.title": "Sprache ge\u00e4ndert",
|
|
||||||
"dialog.language_changed.msg": "Die Spracheinstellung wurde aktualisiert. Starten Sie jetzt neu, um die ausgew\u00e4hlte Sprache \u00fcberall anzuwenden.",
|
|
||||||
"dialog.language_changed.restart_now": "Jetzt neu starten",
|
|
||||||
"dialog.language_changed.restart_later": "Sp\u00e4ter neu starten",
|
|
||||||
"dialog.restart_failed.title": "Neustart fehlgeschlagen",
|
|
||||||
"dialog.restart_failed.msg": "Die Anwendung konnte nicht automatisch neu gestartet werden:\n\n{error}\n\nBitte starten Sie manuell neu.",
|
|
||||||
"dialog.update_timeout.title": "Zeitüberschreitung bei der Update-Pr\u00fcfung",
|
|
||||||
"dialog.update_timeout.msg": "Der Server hat nicht innerhalb von 30 Sekunden geantwortet.\n\nM\u00f6glicherweise liegt ein Netzwerkproblem oder eine Serverunavailability vor.\n\nBitte \u00fcberpr\u00fcfen Sie Ihre Verbindung und versuchen Sie es erneut.",
|
|
||||||
"dialog.update_failed.title": "Update-Pr\u00fcfung fehlgeschlagen",
|
|
||||||
"dialog.update_failed.msg": "Updates konnten nicht gepr\u00fcft werden:\n\n{error}\n\nBitte versuchen Sie es sp\u00e4ter erneut.",
|
|
||||||
"dialog.download_failed.title": "Download fehlgeschlagen",
|
|
||||||
"dialog.download_failed.msg": "Das Update konnte nicht heruntergeladen werden:\n\n{error}\n\nBitte versuchen Sie es sp\u00e4ter erneut.",
|
|
||||||
"dialog.checkout.title": "Asset auschecken",
|
|
||||||
"dialog.checkout.msg": "M\u00f6chten Sie dieses Asset auschecken?\n\n{filename}",
|
|
||||||
|
|
||||||
"about.title": "\u00dcber {app_name}",
|
|
||||||
"about.version": "Version: {version}",
|
|
||||||
"about.description": "Verbindet webbasierte Drag-and-Drop-Workflows mit nativen Dateioperationen f\u00fcr professionelle Desktop-Anwendungen.",
|
|
||||||
"about.drop_zones_title": "Toolbar-Ablagezonen:",
|
|
||||||
"about.open_icon_desc": "\u00d6ffnen-Symbol: \u00d6ffnet abgelegte Dateien mit der Standard-App.",
|
|
||||||
"about.open_with_icon_desc": "\u00d6ffnen-mit-Symbol: Zeigt einen App-Auswahldialog f\u00fcr abgelegte Dateien.",
|
|
||||||
"about.product_of": "Ein Produkt von:",
|
|
||||||
"about.rights": "\u00a9 2026 h\u00f6rl Information Management GmbH. Alle Rechte vorbehalten.",
|
|
||||||
|
|
||||||
"settings.title": "Einstellungen",
|
|
||||||
"settings.tab.web_source": "Web-Quelle",
|
|
||||||
"settings.tab.paths": "Pfade",
|
|
||||||
"settings.tab.urls": "URLs",
|
|
||||||
"settings.tab.logging": "Protokollierung",
|
|
||||||
"settings.tab.window": "Fenster",
|
|
||||||
"settings.tab.profiles": "Profile",
|
|
||||||
"settings.tab.general": "Allgemein",
|
|
||||||
"settings.web_url.label": "Web-Anwendungs-URL:",
|
|
||||||
"settings.web_url.placeholder": "z.B. http://localhost:8080 oder file:///./webapp/index.html",
|
|
||||||
"settings.web_url.open_btn": "\u00d6ffnen",
|
|
||||||
"settings.url_mappings.label": "URL-Zuordnungen (Azure Blob Storage \u2192 Lokale Pfade):",
|
|
||||||
"settings.url_mappings.col_prefix": "URL-Pr\u00e4fix",
|
|
||||||
"settings.url_mappings.col_path": "Lokaler Pfad",
|
|
||||||
"settings.url_mappings.add_btn": "Zuordnung hinzuf\u00fcgen",
|
|
||||||
"settings.url_mappings.edit_btn": "Auswahl bearbeiten",
|
|
||||||
"settings.url_mappings.remove_btn": "Auswahl entfernen",
|
|
||||||
"settings.paths.label": "Erlaubte Stammverzeichnisse f\u00fcr den Dateizugriff:",
|
|
||||||
"settings.paths.add_btn": "Pfad hinzuf\u00fcgen",
|
|
||||||
"settings.paths.remove_btn": "Auswahl entfernen",
|
|
||||||
"settings.urls.label": "Erlaubte Web-URLs (unterst\u00fctzt Platzhalter wie http://*.example.com):",
|
|
||||||
"settings.urls.add_btn": "URL hinzuf\u00fcgen",
|
|
||||||
"settings.urls.remove_btn": "Auswahl entfernen",
|
|
||||||
"settings.log_level.label": "Protokollstufe:",
|
|
||||||
"settings.log_file.label": "Protokolldatei (optional):",
|
|
||||||
"settings.log_file.browse_btn": "Durchsuchen...",
|
|
||||||
"settings.window.width_label": "Fensterbreite:",
|
|
||||||
"settings.window.height_label": "Fensterh\u00f6he:",
|
|
||||||
"settings.profiles.label": "Gespeicherte Konfigurationsprofile:",
|
|
||||||
"settings.profiles.save_btn": "Als Profil speichern",
|
|
||||||
"settings.profiles.load_btn": "Profil laden",
|
|
||||||
"settings.profiles.delete_btn": "Profil l\u00f6schen",
|
|
||||||
"settings.profiles.export_btn": "Konfiguration exportieren",
|
|
||||||
"settings.profiles.import_btn": "Konfiguration importieren",
|
|
||||||
"settings.general.language_label": "Sprache:",
|
|
||||||
"settings.general.language_auto": "Systemstandard (Auto)",
|
|
||||||
"settings.general.language_restart_note": "Sprach\u00e4nderung wirksam nach Neustart.",
|
|
||||||
"settings.add_mapping.url_title": "URL-Zuordnung hinzuf\u00fcgen",
|
|
||||||
"settings.add_mapping.url_prompt": "Azure Blob Storage URL-Pr\u00e4fix eingeben:\n(z.B. https://myblob.blob.core.windows.net/container/)",
|
|
||||||
"settings.add_mapping.path_prompt": "Lokalen Dateisystempfad eingeben:\n(z.B. C:\\Freigabe oder /mnt/share)",
|
|
||||||
"settings.edit_mapping.title": "URL-Zuordnung bearbeiten",
|
|
||||||
"settings.edit_mapping.url_prompt": "Azure Blob Storage URL-Pr\u00e4fix eingeben:",
|
|
||||||
"settings.edit_mapping.path_prompt": "Lokalen Dateisystempfad eingeben:",
|
|
||||||
"settings.add_url.title": "URL hinzuf\u00fcgen",
|
|
||||||
"settings.add_url.prompt": "URL-Muster eingeben (z.B. http://example.com oder http://*.example.com):",
|
|
||||||
"settings.profile.save.title": "Profil speichern",
|
|
||||||
"settings.profile.save.prompt": "Profilnamen eingeben (z.B. Arbeit, Privat):",
|
|
||||||
"settings.select_directory.title": "Verzeichnis ausw\u00e4hlen",
|
|
||||||
"settings.select_log_file.title": "Protokolldatei ausw\u00e4hlen",
|
|
||||||
"settings.export_config.title": "Konfiguration exportieren",
|
|
||||||
"settings.import_config.title": "Konfiguration importieren",
|
|
||||||
"settings.error.select_mapping": "Bitte w\u00e4hlen Sie eine Zuordnung zur Bearbeitung aus",
|
|
||||||
"settings.error.select_profile_load": "Bitte w\u00e4hlen Sie ein Profil zum Laden aus",
|
|
||||||
"settings.error.select_profile_delete": "Bitte w\u00e4hlen Sie ein Profil zum L\u00f6schen aus",
|
|
||||||
|
|
||||||
"update.checking.title": "Update-Pr\u00fcfung",
|
|
||||||
"update.checking.label": "Suche nach Updates...",
|
|
||||||
"update.checking.timeout_info": "Dies kann bis zu 10 Sekunden dauern",
|
|
||||||
"update.available.title": "Update verf\u00fcgbar",
|
|
||||||
"update.available.header": "WebDrop Bridge v{version} ist verf\u00fcgbar",
|
|
||||||
"update.available.changelog_label": "Versionshinweise:",
|
|
||||||
"update.available.update_now_btn": "Jetzt aktualisieren",
|
|
||||||
"update.available.later_btn": "Sp\u00e4ter",
|
|
||||||
"update.downloading.title": "Update wird heruntergeladen",
|
|
||||||
"update.downloading.header": "Update wird heruntergeladen...",
|
|
||||||
"update.downloading.preparing": "Download wird vorbereitet",
|
|
||||||
"update.downloading.filename": "Lade herunter: {filename}",
|
|
||||||
"update.downloading.cancel_btn": "Abbrechen",
|
|
||||||
"update.install.title": "Update installieren",
|
|
||||||
"update.install.header": "Bereit zur Installation",
|
|
||||||
"update.install.message": "Das Update ist zur Installation bereit. Die Anwendung wird neu gestartet.",
|
|
||||||
"update.install.warning": "\u26a0\ufe0f Bitte speichern Sie alle nicht gespeicherten Arbeiten vor dem Fortfahren.\nDie Anwendung wird geschlossen und neu gestartet.",
|
|
||||||
"update.install.now_btn": "Jetzt installieren",
|
|
||||||
"update.install.cancel_btn": "Abbrechen",
|
|
||||||
"update.no_update.title": "Keine Updates verf\u00fcgbar",
|
|
||||||
"update.no_update.message": "\u2713 Sie verwenden die neueste Version",
|
|
||||||
"update.no_update.info": "WebDrop Bridge ist auf dem neuesten Stand.",
|
|
||||||
"update.no_update.ok_btn": "OK",
|
|
||||||
"update.error.title": "Update fehlgeschlagen",
|
|
||||||
"update.error.header": "\u26a0\ufe0f Update fehlgeschlagen",
|
|
||||||
"update.error.info": "Bitte versuchen Sie es erneut oder besuchen Sie die Website, um das Update manuell herunterzuladen.",
|
|
||||||
"update.error.retry_btn": "Wiederholen",
|
|
||||||
"update.error.manual_btn": "Manuell herunterladen",
|
|
||||||
"update.error.cancel_btn": "Abbrechen",
|
|
||||||
|
|
||||||
"worker.server_not_responding": "Server antwortet nicht \u2013 bitte sp\u00e4ter erneut pr\u00fcfen",
|
|
||||||
"worker.no_installer": "Kein Installationspaket in der Version gefunden",
|
|
||||||
"worker.checksum_failed": "Pr\u00fcfsummenverifizierung fehlgeschlagen",
|
|
||||||
"worker.download_timed_out": "Zeitüberschreitung beim Download oder der Verifizierung",
|
|
||||||
"worker.download_error": "Downloadfehler: {error}",
|
|
||||||
"worker.check_failed": "Pr\u00fcfung fehlgeschlagen: {error}"
|
|
||||||
}
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
{
|
|
||||||
"toolbar.tooltip.open_drop": "Drop a file here to open it with its default application",
|
|
||||||
"toolbar.tooltip.open_with_drop": "Drop a file here to choose which app should open it",
|
|
||||||
"toolbar.tooltip.home": "Home",
|
|
||||||
"toolbar.tooltip.about": "About WebDrop Bridge",
|
|
||||||
"toolbar.tooltip.settings": "Settings",
|
|
||||||
"toolbar.tooltip.check_updates": "Check for Updates",
|
|
||||||
"toolbar.tooltip.clear_cache": "Clear Cache and Cookies",
|
|
||||||
"toolbar.tooltip.open_log": "Open Log File",
|
|
||||||
"toolbar.tooltip.dev_tools": "Developer Tools (F12)",
|
|
||||||
|
|
||||||
"status.ready": "Ready",
|
|
||||||
"status.opened": "Opened: {name}",
|
|
||||||
"status.choose_app": "Choose app for: {name}",
|
|
||||||
"status.download_started": "\ud83d\udce5 Download: {filename}",
|
|
||||||
"status.download_completed": "Download completed: {name}",
|
|
||||||
"status.download_cancelled": "\u26a0\ufe0f Download cancelled: {name}",
|
|
||||||
"status.download_failed": "\u274c Download failed: {name}",
|
|
||||||
"status.download_error": "Download error: {error}",
|
|
||||||
|
|
||||||
"update.status.checking": "Checking for updates",
|
|
||||||
"update.status.ready": "Ready",
|
|
||||||
"update.status.available": "Update available: v{version}",
|
|
||||||
"update.status.deferred": "Update deferred",
|
|
||||||
"update.status.downloading": "Downloading v{version}",
|
|
||||||
"update.status.verifying": "Verifying download",
|
|
||||||
"update.status.download_failed": "Download failed",
|
|
||||||
"update.status.verification_failed": "Verification failed",
|
|
||||||
"update.status.timed_out": "Operation timed out",
|
|
||||||
"update.status.ready_to_install": "Ready to install",
|
|
||||||
"update.status.installation_started": "Installation started",
|
|
||||||
"update.status.installation_failed": "Installation failed",
|
|
||||||
"update.status.check_timed_out": "Check timed out - no server response",
|
|
||||||
"update.status.check_failed": "Check failed: {error}",
|
|
||||||
"update.status.download_timed_out": "Download timed out - no server response",
|
|
||||||
|
|
||||||
"dialog.error.title": "Error",
|
|
||||||
"dialog.log_not_found.title": "Log File Not Found",
|
|
||||||
"dialog.log_not_found.msg": "No log file found at:\n{log_file}",
|
|
||||||
"dialog.cache_cleared.title": "Cache Cleared",
|
|
||||||
"dialog.cache_cleared.msg": "Browser cache and cookies have been cleared successfully.\n\nYou may need to reload the page or restart the application for changes to take effect.",
|
|
||||||
"dialog.cache_clear_failed.title": "Error",
|
|
||||||
"dialog.cache_clear_failed.msg": "Failed to clear cache and cookies: {error}",
|
|
||||||
"dialog.drag_error.title": "Drag-and-Drop Error",
|
|
||||||
"dialog.drag_error.msg": "Could not complete the drag-and-drop operation.\n\nError: {error}",
|
|
||||||
"dialog.open_file_error.title": "Open File Error",
|
|
||||||
"dialog.open_file_error.msg": "Could not open the file with its default application.\n\nFile: {file_path}\nError: {error}",
|
|
||||||
"dialog.open_with_error.title": "Open With Error",
|
|
||||||
"dialog.open_with_error.msg": "Could not open an application chooser for this file on your platform.",
|
|
||||||
"dialog.dev_tools.window_title": "\ud83d\udd27 Developer Tools",
|
|
||||||
"dialog.dev_tools.error_title": "Developer Tools",
|
|
||||||
"dialog.dev_tools.error_msg": "Could not open Developer Tools:\n{error}",
|
|
||||||
"dialog.domain_changed.title": "Domain Changed - Restart Recommended",
|
|
||||||
"dialog.domain_changed.msg": "Web Application Domain Has Changed\n\nYou've switched to a different domain. For maximum stability and to ensure proper authentication, the application should be restarted.\n\nThe profile and cache have been cleared, but we recommend restarting.",
|
|
||||||
"dialog.domain_changed.restart_now": "Restart Now",
|
|
||||||
"dialog.domain_changed.restart_later": "Restart Later",
|
|
||||||
"dialog.language_changed.title": "Language Changed",
|
|
||||||
"dialog.language_changed.msg": "The language setting was updated. Restart now to apply the selected language everywhere.",
|
|
||||||
"dialog.language_changed.restart_now": "Restart Now",
|
|
||||||
"dialog.language_changed.restart_later": "Restart Later",
|
|
||||||
"dialog.restart_failed.title": "Restart Failed",
|
|
||||||
"dialog.restart_failed.msg": "Could not automatically restart the application:\n\n{error}\n\nPlease restart manually.",
|
|
||||||
"dialog.update_timeout.title": "Update Check Timeout",
|
|
||||||
"dialog.update_timeout.msg": "The server did not respond within 30 seconds.\n\nThis may be due to a network issue or server unavailability.\n\nPlease check your connection and try again.",
|
|
||||||
"dialog.update_failed.title": "Update Check Failed",
|
|
||||||
"dialog.update_failed.msg": "Could not check for updates:\n\n{error}\n\nPlease try again later.",
|
|
||||||
"dialog.download_failed.title": "Download Failed",
|
|
||||||
"dialog.download_failed.msg": "Could not download the update:\n\n{error}\n\nPlease try again later.",
|
|
||||||
"dialog.checkout.title": "Checkout Asset",
|
|
||||||
"dialog.checkout.msg": "Do you want to check out this asset?\n\n{filename}",
|
|
||||||
|
|
||||||
"about.title": "About {app_name}",
|
|
||||||
"about.version": "Version: {version}",
|
|
||||||
"about.description": "Bridges web-based drag-and-drop workflows with native file operations for professional desktop applications.",
|
|
||||||
"about.drop_zones_title": "Toolbar Drop Zones:",
|
|
||||||
"about.open_icon_desc": "Open icon: Opens dropped files with the system default app.",
|
|
||||||
"about.open_with_icon_desc": "Open-with icon: Shows an app chooser for dropped files.",
|
|
||||||
"about.product_of": "Product of:",
|
|
||||||
"about.rights": "\u00a9 2026 h\u00f6rl Information Management GmbH. All rights reserved.",
|
|
||||||
|
|
||||||
"settings.title": "Settings",
|
|
||||||
"settings.tab.web_source": "Web Source",
|
|
||||||
"settings.tab.paths": "Paths",
|
|
||||||
"settings.tab.urls": "URLs",
|
|
||||||
"settings.tab.logging": "Logging",
|
|
||||||
"settings.tab.window": "Window",
|
|
||||||
"settings.tab.profiles": "Profiles",
|
|
||||||
"settings.tab.general": "General",
|
|
||||||
"settings.web_url.label": "Web Application URL:",
|
|
||||||
"settings.web_url.placeholder": "e.g., http://localhost:8080 or file:///./webapp/index.html",
|
|
||||||
"settings.web_url.open_btn": "Open",
|
|
||||||
"settings.url_mappings.label": "URL Mappings (Azure Blob Storage \u2192 Local Paths):",
|
|
||||||
"settings.url_mappings.col_prefix": "URL Prefix",
|
|
||||||
"settings.url_mappings.col_path": "Local Path",
|
|
||||||
"settings.url_mappings.add_btn": "Add Mapping",
|
|
||||||
"settings.url_mappings.edit_btn": "Edit Selected",
|
|
||||||
"settings.url_mappings.remove_btn": "Remove Selected",
|
|
||||||
"settings.paths.label": "Allowed root directories for file access:",
|
|
||||||
"settings.paths.add_btn": "Add Path",
|
|
||||||
"settings.paths.remove_btn": "Remove Selected",
|
|
||||||
"settings.urls.label": "Allowed web URLs (supports wildcards like http://*.example.com):",
|
|
||||||
"settings.urls.add_btn": "Add URL",
|
|
||||||
"settings.urls.remove_btn": "Remove Selected",
|
|
||||||
"settings.log_level.label": "Log Level:",
|
|
||||||
"settings.log_file.label": "Log File (optional):",
|
|
||||||
"settings.log_file.browse_btn": "Browse...",
|
|
||||||
"settings.window.width_label": "Window Width:",
|
|
||||||
"settings.window.height_label": "Window Height:",
|
|
||||||
"settings.profiles.label": "Saved Configuration Profiles:",
|
|
||||||
"settings.profiles.save_btn": "Save as Profile",
|
|
||||||
"settings.profiles.load_btn": "Load Profile",
|
|
||||||
"settings.profiles.delete_btn": "Delete Profile",
|
|
||||||
"settings.profiles.export_btn": "Export Configuration",
|
|
||||||
"settings.profiles.import_btn": "Import Configuration",
|
|
||||||
"settings.general.language_label": "Language:",
|
|
||||||
"settings.general.language_auto": "System Default (Auto)",
|
|
||||||
"settings.general.language_restart_note": "Language change takes effect after restart.",
|
|
||||||
"settings.add_mapping.url_title": "Add URL Mapping",
|
|
||||||
"settings.add_mapping.url_prompt": "Enter Azure Blob Storage URL prefix:\n(e.g., https://myblob.blob.core.windows.net/container/)",
|
|
||||||
"settings.add_mapping.path_prompt": "Enter local file system path:\n(e.g., C:\\Share or /mnt/share)",
|
|
||||||
"settings.edit_mapping.title": "Edit URL Mapping",
|
|
||||||
"settings.edit_mapping.url_prompt": "Enter Azure Blob Storage URL prefix:",
|
|
||||||
"settings.edit_mapping.path_prompt": "Enter local file system path:",
|
|
||||||
"settings.add_url.title": "Add URL",
|
|
||||||
"settings.add_url.prompt": "Enter URL pattern (e.g., http://example.com or http://*.example.com):",
|
|
||||||
"settings.profile.save.title": "Save Profile",
|
|
||||||
"settings.profile.save.prompt": "Enter profile name (e.g., work, personal):",
|
|
||||||
"settings.select_directory.title": "Select Directory to Allow",
|
|
||||||
"settings.select_log_file.title": "Select Log File",
|
|
||||||
"settings.export_config.title": "Export Configuration",
|
|
||||||
"settings.import_config.title": "Import Configuration",
|
|
||||||
"settings.error.select_mapping": "Please select a mapping to edit",
|
|
||||||
"settings.error.select_profile_load": "Please select a profile to load",
|
|
||||||
"settings.error.select_profile_delete": "Please select a profile to delete",
|
|
||||||
|
|
||||||
"update.checking.title": "Checking for Updates",
|
|
||||||
"update.checking.label": "Checking for updates...",
|
|
||||||
"update.checking.timeout_info": "This may take up to 10 seconds",
|
|
||||||
"update.available.title": "Update Available",
|
|
||||||
"update.available.header": "WebDrop Bridge v{version} is available",
|
|
||||||
"update.available.changelog_label": "Release Notes:",
|
|
||||||
"update.available.update_now_btn": "Update Now",
|
|
||||||
"update.available.later_btn": "Later",
|
|
||||||
"update.downloading.title": "Downloading Update",
|
|
||||||
"update.downloading.header": "Downloading update...",
|
|
||||||
"update.downloading.preparing": "Preparing download",
|
|
||||||
"update.downloading.filename": "Downloading: {filename}",
|
|
||||||
"update.downloading.cancel_btn": "Cancel",
|
|
||||||
"update.install.title": "Install Update",
|
|
||||||
"update.install.header": "Ready to Install",
|
|
||||||
"update.install.message": "The update is ready to install. The application will restart.",
|
|
||||||
"update.install.warning": "\u26a0\ufe0f Please save any unsaved work before continuing.\nThe application will close and restart.",
|
|
||||||
"update.install.now_btn": "Install Now",
|
|
||||||
"update.install.cancel_btn": "Cancel",
|
|
||||||
"update.no_update.title": "No Updates Available",
|
|
||||||
"update.no_update.message": "\u2713 You're using the latest version",
|
|
||||||
"update.no_update.info": "WebDrop Bridge is up to date.",
|
|
||||||
"update.no_update.ok_btn": "OK",
|
|
||||||
"update.error.title": "Update Failed",
|
|
||||||
"update.error.header": "\u26a0\ufe0f Update Failed",
|
|
||||||
"update.error.info": "Please try again or visit the website to download the update manually.",
|
|
||||||
"update.error.retry_btn": "Retry",
|
|
||||||
"update.error.manual_btn": "Download Manually",
|
|
||||||
"update.error.cancel_btn": "Cancel",
|
|
||||||
|
|
||||||
"worker.server_not_responding": "Server not responding - check again later",
|
|
||||||
"worker.no_installer": "No installer found in release",
|
|
||||||
"worker.checksum_failed": "Checksum verification failed",
|
|
||||||
"worker.download_timed_out": "Download or verification timed out (no response from server)",
|
|
||||||
"worker.download_error": "Download error: {error}",
|
|
||||||
"worker.check_failed": "Check failed: {error}"
|
|
||||||
}
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
{
|
|
||||||
"toolbar.tooltip.open_drop": "D\u00e9posez un fichier ici pour l'ouvrir avec son application par d\u00e9faut",
|
|
||||||
"toolbar.tooltip.open_with_drop": "D\u00e9posez un fichier ici pour choisir l'application qui doit l'ouvrir",
|
|
||||||
"toolbar.tooltip.home": "Accueil",
|
|
||||||
"toolbar.tooltip.about": "\u00c0 propos de WebDrop Bridge",
|
|
||||||
"toolbar.tooltip.settings": "Param\u00e8tres",
|
|
||||||
"toolbar.tooltip.check_updates": "Rechercher des mises \u00e0 jour",
|
|
||||||
"toolbar.tooltip.clear_cache": "Vider le cache et les cookies",
|
|
||||||
"toolbar.tooltip.open_log": "Ouvrir le fichier journal",
|
|
||||||
"toolbar.tooltip.dev_tools": "Outils de d\u00e9veloppement (F12)",
|
|
||||||
|
|
||||||
"status.ready": "Pr\u00eat",
|
|
||||||
"status.opened": "Ouvert\u00a0: {name}",
|
|
||||||
"status.choose_app": "Choisir une app pour\u00a0: {name}",
|
|
||||||
"status.download_started": "\ud83d\udce5 T\u00e9l\u00e9chargement\u00a0: {filename}",
|
|
||||||
"status.download_completed": "T\u00e9l\u00e9chargement termin\u00e9\u00a0: {name}",
|
|
||||||
"status.download_cancelled": "\u26a0\ufe0f T\u00e9l\u00e9chargement annul\u00e9\u00a0: {name}",
|
|
||||||
"status.download_failed": "\u274c T\u00e9l\u00e9chargement \u00e9chou\u00e9\u00a0: {name}",
|
|
||||||
"status.download_error": "Erreur de t\u00e9l\u00e9chargement\u00a0: {error}",
|
|
||||||
|
|
||||||
"update.status.checking": "Recherche de mises \u00e0 jour",
|
|
||||||
"update.status.ready": "Pr\u00eat",
|
|
||||||
"update.status.available": "Mise \u00e0 jour disponible\u00a0: v{version}",
|
|
||||||
"update.status.deferred": "Mise \u00e0 jour diff\u00e9r\u00e9e",
|
|
||||||
"update.status.downloading": "T\u00e9l\u00e9chargement de v{version}",
|
|
||||||
"update.status.verifying": "V\u00e9rification du t\u00e9l\u00e9chargement",
|
|
||||||
"update.status.download_failed": "\u00c9chec du t\u00e9l\u00e9chargement",
|
|
||||||
"update.status.verification_failed": "\u00c9chec de la v\u00e9rification",
|
|
||||||
"update.status.timed_out": "D\u00e9lai d'attente d\u00e9pass\u00e9",
|
|
||||||
"update.status.ready_to_install": "Pr\u00eat \u00e0 installer",
|
|
||||||
"update.status.installation_started": "Installation d\u00e9marr\u00e9e",
|
|
||||||
"update.status.installation_failed": "\u00c9chec de l'installation",
|
|
||||||
"update.status.check_timed_out": "D\u00e9lai d\u00e9pass\u00e9 \u2013 aucune r\u00e9ponse du serveur",
|
|
||||||
"update.status.check_failed": "\u00c9chec\u00a0: {error}",
|
|
||||||
"update.status.download_timed_out": "D\u00e9lai d\u00e9pass\u00e9 lors du t\u00e9l\u00e9chargement",
|
|
||||||
|
|
||||||
"dialog.error.title": "Erreur",
|
|
||||||
"dialog.log_not_found.title": "Fichier journal introuvable",
|
|
||||||
"dialog.log_not_found.msg": "Aucun fichier journal trouv\u00e9 \u00e0\u00a0:\n{log_file}",
|
|
||||||
"dialog.cache_cleared.title": "Cache vid\u00e9",
|
|
||||||
"dialog.cache_cleared.msg": "Le cache et les cookies du navigateur ont \u00e9t\u00e9 vid\u00e9s avec succ\u00e8s.\n\nVous devrez peut-\u00eatre recharger la page ou red\u00e9marrer l'application pour que les modifications prennent effet.",
|
|
||||||
"dialog.cache_clear_failed.title": "Erreur",
|
|
||||||
"dialog.cache_clear_failed.msg": "Impossible de vider le cache et les cookies\u00a0: {error}",
|
|
||||||
"dialog.drag_error.title": "Erreur de glisser-d\u00e9poser",
|
|
||||||
"dialog.drag_error.msg": "Impossible de terminer l'op\u00e9ration de glisser-d\u00e9poser.\n\nErreur\u00a0: {error}",
|
|
||||||
"dialog.open_file_error.title": "Erreur d'ouverture",
|
|
||||||
"dialog.open_file_error.msg": "Impossible d'ouvrir le fichier avec son application par d\u00e9faut.\n\nFichier\u00a0: {file_path}\nErreur\u00a0: {error}",
|
|
||||||
"dialog.open_with_error.title": "Erreur Ouvrir avec",
|
|
||||||
"dialog.open_with_error.msg": "Impossible d'ouvrir un s\u00e9lecteur d'application sur cette plate-forme.",
|
|
||||||
"dialog.dev_tools.window_title": "\ud83d\udd27 Outils de d\u00e9veloppement",
|
|
||||||
"dialog.dev_tools.error_title": "Outils de d\u00e9veloppement",
|
|
||||||
"dialog.dev_tools.error_msg": "Impossible d'ouvrir les outils de d\u00e9veloppement\u00a0:\n{error}",
|
|
||||||
"dialog.domain_changed.title": "Domaine modifi\u00e9 \u2013 Red\u00e9marrage recommand\u00e9",
|
|
||||||
"dialog.domain_changed.msg": "Le domaine de l'application web a chang\u00e9\n\nVous avez chang\u00e9 de domaine. Pour une stabilit\u00e9 maximale et une authentification correcte, il est recommand\u00e9 de red\u00e9marrer l'application.\n\nLe profil et le cache ont \u00e9t\u00e9 vid\u00e9s, mais un red\u00e9marrage est recommand\u00e9.",
|
|
||||||
"dialog.domain_changed.restart_now": "Red\u00e9marrer maintenant",
|
|
||||||
"dialog.domain_changed.restart_later": "Red\u00e9marrer plus tard",
|
|
||||||
"dialog.language_changed.title": "Langue modifi\u00e9e",
|
|
||||||
"dialog.language_changed.msg": "Le param\u00e8tre de langue a \u00e9t\u00e9 mis \u00e0 jour. Red\u00e9marrez maintenant pour appliquer la langue s\u00e9lectionn\u00e9e partout.",
|
|
||||||
"dialog.language_changed.restart_now": "Red\u00e9marrer maintenant",
|
|
||||||
"dialog.language_changed.restart_later": "Red\u00e9marrer plus tard",
|
|
||||||
"dialog.restart_failed.title": "\u00c9chec du red\u00e9marrage",
|
|
||||||
"dialog.restart_failed.msg": "Impossible de red\u00e9marrer automatiquement l'application\u00a0:\n\n{error}\n\nVeuillez red\u00e9marrer manuellement.",
|
|
||||||
"dialog.update_timeout.title": "D\u00e9lai de v\u00e9rification des mises \u00e0 jour d\u00e9pass\u00e9",
|
|
||||||
"dialog.update_timeout.msg": "Le serveur n'a pas r\u00e9pondu dans les 30 secondes.\n\nCela peut \u00eatre d\u00fb \u00e0 un probl\u00e8me r\u00e9seau ou \u00e0 une indisponibilit\u00e9 du serveur.\n\nV\u00e9rifiez votre connexion et r\u00e9essayez.",
|
|
||||||
"dialog.update_failed.title": "\u00c9chec de la v\u00e9rification des mises \u00e0 jour",
|
|
||||||
"dialog.update_failed.msg": "Impossible de v\u00e9rifier les mises \u00e0 jour\u00a0:\n\n{error}\n\nVeuillez r\u00e9essayer plus tard.",
|
|
||||||
"dialog.download_failed.title": "\u00c9chec du t\u00e9l\u00e9chargement",
|
|
||||||
"dialog.download_failed.msg": "Impossible de t\u00e9l\u00e9charger la mise \u00e0 jour\u00a0:\n\n{error}\n\nVeuillez r\u00e9essayer plus tard.",
|
|
||||||
"dialog.checkout.title": "Extraire l'actif",
|
|
||||||
"dialog.checkout.msg": "Voulez-vous extraire cet actif\u00a0?\n\n{filename}",
|
|
||||||
|
|
||||||
"about.title": "\u00c0 propos de {app_name}",
|
|
||||||
"about.version": "Version\u00a0: {version}",
|
|
||||||
"about.description": "Connecte les flux de travail de glisser-d\u00e9poser web aux op\u00e9rations de fichiers natives pour les applications de bureau professionnelles.",
|
|
||||||
"about.drop_zones_title": "Zones de d\u00e9p\u00f4t de la barre d'outils\u00a0:",
|
|
||||||
"about.open_icon_desc": "Ic\u00f4ne Ouvrir\u00a0: ouvre les fichiers d\u00e9pos\u00e9s avec l'application par d\u00e9faut.",
|
|
||||||
"about.open_with_icon_desc": "Ic\u00f4ne Ouvrir avec\u00a0: affiche un s\u00e9lecteur d'application pour les fichiers d\u00e9pos\u00e9s.",
|
|
||||||
"about.product_of": "Un produit de\u00a0:",
|
|
||||||
"about.rights": "\u00a9 2026 h\u00f6rl Information Management GmbH. Tous droits r\u00e9serv\u00e9s.",
|
|
||||||
|
|
||||||
"settings.title": "Param\u00e8tres",
|
|
||||||
"settings.tab.web_source": "Source web",
|
|
||||||
"settings.tab.paths": "Chemins",
|
|
||||||
"settings.tab.urls": "URLs",
|
|
||||||
"settings.tab.logging": "Journalisation",
|
|
||||||
"settings.tab.window": "Fen\u00eatre",
|
|
||||||
"settings.tab.profiles": "Profils",
|
|
||||||
"settings.tab.general": "G\u00e9n\u00e9ral",
|
|
||||||
"settings.web_url.label": "URL de l'application web\u00a0:",
|
|
||||||
"settings.web_url.placeholder": "p.ex. http://localhost:8080 ou file:///./webapp/index.html",
|
|
||||||
"settings.web_url.open_btn": "Ouvrir",
|
|
||||||
"settings.url_mappings.label": "Mappages d'URL (Azure Blob Storage \u2192 Chemins locaux)\u00a0:",
|
|
||||||
"settings.url_mappings.col_prefix": "Pr\u00e9fixe URL",
|
|
||||||
"settings.url_mappings.col_path": "Chemin local",
|
|
||||||
"settings.url_mappings.add_btn": "Ajouter un mappage",
|
|
||||||
"settings.url_mappings.edit_btn": "Modifier la s\u00e9lection",
|
|
||||||
"settings.url_mappings.remove_btn": "Supprimer la s\u00e9lection",
|
|
||||||
"settings.paths.label": "R\u00e9pertoires racines autoris\u00e9s pour l'acc\u00e8s aux fichiers\u00a0:",
|
|
||||||
"settings.paths.add_btn": "Ajouter un chemin",
|
|
||||||
"settings.paths.remove_btn": "Supprimer la s\u00e9lection",
|
|
||||||
"settings.urls.label": "URLs web autoris\u00e9es (prise en charge des caract\u00e8res g\u00e9n\u00e9riques comme http://*.example.com)\u00a0:",
|
|
||||||
"settings.urls.add_btn": "Ajouter une URL",
|
|
||||||
"settings.urls.remove_btn": "Supprimer la s\u00e9lection",
|
|
||||||
"settings.log_level.label": "Niveau de journalisation\u00a0:",
|
|
||||||
"settings.log_file.label": "Fichier journal (facultatif)\u00a0:",
|
|
||||||
"settings.log_file.browse_btn": "Parcourir...",
|
|
||||||
"settings.window.width_label": "Largeur de la fen\u00eatre\u00a0:",
|
|
||||||
"settings.window.height_label": "Hauteur de la fen\u00eatre\u00a0:",
|
|
||||||
"settings.profiles.label": "Profils de configuration enregistr\u00e9s\u00a0:",
|
|
||||||
"settings.profiles.save_btn": "Enregistrer comme profil",
|
|
||||||
"settings.profiles.load_btn": "Charger le profil",
|
|
||||||
"settings.profiles.delete_btn": "Supprimer le profil",
|
|
||||||
"settings.profiles.export_btn": "Exporter la configuration",
|
|
||||||
"settings.profiles.import_btn": "Importer la configuration",
|
|
||||||
"settings.general.language_label": "Langue\u00a0:",
|
|
||||||
"settings.general.language_auto": "Par d\u00e9faut du syst\u00e8me (Auto)",
|
|
||||||
"settings.general.language_restart_note": "Le changement de langue prend effet apr\u00e8s red\u00e9marrage.",
|
|
||||||
"settings.add_mapping.url_title": "Ajouter un mappage d'URL",
|
|
||||||
"settings.add_mapping.url_prompt": "Entrez le pr\u00e9fixe URL Azure Blob Storage\u00a0:\n(p.ex. https://myblob.blob.core.windows.net/container/)",
|
|
||||||
"settings.add_mapping.path_prompt": "Entrez le chemin du syst\u00e8me de fichiers local\u00a0:\n(p.ex. C:\\Partage ou /mnt/partage)",
|
|
||||||
"settings.edit_mapping.title": "Modifier le mappage d'URL",
|
|
||||||
"settings.edit_mapping.url_prompt": "Entrez le pr\u00e9fixe URL Azure Blob Storage\u00a0:",
|
|
||||||
"settings.edit_mapping.path_prompt": "Entrez le chemin du syst\u00e8me de fichiers local\u00a0:",
|
|
||||||
"settings.add_url.title": "Ajouter une URL",
|
|
||||||
"settings.add_url.prompt": "Entrez le mod\u00e8le d'URL (p.ex. http://example.com ou http://*.example.com)\u00a0:",
|
|
||||||
"settings.profile.save.title": "Enregistrer le profil",
|
|
||||||
"settings.profile.save.prompt": "Entrez le nom du profil (p.ex. travail, personnel)\u00a0:",
|
|
||||||
"settings.select_directory.title": "S\u00e9lectionner un r\u00e9pertoire autoris\u00e9",
|
|
||||||
"settings.select_log_file.title": "S\u00e9lectionner le fichier journal",
|
|
||||||
"settings.export_config.title": "Exporter la configuration",
|
|
||||||
"settings.import_config.title": "Importer la configuration",
|
|
||||||
"settings.error.select_mapping": "Veuillez s\u00e9lectionner un mappage \u00e0 modifier",
|
|
||||||
"settings.error.select_profile_load": "Veuillez s\u00e9lectionner un profil \u00e0 charger",
|
|
||||||
"settings.error.select_profile_delete": "Veuillez s\u00e9lectionner un profil \u00e0 supprimer",
|
|
||||||
|
|
||||||
"update.checking.title": "V\u00e9rification des mises \u00e0 jour",
|
|
||||||
"update.checking.label": "Recherche de mises \u00e0 jour...",
|
|
||||||
"update.checking.timeout_info": "Cela peut prendre jusqu'\u00e0 10 secondes",
|
|
||||||
"update.available.title": "Mise \u00e0 jour disponible",
|
|
||||||
"update.available.header": "WebDrop Bridge v{version} est disponible",
|
|
||||||
"update.available.changelog_label": "Notes de version\u00a0:",
|
|
||||||
"update.available.update_now_btn": "Mettre \u00e0 jour maintenant",
|
|
||||||
"update.available.later_btn": "Plus tard",
|
|
||||||
"update.downloading.title": "T\u00e9l\u00e9chargement de la mise \u00e0 jour",
|
|
||||||
"update.downloading.header": "T\u00e9l\u00e9chargement en cours...",
|
|
||||||
"update.downloading.preparing": "Pr\u00e9paration du t\u00e9l\u00e9chargement",
|
|
||||||
"update.downloading.filename": "T\u00e9l\u00e9chargement\u00a0: {filename}",
|
|
||||||
"update.downloading.cancel_btn": "Annuler",
|
|
||||||
"update.install.title": "Installer la mise \u00e0 jour",
|
|
||||||
"update.install.header": "Pr\u00eat \u00e0 installer",
|
|
||||||
"update.install.message": "La mise \u00e0 jour est pr\u00eate \u00e0 \u00eatre install\u00e9e. L'application va red\u00e9marrer.",
|
|
||||||
"update.install.warning": "\u26a0\ufe0f Veuillez enregistrer tout travail non sauvegard\u00e9 avant de continuer.\nL'application va se fermer et red\u00e9marrer.",
|
|
||||||
"update.install.now_btn": "Installer maintenant",
|
|
||||||
"update.install.cancel_btn": "Annuler",
|
|
||||||
"update.no_update.title": "Aucune mise \u00e0 jour disponible",
|
|
||||||
"update.no_update.message": "\u2713 Vous utilisez la derni\u00e8re version",
|
|
||||||
"update.no_update.info": "WebDrop Bridge est \u00e0 jour.",
|
|
||||||
"update.no_update.ok_btn": "OK",
|
|
||||||
"update.error.title": "\u00c9chec de la mise \u00e0 jour",
|
|
||||||
"update.error.header": "\u26a0\ufe0f \u00c9chec de la mise \u00e0 jour",
|
|
||||||
"update.error.info": "Veuillez r\u00e9essayer ou visiter le site web pour t\u00e9l\u00e9charger la mise \u00e0 jour manuellement.",
|
|
||||||
"update.error.retry_btn": "R\u00e9essayer",
|
|
||||||
"update.error.manual_btn": "T\u00e9l\u00e9charger manuellement",
|
|
||||||
"update.error.cancel_btn": "Annuler",
|
|
||||||
|
|
||||||
"worker.server_not_responding": "Le serveur ne r\u00e9pond pas \u2013 v\u00e9rifiez plus tard",
|
|
||||||
"worker.no_installer": "Aucun programme d'installation trouv\u00e9 dans la version",
|
|
||||||
"worker.checksum_failed": "\u00c9chec de la v\u00e9rification de la somme de contr\u00f4le",
|
|
||||||
"worker.download_timed_out": "D\u00e9lai d\u00e9pass\u00e9 lors du t\u00e9l\u00e9chargement ou de la v\u00e9rification",
|
|
||||||
"worker.download_error": "Erreur de t\u00e9l\u00e9chargement\u00a0: {error}",
|
|
||||||
"worker.check_failed": "\u00c9chec de la v\u00e9rification\u00a0: {error}"
|
|
||||||
}
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
{
|
|
||||||
"toolbar.tooltip.open_drop": "Trascina qui un file per aprirlo con l'app predefinita",
|
|
||||||
"toolbar.tooltip.open_with_drop": "Trascina qui un file per scegliere con quale app aprirlo",
|
|
||||||
"toolbar.tooltip.home": "Home",
|
|
||||||
"toolbar.tooltip.about": "Informazioni su WebDrop Bridge",
|
|
||||||
"toolbar.tooltip.settings": "Impostazioni",
|
|
||||||
"toolbar.tooltip.check_updates": "Controlla aggiornamenti",
|
|
||||||
"toolbar.tooltip.clear_cache": "Cancella cache e cookie",
|
|
||||||
"toolbar.tooltip.open_log": "Apri file di log",
|
|
||||||
"toolbar.tooltip.dev_tools": "Strumenti sviluppatore (F12)",
|
|
||||||
|
|
||||||
"status.ready": "Pronto",
|
|
||||||
"status.opened": "Aperto: {name}",
|
|
||||||
"status.choose_app": "Scegli app per: {name}",
|
|
||||||
"status.download_started": "📥 Download: {filename}",
|
|
||||||
"status.download_completed": "Download completato: {name}",
|
|
||||||
"status.download_cancelled": "⚠️ Download annullato: {name}",
|
|
||||||
"status.download_failed": "❌ Download non riuscito: {name}",
|
|
||||||
"status.download_error": "Errore download: {error}",
|
|
||||||
|
|
||||||
"update.status.checking": "Controllo aggiornamenti",
|
|
||||||
"update.status.ready": "Pronto",
|
|
||||||
"update.status.available": "Aggiornamento disponibile: v{version}",
|
|
||||||
"update.status.deferred": "Aggiornamento rimandato",
|
|
||||||
"update.status.downloading": "Download v{version}",
|
|
||||||
"update.status.verifying": "Verifica download",
|
|
||||||
"update.status.download_failed": "Download non riuscito",
|
|
||||||
"update.status.verification_failed": "Verifica non riuscita",
|
|
||||||
"update.status.timed_out": "Operazione scaduta",
|
|
||||||
"update.status.ready_to_install": "Pronto per l'installazione",
|
|
||||||
"update.status.installation_started": "Installazione avviata",
|
|
||||||
"update.status.installation_failed": "Installazione non riuscita",
|
|
||||||
"update.status.check_timed_out": "Controllo scaduto - nessuna risposta dal server",
|
|
||||||
"update.status.check_failed": "Controllo non riuscito: {error}",
|
|
||||||
"update.status.download_timed_out": "Download scaduto - nessuna risposta dal server",
|
|
||||||
|
|
||||||
"dialog.error.title": "Errore",
|
|
||||||
"dialog.log_not_found.title": "File di log non trovato",
|
|
||||||
"dialog.log_not_found.msg": "Nessun file di log trovato in:\n{log_file}",
|
|
||||||
"dialog.cache_cleared.title": "Cache cancellata",
|
|
||||||
"dialog.cache_cleared.msg": "Cache del browser e cookie cancellati con successo.\n\nPotrebbe essere necessario ricaricare la pagina o riavviare l'applicazione.",
|
|
||||||
"dialog.cache_clear_failed.title": "Errore",
|
|
||||||
"dialog.cache_clear_failed.msg": "Impossibile cancellare cache e cookie: {error}",
|
|
||||||
"dialog.drag_error.title": "Errore drag-and-drop",
|
|
||||||
"dialog.drag_error.msg": "Impossibile completare l'operazione drag-and-drop.\n\nErrore: {error}",
|
|
||||||
"dialog.open_file_error.title": "Errore apertura file",
|
|
||||||
"dialog.open_file_error.msg": "Impossibile aprire il file con l'applicazione predefinita.\n\nFile: {file_path}\nErrore: {error}",
|
|
||||||
"dialog.open_with_error.title": "Errore Apri con",
|
|
||||||
"dialog.open_with_error.msg": "Impossibile aprire un selettore applicazioni su questa piattaforma.",
|
|
||||||
"dialog.dev_tools.window_title": "🔧 Strumenti sviluppatore",
|
|
||||||
"dialog.dev_tools.error_title": "Strumenti sviluppatore",
|
|
||||||
"dialog.dev_tools.error_msg": "Impossibile aprire gli Strumenti sviluppatore:\n{error}",
|
|
||||||
"dialog.domain_changed.title": "Dominio cambiato - riavvio consigliato",
|
|
||||||
"dialog.domain_changed.msg": "Il dominio dell'app web è cambiato\n\nHai cambiato dominio. Per massima stabilità e corretta autenticazione, è consigliato riavviare l'applicazione.\n\nProfilo e cache sono stati puliti, ma consigliamo il riavvio.",
|
|
||||||
"dialog.domain_changed.restart_now": "Riavvia ora",
|
|
||||||
"dialog.domain_changed.restart_later": "Riavvia più tardi",
|
|
||||||
"dialog.language_changed.title": "Lingua cambiata",
|
|
||||||
"dialog.language_changed.msg": "La lingua è stata aggiornata. Riavvia ora per applicarla ovunque.",
|
|
||||||
"dialog.language_changed.restart_now": "Riavvia ora",
|
|
||||||
"dialog.language_changed.restart_later": "Riavvia più tardi",
|
|
||||||
"dialog.restart_failed.title": "Riavvio non riuscito",
|
|
||||||
"dialog.restart_failed.msg": "Impossibile riavviare automaticamente l'applicazione:\n\n{error}\n\nRiavvia manualmente.",
|
|
||||||
"dialog.update_timeout.title": "Timeout controllo aggiornamenti",
|
|
||||||
"dialog.update_timeout.msg": "Il server non ha risposto entro 30 secondi.\n\nPotrebbe trattarsi di un problema di rete o indisponibilità del server.\n\nControlla la connessione e riprova.",
|
|
||||||
"dialog.update_failed.title": "Controllo aggiornamenti non riuscito",
|
|
||||||
"dialog.update_failed.msg": "Impossibile controllare gli aggiornamenti:\n\n{error}\n\nRiprova più tardi.",
|
|
||||||
"dialog.download_failed.title": "Download non riuscito",
|
|
||||||
"dialog.download_failed.msg": "Impossibile scaricare l'aggiornamento:\n\n{error}\n\nRiprova più tardi.",
|
|
||||||
"dialog.checkout.title": "Checkout asset",
|
|
||||||
"dialog.checkout.msg": "Vuoi eseguire il checkout di questo asset?\n\n{filename}",
|
|
||||||
|
|
||||||
"about.title": "Informazioni su {app_name}",
|
|
||||||
"about.version": "Versione: {version}",
|
|
||||||
"about.description": "Collega i flussi drag-and-drop web alle operazioni file native per applicazioni desktop professionali.",
|
|
||||||
"about.drop_zones_title": "Zone di rilascio barra strumenti:",
|
|
||||||
"about.open_icon_desc": "Icona Apri: apre i file rilasciati con l'app predefinita.",
|
|
||||||
"about.open_with_icon_desc": "Icona Apri con: mostra un selettore app per i file rilasciati.",
|
|
||||||
"about.product_of": "Prodotto di:",
|
|
||||||
"about.rights": "© 2026 hörl Information Management GmbH. Tutti i diritti riservati.",
|
|
||||||
|
|
||||||
"settings.title": "Impostazioni",
|
|
||||||
"settings.tab.web_source": "Sorgente web",
|
|
||||||
"settings.tab.paths": "Percorsi",
|
|
||||||
"settings.tab.urls": "URL",
|
|
||||||
"settings.tab.logging": "Log",
|
|
||||||
"settings.tab.window": "Finestra",
|
|
||||||
"settings.tab.profiles": "Profili",
|
|
||||||
"settings.tab.general": "Generale",
|
|
||||||
"settings.web_url.label": "URL applicazione web:",
|
|
||||||
"settings.web_url.placeholder": "es. http://localhost:8080 o file:///./webapp/index.html",
|
|
||||||
"settings.web_url.open_btn": "Apri",
|
|
||||||
"settings.url_mappings.label": "Mappature URL (Azure Blob Storage → Percorsi locali):",
|
|
||||||
"settings.url_mappings.col_prefix": "Prefisso URL",
|
|
||||||
"settings.url_mappings.col_path": "Percorso locale",
|
|
||||||
"settings.url_mappings.add_btn": "Aggiungi mappatura",
|
|
||||||
"settings.url_mappings.edit_btn": "Modifica selezionato",
|
|
||||||
"settings.url_mappings.remove_btn": "Rimuovi selezionato",
|
|
||||||
"settings.paths.label": "Directory radice consentite per accesso file:",
|
|
||||||
"settings.paths.add_btn": "Aggiungi percorso",
|
|
||||||
"settings.paths.remove_btn": "Rimuovi selezionato",
|
|
||||||
"settings.urls.label": "URL web consentiti (supporta wildcard come http://*.example.com):",
|
|
||||||
"settings.urls.add_btn": "Aggiungi URL",
|
|
||||||
"settings.urls.remove_btn": "Rimuovi selezionato",
|
|
||||||
"settings.log_level.label": "Livello log:",
|
|
||||||
"settings.log_file.label": "File log (opzionale):",
|
|
||||||
"settings.log_file.browse_btn": "Sfoglia...",
|
|
||||||
"settings.window.width_label": "Larghezza finestra:",
|
|
||||||
"settings.window.height_label": "Altezza finestra:",
|
|
||||||
"settings.profiles.label": "Profili configurazione salvati:",
|
|
||||||
"settings.profiles.save_btn": "Salva come profilo",
|
|
||||||
"settings.profiles.load_btn": "Carica profilo",
|
|
||||||
"settings.profiles.delete_btn": "Elimina profilo",
|
|
||||||
"settings.profiles.export_btn": "Esporta configurazione",
|
|
||||||
"settings.profiles.import_btn": "Importa configurazione",
|
|
||||||
"settings.general.language_label": "Lingua:",
|
|
||||||
"settings.general.language_auto": "Predefinita sistema (Auto)",
|
|
||||||
"settings.general.language_restart_note": "La modifica lingua si applica dopo il riavvio.",
|
|
||||||
"settings.add_mapping.url_title": "Aggiungi mappatura URL",
|
|
||||||
"settings.add_mapping.url_prompt": "Inserisci prefisso URL Azure Blob Storage:\n(es. https://myblob.blob.core.windows.net/container/)",
|
|
||||||
"settings.add_mapping.path_prompt": "Inserisci percorso file system locale:\n(es. C:\\Share o /mnt/share)",
|
|
||||||
"settings.edit_mapping.title": "Modifica mappatura URL",
|
|
||||||
"settings.edit_mapping.url_prompt": "Inserisci prefisso URL Azure Blob Storage:",
|
|
||||||
"settings.edit_mapping.path_prompt": "Inserisci percorso file system locale:",
|
|
||||||
"settings.add_url.title": "Aggiungi URL",
|
|
||||||
"settings.add_url.prompt": "Inserisci pattern URL (es. http://example.com o http://*.example.com):",
|
|
||||||
"settings.profile.save.title": "Salva profilo",
|
|
||||||
"settings.profile.save.prompt": "Inserisci nome profilo (es. lavoro, personale):",
|
|
||||||
"settings.select_directory.title": "Seleziona directory da consentire",
|
|
||||||
"settings.select_log_file.title": "Seleziona file di log",
|
|
||||||
"settings.export_config.title": "Esporta configurazione",
|
|
||||||
"settings.import_config.title": "Importa configurazione",
|
|
||||||
"settings.error.select_mapping": "Seleziona una mappatura da modificare",
|
|
||||||
"settings.error.select_profile_load": "Seleziona un profilo da caricare",
|
|
||||||
"settings.error.select_profile_delete": "Seleziona un profilo da eliminare",
|
|
||||||
|
|
||||||
"update.checking.title": "Controllo aggiornamenti",
|
|
||||||
"update.checking.label": "Controllo aggiornamenti...",
|
|
||||||
"update.checking.timeout_info": "Può richiedere fino a 10 secondi",
|
|
||||||
"update.available.title": "Aggiornamento disponibile",
|
|
||||||
"update.available.header": "È disponibile WebDrop Bridge v{version}",
|
|
||||||
"update.available.changelog_label": "Note di rilascio:",
|
|
||||||
"update.available.update_now_btn": "Aggiorna ora",
|
|
||||||
"update.available.later_btn": "Più tardi",
|
|
||||||
"update.downloading.title": "Download aggiornamento",
|
|
||||||
"update.downloading.header": "Download aggiornamento...",
|
|
||||||
"update.downloading.preparing": "Preparazione download",
|
|
||||||
"update.downloading.filename": "Download: {filename}",
|
|
||||||
"update.downloading.cancel_btn": "Annulla",
|
|
||||||
"update.install.title": "Installa aggiornamento",
|
|
||||||
"update.install.header": "Pronto per installare",
|
|
||||||
"update.install.message": "L'aggiornamento è pronto per l'installazione. L'applicazione verrà riavviata.",
|
|
||||||
"update.install.warning": "⚠️ Salva eventuale lavoro non salvato prima di continuare.\nL'applicazione verrà chiusa e riavviata.",
|
|
||||||
"update.install.now_btn": "Installa ora",
|
|
||||||
"update.install.cancel_btn": "Annulla",
|
|
||||||
"update.no_update.title": "Nessun aggiornamento disponibile",
|
|
||||||
"update.no_update.message": "✓ Stai usando l'ultima versione",
|
|
||||||
"update.no_update.info": "WebDrop Bridge è aggiornato.",
|
|
||||||
"update.no_update.ok_btn": "OK",
|
|
||||||
"update.error.title": "Aggiornamento non riuscito",
|
|
||||||
"update.error.header": "⚠️ Aggiornamento non riuscito",
|
|
||||||
"update.error.info": "Riprova o visita il sito per scaricare manualmente l'aggiornamento.",
|
|
||||||
"update.error.retry_btn": "Riprova",
|
|
||||||
"update.error.manual_btn": "Scarica manualmente",
|
|
||||||
"update.error.cancel_btn": "Annulla",
|
|
||||||
|
|
||||||
"worker.server_not_responding": "Il server non risponde - riprova più tardi",
|
|
||||||
"worker.no_installer": "Nessun installer trovato nella release",
|
|
||||||
"worker.checksum_failed": "Verifica checksum non riuscita",
|
|
||||||
"worker.download_timed_out": "Download o verifica scaduti (nessuna risposta dal server)",
|
|
||||||
"worker.download_error": "Errore download: {error}",
|
|
||||||
"worker.check_failed": "Controllo non riuscito: {error}"
|
|
||||||
}
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
{
|
|
||||||
"toolbar.tooltip.open_drop": "Перетащите файл сюда, чтобы открыть его приложением по умолчанию",
|
|
||||||
"toolbar.tooltip.open_with_drop": "Перетащите файл сюда, чтобы выбрать приложение для его открытия",
|
|
||||||
"toolbar.tooltip.home": "Главная",
|
|
||||||
"toolbar.tooltip.about": "О WebDrop Bridge",
|
|
||||||
"toolbar.tooltip.settings": "Настройки",
|
|
||||||
"toolbar.tooltip.check_updates": "Проверить обновления",
|
|
||||||
"toolbar.tooltip.clear_cache": "Очистить кэш и cookie",
|
|
||||||
"toolbar.tooltip.open_log": "Открыть файл журнала",
|
|
||||||
"toolbar.tooltip.dev_tools": "Инструменты разработчика (F12)",
|
|
||||||
|
|
||||||
"status.ready": "Готово",
|
|
||||||
"status.opened": "Открыто: {name}",
|
|
||||||
"status.choose_app": "Выберите приложение для: {name}",
|
|
||||||
"status.download_started": "📥 Загрузка: {filename}",
|
|
||||||
"status.download_completed": "Загрузка завершена: {name}",
|
|
||||||
"status.download_cancelled": "⚠️ Загрузка отменена: {name}",
|
|
||||||
"status.download_failed": "❌ Ошибка загрузки: {name}",
|
|
||||||
"status.download_error": "Ошибка загрузки: {error}",
|
|
||||||
|
|
||||||
"update.status.checking": "Проверка обновлений",
|
|
||||||
"update.status.ready": "Готово",
|
|
||||||
"update.status.available": "Доступно обновление: v{version}",
|
|
||||||
"update.status.deferred": "Обновление отложено",
|
|
||||||
"update.status.downloading": "Загрузка v{version}",
|
|
||||||
"update.status.verifying": "Проверка загрузки",
|
|
||||||
"update.status.download_failed": "Ошибка загрузки",
|
|
||||||
"update.status.verification_failed": "Ошибка проверки",
|
|
||||||
"update.status.timed_out": "Время ожидания истекло",
|
|
||||||
"update.status.ready_to_install": "Готово к установке",
|
|
||||||
"update.status.installation_started": "Установка начата",
|
|
||||||
"update.status.installation_failed": "Ошибка установки",
|
|
||||||
"update.status.check_timed_out": "Проверка прервана по таймауту - нет ответа сервера",
|
|
||||||
"update.status.check_failed": "Ошибка проверки: {error}",
|
|
||||||
"update.status.download_timed_out": "Загрузка прервана по таймауту - нет ответа сервера",
|
|
||||||
|
|
||||||
"dialog.error.title": "Ошибка",
|
|
||||||
"dialog.log_not_found.title": "Файл журнала не найден",
|
|
||||||
"dialog.log_not_found.msg": "Файл журнала не найден по пути:\n{log_file}",
|
|
||||||
"dialog.cache_cleared.title": "Кэш очищен",
|
|
||||||
"dialog.cache_cleared.msg": "Кэш браузера и файлы cookie успешно очищены.\n\nВозможно, потребуется перезагрузить страницу или перезапустить приложение.",
|
|
||||||
"dialog.cache_clear_failed.title": "Ошибка",
|
|
||||||
"dialog.cache_clear_failed.msg": "Не удалось очистить кэш и файлы cookie: {error}",
|
|
||||||
"dialog.drag_error.title": "Ошибка drag-and-drop",
|
|
||||||
"dialog.drag_error.msg": "Не удалось завершить операцию drag-and-drop.\n\nОшибка: {error}",
|
|
||||||
"dialog.open_file_error.title": "Ошибка открытия файла",
|
|
||||||
"dialog.open_file_error.msg": "Не удалось открыть файл приложением по умолчанию.\n\nФайл: {file_path}\nОшибка: {error}",
|
|
||||||
"dialog.open_with_error.title": "Ошибка «Открыть с помощью»",
|
|
||||||
"dialog.open_with_error.msg": "Невозможно открыть выбор приложения на этой платформе.",
|
|
||||||
"dialog.dev_tools.window_title": "🔧 Инструменты разработчика",
|
|
||||||
"dialog.dev_tools.error_title": "Инструменты разработчика",
|
|
||||||
"dialog.dev_tools.error_msg": "Не удалось открыть инструменты разработчика:\n{error}",
|
|
||||||
"dialog.domain_changed.title": "Домен изменен — рекомендуется перезапуск",
|
|
||||||
"dialog.domain_changed.msg": "Домен веб-приложения изменился\n\nВы переключились на другой домен. Для максимальной стабильности и корректной аутентификации рекомендуется перезапустить приложение.\n\nПрофиль и кэш очищены, но перезапуск по-прежнему рекомендуется.",
|
|
||||||
"dialog.domain_changed.restart_now": "Перезапустить сейчас",
|
|
||||||
"dialog.domain_changed.restart_later": "Перезапустить позже",
|
|
||||||
"dialog.language_changed.title": "Язык изменен",
|
|
||||||
"dialog.language_changed.msg": "Настройка языка обновлена. Перезапустите сейчас, чтобы применить язык везде.",
|
|
||||||
"dialog.language_changed.restart_now": "Перезапустить сейчас",
|
|
||||||
"dialog.language_changed.restart_later": "Перезапустить позже",
|
|
||||||
"dialog.restart_failed.title": "Сбой перезапуска",
|
|
||||||
"dialog.restart_failed.msg": "Не удалось автоматически перезапустить приложение:\n\n{error}\n\nПерезапустите вручную.",
|
|
||||||
"dialog.update_timeout.title": "Таймаут проверки обновлений",
|
|
||||||
"dialog.update_timeout.msg": "Сервер не ответил в течение 30 секунд.\n\nВозможна проблема сети или недоступность сервера.\n\nПроверьте соединение и попробуйте снова.",
|
|
||||||
"dialog.update_failed.title": "Ошибка проверки обновлений",
|
|
||||||
"dialog.update_failed.msg": "Не удалось проверить обновления:\n\n{error}\n\nПовторите позже.",
|
|
||||||
"dialog.download_failed.title": "Ошибка загрузки",
|
|
||||||
"dialog.download_failed.msg": "Не удалось скачать обновление:\n\n{error}\n\nПовторите позже.",
|
|
||||||
"dialog.checkout.title": "Выдача ресурса",
|
|
||||||
"dialog.checkout.msg": "Выполнить выдачу этого ресурса?\n\n{filename}",
|
|
||||||
|
|
||||||
"about.title": "О программе {app_name}",
|
|
||||||
"about.version": "Версия: {version}",
|
|
||||||
"about.description": "Связывает веб-сценарии drag-and-drop с нативными файловыми операциями для профессиональных настольных приложений.",
|
|
||||||
"about.drop_zones_title": "Зоны перетаскивания на панели:",
|
|
||||||
"about.open_icon_desc": "Иконка «Открыть»: открывает перетащенные файлы приложением по умолчанию.",
|
|
||||||
"about.open_with_icon_desc": "Иконка «Открыть с помощью»: показывает выбор приложения для перетащенных файлов.",
|
|
||||||
"about.product_of": "Продукт компании:",
|
|
||||||
"about.rights": "© 2026 hörl Information Management GmbH. Все права защищены.",
|
|
||||||
|
|
||||||
"settings.title": "Настройки",
|
|
||||||
"settings.tab.web_source": "Веб-источник",
|
|
||||||
"settings.tab.paths": "Пути",
|
|
||||||
"settings.tab.urls": "URL",
|
|
||||||
"settings.tab.logging": "Логирование",
|
|
||||||
"settings.tab.window": "Окно",
|
|
||||||
"settings.tab.profiles": "Профили",
|
|
||||||
"settings.tab.general": "Общие настройки",
|
|
||||||
"settings.web_url.label": "URL веб-приложения:",
|
|
||||||
"settings.web_url.placeholder": "например, http://localhost:8080 или file:///./webapp/index.html",
|
|
||||||
"settings.web_url.open_btn": "Открыть",
|
|
||||||
"settings.url_mappings.label": "Сопоставления URL (Azure Blob Storage → локальные пути):",
|
|
||||||
"settings.url_mappings.col_prefix": "Префикс URL",
|
|
||||||
"settings.url_mappings.col_path": "Локальный путь",
|
|
||||||
"settings.url_mappings.add_btn": "Добавить сопоставление",
|
|
||||||
"settings.url_mappings.edit_btn": "Изменить выбранное",
|
|
||||||
"settings.url_mappings.remove_btn": "Удалить выбранное",
|
|
||||||
"settings.paths.label": "Разрешенные корневые каталоги для доступа к файлам:",
|
|
||||||
"settings.paths.add_btn": "Добавить путь",
|
|
||||||
"settings.paths.remove_btn": "Удалить выбранное",
|
|
||||||
"settings.urls.label": "Разрешенные веб URL (поддержка масок, напр. http://*.example.com):",
|
|
||||||
"settings.urls.add_btn": "Добавить URL",
|
|
||||||
"settings.urls.remove_btn": "Удалить выбранное",
|
|
||||||
"settings.log_level.label": "Уровень логирования:",
|
|
||||||
"settings.log_file.label": "Файл журнала (необязательно):",
|
|
||||||
"settings.log_file.browse_btn": "Обзор...",
|
|
||||||
"settings.window.width_label": "Ширина окна:",
|
|
||||||
"settings.window.height_label": "Высота окна:",
|
|
||||||
"settings.profiles.label": "Сохраненные профили конфигурации:",
|
|
||||||
"settings.profiles.save_btn": "Сохранить как профиль",
|
|
||||||
"settings.profiles.load_btn": "Загрузить профиль",
|
|
||||||
"settings.profiles.delete_btn": "Удалить профиль",
|
|
||||||
"settings.profiles.export_btn": "Экспорт конфигурации",
|
|
||||||
"settings.profiles.import_btn": "Импорт конфигурации",
|
|
||||||
"settings.general.language_label": "Язык:",
|
|
||||||
"settings.general.language_auto": "Системный язык (авто)",
|
|
||||||
"settings.general.language_restart_note": "Изменение языка вступает в силу после перезапуска.",
|
|
||||||
"settings.add_mapping.url_title": "Добавить сопоставление URL",
|
|
||||||
"settings.add_mapping.url_prompt": "Введите префикс URL Azure Blob Storage:\n(например, https://myblob.blob.core.windows.net/container/)",
|
|
||||||
"settings.add_mapping.path_prompt": "Введите локальный путь файловой системы:\n(например, C:\\Share или /mnt/share)",
|
|
||||||
"settings.edit_mapping.title": "Изменить сопоставление URL",
|
|
||||||
"settings.edit_mapping.url_prompt": "Введите префикс URL Azure Blob Storage:",
|
|
||||||
"settings.edit_mapping.path_prompt": "Введите локальный путь файловой системы:",
|
|
||||||
"settings.add_url.title": "Добавить URL",
|
|
||||||
"settings.add_url.prompt": "Введите шаблон URL (например, http://example.com или http://*.example.com):",
|
|
||||||
"settings.profile.save.title": "Сохранить профиль",
|
|
||||||
"settings.profile.save.prompt": "Введите имя профиля (например, работа, личный):",
|
|
||||||
"settings.select_directory.title": "Выберите разрешенную папку",
|
|
||||||
"settings.select_log_file.title": "Выберите файл журнала",
|
|
||||||
"settings.export_config.title": "Экспорт конфигурации",
|
|
||||||
"settings.import_config.title": "Импорт конфигурации",
|
|
||||||
"settings.error.select_mapping": "Выберите сопоставление для редактирования",
|
|
||||||
"settings.error.select_profile_load": "Выберите профиль для загрузки",
|
|
||||||
"settings.error.select_profile_delete": "Выберите профиль для удаления",
|
|
||||||
|
|
||||||
"update.checking.title": "Проверка обновлений",
|
|
||||||
"update.checking.label": "Проверка обновлений...",
|
|
||||||
"update.checking.timeout_info": "Это может занять до 10 секунд",
|
|
||||||
"update.available.title": "Доступно обновление",
|
|
||||||
"update.available.header": "Доступна версия WebDrop Bridge v{version}",
|
|
||||||
"update.available.changelog_label": "Примечания к релизу:",
|
|
||||||
"update.available.update_now_btn": "Обновить сейчас",
|
|
||||||
"update.available.later_btn": "Позже",
|
|
||||||
"update.downloading.title": "Загрузка обновления",
|
|
||||||
"update.downloading.header": "Загрузка обновления...",
|
|
||||||
"update.downloading.preparing": "Подготовка загрузки",
|
|
||||||
"update.downloading.filename": "Загрузка: {filename}",
|
|
||||||
"update.downloading.cancel_btn": "Отмена",
|
|
||||||
"update.install.title": "Установить обновление",
|
|
||||||
"update.install.header": "Готово к установке",
|
|
||||||
"update.install.message": "Обновление готово к установке. Приложение будет перезапущено.",
|
|
||||||
"update.install.warning": "⚠️ Сохраните несохраненные данные перед продолжением.\nПриложение будет закрыто и перезапущено.",
|
|
||||||
"update.install.now_btn": "Установить сейчас",
|
|
||||||
"update.install.cancel_btn": "Отмена",
|
|
||||||
"update.no_update.title": "Обновлений нет",
|
|
||||||
"update.no_update.message": "✓ У вас установлена последняя версия",
|
|
||||||
"update.no_update.info": "WebDrop Bridge уже обновлен.",
|
|
||||||
"update.no_update.ok_btn": "OK",
|
|
||||||
"update.error.title": "Ошибка обновления",
|
|
||||||
"update.error.header": "⚠️ Ошибка обновления",
|
|
||||||
"update.error.info": "Повторите попытку или загрузите обновление вручную с сайта.",
|
|
||||||
"update.error.retry_btn": "Повторить",
|
|
||||||
"update.error.manual_btn": "Скачать вручную",
|
|
||||||
"update.error.cancel_btn": "Отмена",
|
|
||||||
|
|
||||||
"worker.server_not_responding": "Сервер не отвечает — попробуйте позже",
|
|
||||||
"worker.no_installer": "В релизе не найден установщик",
|
|
||||||
"worker.checksum_failed": "Проверка контрольной суммы не пройдена",
|
|
||||||
"worker.download_timed_out": "Таймаут загрузки или проверки (нет ответа сервера)",
|
|
||||||
"worker.download_error": "Ошибка загрузки: {error}",
|
|
||||||
"worker.check_failed": "Ошибка проверки: {error}"
|
|
||||||
}
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
{
|
|
||||||
"toolbar.tooltip.open_drop": "将文件拖到此处以使用默认应用打开",
|
|
||||||
"toolbar.tooltip.open_with_drop": "将文件拖到此处以选择用于打开的应用",
|
|
||||||
"toolbar.tooltip.home": "主页",
|
|
||||||
"toolbar.tooltip.about": "关于 WebDrop Bridge",
|
|
||||||
"toolbar.tooltip.settings": "设置",
|
|
||||||
"toolbar.tooltip.check_updates": "检查更新",
|
|
||||||
"toolbar.tooltip.clear_cache": "清除缓存和 Cookie",
|
|
||||||
"toolbar.tooltip.open_log": "打开日志文件",
|
|
||||||
"toolbar.tooltip.dev_tools": "开发者工具 (F12)",
|
|
||||||
|
|
||||||
"status.ready": "就绪",
|
|
||||||
"status.opened": "已打开: {name}",
|
|
||||||
"status.choose_app": "为此文件选择应用: {name}",
|
|
||||||
"status.download_started": "📥 下载: {filename}",
|
|
||||||
"status.download_completed": "下载完成: {name}",
|
|
||||||
"status.download_cancelled": "⚠️ 下载已取消: {name}",
|
|
||||||
"status.download_failed": "❌ 下载失败: {name}",
|
|
||||||
"status.download_error": "下载错误: {error}",
|
|
||||||
|
|
||||||
"update.status.checking": "正在检查更新",
|
|
||||||
"update.status.ready": "就绪",
|
|
||||||
"update.status.available": "有可用更新: v{version}",
|
|
||||||
"update.status.deferred": "更新已延后",
|
|
||||||
"update.status.downloading": "正在下载 v{version}",
|
|
||||||
"update.status.verifying": "正在验证下载",
|
|
||||||
"update.status.download_failed": "下载失败",
|
|
||||||
"update.status.verification_failed": "验证失败",
|
|
||||||
"update.status.timed_out": "操作超时",
|
|
||||||
"update.status.ready_to_install": "准备安装",
|
|
||||||
"update.status.installation_started": "已开始安装",
|
|
||||||
"update.status.installation_failed": "安装失败",
|
|
||||||
"update.status.check_timed_out": "检查超时 - 服务器无响应",
|
|
||||||
"update.status.check_failed": "检查失败: {error}",
|
|
||||||
"update.status.download_timed_out": "下载超时 - 服务器无响应",
|
|
||||||
|
|
||||||
"dialog.error.title": "错误",
|
|
||||||
"dialog.log_not_found.title": "未找到日志文件",
|
|
||||||
"dialog.log_not_found.msg": "在以下位置未找到日志文件:\n{log_file}",
|
|
||||||
"dialog.cache_cleared.title": "缓存已清除",
|
|
||||||
"dialog.cache_cleared.msg": "浏览器缓存和 Cookie 已成功清除。\n\n你可能需要刷新页面或重启应用以使更改生效。",
|
|
||||||
"dialog.cache_clear_failed.title": "错误",
|
|
||||||
"dialog.cache_clear_failed.msg": "清除缓存和 Cookie 失败: {error}",
|
|
||||||
"dialog.drag_error.title": "拖放错误",
|
|
||||||
"dialog.drag_error.msg": "无法完成拖放操作。\n\n错误: {error}",
|
|
||||||
"dialog.open_file_error.title": "打开文件错误",
|
|
||||||
"dialog.open_file_error.msg": "无法使用默认应用打开该文件。\n\n文件: {file_path}\n错误: {error}",
|
|
||||||
"dialog.open_with_error.title": "“打开方式”错误",
|
|
||||||
"dialog.open_with_error.msg": "当前平台无法打开应用选择器。",
|
|
||||||
"dialog.dev_tools.window_title": "🔧 开发者工具",
|
|
||||||
"dialog.dev_tools.error_title": "开发者工具",
|
|
||||||
"dialog.dev_tools.error_msg": "无法打开开发者工具:\n{error}",
|
|
||||||
"dialog.domain_changed.title": "域名已变更 - 建议重启",
|
|
||||||
"dialog.domain_changed.msg": "Web 应用域名已变更\n\n你已切换到其他域名。为保证稳定性与认证正确性,建议重启应用。\n\n配置与缓存已清理,但仍建议重启。",
|
|
||||||
"dialog.domain_changed.restart_now": "立即重启",
|
|
||||||
"dialog.domain_changed.restart_later": "稍后重启",
|
|
||||||
"dialog.language_changed.title": "语言已更改",
|
|
||||||
"dialog.language_changed.msg": "语言设置已更新。立即重启可在所有界面生效。",
|
|
||||||
"dialog.language_changed.restart_now": "立即重启",
|
|
||||||
"dialog.language_changed.restart_later": "稍后重启",
|
|
||||||
"dialog.restart_failed.title": "重启失败",
|
|
||||||
"dialog.restart_failed.msg": "无法自动重启应用:\n\n{error}\n\n请手动重启。",
|
|
||||||
"dialog.update_timeout.title": "更新检查超时",
|
|
||||||
"dialog.update_timeout.msg": "服务器在 30 秒内未响应。\n\n可能是网络问题或服务器不可用。\n\n请检查连接后重试。",
|
|
||||||
"dialog.update_failed.title": "更新检查失败",
|
|
||||||
"dialog.update_failed.msg": "无法检查更新:\n\n{error}\n\n请稍后重试。",
|
|
||||||
"dialog.download_failed.title": "下载失败",
|
|
||||||
"dialog.download_failed.msg": "无法下载更新:\n\n{error}\n\n请稍后重试。",
|
|
||||||
"dialog.checkout.title": "签出资产",
|
|
||||||
"dialog.checkout.msg": "是否签出该资产?\n\n{filename}",
|
|
||||||
|
|
||||||
"about.title": "关于 {app_name}",
|
|
||||||
"about.version": "版本: {version}",
|
|
||||||
"about.description": "将基于 Web 的拖放流程与桌面原生文件操作无缝衔接。",
|
|
||||||
"about.drop_zones_title": "工具栏拖放区域:",
|
|
||||||
"about.open_icon_desc": "打开图标: 使用系统默认应用打开拖入文件。",
|
|
||||||
"about.open_with_icon_desc": "打开方式图标: 为拖入文件显示应用选择器。",
|
|
||||||
"about.product_of": "产品提供方:",
|
|
||||||
"about.rights": "© 2026 hörl Information Management GmbH. 保留所有权利。",
|
|
||||||
|
|
||||||
"settings.title": "设置",
|
|
||||||
"settings.tab.web_source": "Web 来源",
|
|
||||||
"settings.tab.paths": "路径",
|
|
||||||
"settings.tab.urls": "URL",
|
|
||||||
"settings.tab.logging": "日志",
|
|
||||||
"settings.tab.window": "窗口",
|
|
||||||
"settings.tab.profiles": "配置档案",
|
|
||||||
"settings.tab.general": "通用",
|
|
||||||
"settings.web_url.label": "Web 应用 URL:",
|
|
||||||
"settings.web_url.placeholder": "例如: http://localhost:8080 或 file:///./webapp/index.html",
|
|
||||||
"settings.web_url.open_btn": "打开",
|
|
||||||
"settings.url_mappings.label": "URL 映射(Azure Blob Storage → 本地路径):",
|
|
||||||
"settings.url_mappings.col_prefix": "URL 前缀",
|
|
||||||
"settings.url_mappings.col_path": "本地路径",
|
|
||||||
"settings.url_mappings.add_btn": "添加映射",
|
|
||||||
"settings.url_mappings.edit_btn": "编辑所选",
|
|
||||||
"settings.url_mappings.remove_btn": "删除所选",
|
|
||||||
"settings.paths.label": "允许访问文件的根目录:",
|
|
||||||
"settings.paths.add_btn": "添加路径",
|
|
||||||
"settings.paths.remove_btn": "删除所选",
|
|
||||||
"settings.urls.label": "允许的 Web URL(支持通配符,例如 http://*.example.com):",
|
|
||||||
"settings.urls.add_btn": "添加 URL",
|
|
||||||
"settings.urls.remove_btn": "删除所选",
|
|
||||||
"settings.log_level.label": "日志级别:",
|
|
||||||
"settings.log_file.label": "日志文件(可选):",
|
|
||||||
"settings.log_file.browse_btn": "浏览...",
|
|
||||||
"settings.window.width_label": "窗口宽度:",
|
|
||||||
"settings.window.height_label": "窗口高度:",
|
|
||||||
"settings.profiles.label": "已保存配置档案:",
|
|
||||||
"settings.profiles.save_btn": "保存为档案",
|
|
||||||
"settings.profiles.load_btn": "加载档案",
|
|
||||||
"settings.profiles.delete_btn": "删除档案",
|
|
||||||
"settings.profiles.export_btn": "导出配置",
|
|
||||||
"settings.profiles.import_btn": "导入配置",
|
|
||||||
"settings.general.language_label": "语言:",
|
|
||||||
"settings.general.language_auto": "跟随系统(自动)",
|
|
||||||
"settings.general.language_restart_note": "语言更改将在重启后生效。",
|
|
||||||
"settings.add_mapping.url_title": "添加 URL 映射",
|
|
||||||
"settings.add_mapping.url_prompt": "输入 Azure Blob Storage URL 前缀:\n(例如: https://myblob.blob.core.windows.net/container/)",
|
|
||||||
"settings.add_mapping.path_prompt": "输入本地文件系统路径:\n(例如: C:\\Share 或 /mnt/share)",
|
|
||||||
"settings.edit_mapping.title": "编辑 URL 映射",
|
|
||||||
"settings.edit_mapping.url_prompt": "输入 Azure Blob Storage URL 前缀:",
|
|
||||||
"settings.edit_mapping.path_prompt": "输入本地文件系统路径:",
|
|
||||||
"settings.add_url.title": "添加 URL",
|
|
||||||
"settings.add_url.prompt": "输入 URL 模式(例如: http://example.com 或 http://*.example.com):",
|
|
||||||
"settings.profile.save.title": "保存档案",
|
|
||||||
"settings.profile.save.prompt": "输入配置档案名称(例如: 工作, 个人):",
|
|
||||||
"settings.select_directory.title": "选择允许目录",
|
|
||||||
"settings.select_log_file.title": "选择日志文件",
|
|
||||||
"settings.export_config.title": "导出配置",
|
|
||||||
"settings.import_config.title": "导入配置",
|
|
||||||
"settings.error.select_mapping": "请选择要编辑的映射",
|
|
||||||
"settings.error.select_profile_load": "请选择要加载的档案",
|
|
||||||
"settings.error.select_profile_delete": "请选择要删除的档案",
|
|
||||||
|
|
||||||
"update.checking.title": "检查更新",
|
|
||||||
"update.checking.label": "正在检查更新...",
|
|
||||||
"update.checking.timeout_info": "这可能需要最多 10 秒",
|
|
||||||
"update.available.title": "有可用更新",
|
|
||||||
"update.available.header": "检测到可用版本:WebDrop Bridge v{version}",
|
|
||||||
"update.available.changelog_label": "更新说明:",
|
|
||||||
"update.available.update_now_btn": "立即更新",
|
|
||||||
"update.available.later_btn": "稍后",
|
|
||||||
"update.downloading.title": "正在下载更新",
|
|
||||||
"update.downloading.header": "正在下载更新...",
|
|
||||||
"update.downloading.preparing": "准备下载",
|
|
||||||
"update.downloading.filename": "正在下载: {filename}",
|
|
||||||
"update.downloading.cancel_btn": "取消",
|
|
||||||
"update.install.title": "安装更新",
|
|
||||||
"update.install.header": "准备安装",
|
|
||||||
"update.install.message": "更新已准备好安装。应用将重启。",
|
|
||||||
"update.install.warning": "⚠️ 继续前请保存未保存的工作。\n应用将关闭并重启。",
|
|
||||||
"update.install.now_btn": "立即安装",
|
|
||||||
"update.install.cancel_btn": "取消",
|
|
||||||
"update.no_update.title": "无可用更新",
|
|
||||||
"update.no_update.message": "✓ 你正在使用最新版本",
|
|
||||||
"update.no_update.info": "WebDrop Bridge 已为最新版本。",
|
|
||||||
"update.no_update.ok_btn": "确定",
|
|
||||||
"update.error.title": "更新失败",
|
|
||||||
"update.error.header": "⚠️ 更新失败",
|
|
||||||
"update.error.info": "请重试,或前往网站手动下载更新包。",
|
|
||||||
"update.error.retry_btn": "重试",
|
|
||||||
"update.error.manual_btn": "手动下载",
|
|
||||||
"update.error.cancel_btn": "取消",
|
|
||||||
|
|
||||||
"worker.server_not_responding": "服务器无响应,请稍后再试",
|
|
||||||
"worker.no_installer": "发布包中未找到安装程序",
|
|
||||||
"worker.checksum_failed": "校验和验证失败",
|
|
||||||
"worker.download_timed_out": "下载或验证超时(服务器无响应)",
|
|
||||||
"worker.download_error": "下载错误: {error}",
|
|
||||||
"worker.check_failed": "检查失败: {error}"
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""WebDrop Bridge - Qt-based desktop application for intelligent drag-and-drop file handling."""
|
"""WebDrop Bridge - Qt-based desktop application for intelligent drag-and-drop file handling."""
|
||||||
|
|
||||||
__version__ = "0.8.6"
|
__version__ = "0.6.0"
|
||||||
__author__ = "WebDrop Team"
|
__author__ = "WebDrop Team"
|
||||||
__license__ = "MIT"
|
__license__ = "MIT"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
@ -12,13 +11,6 @@ from dotenv import load_dotenv
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_BRAND_ID = "webdrop_bridge"
|
|
||||||
DEFAULT_CONFIG_DIR_NAME = "webdrop_bridge"
|
|
||||||
DEFAULT_UPDATE_BASE_URL = "https://git.him-tools.de"
|
|
||||||
DEFAULT_UPDATE_REPO = "HIM-public/webdrop-bridge"
|
|
||||||
DEFAULT_UPDATE_CHANNEL = "stable"
|
|
||||||
DEFAULT_UPDATE_MANIFEST_NAME = "release-manifest.json"
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigurationError(Exception):
|
class ConfigurationError(Exception):
|
||||||
"""Raised when configuration is invalid."""
|
"""Raised when configuration is invalid."""
|
||||||
|
|
@ -66,14 +58,6 @@ class Config:
|
||||||
window_height: Initial window height in pixels
|
window_height: Initial window height in pixels
|
||||||
window_title: Main window title (default: "{app_name} v{app_version}")
|
window_title: Main window title (default: "{app_name} v{app_version}")
|
||||||
enable_logging: Whether to write logs to file
|
enable_logging: Whether to write logs to file
|
||||||
enable_checkout: Whether to check asset checkout status and show checkout dialog
|
|
||||||
on drag. Disabled by default as checkout support is optional.
|
|
||||||
brand_id: Stable brand identifier used for packaging and update selection
|
|
||||||
config_dir_name: AppData/config directory name for this branded variant
|
|
||||||
update_base_url: Base Forgejo URL used for release checks
|
|
||||||
update_repo: Forgejo repository containing shared releases
|
|
||||||
update_channel: Update channel name used by release manifest selection
|
|
||||||
update_manifest_name: Asset name of the shared release manifest
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ConfigurationError: If configuration values are invalid
|
ConfigurationError: If configuration values are invalid
|
||||||
|
|
@ -94,14 +78,6 @@ class Config:
|
||||||
window_height: int = 768
|
window_height: int = 768
|
||||||
window_title: str = ""
|
window_title: str = ""
|
||||||
enable_logging: bool = True
|
enable_logging: bool = True
|
||||||
enable_checkout: bool = False
|
|
||||||
language: str = "auto"
|
|
||||||
brand_id: str = DEFAULT_BRAND_ID
|
|
||||||
config_dir_name: str = DEFAULT_CONFIG_DIR_NAME
|
|
||||||
update_base_url: str = DEFAULT_UPDATE_BASE_URL
|
|
||||||
update_repo: str = DEFAULT_UPDATE_REPO
|
|
||||||
update_channel: str = DEFAULT_UPDATE_CHANNEL
|
|
||||||
update_manifest_name: str = DEFAULT_UPDATE_MANIFEST_NAME
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_file(cls, config_path: Path) -> "Config":
|
def from_file(cls, config_path: Path) -> "Config":
|
||||||
|
|
@ -130,7 +106,10 @@ class Config:
|
||||||
|
|
||||||
# Parse URL mappings
|
# Parse URL mappings
|
||||||
mappings = [
|
mappings = [
|
||||||
URLMapping(url_prefix=m["url_prefix"], local_path=m["local_path"])
|
URLMapping(
|
||||||
|
url_prefix=m["url_prefix"],
|
||||||
|
local_path=m["local_path"]
|
||||||
|
)
|
||||||
for m in data.get("url_mappings", [])
|
for m in data.get("url_mappings", [])
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -144,9 +123,6 @@ class Config:
|
||||||
elif not root.is_dir():
|
elif not root.is_dir():
|
||||||
raise ConfigurationError(f"Allowed root is not a directory: {root}")
|
raise ConfigurationError(f"Allowed root is not a directory: {root}")
|
||||||
|
|
||||||
brand_id = data.get("brand_id", DEFAULT_BRAND_ID)
|
|
||||||
config_dir_name = data.get("config_dir_name", cls._slugify_config_dir_name(brand_id))
|
|
||||||
|
|
||||||
# Get log file path
|
# Get log file path
|
||||||
log_file = None
|
log_file = None
|
||||||
if data.get("enable_logging", True):
|
if data.get("enable_logging", True):
|
||||||
|
|
@ -155,10 +131,10 @@ class Config:
|
||||||
log_file = Path(log_file_str)
|
log_file = Path(log_file_str)
|
||||||
# If relative path, resolve relative to app data directory instead of cwd
|
# If relative path, resolve relative to app data directory instead of cwd
|
||||||
if not log_file.is_absolute():
|
if not log_file.is_absolute():
|
||||||
log_file = Config.get_default_log_dir(config_dir_name) / log_file
|
log_file = Config.get_default_log_dir() / log_file
|
||||||
else:
|
else:
|
||||||
# Use default log path in app data
|
# Use default log path in app data
|
||||||
log_file = Config.get_default_log_path(config_dir_name)
|
log_file = Config.get_default_log_path()
|
||||||
|
|
||||||
app_name = data.get("app_name", "WebDrop Bridge")
|
app_name = data.get("app_name", "WebDrop Bridge")
|
||||||
stored_window_title = data.get("window_title", "")
|
stored_window_title = data.get("window_title", "")
|
||||||
|
|
@ -167,7 +143,6 @@ class Config:
|
||||||
# If the stored title matches the pattern "{app_name} v{version}", regenerate it
|
# If the stored title matches the pattern "{app_name} v{version}", regenerate it
|
||||||
# with the current version. This ensures the title updates automatically on upgrades.
|
# with the current version. This ensures the title updates automatically on upgrades.
|
||||||
import re
|
import re
|
||||||
|
|
||||||
version_pattern = re.compile(rf"^{re.escape(app_name)}\s+v[\d.]+$")
|
version_pattern = re.compile(rf"^{re.escape(app_name)}\s+v[\d.]+$")
|
||||||
if stored_window_title and version_pattern.match(stored_window_title):
|
if stored_window_title and version_pattern.match(stored_window_title):
|
||||||
# Detected a default-pattern title with old version, regenerate
|
# Detected a default-pattern title with old version, regenerate
|
||||||
|
|
@ -195,14 +170,6 @@ class Config:
|
||||||
window_height=data.get("window_height", 768),
|
window_height=data.get("window_height", 768),
|
||||||
window_title=window_title,
|
window_title=window_title,
|
||||||
enable_logging=data.get("enable_logging", True),
|
enable_logging=data.get("enable_logging", True),
|
||||||
enable_checkout=data.get("enable_checkout", False),
|
|
||||||
language=data.get("language", "auto"),
|
|
||||||
brand_id=brand_id,
|
|
||||||
config_dir_name=config_dir_name,
|
|
||||||
update_base_url=data.get("update_base_url", DEFAULT_UPDATE_BASE_URL),
|
|
||||||
update_repo=data.get("update_repo", DEFAULT_UPDATE_REPO),
|
|
||||||
update_channel=data.get("update_channel", DEFAULT_UPDATE_CHANNEL),
|
|
||||||
update_manifest_name=data.get("update_manifest_name", DEFAULT_UPDATE_MANIFEST_NAME),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -228,10 +195,7 @@ class Config:
|
||||||
app_name = os.getenv("APP_NAME", "WebDrop Bridge")
|
app_name = os.getenv("APP_NAME", "WebDrop Bridge")
|
||||||
# Version always comes from __init__.py for consistency
|
# Version always comes from __init__.py for consistency
|
||||||
from webdrop_bridge import __version__
|
from webdrop_bridge import __version__
|
||||||
|
|
||||||
app_version = __version__
|
app_version = __version__
|
||||||
brand_id = os.getenv("BRAND_ID", DEFAULT_BRAND_ID)
|
|
||||||
config_dir_name = os.getenv("APP_CONFIG_DIR_NAME", cls._slugify_config_dir_name(brand_id))
|
|
||||||
|
|
||||||
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
log_file_str = os.getenv("LOG_FILE", None)
|
log_file_str = os.getenv("LOG_FILE", None)
|
||||||
|
|
@ -244,18 +208,13 @@ class Config:
|
||||||
default_title = f"{app_name} v{app_version}"
|
default_title = f"{app_name} v{app_version}"
|
||||||
window_title = os.getenv("WINDOW_TITLE", default_title)
|
window_title = os.getenv("WINDOW_TITLE", default_title)
|
||||||
enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true"
|
enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true"
|
||||||
enable_checkout = os.getenv("ENABLE_CHECKOUT", "false").lower() == "true"
|
|
||||||
language = os.getenv("LANGUAGE", "auto")
|
|
||||||
update_base_url = os.getenv("UPDATE_BASE_URL", DEFAULT_UPDATE_BASE_URL)
|
|
||||||
update_repo = os.getenv("UPDATE_REPO", DEFAULT_UPDATE_REPO)
|
|
||||||
update_channel = os.getenv("UPDATE_CHANNEL", DEFAULT_UPDATE_CHANNEL)
|
|
||||||
update_manifest_name = os.getenv("UPDATE_MANIFEST_NAME", DEFAULT_UPDATE_MANIFEST_NAME)
|
|
||||||
|
|
||||||
# Validate log level
|
# Validate log level
|
||||||
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
||||||
if log_level not in valid_levels:
|
if log_level not in valid_levels:
|
||||||
raise ConfigurationError(
|
raise ConfigurationError(
|
||||||
f"Invalid LOG_LEVEL: {log_level}. " f"Must be one of: {', '.join(valid_levels)}"
|
f"Invalid LOG_LEVEL: {log_level}. "
|
||||||
|
f"Must be one of: {', '.join(valid_levels)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate and parse allowed roots
|
# Validate and parse allowed roots
|
||||||
|
|
@ -266,7 +225,9 @@ class Config:
|
||||||
if not root_path.exists():
|
if not root_path.exists():
|
||||||
logger.warning(f"Allowed root does not exist: {p.strip()}")
|
logger.warning(f"Allowed root does not exist: {p.strip()}")
|
||||||
elif not root_path.is_dir():
|
elif not root_path.is_dir():
|
||||||
raise ConfigurationError(f"Allowed root '{p.strip()}' is not a directory")
|
raise ConfigurationError(
|
||||||
|
f"Allowed root '{p.strip()}' is not a directory"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
allowed_roots.append(root_path)
|
allowed_roots.append(root_path)
|
||||||
except ConfigurationError:
|
except ConfigurationError:
|
||||||
|
|
@ -279,7 +240,8 @@ class Config:
|
||||||
# Validate window dimensions
|
# Validate window dimensions
|
||||||
if window_width <= 0 or window_height <= 0:
|
if window_width <= 0 or window_height <= 0:
|
||||||
raise ConfigurationError(
|
raise ConfigurationError(
|
||||||
f"Window dimensions must be positive: " f"{window_width}x{window_height}"
|
f"Window dimensions must be positive: "
|
||||||
|
f"{window_width}x{window_height}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create log file path if logging enabled
|
# Create log file path if logging enabled
|
||||||
|
|
@ -289,21 +251,20 @@ class Config:
|
||||||
log_file = Path(log_file_str)
|
log_file = Path(log_file_str)
|
||||||
# If relative path, resolve relative to app data directory instead of cwd
|
# If relative path, resolve relative to app data directory instead of cwd
|
||||||
if not log_file.is_absolute():
|
if not log_file.is_absolute():
|
||||||
log_file = Config.get_default_log_dir(config_dir_name) / log_file
|
log_file = Config.get_default_log_dir() / log_file
|
||||||
else:
|
else:
|
||||||
# Use default log path in app data
|
# Use default log path in app data
|
||||||
log_file = Config.get_default_log_path(config_dir_name)
|
log_file = Config.get_default_log_path()
|
||||||
|
|
||||||
# Validate webapp URL is not empty
|
# Validate webapp URL is not empty
|
||||||
if not webapp_url:
|
if not webapp_url:
|
||||||
raise ConfigurationError("WEBAPP_URL cannot be empty")
|
raise ConfigurationError("WEBAPP_URL cannot be empty")
|
||||||
|
|
||||||
# Parse allowed URLs (empty string = no restriction)
|
# Parse allowed URLs (empty string = no restriction)
|
||||||
allowed_urls = (
|
allowed_urls = [
|
||||||
[url.strip() for url in allowed_urls_str.split(",") if url.strip()]
|
url.strip() for url in allowed_urls_str.split(",")
|
||||||
if allowed_urls_str
|
if url.strip()
|
||||||
else []
|
] if allowed_urls_str else []
|
||||||
)
|
|
||||||
|
|
||||||
# Parse URL mappings (Azure Blob Storage → Local Paths)
|
# Parse URL mappings (Azure Blob Storage → Local Paths)
|
||||||
# Format: url_prefix1=local_path1;url_prefix2=local_path2
|
# Format: url_prefix1=local_path1;url_prefix2=local_path2
|
||||||
|
|
@ -321,7 +282,10 @@ class Config:
|
||||||
)
|
)
|
||||||
url_prefix, local_path_str = mapping.split("=", 1)
|
url_prefix, local_path_str = mapping.split("=", 1)
|
||||||
url_mappings.append(
|
url_mappings.append(
|
||||||
URLMapping(url_prefix=url_prefix.strip(), local_path=local_path_str.strip())
|
URLMapping(
|
||||||
|
url_prefix=url_prefix.strip(),
|
||||||
|
local_path=local_path_str.strip()
|
||||||
|
)
|
||||||
)
|
)
|
||||||
except (ValueError, OSError) as e:
|
except (ValueError, OSError) as e:
|
||||||
raise ConfigurationError(
|
raise ConfigurationError(
|
||||||
|
|
@ -341,14 +305,6 @@ class Config:
|
||||||
window_height=window_height,
|
window_height=window_height,
|
||||||
window_title=window_title,
|
window_title=window_title,
|
||||||
enable_logging=enable_logging,
|
enable_logging=enable_logging,
|
||||||
enable_checkout=enable_checkout,
|
|
||||||
language=language,
|
|
||||||
brand_id=brand_id,
|
|
||||||
config_dir_name=config_dir_name,
|
|
||||||
update_base_url=update_base_url,
|
|
||||||
update_repo=update_repo,
|
|
||||||
update_channel=update_channel,
|
|
||||||
update_manifest_name=update_manifest_name,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_file(self, config_path: Path) -> None:
|
def to_file(self, config_path: Path) -> None:
|
||||||
|
|
@ -363,7 +319,11 @@ class Config:
|
||||||
"app_name": self.app_name,
|
"app_name": self.app_name,
|
||||||
"webapp_url": self.webapp_url,
|
"webapp_url": self.webapp_url,
|
||||||
"url_mappings": [
|
"url_mappings": [
|
||||||
{"url_prefix": m.url_prefix, "local_path": m.local_path} for m in self.url_mappings
|
{
|
||||||
|
"url_prefix": m.url_prefix,
|
||||||
|
"local_path": m.local_path
|
||||||
|
}
|
||||||
|
for m in self.url_mappings
|
||||||
],
|
],
|
||||||
"allowed_roots": [str(p) for p in self.allowed_roots],
|
"allowed_roots": [str(p) for p in self.allowed_roots],
|
||||||
"allowed_urls": self.allowed_urls,
|
"allowed_urls": self.allowed_urls,
|
||||||
|
|
@ -376,14 +336,6 @@ class Config:
|
||||||
"window_height": self.window_height,
|
"window_height": self.window_height,
|
||||||
"window_title": self.window_title,
|
"window_title": self.window_title,
|
||||||
"enable_logging": self.enable_logging,
|
"enable_logging": self.enable_logging,
|
||||||
"enable_checkout": self.enable_checkout,
|
|
||||||
"language": self.language,
|
|
||||||
"brand_id": self.brand_id,
|
|
||||||
"config_dir_name": self.config_dir_name,
|
|
||||||
"update_base_url": self.update_base_url,
|
|
||||||
"update_repo": self.update_repo,
|
|
||||||
"update_channel": self.update_channel,
|
|
||||||
"update_manifest_name": self.update_manifest_name,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -391,72 +343,21 @@ class Config:
|
||||||
json.dump(data, f, indent=2)
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def load_bootstrap_env(env_file: str | None = None) -> Path | None:
|
def get_default_config_path() -> Path:
|
||||||
"""Load a bootstrap .env before configuration path lookup.
|
|
||||||
|
|
||||||
This lets branded builds decide their config directory before the main
|
|
||||||
config file is loaded.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
env_file: Optional explicit .env path
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to the loaded .env file, or None if nothing was loaded
|
|
||||||
"""
|
|
||||||
candidate_paths: list[Path] = []
|
|
||||||
if env_file:
|
|
||||||
candidate_paths.append(Path(env_file).resolve())
|
|
||||||
else:
|
|
||||||
if getattr(sys, "frozen", False):
|
|
||||||
exe_dir = Path(sys.executable).resolve().parent
|
|
||||||
# One-folder fallback: some packagers place data files in _internal.
|
|
||||||
candidate_paths.append(exe_dir / ".env")
|
|
||||||
candidate_paths.append(exe_dir / "_internal" / ".env")
|
|
||||||
|
|
||||||
# PyInstaller runtime extraction directory (one-file and one-folder).
|
|
||||||
meipass = getattr(sys, "_MEIPASS", None)
|
|
||||||
if meipass:
|
|
||||||
candidate_paths.append(Path(meipass).resolve() / ".env")
|
|
||||||
|
|
||||||
candidate_paths.append(Path.cwd() / ".env")
|
|
||||||
candidate_paths.append(Path(__file__).resolve().parents[2] / ".env")
|
|
||||||
|
|
||||||
for path in candidate_paths:
|
|
||||||
if path.exists():
|
|
||||||
load_dotenv(path, override=False)
|
|
||||||
logger.debug(f"Loaded bootstrap environment from {path}")
|
|
||||||
return path
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _slugify_config_dir_name(value: str) -> str:
|
|
||||||
"""Convert brand-like identifiers into a filesystem-safe directory name."""
|
|
||||||
sanitized = "".join(c.lower() if c.isalnum() else "_" for c in value).strip("_")
|
|
||||||
return sanitized or DEFAULT_CONFIG_DIR_NAME
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_default_config_dir_name() -> str:
|
|
||||||
"""Get the default config directory name from environment or fallback."""
|
|
||||||
return os.getenv("APP_CONFIG_DIR_NAME", DEFAULT_CONFIG_DIR_NAME)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_default_config_path(config_dir_name: str | None = None) -> Path:
|
|
||||||
"""Get the default configuration file path.
|
"""Get the default configuration file path.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path to default config file in user's AppData/Roaming
|
Path to default config file in user's AppData/Roaming
|
||||||
"""
|
"""
|
||||||
import platform
|
import platform
|
||||||
|
|
||||||
if platform.system() == "Windows":
|
if platform.system() == "Windows":
|
||||||
base = Path.home() / "AppData" / "Roaming"
|
base = Path.home() / "AppData" / "Roaming"
|
||||||
else:
|
else:
|
||||||
base = Path.home() / ".config"
|
base = Path.home() / ".config"
|
||||||
return base / (config_dir_name or Config.get_default_config_dir_name()) / "config.json"
|
return base / "webdrop_bridge" / "config.json"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_default_log_dir(config_dir_name: str | None = None) -> Path:
|
def get_default_log_dir() -> Path:
|
||||||
"""Get the default directory for log files.
|
"""Get the default directory for log files.
|
||||||
|
|
||||||
Always uses user's AppData directory to ensure permissions work
|
Always uses user's AppData directory to ensure permissions work
|
||||||
|
|
@ -466,36 +367,25 @@ class Config:
|
||||||
Path to default logs directory in user's AppData/Roaming
|
Path to default logs directory in user's AppData/Roaming
|
||||||
"""
|
"""
|
||||||
import platform
|
import platform
|
||||||
|
|
||||||
if platform.system() == "Windows":
|
if platform.system() == "Windows":
|
||||||
base = Path.home() / "AppData" / "Roaming"
|
base = Path.home() / "AppData" / "Roaming"
|
||||||
else:
|
else:
|
||||||
base = Path.home() / ".local" / "share"
|
base = Path.home() / ".local" / "share"
|
||||||
return base / (config_dir_name or Config.get_default_config_dir_name()) / "logs"
|
return base / "webdrop_bridge" / "logs"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_default_log_path(config_dir_name: str | None = None) -> Path:
|
def get_default_log_path() -> Path:
|
||||||
"""Get the default log file path.
|
"""Get the default log file path.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path to default log file in user's AppData/Roaming/webdrop_bridge/logs
|
Path to default log file in user's AppData/Roaming/webdrop_bridge/logs
|
||||||
"""
|
"""
|
||||||
dir_name = config_dir_name or Config.get_default_config_dir_name()
|
return Config.get_default_log_dir() / "webdrop_bridge.log"
|
||||||
return Config.get_default_log_dir(dir_name) / f"{dir_name}.log"
|
|
||||||
|
|
||||||
def get_config_path(self) -> Path:
|
|
||||||
"""Get the default config file path for this configured brand."""
|
|
||||||
return self.get_default_config_path(self.config_dir_name)
|
|
||||||
|
|
||||||
def get_cache_dir(self) -> Path:
|
|
||||||
"""Get the update/cache directory for this configured brand."""
|
|
||||||
return self.get_default_config_path(self.config_dir_name).parent / "cache"
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
"""Return developer-friendly representation."""
|
"""Return developer-friendly representation."""
|
||||||
return (
|
return (
|
||||||
f"Config(app={self.app_name} v{self.app_version}, "
|
f"Config(app={self.app_name} v{self.app_version}, "
|
||||||
f"brand={self.brand_id}, "
|
|
||||||
f"log_level={self.log_level}, "
|
f"log_level={self.log_level}, "
|
||||||
f"allowed_roots={len(self.allowed_roots)} dirs, "
|
f"allowed_roots={len(self.allowed_roots)} dirs, "
|
||||||
f"window={self.window_width}x{self.window_height})"
|
f"window={self.window_width}x{self.window_height})"
|
||||||
|
|
|
||||||
|
|
@ -53,9 +53,7 @@ class ConfigValidator:
|
||||||
# Check type
|
# Check type
|
||||||
expected_type = rules.get("type")
|
expected_type = rules.get("type")
|
||||||
if expected_type and not isinstance(value, expected_type):
|
if expected_type and not isinstance(value, expected_type):
|
||||||
errors.append(
|
errors.append(f"{field}: expected {expected_type.__name__}, got {type(value).__name__}")
|
||||||
f"{field}: expected {expected_type.__name__}, got {type(value).__name__}"
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check allowed values
|
# Check allowed values
|
||||||
|
|
@ -101,13 +99,14 @@ class ConfigValidator:
|
||||||
class ConfigProfile:
|
class ConfigProfile:
|
||||||
"""Manages named configuration profiles.
|
"""Manages named configuration profiles.
|
||||||
|
|
||||||
Profiles are stored in the brand-specific app config directory.
|
Profiles are stored in ~/.webdrop-bridge/profiles/ directory as JSON files.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, config_dir_name: str = "webdrop_bridge") -> None:
|
PROFILES_DIR = Path.home() / ".webdrop-bridge" / "profiles"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
"""Initialize profile manager."""
|
"""Initialize profile manager."""
|
||||||
self.profiles_dir = Config.get_default_config_path(config_dir_name).parent / "profiles"
|
self.PROFILES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
self.profiles_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
def save_profile(self, profile_name: str, config: Config) -> Path:
|
def save_profile(self, profile_name: str, config: Config) -> Path:
|
||||||
"""Save configuration as a named profile.
|
"""Save configuration as a named profile.
|
||||||
|
|
@ -125,7 +124,7 @@ class ConfigProfile:
|
||||||
if not profile_name or "/" in profile_name or "\\" in profile_name:
|
if not profile_name or "/" in profile_name or "\\" in profile_name:
|
||||||
raise ConfigurationError(f"Invalid profile name: {profile_name}")
|
raise ConfigurationError(f"Invalid profile name: {profile_name}")
|
||||||
|
|
||||||
profile_path = self.profiles_dir / f"{profile_name}.json"
|
profile_path = self.PROFILES_DIR / f"{profile_name}.json"
|
||||||
|
|
||||||
config_data = {
|
config_data = {
|
||||||
"app_name": config.app_name,
|
"app_name": config.app_name,
|
||||||
|
|
@ -159,7 +158,7 @@ class ConfigProfile:
|
||||||
Raises:
|
Raises:
|
||||||
ConfigurationError: If profile not found or invalid
|
ConfigurationError: If profile not found or invalid
|
||||||
"""
|
"""
|
||||||
profile_path = self.profiles_dir / f"{profile_name}.json"
|
profile_path = self.PROFILES_DIR / f"{profile_name}.json"
|
||||||
|
|
||||||
if not profile_path.exists():
|
if not profile_path.exists():
|
||||||
raise ConfigurationError(f"Profile not found: {profile_name}")
|
raise ConfigurationError(f"Profile not found: {profile_name}")
|
||||||
|
|
@ -178,10 +177,10 @@ class ConfigProfile:
|
||||||
Returns:
|
Returns:
|
||||||
List of profile names (without .json extension)
|
List of profile names (without .json extension)
|
||||||
"""
|
"""
|
||||||
if not self.profiles_dir.exists():
|
if not self.PROFILES_DIR.exists():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
return sorted([p.stem for p in self.profiles_dir.glob("*.json")])
|
return sorted([p.stem for p in self.PROFILES_DIR.glob("*.json")])
|
||||||
|
|
||||||
def delete_profile(self, profile_name: str) -> None:
|
def delete_profile(self, profile_name: str) -> None:
|
||||||
"""Delete a profile.
|
"""Delete a profile.
|
||||||
|
|
@ -192,7 +191,7 @@ class ConfigProfile:
|
||||||
Raises:
|
Raises:
|
||||||
ConfigurationError: If profile not found
|
ConfigurationError: If profile not found
|
||||||
"""
|
"""
|
||||||
profile_path = self.profiles_dir / f"{profile_name}.json"
|
profile_path = self.PROFILES_DIR / f"{profile_name}.json"
|
||||||
|
|
||||||
if not profile_path.exists():
|
if not profile_path.exists():
|
||||||
raise ConfigurationError(f"Profile not found: {profile_name}")
|
raise ConfigurationError(f"Profile not found: {profile_name}")
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional, Union
|
from typing import List, Optional
|
||||||
|
|
||||||
from PySide6.QtCore import QMimeData, Qt, QUrl, Signal
|
from PySide6.QtCore import QMimeData, Qt, QUrl, Signal
|
||||||
from PySide6.QtGui import QDrag
|
from PySide6.QtGui import QDrag
|
||||||
|
|
@ -21,18 +21,14 @@ class DragInterceptor(QWidget):
|
||||||
Intercepts drag events from web content, converts Azure Blob Storage URLs
|
Intercepts drag events from web content, converts Azure Blob Storage URLs
|
||||||
to local paths, validates them, and initiates native Qt drag operations.
|
to local paths, validates them, and initiates native Qt drag operations.
|
||||||
|
|
||||||
Supports both single and multiple file drag operations.
|
|
||||||
|
|
||||||
Signals:
|
Signals:
|
||||||
drag_started: Emitted when a drag operation begins successfully
|
drag_started: Emitted when a drag operation begins successfully
|
||||||
(source_urls_or_paths: str, local_paths: str - comma-separated for multiple)
|
|
||||||
drag_failed: Emitted when drag initiation fails
|
drag_failed: Emitted when drag initiation fails
|
||||||
(source_urls_or_paths: str, error_message: str)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Signals with string parameters
|
# Signals with string parameters
|
||||||
drag_started = Signal(str, str) # (source_urls_or_paths, local_paths)
|
drag_started = Signal(str, str) # (url_or_path, local_path)
|
||||||
drag_failed = Signal(str, str) # (source_urls_or_paths, error_message)
|
drag_failed = Signal(str, str) # (url_or_path, error_message)
|
||||||
|
|
||||||
def __init__(self, config: Config, parent: Optional[QWidget] = None):
|
def __init__(self, config: Config, parent: Optional[QWidget] = None):
|
||||||
"""Initialize the drag interceptor.
|
"""Initialize the drag interceptor.
|
||||||
|
|
@ -44,66 +40,45 @@ class DragInterceptor(QWidget):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.config = config
|
self.config = config
|
||||||
self._validator = PathValidator(
|
self._validator = PathValidator(
|
||||||
config.allowed_roots, check_file_exists=config.check_file_exists
|
config.allowed_roots,
|
||||||
|
check_file_exists=config.check_file_exists
|
||||||
)
|
)
|
||||||
self._url_converter = URLConverter(config)
|
self._url_converter = URLConverter(config)
|
||||||
|
|
||||||
def handle_drag(self, text_or_list: Union[str, List[str]]) -> bool:
|
def handle_drag(self, text: str) -> bool:
|
||||||
"""Handle drag event from web view (single or multiple files).
|
"""Handle drag event from web view.
|
||||||
|
|
||||||
Determines if the text/list contains Azure URLs or file paths, converts if needed,
|
Determines if the text is an Azure URL or file path, converts if needed,
|
||||||
validates, and initiates native drag operation.
|
validates, and initiates native drag operation.
|
||||||
|
|
||||||
Supports:
|
|
||||||
- Single string (backward compatible)
|
|
||||||
- List of strings (multiple drag support)
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text_or_list: Azure URL/file path (str) or list of URLs/paths (List[str])
|
text: Azure Blob Storage URL or file path from web drag
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if native drag was initiated, False otherwise
|
True if native drag was initiated, False otherwise
|
||||||
"""
|
"""
|
||||||
# Normalize input to list
|
if not text or not text.strip():
|
||||||
if isinstance(text_or_list, str):
|
|
||||||
text_list = [text_or_list]
|
|
||||||
elif isinstance(text_or_list, (list, tuple)):
|
|
||||||
text_list = list(text_or_list)
|
|
||||||
else:
|
|
||||||
error_msg = f"Unexpected drag data type: {type(text_or_list)}"
|
|
||||||
logger.error(error_msg)
|
|
||||||
self.drag_failed.emit("", error_msg)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Validate that we have content
|
|
||||||
if not text_list or all(not t or not str(t).strip() for t in text_list):
|
|
||||||
error_msg = "Empty drag text"
|
error_msg = "Empty drag text"
|
||||||
logger.warning(error_msg)
|
logger.warning(error_msg)
|
||||||
self.drag_failed.emit("", error_msg)
|
self.drag_failed.emit("", error_msg)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Clean up text items
|
text = text.strip()
|
||||||
text_list = [str(t).strip() for t in text_list if str(t).strip()]
|
logger.debug(f"Handling drag for text: {text}")
|
||||||
logger.debug(f"Handling drag for {len(text_list)} item(s)")
|
|
||||||
|
|
||||||
# Convert each text to local path
|
|
||||||
local_paths = []
|
|
||||||
source_texts = []
|
|
||||||
|
|
||||||
for text in text_list:
|
|
||||||
# Check if it's an Azure URL and convert to local path
|
# Check if it's an Azure URL and convert to local path
|
||||||
if self._url_converter.is_azure_url(text):
|
if self._url_converter.is_azure_url(text):
|
||||||
local_path = self._url_converter.convert_url_to_path(text)
|
local_path = self._url_converter.convert_url_to_path(text)
|
||||||
if local_path is None:
|
if local_path is None:
|
||||||
error_msg = f"No mapping found for URL: {text}"
|
error_msg = "No mapping found for URL"
|
||||||
logger.warning(error_msg)
|
logger.warning(f"{error_msg}: {text}")
|
||||||
self.drag_failed.emit(text, error_msg)
|
self.drag_failed.emit(text, error_msg)
|
||||||
return False
|
return False
|
||||||
source_texts.append(text)
|
source_text = text
|
||||||
else:
|
else:
|
||||||
# Treat as direct file path
|
# Treat as direct file path
|
||||||
local_path = Path(text)
|
local_path = Path(text)
|
||||||
source_texts.append(text)
|
source_text = text
|
||||||
|
|
||||||
# Validate the path
|
# Validate the path
|
||||||
try:
|
try:
|
||||||
|
|
@ -111,56 +86,37 @@ class DragInterceptor(QWidget):
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
logger.warning(f"Validation failed for {local_path}: {error_msg}")
|
logger.warning(f"Validation failed for {local_path}: {error_msg}")
|
||||||
self.drag_failed.emit(text, error_msg)
|
self.drag_failed.emit(source_text, error_msg)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
local_paths.append(local_path)
|
logger.info(f"Initiating drag for: {local_path}")
|
||||||
|
|
||||||
logger.info(
|
# Create native file drag
|
||||||
f"Initiating drag for {len(local_paths)} file(s): {[str(p) for p in local_paths]}"
|
success = self._create_native_drag(local_path)
|
||||||
)
|
|
||||||
|
|
||||||
# Create native file drag with all paths
|
|
||||||
success = self._create_native_drag(local_paths)
|
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
source_str = " | ".join(source_texts) if len(source_texts) > 1 else source_texts[0]
|
self.drag_started.emit(source_text, str(local_path))
|
||||||
paths_str = (
|
|
||||||
" | ".join(str(p) for p in local_paths)
|
|
||||||
if len(local_paths) > 1
|
|
||||||
else str(local_paths[0])
|
|
||||||
)
|
|
||||||
self.drag_started.emit(source_str, paths_str)
|
|
||||||
else:
|
else:
|
||||||
error_msg = "Failed to create native drag operation"
|
error_msg = "Failed to create native drag operation"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
source_str = " | ".join(source_texts) if len(source_texts) > 1 else source_texts[0]
|
self.drag_failed.emit(source_text, error_msg)
|
||||||
self.drag_failed.emit(source_str, error_msg)
|
|
||||||
|
|
||||||
return success
|
return success
|
||||||
|
|
||||||
def _create_native_drag(self, file_paths: Union[Path, List[Path]]) -> bool:
|
def _create_native_drag(self, file_path: Path) -> bool:
|
||||||
"""Create a native file system drag operation.
|
"""Create a native file system drag operation.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_paths: Single local file path or list of local file paths
|
file_path: Local file path to drag
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if drag was created successfully
|
True if drag was created successfully
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Normalize to list
|
# Create MIME data with file URL
|
||||||
if isinstance(file_paths, Path):
|
|
||||||
paths_list = [file_paths]
|
|
||||||
else:
|
|
||||||
paths_list = list(file_paths)
|
|
||||||
|
|
||||||
# Create MIME data with file URLs
|
|
||||||
mime_data = QMimeData()
|
mime_data = QMimeData()
|
||||||
file_urls = [QUrl.fromLocalFile(str(p)) for p in paths_list]
|
file_url = QUrl.fromLocalFile(str(file_path))
|
||||||
mime_data.setUrls(file_urls)
|
mime_data.setUrls([file_url])
|
||||||
|
|
||||||
logger.debug(f"Creating drag with {len(file_urls)} file(s)")
|
|
||||||
|
|
||||||
# Create and execute drag
|
# Create and execute drag
|
||||||
drag = QDrag(self)
|
drag = QDrag(self)
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,9 @@ verifying checksums from Forgejo releases.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import fnmatch
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import platform
|
|
||||||
import socket
|
import socket
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
@ -36,16 +34,7 @@ class Release:
|
||||||
class UpdateManager:
|
class UpdateManager:
|
||||||
"""Manages auto-updates via Forgejo releases API."""
|
"""Manages auto-updates via Forgejo releases API."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, current_version: str, config_dir: Optional[Path] = None):
|
||||||
self,
|
|
||||||
current_version: str,
|
|
||||||
config_dir: Optional[Path] = None,
|
|
||||||
brand_id: str = "webdrop_bridge",
|
|
||||||
forgejo_url: str = "https://git.him-tools.de",
|
|
||||||
repo: str = "HIM-public/webdrop-bridge",
|
|
||||||
update_channel: str = "stable",
|
|
||||||
manifest_name: str = "release-manifest.json",
|
|
||||||
):
|
|
||||||
"""Initialize update manager.
|
"""Initialize update manager.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -53,12 +42,11 @@ class UpdateManager:
|
||||||
config_dir: Directory for storing update cache. Defaults to temp.
|
config_dir: Directory for storing update cache. Defaults to temp.
|
||||||
"""
|
"""
|
||||||
self.current_version = current_version
|
self.current_version = current_version
|
||||||
self.brand_id = brand_id
|
self.forgejo_url = "https://git.him-tools.de"
|
||||||
self.forgejo_url = forgejo_url.rstrip("/")
|
self.repo = "HIM-public/webdrop-bridge"
|
||||||
self.repo = repo
|
self.api_endpoint = (
|
||||||
self.update_channel = update_channel
|
f"{self.forgejo_url}/api/v1/repos/{self.repo}/releases/latest"
|
||||||
self.manifest_name = manifest_name
|
)
|
||||||
self.api_endpoint = f"{self.forgejo_url}/api/v1/repos/{self.repo}/releases/latest"
|
|
||||||
|
|
||||||
# Cache management
|
# Cache management
|
||||||
self.cache_dir = config_dir or Path.home() / ".webdrop-bridge"
|
self.cache_dir = config_dir or Path.home() / ".webdrop-bridge"
|
||||||
|
|
@ -66,150 +54,6 @@ class UpdateManager:
|
||||||
self.cache_file = self.cache_dir / "update_check.json"
|
self.cache_file = self.cache_dir / "update_check.json"
|
||||||
self.cache_ttl = timedelta(hours=24)
|
self.cache_ttl = timedelta(hours=24)
|
||||||
|
|
||||||
def _get_platform_key(self) -> str:
|
|
||||||
"""Return the release-manifest platform key for the current system."""
|
|
||||||
system = platform.system()
|
|
||||||
machine = platform.machine().lower()
|
|
||||||
|
|
||||||
if system == "Windows":
|
|
||||||
arch = "x64" if machine in {"amd64", "x86_64"} else machine
|
|
||||||
return f"windows-{arch}"
|
|
||||||
if system == "Darwin":
|
|
||||||
return "macos-universal"
|
|
||||||
return f"{system.lower()}-{machine}"
|
|
||||||
|
|
||||||
def _find_asset(self, assets: list[dict], asset_name: str) -> Optional[dict]:
|
|
||||||
"""Find an asset by exact name."""
|
|
||||||
for asset in assets:
|
|
||||||
if asset.get("name") == asset_name:
|
|
||||||
return asset
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _find_manifest_asset(self, release: Release) -> Optional[dict]:
|
|
||||||
"""Find the shared release manifest asset if present."""
|
|
||||||
return self._find_asset(release.assets, self.manifest_name)
|
|
||||||
|
|
||||||
def _download_json_asset(self, url: str) -> Optional[dict]:
|
|
||||||
"""Download and parse a JSON asset from a release."""
|
|
||||||
try:
|
|
||||||
with urlopen(url, timeout=10) as response:
|
|
||||||
# Some release pipelines may upload JSON files with UTF-8 BOM.
|
|
||||||
# Use utf-8-sig to transparently handle both BOM and non-BOM files.
|
|
||||||
return json.loads(response.read().decode("utf-8-sig"))
|
|
||||||
except (URLError, json.JSONDecodeError) as e:
|
|
||||||
logger.error(f"Failed to download JSON asset: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _load_release_manifest(self, release: Release) -> Optional[dict]:
|
|
||||||
"""Load the shared release manifest if present."""
|
|
||||||
manifest_asset = self._find_manifest_asset(release)
|
|
||||||
if not manifest_asset:
|
|
||||||
return None
|
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
return await asyncio.wait_for(
|
|
||||||
loop.run_in_executor(
|
|
||||||
None, self._download_json_asset, manifest_asset["browser_download_url"]
|
|
||||||
),
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _resolve_assets_from_manifest(
|
|
||||||
self, release: Release, manifest: dict
|
|
||||||
) -> tuple[Optional[dict], Optional[dict]]:
|
|
||||||
"""Resolve installer and checksum assets from a shared release manifest."""
|
|
||||||
if manifest.get("channel") not in {None, "", self.update_channel}:
|
|
||||||
logger.info(
|
|
||||||
"Release manifest channel %s does not match configured channel %s",
|
|
||||||
manifest.get("channel"),
|
|
||||||
self.update_channel,
|
|
||||||
)
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
brand_entry = manifest.get("brands", {}).get(self.brand_id, {})
|
|
||||||
platform_entry = brand_entry.get(self._get_platform_key(), {})
|
|
||||||
installer_name = platform_entry.get("installer")
|
|
||||||
checksum_name = platform_entry.get("checksum")
|
|
||||||
|
|
||||||
if not installer_name:
|
|
||||||
logger.warning(
|
|
||||||
"No installer entry found for brand=%s platform=%s in release manifest",
|
|
||||||
self.brand_id,
|
|
||||||
self._get_platform_key(),
|
|
||||||
)
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
return self._find_asset(release.assets, installer_name), self._find_asset(
|
|
||||||
release.assets, checksum_name
|
|
||||||
)
|
|
||||||
|
|
||||||
def _resolve_assets_legacy(self, release: Release) -> tuple[Optional[dict], Optional[dict]]:
|
|
||||||
"""Resolve installer and checksum assets using legacy filename matching."""
|
|
||||||
is_windows = platform.system() == "Windows"
|
|
||||||
extension = ".msi" if is_windows else ".dmg"
|
|
||||||
brand_prefix = f"{self.brand_id}-*"
|
|
||||||
|
|
||||||
installer_asset = None
|
|
||||||
|
|
||||||
# Prefer brand-specific naming when possible.
|
|
||||||
if self.brand_id == "webdrop_bridge":
|
|
||||||
preferred_patterns = ["webdropbridge-*.msi", "webdropbridge*.msi"]
|
|
||||||
else:
|
|
||||||
preferred_patterns = [f"{self.brand_id.lower()}-*.msi", f"{self.brand_id.lower()}*.msi"]
|
|
||||||
|
|
||||||
# 1) Try strict brand-pattern match first
|
|
||||||
for asset in release.assets:
|
|
||||||
asset_name = asset.get("name", "")
|
|
||||||
asset_name_lower = asset_name.lower()
|
|
||||||
if not asset_name_lower.endswith(extension):
|
|
||||||
continue
|
|
||||||
if any(fnmatch.fnmatch(asset_name_lower, pattern) for pattern in preferred_patterns):
|
|
||||||
installer_asset = asset
|
|
||||||
break
|
|
||||||
|
|
||||||
# 2) Fallback: preserve previous behavior (first installer for platform)
|
|
||||||
for asset in release.assets:
|
|
||||||
if installer_asset:
|
|
||||||
break
|
|
||||||
asset_name = asset.get("name", "")
|
|
||||||
if not asset_name.endswith(extension):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if self.brand_id != "webdrop_bridge" and fnmatch.fnmatch(
|
|
||||||
asset_name.lower(), brand_prefix.lower()
|
|
||||||
):
|
|
||||||
installer_asset = asset
|
|
||||||
break
|
|
||||||
|
|
||||||
if self.brand_id == "webdrop_bridge":
|
|
||||||
installer_asset = asset
|
|
||||||
break
|
|
||||||
|
|
||||||
if not installer_asset:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
checksum_asset = self._find_asset(release.assets, f"{installer_asset['name']}.sha256")
|
|
||||||
return installer_asset, checksum_asset
|
|
||||||
|
|
||||||
async def _resolve_release_assets(
|
|
||||||
self, release: Release
|
|
||||||
) -> tuple[Optional[dict], Optional[dict]]:
|
|
||||||
"""Resolve installer and checksum assets for the configured brand."""
|
|
||||||
try:
|
|
||||||
manifest = await self._load_release_manifest(release)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning(
|
|
||||||
"Timed out while loading release manifest, falling back to legacy lookup"
|
|
||||||
)
|
|
||||||
manifest = None
|
|
||||||
|
|
||||||
if manifest:
|
|
||||||
installer_asset, checksum_asset = self._resolve_assets_from_manifest(release, manifest)
|
|
||||||
if installer_asset:
|
|
||||||
return installer_asset, checksum_asset
|
|
||||||
|
|
||||||
return self._resolve_assets_legacy(release)
|
|
||||||
|
|
||||||
def _parse_version(self, version_str: str) -> tuple[int, int, int]:
|
def _parse_version(self, version_str: str) -> tuple[int, int, int]:
|
||||||
"""Parse semantic version string to tuple.
|
"""Parse semantic version string to tuple.
|
||||||
|
|
||||||
|
|
@ -303,44 +147,43 @@ class UpdateManager:
|
||||||
"""
|
"""
|
||||||
logger.debug(f"check_for_updates() called, current version: {self.current_version}")
|
logger.debug(f"check_for_updates() called, current version: {self.current_version}")
|
||||||
|
|
||||||
# Only use cache when a pending update was already found (avoids
|
# Try cache first
|
||||||
# showing the update dialog on every start). "No update" is never
|
logger.debug("Checking cache...")
|
||||||
# cached so that a freshly published release is visible immediately.
|
|
||||||
logger.debug("Checking cache for pending update...")
|
|
||||||
cached = self._load_cache()
|
cached = self._load_cache()
|
||||||
if cached:
|
if cached:
|
||||||
|
logger.debug("Found cached release")
|
||||||
release_data = cached.get("release")
|
release_data = cached.get("release")
|
||||||
if release_data:
|
if release_data:
|
||||||
version = release_data["tag_name"].lstrip("v")
|
version = release_data["tag_name"].lstrip("v")
|
||||||
logger.debug(f"Cached pending update version: {version}")
|
if not self._is_newer_version(version):
|
||||||
if self._is_newer_version(version):
|
logger.info("No newer version available (cached)")
|
||||||
logger.info(f"Returning cached pending update: {version}")
|
return None
|
||||||
return Release(**release_data)
|
return Release(**release_data)
|
||||||
else:
|
|
||||||
# Current version is >= cached release (e.g. already updated)
|
|
||||||
logger.debug("Cached release is no longer newer — discarding cache")
|
|
||||||
self.cache_file.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
# Always fetch fresh from API so new releases are seen immediately
|
# Fetch from API
|
||||||
logger.debug("Fetching from API...")
|
logger.debug("Fetching from API...")
|
||||||
try:
|
try:
|
||||||
logger.info(f"Checking for updates from {self.api_endpoint}")
|
logger.info(f"Checking for updates from {self.api_endpoint}")
|
||||||
|
|
||||||
|
# Run in thread pool with aggressive timeout
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
response = await asyncio.wait_for(
|
response = await asyncio.wait_for(
|
||||||
loop.run_in_executor(None, self._fetch_release),
|
loop.run_in_executor(
|
||||||
timeout=8,
|
None, self._fetch_release
|
||||||
|
),
|
||||||
|
timeout=8 # Timeout after network call also has timeout
|
||||||
)
|
)
|
||||||
|
|
||||||
if not response:
|
if not response:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Check if newer version
|
||||||
version = response["tag_name"].lstrip("v")
|
version = response["tag_name"].lstrip("v")
|
||||||
if not self._is_newer_version(version):
|
if not self._is_newer_version(version):
|
||||||
logger.info(f"Latest version {version} is not newer than {self.current_version}")
|
logger.info(f"Latest version {version} is not newer than {self.current_version}")
|
||||||
|
self._save_cache(response)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Cache the found update so repeated starts don't hammer the API
|
|
||||||
logger.info(f"New version available: {version}")
|
logger.info(f"New version available: {version}")
|
||||||
release = Release(**response)
|
release = Release(**response)
|
||||||
self._save_cache(response)
|
self._save_cache(response)
|
||||||
|
|
@ -388,15 +231,17 @@ class UpdateManager:
|
||||||
except socket.timeout as e:
|
except socket.timeout as e:
|
||||||
logger.error(f"Socket timeout (5s) connecting to {self.api_endpoint}")
|
logger.error(f"Socket timeout (5s) connecting to {self.api_endpoint}")
|
||||||
return None
|
return None
|
||||||
|
except TimeoutError as e:
|
||||||
|
logger.error(f"Timeout error: {e}")
|
||||||
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to fetch release: {type(e).__name__}: {e}")
|
logger.error(f"Failed to fetch release: {type(e).__name__}: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
logger.debug(traceback.format_exc())
|
logger.debug(traceback.format_exc())
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def download_update(
|
async def download_update(
|
||||||
self, release: Release, output_dir: Optional[Path] = None, progress_callback=None
|
self, release: Release, output_dir: Optional[Path] = None
|
||||||
) -> Optional[Path]:
|
) -> Optional[Path]:
|
||||||
"""Download installer from release assets.
|
"""Download installer from release assets.
|
||||||
|
|
||||||
|
|
@ -411,7 +256,12 @@ class UpdateManager:
|
||||||
logger.error("No assets found in release")
|
logger.error("No assets found in release")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
installer_asset, _ = await self._resolve_release_assets(release)
|
# 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:
|
if not installer_asset:
|
||||||
logger.error("No installer found in release assets")
|
logger.error("No installer found in release assets")
|
||||||
|
|
@ -432,9 +282,8 @@ class UpdateManager:
|
||||||
self._download_file,
|
self._download_file,
|
||||||
installer_asset["browser_download_url"],
|
installer_asset["browser_download_url"],
|
||||||
output_file,
|
output_file,
|
||||||
progress_callback,
|
|
||||||
),
|
),
|
||||||
timeout=300,
|
timeout=300
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
|
|
@ -453,13 +302,12 @@ class UpdateManager:
|
||||||
output_file.unlink()
|
output_file.unlink()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _download_file(self, url: str, output_path: Path, progress_callback=None) -> bool:
|
def _download_file(self, url: str, output_path: Path) -> bool:
|
||||||
"""Download file from URL (blocking).
|
"""Download file from URL (blocking).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url: URL to download from
|
url: URL to download from
|
||||||
output_path: Path to save file
|
output_path: Path to save file
|
||||||
progress_callback: Optional callable(bytes_downloaded, total_bytes)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if successful, False otherwise
|
True if successful, False otherwise
|
||||||
|
|
@ -467,28 +315,17 @@ class UpdateManager:
|
||||||
try:
|
try:
|
||||||
logger.debug(f"Downloading from {url}")
|
logger.debug(f"Downloading from {url}")
|
||||||
with urlopen(url, timeout=300) as response: # 5 min timeout
|
with urlopen(url, timeout=300) as response: # 5 min timeout
|
||||||
total = int(response.headers.get("Content-Length", 0))
|
|
||||||
downloaded = 0
|
|
||||||
chunk_size = 65536 # 64 KB chunks
|
|
||||||
with open(output_path, "wb") as f:
|
with open(output_path, "wb") as f:
|
||||||
while True:
|
f.write(response.read())
|
||||||
chunk = response.read(chunk_size)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
f.write(chunk)
|
|
||||||
downloaded += len(chunk)
|
|
||||||
if progress_callback:
|
|
||||||
try:
|
|
||||||
progress_callback(downloaded, total)
|
|
||||||
except Exception:
|
|
||||||
pass # Never let progress errors abort the download
|
|
||||||
logger.debug(f"Downloaded {output_path.stat().st_size} bytes")
|
logger.debug(f"Downloaded {output_path.stat().st_size} bytes")
|
||||||
return True
|
return True
|
||||||
except URLError as e:
|
except URLError as e:
|
||||||
logger.error(f"Download failed: {e}")
|
logger.error(f"Download failed: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def verify_checksum(self, file_path: Path, release: Release) -> bool:
|
async def verify_checksum(
|
||||||
|
self, file_path: Path, release: Release
|
||||||
|
) -> bool:
|
||||||
"""Verify file checksum against release checksum file.
|
"""Verify file checksum against release checksum file.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -498,11 +335,12 @@ class UpdateManager:
|
||||||
Returns:
|
Returns:
|
||||||
True if checksum matches, False otherwise
|
True if checksum matches, False otherwise
|
||||||
"""
|
"""
|
||||||
installer_asset, checksum_asset = await self._resolve_release_assets(release)
|
# Find .sha256 file in release assets
|
||||||
installer_name = installer_asset["name"] if installer_asset else file_path.name
|
checksum_asset = None
|
||||||
|
for asset in release.assets:
|
||||||
if not checksum_asset:
|
if asset["name"].endswith(".sha256"):
|
||||||
checksum_asset = self._find_asset(release.assets, f"{installer_name}.sha256")
|
checksum_asset = asset
|
||||||
|
break
|
||||||
|
|
||||||
if not checksum_asset:
|
if not checksum_asset:
|
||||||
logger.warning("No checksum file found in release")
|
logger.warning("No checksum file found in release")
|
||||||
|
|
@ -519,7 +357,7 @@ class UpdateManager:
|
||||||
self._download_checksum,
|
self._download_checksum,
|
||||||
checksum_asset["browser_download_url"],
|
checksum_asset["browser_download_url"],
|
||||||
),
|
),
|
||||||
timeout=30,
|
timeout=30
|
||||||
)
|
)
|
||||||
|
|
||||||
if not checksum_content:
|
if not checksum_content:
|
||||||
|
|
@ -539,7 +377,9 @@ class UpdateManager:
|
||||||
logger.info("Checksum verification passed")
|
logger.info("Checksum verification passed")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.error(f"Checksum mismatch: {file_checksum} != {expected_checksum}")
|
logger.error(
|
||||||
|
f"Checksum mismatch: {file_checksum} != {expected_checksum}"
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
|
|
@ -586,11 +426,8 @@ class UpdateManager:
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
if platform.system() == "Windows":
|
if platform.system() == "Windows":
|
||||||
# Windows: MSI files must be launched via msiexec
|
# Windows: Run MSI installer
|
||||||
logger.info(f"Launching installer: {installer_path}")
|
logger.info(f"Launching installer: {installer_path}")
|
||||||
if str(installer_path).lower().endswith(".msi"):
|
|
||||||
subprocess.Popen(["msiexec.exe", "/i", str(installer_path)])
|
|
||||||
else:
|
|
||||||
subprocess.Popen([str(installer_path)])
|
subprocess.Popen([str(installer_path)])
|
||||||
return True
|
return True
|
||||||
elif platform.system() == "Darwin":
|
elif platform.system() == "Darwin":
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,11 @@
|
||||||
"""WebDrop Bridge - Application entry point."""
|
"""WebDrop Bridge - Application entry point."""
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Force Chromium to treat hover as primary input method and disable touch detection
|
|
||||||
# This ensures CSS media queries (hover: hover) evaluate correctly for desktop applications
|
|
||||||
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--touch-events=disabled"
|
|
||||||
|
|
||||||
# Enable Qt WebEngine Remote Debugging Protocol (Chromium Developer Tools)
|
|
||||||
# Allows debugging via browser DevTools at http://localhost:9222 or edge://inspect
|
|
||||||
os.environ["QTWEBENGINE_REMOTE_DEBUGGING"] = "9222"
|
|
||||||
|
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from webdrop_bridge.config import Config, ConfigurationError
|
from webdrop_bridge.config import Config, ConfigurationError
|
||||||
from webdrop_bridge.ui.main_window import MainWindow
|
from webdrop_bridge.ui.main_window import MainWindow
|
||||||
from webdrop_bridge.utils.i18n import get_translations_dir
|
|
||||||
from webdrop_bridge.utils.i18n import initialize as i18n_init
|
|
||||||
from webdrop_bridge.utils.logging import get_logger, setup_logging
|
from webdrop_bridge.utils.logging import get_logger, setup_logging
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -30,8 +19,6 @@ def main() -> int:
|
||||||
int: Exit code (0 for success, non-zero for error)
|
int: Exit code (0 for success, non-zero for error)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
Config.load_bootstrap_env()
|
|
||||||
|
|
||||||
# Load configuration from file if it exists, otherwise from environment
|
# Load configuration from file if it exists, otherwise from environment
|
||||||
config_path = Config.get_default_config_path()
|
config_path = Config.get_default_config_path()
|
||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
|
|
@ -54,11 +41,6 @@ def main() -> int:
|
||||||
logger.info(f"Starting {config.app_name} v{config.app_version}")
|
logger.info(f"Starting {config.app_name} v{config.app_version}")
|
||||||
logger.debug(f"Configuration: {config}")
|
logger.debug(f"Configuration: {config}")
|
||||||
|
|
||||||
# Initialize internationalization
|
|
||||||
translations_dir = get_translations_dir()
|
|
||||||
i18n_init(config.language, translations_dir)
|
|
||||||
logger.debug(f"i18n initialized: language={config.language}, dir={translations_dir}")
|
|
||||||
|
|
||||||
except ConfigurationError as e:
|
except ConfigurationError as e:
|
||||||
print(f"Configuration error: {e}", file=sys.stderr)
|
print(f"Configuration error: {e}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
|
||||||
|
|
@ -11,16 +11,13 @@
|
||||||
|
|
||||||
console.log('%c[WebDrop Intercept] Script loaded - INTERCEPT_ENABLED=' + INTERCEPT_ENABLED, 'background: #2196F3; color: white; font-weight: bold; padding: 4px 8px;');
|
console.log('%c[WebDrop Intercept] Script loaded - INTERCEPT_ENABLED=' + INTERCEPT_ENABLED, 'background: #2196F3; color: white; font-weight: bold; padding: 4px 8px;');
|
||||||
|
|
||||||
var currentDragUrls = []; // Array to support multiple URLs
|
var currentDragUrl = null;
|
||||||
var angularDragHandlers = [];
|
var angularDragHandlers = [];
|
||||||
var originalAddEventListener = EventTarget.prototype.addEventListener;
|
var originalAddEventListener = EventTarget.prototype.addEventListener;
|
||||||
var listenerPatchActive = true;
|
var listenerPatchActive = true;
|
||||||
var dragHandlerInstalled = false;
|
|
||||||
|
|
||||||
// Capture Authorization token from XHR requests (only if checkout is enabled)
|
// Capture Authorization token from XHR requests
|
||||||
window.capturedAuthToken = null;
|
window.capturedAuthToken = null;
|
||||||
if (window.webdropConfig && window.webdropConfig.enableCheckout) {
|
|
||||||
console.log('[Intercept] Auth token capture enabled (checkout feature active)');
|
|
||||||
var originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
|
var originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
|
||||||
XMLHttpRequest.prototype.setRequestHeader = function(header, value) {
|
XMLHttpRequest.prototype.setRequestHeader = function(header, value) {
|
||||||
if (header === 'Authorization' && value.startsWith('Bearer ')) {
|
if (header === 'Authorization' && value.startsWith('Bearer ')) {
|
||||||
|
|
@ -29,12 +26,11 @@
|
||||||
}
|
}
|
||||||
return originalXHRSetRequestHeader.apply(this, arguments);
|
return originalXHRSetRequestHeader.apply(this, arguments);
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
console.log('[Intercept] Auth token capture disabled (checkout feature inactive)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only patch addEventListener for dragstart events
|
// ============================================================================
|
||||||
// This minimizes impact on other event listeners (mouseover, mouseenter, etc.)
|
// PART 1: Intercept Angular's dragstart listener registration
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
EventTarget.prototype.addEventListener = function(type, listener, options) {
|
EventTarget.prototype.addEventListener = function(type, listener, options) {
|
||||||
if (listenerPatchActive && type === 'dragstart' && listener) {
|
if (listenerPatchActive && type === 'dragstart' && listener) {
|
||||||
// Store Angular's dragstart handler instead of registering it
|
// Store Angular's dragstart handler instead of registering it
|
||||||
|
|
@ -46,8 +42,7 @@
|
||||||
});
|
});
|
||||||
return; // Don't actually register it yet
|
return; // Don't actually register it yet
|
||||||
}
|
}
|
||||||
// All other events (mouseover, mouseenter, mousedown, etc.): use original
|
// All other events: use original
|
||||||
// This is critical to ensure mouseover/hover events work properly
|
|
||||||
return originalAddEventListener.call(this, type, listener, options);
|
return originalAddEventListener.call(this, type, listener, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -59,14 +54,8 @@
|
||||||
|
|
||||||
DataTransfer.prototype.setData = function(format, data) {
|
DataTransfer.prototype.setData = function(format, data) {
|
||||||
if (format === 'text/plain' || format === 'text/uri-list') {
|
if (format === 'text/plain' || format === 'text/uri-list') {
|
||||||
// text/uri-list contains newline-separated URLs
|
currentDragUrl = data;
|
||||||
// text/plain may be single URL or multiple newline-separated URLs
|
console.log('%c[Intercept] Captured URL:', 'color: #4CAF50; font-weight: bold;', data.substring(0, 80));
|
||||||
currentDragUrls = data.trim().split('\n').filter(function(url) {
|
|
||||||
return url.trim().length > 0;
|
|
||||||
}).map(function(url) {
|
|
||||||
return url.trim();
|
|
||||||
});
|
|
||||||
console.log('%c[Intercept] Captured ' + currentDragUrls.length + ' URL(s)', 'color: #4CAF50; font-weight: bold;', currentDragUrls[0].substring(0, 60));
|
|
||||||
}
|
}
|
||||||
return originalSetData.call(this, format, data);
|
return originalSetData.call(this, format, data);
|
||||||
};
|
};
|
||||||
|
|
@ -86,20 +75,14 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only install once, even if called multiple times
|
// Stop intercepting addEventListener
|
||||||
if (dragHandlerInstalled) {
|
listenerPatchActive = false;
|
||||||
console.log('[Intercept] Handler already installed, skipping');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dragHandlerInstalled = true;
|
|
||||||
|
|
||||||
// NOTE: Keep listenerPatchActive = true to catch new Angular handlers registered later
|
|
||||||
// This is important for page reloads where Angular might register handlers at different times
|
|
||||||
|
|
||||||
// Register OUR handler in capture phase
|
// Register OUR handler in capture phase
|
||||||
originalAddEventListener.call(document, 'dragstart', function(e) {
|
originalAddEventListener.call(document, 'dragstart', function(e) {
|
||||||
currentDragUrls = []; // Reset
|
currentDragUrl = null; // Reset
|
||||||
|
|
||||||
|
console.log('%c[Intercept] dragstart', 'background: #FF9800; color: white; padding: 2px 6px;', 'ALT:', e.altKey);
|
||||||
|
|
||||||
// Call Angular's handlers first to let them set the data
|
// Call Angular's handlers first to let them set the data
|
||||||
var handled = 0;
|
var handled = 0;
|
||||||
|
|
@ -116,41 +99,32 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Intercept] Called', handled, 'Angular handlers, URLs:', currentDragUrls.length, 'URL(s)', currentDragUrls.length > 0 ? currentDragUrls[0].substring(0, 60) : 'none');
|
console.log('[Intercept] Called', handled, 'Angular handlers, URL:', currentDragUrl ? currentDragUrl.substring(0, 60) : 'none');
|
||||||
|
|
||||||
// NOW check if we should intercept
|
// NOW check if we should intercept
|
||||||
// Intercept any drag with URLs that match our configured mappings
|
if (e.altKey && currentDragUrl) {
|
||||||
if (currentDragUrls.length > 0) {
|
|
||||||
var shouldIntercept = false;
|
var shouldIntercept = false;
|
||||||
|
|
||||||
// Check each URL against configured URL mappings
|
// Check against configured URL mappings
|
||||||
// Intercept if ANY URL matches
|
|
||||||
if (window.webdropConfig && window.webdropConfig.urlMappings) {
|
if (window.webdropConfig && window.webdropConfig.urlMappings) {
|
||||||
for (var k = 0; k < currentDragUrls.length; k++) {
|
|
||||||
var dragUrl = currentDragUrls[k];
|
|
||||||
for (var j = 0; j < window.webdropConfig.urlMappings.length; j++) {
|
for (var j = 0; j < window.webdropConfig.urlMappings.length; j++) {
|
||||||
var mapping = window.webdropConfig.urlMappings[j];
|
var mapping = window.webdropConfig.urlMappings[j];
|
||||||
if (dragUrl.toLowerCase().startsWith(mapping.url_prefix.toLowerCase())) {
|
if (currentDragUrl.toLowerCase().startsWith(mapping.url_prefix.toLowerCase())) {
|
||||||
shouldIntercept = true;
|
shouldIntercept = true;
|
||||||
console.log('[Intercept] URL #' + (k+1) + ' matches mapping for:', mapping.local_path);
|
console.log('[Intercept] URL matches mapping for:', mapping.local_path);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (shouldIntercept) break;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback: Check for legacy Z: drive pattern if no config available
|
// Fallback: Check for legacy Z: drive pattern if no config available
|
||||||
for (var k = 0; k < currentDragUrls.length; k++) {
|
shouldIntercept = /^z:/i.test(currentDragUrl);
|
||||||
if (/^z:/i.test(currentDragUrls[k])) {
|
if (shouldIntercept) {
|
||||||
shouldIntercept = true;
|
|
||||||
console.warn('[Intercept] Using fallback Z: drive pattern (no URL mappings configured)');
|
console.warn('[Intercept] Using fallback Z: drive pattern (no URL mappings configured)');
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldIntercept) {
|
if (shouldIntercept) {
|
||||||
console.log('%c[Intercept] PREVENTING browser drag, using Qt for ' + currentDragUrls.length + ' file(s)',
|
console.log('%c[Intercept] PREVENTING browser drag, using Qt',
|
||||||
'background: #F44336; color: white; font-weight: bold; padding: 4px 8px;');
|
'background: #F44336; color: white; font-weight: bold; padding: 4px 8px;');
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -158,15 +132,14 @@
|
||||||
|
|
||||||
ensureChannel(function() {
|
ensureChannel(function() {
|
||||||
if (window.bridge && typeof window.bridge.start_file_drag === 'function') {
|
if (window.bridge && typeof window.bridge.start_file_drag === 'function') {
|
||||||
console.log('%c[Intercept] → Qt: start_file_drag with ' + currentDragUrls.length + ' file(s)', 'color: #9C27B0; font-weight: bold;');
|
console.log('%c[Intercept] → Qt: start_file_drag', 'color: #9C27B0; font-weight: bold;');
|
||||||
// Pass as JSON string to avoid Qt WebChannel array conversion issues
|
window.bridge.start_file_drag(currentDragUrl);
|
||||||
window.bridge.start_file_drag(JSON.stringify(currentDragUrls));
|
|
||||||
} else {
|
} else {
|
||||||
console.error('[Intercept] bridge.start_file_drag not available!');
|
console.error('[Intercept] bridge.start_file_drag not available!');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
currentDragUrls = [];
|
currentDragUrl = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -178,23 +151,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for Angular to register its listeners, then install our handler
|
// Wait for Angular to register its listeners, then install our handler
|
||||||
// Start checking after 3 seconds (give Angular time to load), then retry for up to 30 seconds
|
// Start checking after 2 seconds (give Angular time to load on first page load)
|
||||||
var installRetries = 0;
|
setTimeout(installDragHandler, 2000);
|
||||||
var maxRetries = 27; // 3 initial + 27 retries * 1s = 30s total
|
|
||||||
|
|
||||||
function scheduleInstall() {
|
|
||||||
if (dragHandlerInstalled) return; // Already done
|
|
||||||
installRetries++;
|
|
||||||
console.log('[Intercept] Install attempt', installRetries, '/', maxRetries + 3);
|
|
||||||
installDragHandler();
|
|
||||||
if (!dragHandlerInstalled && installRetries < maxRetries) {
|
|
||||||
setTimeout(scheduleInstall, 1000);
|
|
||||||
} else if (!dragHandlerInstalled) {
|
|
||||||
console.warn('[Intercept] Gave up waiting for Angular handlers after 30s');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(scheduleInstall, 3000);
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// PART 3: QWebChannel connection
|
// PART 3: QWebChannel connection
|
||||||
|
|
@ -230,7 +188,7 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('%c[WebDrop Intercept] Ready! URL-mapped drags will use Qt file drag.',
|
console.log('%c[WebDrop Intercept] Ready! ALT-drag will use Qt file drag.',
|
||||||
'background: #4CAF50; color: white; font-weight: bold; padding: 4px 8px;');
|
'background: #4CAF50; color: white; font-weight: bold; padding: 4px 8px;');
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error('[WebDrop Intercept] FATAL ERROR in bridge script:', e);
|
console.error('[WebDrop Intercept] FATAL ERROR in bridge script:', e);
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
"""Developer Tools for WebDrop Bridge - using Chromium Remote Debugging Protocol."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from PySide6.QtCore import QTimer, QUrl
|
|
||||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
|
||||||
from PySide6.QtWidgets import QVBoxLayout, QWidget
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
__all__ = ["DeveloperToolsWidget"]
|
|
||||||
|
|
||||||
|
|
||||||
class DeveloperToolsWidget(QWidget):
|
|
||||||
"""Embedded Chromium Developer Tools Inspector.
|
|
||||||
|
|
||||||
Loads the Chromium DevTools UI using the Remote Debugging Protocol
|
|
||||||
running on localhost:9222.
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Real HTML/CSS Inspector with live editing
|
|
||||||
- Full JavaScript Console with all DevTools features
|
|
||||||
- Network monitoring
|
|
||||||
- Performance profiling
|
|
||||||
- Storage inspection
|
|
||||||
- All standard Chromium DevTools features
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, web_view: Any) -> None:
|
|
||||||
"""Initialize Developer Tools.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
web_view: The QWebEngineView to debug
|
|
||||||
"""
|
|
||||||
super().__init__()
|
|
||||||
self.web_view = web_view
|
|
||||||
|
|
||||||
# Create layout
|
|
||||||
layout = QVBoxLayout(self)
|
|
||||||
layout.setContentsMargins(0, 0, 0, 0)
|
|
||||||
layout.setSpacing(0)
|
|
||||||
|
|
||||||
# Create WebEngineView for DevTools UI
|
|
||||||
self.dev_tools_view = QWebEngineView()
|
|
||||||
layout.addWidget(self.dev_tools_view)
|
|
||||||
|
|
||||||
# Load DevTools after delay to let debugger start
|
|
||||||
QTimer.singleShot(500, self._load_devtools)
|
|
||||||
|
|
||||||
def _load_devtools(self) -> None:
|
|
||||||
"""Load the DevTools targets page from localhost:9222."""
|
|
||||||
logger.info("Loading DevTools from http://localhost:9222")
|
|
||||||
self.dev_tools_view.load(QUrl("http://localhost:9222"))
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,186 +0,0 @@
|
||||||
// Mouse Event Emulator for Qt WebEngineView
|
|
||||||
// Qt WebEngineView may not forward all mouse events to JavaScript properly
|
|
||||||
// This script uses polling with document.elementFromPoint() to detect hover changes
|
|
||||||
// and manually dispatches mouseover/mouseenter/mouseleave events.
|
|
||||||
// ALSO: Injects a CSS stylesheet that simulates :hover effects using classes
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
try {
|
|
||||||
if (window.__mouse_emulator_injected) return;
|
|
||||||
window.__mouse_emulator_injected = true;
|
|
||||||
|
|
||||||
console.log("[MouseEventEmulator] Initialized - polling for hover state changes");
|
|
||||||
|
|
||||||
// ========================================================
|
|
||||||
// PART 1: Inject CSS stylesheet for hover simulation
|
|
||||||
// ========================================================
|
|
||||||
|
|
||||||
var style = document.createElement("style");
|
|
||||||
style.type = "text/css";
|
|
||||||
style.id = "__mouse_emulator_hover_styles";
|
|
||||||
style.innerHTML = `
|
|
||||||
/* Checkbox hover simulation */
|
|
||||||
input[type="checkbox"].__mouse_hover {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Link hover simulation */
|
|
||||||
a.__mouse_hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (document.head) {
|
|
||||||
document.head.insertBefore(style, document.head.firstChild);
|
|
||||||
} else {
|
|
||||||
document.body.insertBefore(style, document.body.firstChild);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================================
|
|
||||||
// PART 2: Track hover state and apply hover class
|
|
||||||
// ========================================================
|
|
||||||
|
|
||||||
var lastElement = null;
|
|
||||||
var lastX = -1;
|
|
||||||
var lastY = -1;
|
|
||||||
|
|
||||||
// High-frequency polling to detect element changes at mouse position
|
|
||||||
var pollIntervalId = setInterval(function() {
|
|
||||||
if (!window.__lastMousePos) {
|
|
||||||
window.__lastMousePos = { x: 0, y: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
var x = window.__lastMousePos.x;
|
|
||||||
var y = window.__lastMousePos.y;
|
|
||||||
|
|
||||||
lastX = x;
|
|
||||||
lastY = y;
|
|
||||||
|
|
||||||
var element = document.elementFromPoint(x, y);
|
|
||||||
|
|
||||||
if (!element || element === document || element.tagName === "HTML") {
|
|
||||||
if (lastElement && lastElement !== document) {
|
|
||||||
try {
|
|
||||||
lastElement.classList.remove("__mouse_hover");
|
|
||||||
var leaveEvent = new MouseEvent("mouseleave", {
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
view: window,
|
|
||||||
});
|
|
||||||
lastElement.dispatchEvent(leaveEvent);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("[MouseEventEmulator] Error in leave handler:", err);
|
|
||||||
}
|
|
||||||
lastElement = null;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Element changed
|
|
||||||
if (element !== lastElement) {
|
|
||||||
// Remove hover class from previous element
|
|
||||||
if (lastElement && lastElement !== document && lastElement !== element) {
|
|
||||||
try {
|
|
||||||
lastElement.classList.remove("__mouse_hover");
|
|
||||||
var leaveEvent = new MouseEvent("mouseleave", {
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
view: window,
|
|
||||||
clientX: x,
|
|
||||||
clientY: y,
|
|
||||||
});
|
|
||||||
lastElement.dispatchEvent(leaveEvent);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("[MouseEventEmulator] Error dispatching mouseleave:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add hover class and dispatch events for new element
|
|
||||||
if (element) {
|
|
||||||
try {
|
|
||||||
element.classList.add("__mouse_hover");
|
|
||||||
|
|
||||||
var overEvent = new MouseEvent("mouseover", {
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
view: window,
|
|
||||||
clientX: x,
|
|
||||||
clientY: y,
|
|
||||||
});
|
|
||||||
element.dispatchEvent(overEvent);
|
|
||||||
|
|
||||||
var enterEvent = new MouseEvent("mouseenter", {
|
|
||||||
bubbles: false,
|
|
||||||
cancelable: true,
|
|
||||||
view: window,
|
|
||||||
clientX: x,
|
|
||||||
clientY: y,
|
|
||||||
});
|
|
||||||
element.dispatchEvent(enterEvent);
|
|
||||||
|
|
||||||
var moveEvent = new MouseEvent("mousemove", {
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
view: window,
|
|
||||||
clientX: x,
|
|
||||||
clientY: y,
|
|
||||||
});
|
|
||||||
element.dispatchEvent(moveEvent);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("[MouseEventEmulator] Error dispatching mouse events:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lastElement = element;
|
|
||||||
}
|
|
||||||
}, 50);
|
|
||||||
|
|
||||||
// Track mouse position from all available events
|
|
||||||
document.addEventListener(
|
|
||||||
"mousemove",
|
|
||||||
function(e) {
|
|
||||||
window.__lastMousePos = { x: e.clientX, y: e.clientY };
|
|
||||||
},
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
document.addEventListener(
|
|
||||||
"mousedown",
|
|
||||||
function(e) {
|
|
||||||
window.__lastMousePos = { x: e.clientX, y: e.clientY };
|
|
||||||
},
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
document.addEventListener(
|
|
||||||
"mouseup",
|
|
||||||
function(e) {
|
|
||||||
window.__lastMousePos = { x: e.clientX, y: e.clientY };
|
|
||||||
},
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
document.addEventListener(
|
|
||||||
"mouseover",
|
|
||||||
function(e) {
|
|
||||||
window.__lastMousePos = { x: e.clientX, y: e.clientY };
|
|
||||||
},
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
document.addEventListener(
|
|
||||||
"mouseenter",
|
|
||||||
function(e) {
|
|
||||||
window.__lastMousePos = { x: e.clientX, y: e.clientY };
|
|
||||||
},
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("[MouseEventEmulator] Ready - polling enabled for hover state detection");
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[MouseEventEmulator] FATAL ERROR:", e);
|
|
||||||
if (e.stack) {
|
|
||||||
console.error("[MouseEventEmulator] Stack:", e.stack);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
@ -1,24 +1,21 @@
|
||||||
"""Restricted web view with URL whitelist enforcement for Kiosk-mode."""
|
"""Restricted web view with URL whitelist enforcement for Kiosk-mode."""
|
||||||
|
|
||||||
import fnmatch
|
import fnmatch
|
||||||
import hashlib
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional, Union
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
from PySide6.QtCore import QStandardPaths, QUrl
|
from PySide6.QtCore import QStandardPaths, QUrl
|
||||||
from PySide6.QtGui import QDesktopServices
|
from PySide6.QtGui import QDesktopServices
|
||||||
from PySide6.QtWebEngineCore import (
|
from PySide6.QtWebEngineCore import QWebEngineNavigationRequest, QWebEnginePage, QWebEngineProfile
|
||||||
QWebEngineNavigationRequest,
|
|
||||||
QWebEnginePage,
|
|
||||||
QWebEngineProfile,
|
|
||||||
QWebEngineSettings,
|
|
||||||
)
|
|
||||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class CustomWebEnginePage(QWebEnginePage):
|
class CustomWebEnginePage(QWebEnginePage):
|
||||||
"""Custom page that handles new window requests for downloads."""
|
"""Custom page that handles new window requests for downloads."""
|
||||||
|
|
||||||
|
|
@ -111,53 +108,21 @@ class RestrictedWebEngineView(QWebEngineView):
|
||||||
If allowed_urls is empty, no restrictions are applied.
|
If allowed_urls is empty, no restrictions are applied.
|
||||||
If allowed_urls is not empty, only matching URLs are loaded in the view.
|
If allowed_urls is not empty, only matching URLs are loaded in the view.
|
||||||
Non-matching URLs open in the system default browser.
|
Non-matching URLs open in the system default browser.
|
||||||
|
|
||||||
Each webapp_url gets an isolated profile to prevent cache corruption
|
|
||||||
from old domains affecting new domains.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, allowed_urls: Optional[List[str]] = None, webapp_url: Optional[str] = None):
|
def __init__(self, allowed_urls: Optional[List[str]] = None):
|
||||||
"""Initialize the restricted web view.
|
"""Initialize the restricted web view.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
allowed_urls: List of allowed URL patterns (empty = no restriction)
|
allowed_urls: List of allowed URL patterns (empty = no restriction)
|
||||||
Patterns support wildcards: *.example.com, localhost, etc.
|
Patterns support wildcards: *.example.com, localhost, etc.
|
||||||
webapp_url: The web application URL for profile isolation. If provided,
|
|
||||||
creates a unique profile per domain to avoid cache corruption.
|
|
||||||
"""
|
"""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.allowed_urls = allowed_urls or []
|
self.allowed_urls = allowed_urls or []
|
||||||
self.webapp_url = webapp_url
|
|
||||||
|
|
||||||
# Create persistent profile for cookie and session storage
|
# Create persistent profile for cookie and session storage
|
||||||
# Profile is unique per domain to prevent cache corruption
|
|
||||||
self.profile = self._create_persistent_profile()
|
self.profile = self._create_persistent_profile()
|
||||||
|
|
||||||
# Configure WebEngine settings on the profile for proper JavaScript and mouse event support
|
|
||||||
settings = self.profile.settings()
|
|
||||||
|
|
||||||
# Enable JavaScript (required for mouseover events and interactive features)
|
|
||||||
settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptEnabled, True)
|
|
||||||
|
|
||||||
# Enable JavaScript access to clipboard (some web apps need this)
|
|
||||||
settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanAccessClipboard, True)
|
|
||||||
|
|
||||||
# Enable JavaScript to open windows (for dialogs, popups)
|
|
||||||
settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows, True)
|
|
||||||
|
|
||||||
# Enable local content access (needed for drag operations)
|
|
||||||
settings.setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessFileUrls, True)
|
|
||||||
|
|
||||||
# Allow local content to access remote resources (some web apps may need this)
|
|
||||||
settings.setAttribute(
|
|
||||||
QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, False
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"RestrictedWebEngineView WebEngine settings configured: "
|
|
||||||
"JavaScript=enabled, Clipboard=enabled, WindowOpen=enabled, LocalFileAccess=enabled"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Use custom page for better download handling with persistent profile
|
# Use custom page for better download handling with persistent profile
|
||||||
custom_page = CustomWebEnginePage(self.profile, self)
|
custom_page = CustomWebEnginePage(self.profile, self)
|
||||||
self.setPage(custom_page)
|
self.setPage(custom_page)
|
||||||
|
|
@ -166,23 +131,6 @@ class RestrictedWebEngineView(QWebEngineView):
|
||||||
"RestrictedWebEngineView initialized with CustomWebEnginePage and persistent profile"
|
"RestrictedWebEngineView initialized with CustomWebEnginePage and persistent profile"
|
||||||
)
|
)
|
||||||
|
|
||||||
# CRITICAL: Also configure settings on the page itself after setPage()
|
|
||||||
# This ensures Page-level settings override Profile defaults for event handling
|
|
||||||
page_settings = self.page().settings()
|
|
||||||
page_settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptEnabled, True)
|
|
||||||
page_settings.setAttribute(
|
|
||||||
QWebEngineSettings.WebAttribute.JavascriptCanAccessClipboard, True
|
|
||||||
)
|
|
||||||
page_settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows, True)
|
|
||||||
page_settings.setAttribute(
|
|
||||||
QWebEngineSettings.WebAttribute.LocalContentCanAccessFileUrls, True
|
|
||||||
)
|
|
||||||
page_settings.setAttribute(
|
|
||||||
QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, False
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug("Page-level WebEngine settings configured for mouse event handling")
|
|
||||||
|
|
||||||
# Connect to navigation request handler
|
# Connect to navigation request handler
|
||||||
self.page().navigationRequested.connect(self._on_navigation_requested)
|
self.page().navigationRequested.connect(self._on_navigation_requested)
|
||||||
|
|
||||||
|
|
@ -193,9 +141,6 @@ class RestrictedWebEngineView(QWebEngineView):
|
||||||
authentication sessions (e.g., Microsoft login) to persist
|
authentication sessions (e.g., Microsoft login) to persist
|
||||||
across application restarts.
|
across application restarts.
|
||||||
|
|
||||||
Each unique webapp domain gets its own profile to prevent
|
|
||||||
cache corruption from old domains affecting new domains.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Configured QWebEngineProfile with persistent storage
|
Configured QWebEngineProfile with persistent storage
|
||||||
"""
|
"""
|
||||||
|
|
@ -204,32 +149,14 @@ class RestrictedWebEngineView(QWebEngineView):
|
||||||
QStandardPaths.StandardLocation.AppDataLocation
|
QStandardPaths.StandardLocation.AppDataLocation
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create unique profile name based on webapp_url domain
|
|
||||||
# This ensures different domains get isolated profiles
|
|
||||||
if self.webapp_url:
|
|
||||||
# Extract domain/path for profile naming
|
|
||||||
if self.webapp_url.startswith("http://") or self.webapp_url.startswith("https://"):
|
|
||||||
# Remote URL - use domain
|
|
||||||
url_obj = QUrl(self.webapp_url)
|
|
||||||
domain = url_obj.host() or "remote"
|
|
||||||
else:
|
|
||||||
# Local file - use hash of path
|
|
||||||
domain = "local"
|
|
||||||
else:
|
|
||||||
domain = "default"
|
|
||||||
|
|
||||||
# Create a stable hash of the domain
|
|
||||||
# This creates a unique but consistent profile name per domain
|
|
||||||
domain_hash = hashlib.md5(domain.encode()).hexdigest()[:8]
|
|
||||||
profile_name = f"webdrop_bridge_{domain_hash}"
|
|
||||||
|
|
||||||
# Create profile directory path
|
# Create profile directory path
|
||||||
profile_path = Path(app_data_dir) / "webdrop_bridge" / profile_name
|
profile_path = Path(app_data_dir) / "WebEngineProfile"
|
||||||
profile_path.mkdir(parents=True, exist_ok=True)
|
profile_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Create persistent profile with custom storage location
|
# Create persistent profile with custom storage location
|
||||||
# Using unique profile name so different domains have isolated caches
|
# Using "WebDropBridge" as the profile name
|
||||||
profile = QWebEngineProfile(profile_name)
|
# Note: No parent specified so we control the lifecycle
|
||||||
|
profile = QWebEngineProfile("WebDropBridge")
|
||||||
profile.setPersistentStoragePath(str(profile_path))
|
profile.setPersistentStoragePath(str(profile_path))
|
||||||
|
|
||||||
# Configure persistent cookies (critical for authentication)
|
# Configure persistent cookies (critical for authentication)
|
||||||
|
|
@ -243,8 +170,7 @@ class RestrictedWebEngineView(QWebEngineView):
|
||||||
# Set cache size to 100 MB
|
# Set cache size to 100 MB
|
||||||
profile.setHttpCacheMaximumSize(100 * 1024 * 1024)
|
profile.setHttpCacheMaximumSize(100 * 1024 * 1024)
|
||||||
|
|
||||||
logger.debug(f"Created persistent profile '{profile_name}' at: {profile_path}")
|
logger.debug(f"Created persistent profile at: {profile_path}")
|
||||||
logger.debug(f"Profile domain identifier: {domain}")
|
|
||||||
logger.debug("Cookies policy: ForcePersistentCookies")
|
logger.debug("Cookies policy: ForcePersistentCookies")
|
||||||
logger.debug("HTTP cache: DiskHttpCache (100 MB)")
|
logger.debug("HTTP cache: DiskHttpCache (100 MB)")
|
||||||
|
|
||||||
|
|
@ -278,7 +204,7 @@ class RestrictedWebEngineView(QWebEngineView):
|
||||||
- Exact domain matches: example.com
|
- Exact domain matches: example.com
|
||||||
- Wildcard patterns: *.example.com
|
- Wildcard patterns: *.example.com
|
||||||
- Localhost variations: localhost, 127.0.0.1
|
- Localhost variations: localhost, 127.0.0.1
|
||||||
- Internal/local URLs: file://, data:, about:, blob:, qrc:
|
- File URLs: file://...
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url: QUrl to check
|
url: QUrl to check
|
||||||
|
|
@ -290,8 +216,8 @@ class RestrictedWebEngineView(QWebEngineView):
|
||||||
host = url.host()
|
host = url.host()
|
||||||
scheme = url.scheme()
|
scheme = url.scheme()
|
||||||
|
|
||||||
# Allow internal browser/Qt schemes (never send these to the OS)
|
# Allow file:// URLs (local webapp)
|
||||||
if scheme in ("file", "data", "about", "blob", "qrc"):
|
if scheme == "file":
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# If no whitelist, allow all URLs
|
# If no whitelist, allow all URLs
|
||||||
|
|
@ -316,19 +242,3 @@ class RestrictedWebEngineView(QWebEngineView):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def clear_cache_and_cookies(self) -> None:
|
|
||||||
"""Clear the profile cache and cookies.
|
|
||||||
|
|
||||||
Use this method when the webapp URL changes to prevent cache corruption
|
|
||||||
from old domains affecting the new domain's authentication.
|
|
||||||
"""
|
|
||||||
logger.debug(f"Clearing cache and cookies for profile: {self.profile.storageName()}")
|
|
||||||
|
|
||||||
# Clear all cookies
|
|
||||||
self.profile.cookieStore().deleteAllCookies()
|
|
||||||
|
|
||||||
# Clear cache
|
|
||||||
self.profile.clearHttpCache()
|
|
||||||
|
|
||||||
logger.debug("Cache and cookies cleared successfully")
|
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QComboBox,
|
|
||||||
QDialog,
|
QDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
|
|
@ -13,6 +13,7 @@ from PySide6.QtWidgets import (
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
QListWidget,
|
QListWidget,
|
||||||
|
QListWidgetItem,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QSpinBox,
|
QSpinBox,
|
||||||
QTableWidget,
|
QTableWidget,
|
||||||
|
|
@ -24,16 +25,23 @@ from PySide6.QtWidgets import (
|
||||||
|
|
||||||
from webdrop_bridge.config import Config, ConfigurationError
|
from webdrop_bridge.config import Config, ConfigurationError
|
||||||
from webdrop_bridge.core.config_manager import ConfigExporter, ConfigProfile, ConfigValidator
|
from webdrop_bridge.core.config_manager import ConfigExporter, ConfigProfile, ConfigValidator
|
||||||
from webdrop_bridge.utils.i18n import get_available_languages, tr
|
|
||||||
from webdrop_bridge.utils.logging import reconfigure_logging
|
from webdrop_bridge.utils.logging import reconfigure_logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SettingsDialog(QDialog):
|
class SettingsDialog(QDialog):
|
||||||
"""Dialog for managing application settings and configuration."""
|
"""Dialog for managing application settings and configuration.
|
||||||
|
|
||||||
def __init__(self, config: Config, parent: Optional[QWidget] = None):
|
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.
|
"""Initialize the settings dialog.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -42,8 +50,8 @@ class SettingsDialog(QDialog):
|
||||||
"""
|
"""
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.config = config
|
self.config = config
|
||||||
self.profile_manager = ConfigProfile(config.config_dir_name)
|
self.profile_manager = ConfigProfile()
|
||||||
self.setWindowTitle(tr("settings.title"))
|
self.setWindowTitle("Settings")
|
||||||
self.setGeometry(100, 100, 600, 500)
|
self.setGeometry(100, 100, 600, 500)
|
||||||
|
|
||||||
self.setup_ui()
|
self.setup_ui()
|
||||||
|
|
@ -52,16 +60,20 @@ class SettingsDialog(QDialog):
|
||||||
"""Set up the dialog UI with tabs."""
|
"""Set up the dialog UI with tabs."""
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
|
# Create tab widget
|
||||||
self.tabs = QTabWidget()
|
self.tabs = QTabWidget()
|
||||||
self.tabs.addTab(self._create_general_tab(), tr("settings.tab.general"))
|
|
||||||
self.tabs.addTab(self._create_web_source_tab(), tr("settings.tab.web_source"))
|
# Add tabs
|
||||||
self.tabs.addTab(self._create_paths_tab(), tr("settings.tab.paths"))
|
self.tabs.addTab(self._create_web_source_tab(), "Web Source")
|
||||||
self.tabs.addTab(self._create_urls_tab(), tr("settings.tab.urls"))
|
self.tabs.addTab(self._create_paths_tab(), "Paths")
|
||||||
self.tabs.addTab(self._create_logging_tab(), tr("settings.tab.logging"))
|
self.tabs.addTab(self._create_urls_tab(), "URLs")
|
||||||
self.tabs.addTab(self._create_window_tab(), tr("settings.tab.window"))
|
self.tabs.addTab(self._create_logging_tab(), "Logging")
|
||||||
self.tabs.addTab(self._create_profiles_tab(), tr("settings.tab.profiles"))
|
self.tabs.addTab(self._create_window_tab(), "Window")
|
||||||
|
self.tabs.addTab(self._create_profiles_tab(), "Profiles")
|
||||||
|
|
||||||
layout.addWidget(self.tabs)
|
layout.addWidget(self.tabs)
|
||||||
|
|
||||||
|
# Add buttons
|
||||||
button_box = QDialogButtonBox(
|
button_box = QDialogButtonBox(
|
||||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||||
)
|
)
|
||||||
|
|
@ -72,23 +84,31 @@ class SettingsDialog(QDialog):
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
def accept(self) -> None:
|
def accept(self) -> None:
|
||||||
"""Handle OK button - save configuration changes to file."""
|
"""Handle OK button - save configuration changes to file.
|
||||||
|
|
||||||
|
Validates configuration and saves to the default config path.
|
||||||
|
Applies log level changes immediately in the running application.
|
||||||
|
If validation or save fails, shows error and stays in dialog.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Get updated configuration data from UI
|
||||||
config_data = self.get_config_data()
|
config_data = self.get_config_data()
|
||||||
|
|
||||||
|
# Convert URL mappings from dict to URLMapping objects
|
||||||
from webdrop_bridge.config import URLMapping
|
from webdrop_bridge.config import URLMapping
|
||||||
|
|
||||||
url_mappings = [
|
url_mappings = [
|
||||||
URLMapping(url_prefix=m["url_prefix"], local_path=m["local_path"])
|
URLMapping(
|
||||||
|
url_prefix=m["url_prefix"],
|
||||||
|
local_path=m["local_path"]
|
||||||
|
)
|
||||||
for m in config_data["url_mappings"]
|
for m in config_data["url_mappings"]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Update the config object with new values
|
||||||
old_log_level = self.config.log_level
|
old_log_level = self.config.log_level
|
||||||
self.config.language = config_data["language"]
|
|
||||||
self.config.log_level = config_data["log_level"]
|
self.config.log_level = config_data["log_level"]
|
||||||
self.config.log_file = (
|
self.config.log_file = Path(config_data["log_file"]) if config_data["log_file"] else None
|
||||||
Path(config_data["log_file"]) if config_data["log_file"] else None
|
|
||||||
)
|
|
||||||
self.config.allowed_roots = [Path(r).resolve() for r in config_data["allowed_roots"]]
|
self.config.allowed_roots = [Path(r).resolve() for r in config_data["allowed_roots"]]
|
||||||
self.config.allowed_urls = config_data["allowed_urls"]
|
self.config.allowed_urls = config_data["allowed_urls"]
|
||||||
self.config.webapp_url = config_data["webapp_url"]
|
self.config.webapp_url = config_data["webapp_url"]
|
||||||
|
|
@ -96,21 +116,25 @@ class SettingsDialog(QDialog):
|
||||||
self.config.window_width = config_data["window_width"]
|
self.config.window_width = config_data["window_width"]
|
||||||
self.config.window_height = config_data["window_height"]
|
self.config.window_height = config_data["window_height"]
|
||||||
|
|
||||||
config_path = self.config.get_config_path()
|
# Save to file (creates parent dirs if needed)
|
||||||
|
config_path = Config.get_default_config_path()
|
||||||
self.config.to_file(config_path)
|
self.config.to_file(config_path)
|
||||||
|
|
||||||
logger.info(f"Configuration saved to {config_path}")
|
logger.info(f"Configuration saved to {config_path}")
|
||||||
logger.info(f" Log level: {self.config.log_level} (was: {old_log_level})")
|
logger.info(f" Log level: {self.config.log_level} (was: {old_log_level})")
|
||||||
logger.info(f" Window size: {self.config.window_width}x{self.config.window_height}")
|
logger.info(f" Window size: {self.config.window_width}x{self.config.window_height}")
|
||||||
|
|
||||||
|
# Apply log level change immediately to running application
|
||||||
if old_log_level != self.config.log_level:
|
if old_log_level != self.config.log_level:
|
||||||
|
logger.info(f"🔄 Updating log level: {old_log_level} → {self.config.log_level}")
|
||||||
reconfigure_logging(
|
reconfigure_logging(
|
||||||
logger_name="webdrop_bridge",
|
logger_name="webdrop_bridge",
|
||||||
level=self.config.log_level,
|
level=self.config.log_level,
|
||||||
log_file=self.config.log_file,
|
log_file=self.config.log_file
|
||||||
)
|
)
|
||||||
logger.info(f"Log level updated to {self.config.log_level}")
|
logger.info(f"✅ Log level updated to {self.config.log_level}")
|
||||||
|
|
||||||
|
# Call parent accept to close dialog
|
||||||
super().accept()
|
super().accept()
|
||||||
|
|
||||||
except ConfigurationError as e:
|
except ConfigurationError as e:
|
||||||
|
|
@ -120,70 +144,38 @@ class SettingsDialog(QDialog):
|
||||||
logger.error(f"Failed to save configuration: {e}", exc_info=True)
|
logger.error(f"Failed to save configuration: {e}", exc_info=True)
|
||||||
self._show_error(f"Failed to save configuration:\n\n{e}")
|
self._show_error(f"Failed to save configuration:\n\n{e}")
|
||||||
|
|
||||||
def _create_general_tab(self) -> QWidget:
|
|
||||||
"""Create general settings tab with language selector."""
|
|
||||||
widget = QWidget()
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
|
|
||||||
lang_layout = QHBoxLayout()
|
|
||||||
lang_layout.addWidget(QLabel(tr("settings.general.language_label")))
|
|
||||||
|
|
||||||
self.language_combo = QComboBox()
|
|
||||||
self.language_combo.addItem(tr("settings.general.language_auto"), "auto")
|
|
||||||
available = get_available_languages()
|
|
||||||
current_lang = self.config.language
|
|
||||||
for code, name in available.items():
|
|
||||||
self.language_combo.addItem(name, code)
|
|
||||||
|
|
||||||
idx = self.language_combo.findData(current_lang)
|
|
||||||
if idx >= 0:
|
|
||||||
self.language_combo.setCurrentIndex(idx)
|
|
||||||
|
|
||||||
lang_layout.addWidget(self.language_combo)
|
|
||||||
lang_layout.addStretch()
|
|
||||||
layout.addLayout(lang_layout)
|
|
||||||
|
|
||||||
note = QLabel(tr("settings.general.language_restart_note"))
|
|
||||||
note.setStyleSheet("color: gray; font-size: 11px;")
|
|
||||||
layout.addWidget(note)
|
|
||||||
|
|
||||||
layout.addStretch()
|
|
||||||
widget.setLayout(layout)
|
|
||||||
return widget
|
|
||||||
|
|
||||||
def _create_web_source_tab(self) -> QWidget:
|
def _create_web_source_tab(self) -> QWidget:
|
||||||
"""Create web source configuration tab."""
|
"""Create web source configuration tab."""
|
||||||
|
from PySide6.QtWidgets import QTableWidget, QTableWidgetItem
|
||||||
|
|
||||||
widget = QWidget()
|
widget = QWidget()
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
layout.addWidget(QLabel(tr("settings.web_source.url_label")))
|
# Webapp URL configuration
|
||||||
|
layout.addWidget(QLabel("Web Application URL:"))
|
||||||
url_layout = QHBoxLayout()
|
url_layout = QHBoxLayout()
|
||||||
|
|
||||||
self.webapp_url_input = QLineEdit()
|
self.webapp_url_input = QLineEdit()
|
||||||
self.webapp_url_input.setText(self.config.webapp_url)
|
self.webapp_url_input.setText(self.config.webapp_url)
|
||||||
self.webapp_url_input.setPlaceholderText(
|
self.webapp_url_input.setPlaceholderText("e.g., http://localhost:8080 or file:///./webapp/index.html")
|
||||||
"e.g., http://localhost:8080 or file:///./webapp/index.html"
|
|
||||||
)
|
|
||||||
url_layout.addWidget(self.webapp_url_input)
|
url_layout.addWidget(self.webapp_url_input)
|
||||||
|
|
||||||
open_btn = QPushButton(tr("settings.web_source.open_btn"))
|
open_btn = QPushButton("Open")
|
||||||
open_btn.clicked.connect(self._open_webapp_url)
|
open_btn.clicked.connect(self._open_webapp_url)
|
||||||
url_layout.addWidget(open_btn)
|
url_layout.addWidget(open_btn)
|
||||||
|
|
||||||
layout.addLayout(url_layout)
|
layout.addLayout(url_layout)
|
||||||
|
|
||||||
layout.addWidget(QLabel(tr("settings.web_source.url_mappings_label")))
|
# URL Mappings (Azure Blob URL → Local Path)
|
||||||
|
layout.addWidget(QLabel("URL Mappings (Azure Blob Storage → Local Paths):"))
|
||||||
|
|
||||||
|
# Create table for URL mappings
|
||||||
self.url_mappings_table = QTableWidget()
|
self.url_mappings_table = QTableWidget()
|
||||||
self.url_mappings_table.setColumnCount(2)
|
self.url_mappings_table.setColumnCount(2)
|
||||||
self.url_mappings_table.setHorizontalHeaderLabels(
|
self.url_mappings_table.setHorizontalHeaderLabels(["URL Prefix", "Local Path"])
|
||||||
[
|
|
||||||
tr("settings.web_source.col_url_prefix"),
|
|
||||||
tr("settings.web_source.col_local_path"),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
self.url_mappings_table.horizontalHeader().setStretchLastSection(True)
|
self.url_mappings_table.horizontalHeader().setStretchLastSection(True)
|
||||||
|
|
||||||
|
# Populate from config
|
||||||
for mapping in self.config.url_mappings:
|
for mapping in self.config.url_mappings:
|
||||||
row = self.url_mappings_table.rowCount()
|
row = self.url_mappings_table.rowCount()
|
||||||
self.url_mappings_table.insertRow(row)
|
self.url_mappings_table.insertRow(row)
|
||||||
|
|
@ -192,17 +184,18 @@ class SettingsDialog(QDialog):
|
||||||
|
|
||||||
layout.addWidget(self.url_mappings_table)
|
layout.addWidget(self.url_mappings_table)
|
||||||
|
|
||||||
|
# Buttons for URL mapping management
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
|
|
||||||
add_mapping_btn = QPushButton(tr("settings.web_source.add_mapping_btn"))
|
add_mapping_btn = QPushButton("Add Mapping")
|
||||||
add_mapping_btn.clicked.connect(self._add_url_mapping)
|
add_mapping_btn.clicked.connect(self._add_url_mapping)
|
||||||
button_layout.addWidget(add_mapping_btn)
|
button_layout.addWidget(add_mapping_btn)
|
||||||
|
|
||||||
edit_mapping_btn = QPushButton(tr("settings.web_source.edit_mapping_btn"))
|
edit_mapping_btn = QPushButton("Edit Selected")
|
||||||
edit_mapping_btn.clicked.connect(self._edit_url_mapping)
|
edit_mapping_btn.clicked.connect(self._edit_url_mapping)
|
||||||
button_layout.addWidget(edit_mapping_btn)
|
button_layout.addWidget(edit_mapping_btn)
|
||||||
|
|
||||||
remove_mapping_btn = QPushButton(tr("settings.web_source.remove_mapping_btn"))
|
remove_mapping_btn = QPushButton("Remove Selected")
|
||||||
remove_mapping_btn.clicked.connect(self._remove_url_mapping)
|
remove_mapping_btn.clicked.connect(self._remove_url_mapping)
|
||||||
button_layout.addWidget(remove_mapping_btn)
|
button_layout.addWidget(remove_mapping_btn)
|
||||||
|
|
||||||
|
|
@ -215,10 +208,9 @@ class SettingsDialog(QDialog):
|
||||||
def _open_webapp_url(self) -> None:
|
def _open_webapp_url(self) -> None:
|
||||||
"""Open the webapp URL in the default browser."""
|
"""Open the webapp URL in the default browser."""
|
||||||
import webbrowser
|
import webbrowser
|
||||||
|
|
||||||
url = self.webapp_url_input.text().strip()
|
url = self.webapp_url_input.text().strip()
|
||||||
if not url:
|
if url:
|
||||||
return
|
# Handle file:// URLs
|
||||||
try:
|
try:
|
||||||
webbrowser.open(url)
|
webbrowser.open(url)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -231,15 +223,15 @@ class SettingsDialog(QDialog):
|
||||||
|
|
||||||
url_prefix, ok1 = QInputDialog.getText(
|
url_prefix, ok1 = QInputDialog.getText(
|
||||||
self,
|
self,
|
||||||
tr("settings.web_source.add_mapping_title"),
|
"Add URL Mapping",
|
||||||
tr("settings.web_source.add_mapping_url_prompt"),
|
"Enter Azure Blob Storage URL prefix:\n(e.g., https://myblob.blob.core.windows.net/container/)"
|
||||||
)
|
)
|
||||||
|
|
||||||
if ok1 and url_prefix:
|
if ok1 and url_prefix:
|
||||||
local_path, ok2 = QInputDialog.getText(
|
local_path, ok2 = QInputDialog.getText(
|
||||||
self,
|
self,
|
||||||
tr("settings.web_source.add_mapping_title"),
|
"Add URL Mapping",
|
||||||
tr("settings.web_source.add_mapping_path_prompt"),
|
"Enter local file system path:\n(e.g., C:\\Share or /mnt/share)"
|
||||||
)
|
)
|
||||||
|
|
||||||
if ok2 and local_path:
|
if ok2 and local_path:
|
||||||
|
|
@ -254,25 +246,25 @@ class SettingsDialog(QDialog):
|
||||||
|
|
||||||
current_row = self.url_mappings_table.currentRow()
|
current_row = self.url_mappings_table.currentRow()
|
||||||
if current_row < 0:
|
if current_row < 0:
|
||||||
self._show_error(tr("settings.web_source.select_mapping_to_edit"))
|
self._show_error("Please select a mapping to edit")
|
||||||
return
|
return
|
||||||
|
|
||||||
url_prefix = self.url_mappings_table.item(current_row, 0).text() # type: ignore
|
url_prefix = self.url_mappings_table.item(current_row, 0).text()
|
||||||
local_path = self.url_mappings_table.item(current_row, 1).text() # type: ignore
|
local_path = self.url_mappings_table.item(current_row, 1).text()
|
||||||
|
|
||||||
new_url_prefix, ok1 = QInputDialog.getText(
|
new_url_prefix, ok1 = QInputDialog.getText(
|
||||||
self,
|
self,
|
||||||
tr("settings.web_source.edit_mapping_title"),
|
"Edit URL Mapping",
|
||||||
tr("settings.web_source.edit_mapping_url_prompt"),
|
"Enter Azure Blob Storage URL prefix:",
|
||||||
text=url_prefix,
|
text=url_prefix
|
||||||
)
|
)
|
||||||
|
|
||||||
if ok1 and new_url_prefix:
|
if ok1 and new_url_prefix:
|
||||||
new_local_path, ok2 = QInputDialog.getText(
|
new_local_path, ok2 = QInputDialog.getText(
|
||||||
self,
|
self,
|
||||||
tr("settings.web_source.edit_mapping_title"),
|
"Edit URL Mapping",
|
||||||
tr("settings.web_source.edit_mapping_path_prompt"),
|
"Enter local file system path:",
|
||||||
text=local_path,
|
text=local_path
|
||||||
)
|
)
|
||||||
|
|
||||||
if ok2 and new_local_path:
|
if ok2 and new_local_path:
|
||||||
|
|
@ -290,20 +282,22 @@ class SettingsDialog(QDialog):
|
||||||
widget = QWidget()
|
widget = QWidget()
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
layout.addWidget(QLabel(tr("settings.paths.label")))
|
layout.addWidget(QLabel("Allowed root directories for file access:"))
|
||||||
|
|
||||||
|
# List widget for paths
|
||||||
self.paths_list = QListWidget()
|
self.paths_list = QListWidget()
|
||||||
for path in self.config.allowed_roots:
|
for path in self.config.allowed_roots:
|
||||||
self.paths_list.addItem(str(path))
|
self.paths_list.addItem(str(path))
|
||||||
layout.addWidget(self.paths_list)
|
layout.addWidget(self.paths_list)
|
||||||
|
|
||||||
|
# Buttons for path management
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
|
|
||||||
add_path_btn = QPushButton(tr("settings.paths.add_btn"))
|
add_path_btn = QPushButton("Add Path")
|
||||||
add_path_btn.clicked.connect(self._add_path)
|
add_path_btn.clicked.connect(self._add_path)
|
||||||
button_layout.addWidget(add_path_btn)
|
button_layout.addWidget(add_path_btn)
|
||||||
|
|
||||||
remove_path_btn = QPushButton(tr("settings.paths.remove_btn"))
|
remove_path_btn = QPushButton("Remove Selected")
|
||||||
remove_path_btn.clicked.connect(self._remove_path)
|
remove_path_btn.clicked.connect(self._remove_path)
|
||||||
button_layout.addWidget(remove_path_btn)
|
button_layout.addWidget(remove_path_btn)
|
||||||
|
|
||||||
|
|
@ -318,20 +312,22 @@ class SettingsDialog(QDialog):
|
||||||
widget = QWidget()
|
widget = QWidget()
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
layout.addWidget(QLabel(tr("settings.urls.label")))
|
layout.addWidget(QLabel("Allowed web URLs (supports wildcards like http://*.example.com):"))
|
||||||
|
|
||||||
|
# List widget for URLs
|
||||||
self.urls_list = QListWidget()
|
self.urls_list = QListWidget()
|
||||||
for url in self.config.allowed_urls:
|
for url in self.config.allowed_urls:
|
||||||
self.urls_list.addItem(url)
|
self.urls_list.addItem(url)
|
||||||
layout.addWidget(self.urls_list)
|
layout.addWidget(self.urls_list)
|
||||||
|
|
||||||
|
# Buttons for URL management
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
|
|
||||||
add_url_btn = QPushButton(tr("settings.urls.add_btn"))
|
add_url_btn = QPushButton("Add URL")
|
||||||
add_url_btn.clicked.connect(self._add_url)
|
add_url_btn.clicked.connect(self._add_url)
|
||||||
button_layout.addWidget(add_url_btn)
|
button_layout.addWidget(add_url_btn)
|
||||||
|
|
||||||
remove_url_btn = QPushButton(tr("settings.urls.remove_btn"))
|
remove_url_btn = QPushButton("Remove Selected")
|
||||||
remove_url_btn.clicked.connect(self._remove_url)
|
remove_url_btn.clicked.connect(self._remove_url)
|
||||||
button_layout.addWidget(remove_url_btn)
|
button_layout.addWidget(remove_url_btn)
|
||||||
|
|
||||||
|
|
@ -346,22 +342,26 @@ class SettingsDialog(QDialog):
|
||||||
widget = QWidget()
|
widget = QWidget()
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
layout.addWidget(QLabel(tr("settings.logging.level_label")))
|
# Log level selection
|
||||||
|
layout.addWidget(QLabel("Log Level:"))
|
||||||
|
from PySide6.QtWidgets import QComboBox
|
||||||
self.log_level_combo: QComboBox = self._create_log_level_widget()
|
self.log_level_combo: QComboBox = self._create_log_level_widget()
|
||||||
layout.addWidget(self.log_level_combo)
|
layout.addWidget(self.log_level_combo)
|
||||||
|
|
||||||
layout.addWidget(QLabel(tr("settings.logging.file_label")))
|
# Log file path
|
||||||
|
layout.addWidget(QLabel("Log File (optional):"))
|
||||||
log_file_layout = QHBoxLayout()
|
log_file_layout = QHBoxLayout()
|
||||||
|
|
||||||
self.log_file_input = QLineEdit()
|
self.log_file_input = QLineEdit()
|
||||||
self.log_file_input.setText(str(self.config.log_file) if self.config.log_file else "")
|
self.log_file_input.setText(str(self.config.log_file) if self.config.log_file else "")
|
||||||
log_file_layout.addWidget(self.log_file_input)
|
log_file_layout.addWidget(self.log_file_input)
|
||||||
|
|
||||||
browse_btn = QPushButton(tr("settings.logging.browse_btn"))
|
browse_btn = QPushButton("Browse...")
|
||||||
browse_btn.clicked.connect(self._browse_log_file)
|
browse_btn.clicked.connect(self._browse_log_file)
|
||||||
log_file_layout.addWidget(browse_btn)
|
log_file_layout.addWidget(browse_btn)
|
||||||
|
|
||||||
layout.addLayout(log_file_layout)
|
layout.addLayout(log_file_layout)
|
||||||
|
|
||||||
layout.addStretch()
|
layout.addStretch()
|
||||||
widget.setLayout(layout)
|
widget.setLayout(layout)
|
||||||
return widget
|
return widget
|
||||||
|
|
@ -371,8 +371,9 @@ class SettingsDialog(QDialog):
|
||||||
widget = QWidget()
|
widget = QWidget()
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
|
# Window width
|
||||||
width_layout = QHBoxLayout()
|
width_layout = QHBoxLayout()
|
||||||
width_layout.addWidget(QLabel(tr("settings.window.width_label")))
|
width_layout.addWidget(QLabel("Window Width:"))
|
||||||
self.width_spin = QSpinBox()
|
self.width_spin = QSpinBox()
|
||||||
self.width_spin.setMinimum(400)
|
self.width_spin.setMinimum(400)
|
||||||
self.width_spin.setMaximum(5000)
|
self.width_spin.setMaximum(5000)
|
||||||
|
|
@ -381,8 +382,9 @@ class SettingsDialog(QDialog):
|
||||||
width_layout.addStretch()
|
width_layout.addStretch()
|
||||||
layout.addLayout(width_layout)
|
layout.addLayout(width_layout)
|
||||||
|
|
||||||
|
# Window height
|
||||||
height_layout = QHBoxLayout()
|
height_layout = QHBoxLayout()
|
||||||
height_layout.addWidget(QLabel(tr("settings.window.height_label")))
|
height_layout.addWidget(QLabel("Window Height:"))
|
||||||
self.height_spin = QSpinBox()
|
self.height_spin = QSpinBox()
|
||||||
self.height_spin.setMinimum(300)
|
self.height_spin.setMinimum(300)
|
||||||
self.height_spin.setMaximum(5000)
|
self.height_spin.setMaximum(5000)
|
||||||
|
|
@ -400,35 +402,38 @@ class SettingsDialog(QDialog):
|
||||||
widget = QWidget()
|
widget = QWidget()
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
layout.addWidget(QLabel(tr("settings.profiles.label")))
|
layout.addWidget(QLabel("Saved Configuration Profiles:"))
|
||||||
|
|
||||||
|
# List of profiles
|
||||||
self.profiles_list = QListWidget()
|
self.profiles_list = QListWidget()
|
||||||
self._refresh_profiles_list()
|
self._refresh_profiles_list()
|
||||||
layout.addWidget(self.profiles_list)
|
layout.addWidget(self.profiles_list)
|
||||||
|
|
||||||
|
# Profile management buttons
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
|
|
||||||
save_profile_btn = QPushButton(tr("settings.profiles.save_btn"))
|
save_profile_btn = QPushButton("Save as Profile")
|
||||||
save_profile_btn.clicked.connect(self._save_profile)
|
save_profile_btn.clicked.connect(self._save_profile)
|
||||||
button_layout.addWidget(save_profile_btn)
|
button_layout.addWidget(save_profile_btn)
|
||||||
|
|
||||||
load_profile_btn = QPushButton(tr("settings.profiles.load_btn"))
|
load_profile_btn = QPushButton("Load Profile")
|
||||||
load_profile_btn.clicked.connect(self._load_profile)
|
load_profile_btn.clicked.connect(self._load_profile)
|
||||||
button_layout.addWidget(load_profile_btn)
|
button_layout.addWidget(load_profile_btn)
|
||||||
|
|
||||||
delete_profile_btn = QPushButton(tr("settings.profiles.delete_btn"))
|
delete_profile_btn = QPushButton("Delete Profile")
|
||||||
delete_profile_btn.clicked.connect(self._delete_profile)
|
delete_profile_btn.clicked.connect(self._delete_profile)
|
||||||
button_layout.addWidget(delete_profile_btn)
|
button_layout.addWidget(delete_profile_btn)
|
||||||
|
|
||||||
layout.addLayout(button_layout)
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
|
# Export/Import buttons
|
||||||
export_layout = QHBoxLayout()
|
export_layout = QHBoxLayout()
|
||||||
|
|
||||||
export_btn = QPushButton(tr("settings.profiles.export_btn"))
|
export_btn = QPushButton("Export Configuration")
|
||||||
export_btn.clicked.connect(self._export_config)
|
export_btn.clicked.connect(self._export_config)
|
||||||
export_layout.addWidget(export_btn)
|
export_layout.addWidget(export_btn)
|
||||||
|
|
||||||
import_btn = QPushButton(tr("settings.profiles.import_btn"))
|
import_btn = QPushButton("Import Configuration")
|
||||||
import_btn.clicked.connect(self._import_config)
|
import_btn.clicked.connect(self._import_config)
|
||||||
export_layout.addWidget(import_btn)
|
export_layout.addWidget(import_btn)
|
||||||
|
|
||||||
|
|
@ -438,8 +443,10 @@ class SettingsDialog(QDialog):
|
||||||
widget.setLayout(layout)
|
widget.setLayout(layout)
|
||||||
return widget
|
return widget
|
||||||
|
|
||||||
def _create_log_level_widget(self) -> QComboBox:
|
def _create_log_level_widget(self):
|
||||||
"""Create log level selection widget."""
|
"""Create log level selection widget."""
|
||||||
|
from PySide6.QtWidgets import QComboBox
|
||||||
|
|
||||||
combo = QComboBox()
|
combo = QComboBox()
|
||||||
levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||||
combo.addItems(levels)
|
combo.addItems(levels)
|
||||||
|
|
@ -448,7 +455,7 @@ class SettingsDialog(QDialog):
|
||||||
|
|
||||||
def _add_path(self) -> None:
|
def _add_path(self) -> None:
|
||||||
"""Add a new allowed path."""
|
"""Add a new allowed path."""
|
||||||
path = QFileDialog.getExistingDirectory(self, tr("settings.paths.select_dir_title"))
|
path = QFileDialog.getExistingDirectory(self, "Select Directory to Allow")
|
||||||
if path:
|
if path:
|
||||||
self.paths_list.addItem(path)
|
self.paths_list.addItem(path)
|
||||||
|
|
||||||
|
|
@ -462,7 +469,9 @@ class SettingsDialog(QDialog):
|
||||||
from PySide6.QtWidgets import QInputDialog
|
from PySide6.QtWidgets import QInputDialog
|
||||||
|
|
||||||
url, ok = QInputDialog.getText(
|
url, ok = QInputDialog.getText(
|
||||||
self, tr("settings.urls.add_title"), tr("settings.urls.add_prompt")
|
self,
|
||||||
|
"Add URL",
|
||||||
|
"Enter URL pattern (e.g., http://example.com or http://*.example.com):"
|
||||||
)
|
)
|
||||||
if ok and url:
|
if ok and url:
|
||||||
self.urls_list.addItem(url)
|
self.urls_list.addItem(url)
|
||||||
|
|
@ -476,9 +485,9 @@ class SettingsDialog(QDialog):
|
||||||
"""Browse for log file location."""
|
"""Browse for log file location."""
|
||||||
file_path, _ = QFileDialog.getSaveFileName(
|
file_path, _ = QFileDialog.getSaveFileName(
|
||||||
self,
|
self,
|
||||||
tr("settings.logging.select_file_title"),
|
"Select Log File",
|
||||||
str(Path.home()),
|
str(Path.home()),
|
||||||
"Log Files (*.log);;All Files (*)",
|
"Log Files (*.log);;All Files (*)"
|
||||||
)
|
)
|
||||||
if file_path:
|
if file_path:
|
||||||
self.log_file_input.setText(file_path)
|
self.log_file_input.setText(file_path)
|
||||||
|
|
@ -494,7 +503,9 @@ class SettingsDialog(QDialog):
|
||||||
from PySide6.QtWidgets import QInputDialog
|
from PySide6.QtWidgets import QInputDialog
|
||||||
|
|
||||||
profile_name, ok = QInputDialog.getText(
|
profile_name, ok = QInputDialog.getText(
|
||||||
self, tr("settings.profiles.save_title"), tr("settings.profiles.save_prompt")
|
self,
|
||||||
|
"Save Profile",
|
||||||
|
"Enter profile name (e.g., work, personal):"
|
||||||
)
|
)
|
||||||
|
|
||||||
if ok and profile_name:
|
if ok and profile_name:
|
||||||
|
|
@ -508,7 +519,7 @@ class SettingsDialog(QDialog):
|
||||||
"""Load a saved profile."""
|
"""Load a saved profile."""
|
||||||
current_item = self.profiles_list.currentItem()
|
current_item = self.profiles_list.currentItem()
|
||||||
if not current_item:
|
if not current_item:
|
||||||
self._show_error(tr("settings.profiles.select_to_load"))
|
self._show_error("Please select a profile to load")
|
||||||
return
|
return
|
||||||
|
|
||||||
profile_name = current_item.text()
|
profile_name = current_item.text()
|
||||||
|
|
@ -522,7 +533,7 @@ class SettingsDialog(QDialog):
|
||||||
"""Delete a saved profile."""
|
"""Delete a saved profile."""
|
||||||
current_item = self.profiles_list.currentItem()
|
current_item = self.profiles_list.currentItem()
|
||||||
if not current_item:
|
if not current_item:
|
||||||
self._show_error(tr("settings.profiles.select_to_delete"))
|
self._show_error("Please select a profile to delete")
|
||||||
return
|
return
|
||||||
|
|
||||||
profile_name = current_item.text()
|
profile_name = current_item.text()
|
||||||
|
|
@ -536,9 +547,9 @@ class SettingsDialog(QDialog):
|
||||||
"""Export configuration to file."""
|
"""Export configuration to file."""
|
||||||
file_path, _ = QFileDialog.getSaveFileName(
|
file_path, _ = QFileDialog.getSaveFileName(
|
||||||
self,
|
self,
|
||||||
tr("settings.profiles.export_title"),
|
"Export Configuration",
|
||||||
str(Path.home()),
|
str(Path.home()),
|
||||||
"JSON Files (*.json);;All Files (*)",
|
"JSON Files (*.json);;All Files (*)"
|
||||||
)
|
)
|
||||||
|
|
||||||
if file_path:
|
if file_path:
|
||||||
|
|
@ -551,9 +562,9 @@ class SettingsDialog(QDialog):
|
||||||
"""Import configuration from file."""
|
"""Import configuration from file."""
|
||||||
file_path, _ = QFileDialog.getOpenFileName(
|
file_path, _ = QFileDialog.getOpenFileName(
|
||||||
self,
|
self,
|
||||||
tr("settings.profiles.import_title"),
|
"Import Configuration",
|
||||||
str(Path.home()),
|
str(Path.home()),
|
||||||
"JSON Files (*.json);;All Files (*)",
|
"JSON Files (*.json);;All Files (*)"
|
||||||
)
|
)
|
||||||
|
|
||||||
if file_path:
|
if file_path:
|
||||||
|
|
@ -563,33 +574,32 @@ class SettingsDialog(QDialog):
|
||||||
except ConfigurationError as e:
|
except ConfigurationError as e:
|
||||||
self._show_error(f"Failed to import configuration: {e}")
|
self._show_error(f"Failed to import configuration: {e}")
|
||||||
|
|
||||||
def _apply_config_data(self, config_data: Dict[str, Any]) -> None:
|
def _apply_config_data(self, config_data: dict) -> None:
|
||||||
"""Apply imported configuration data to UI.
|
"""Apply imported configuration data to UI.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config_data: Configuration dictionary
|
config_data: Configuration dictionary
|
||||||
"""
|
"""
|
||||||
|
# Apply paths
|
||||||
self.paths_list.clear()
|
self.paths_list.clear()
|
||||||
for path in config_data.get("allowed_roots", []):
|
for path in config_data.get("allowed_roots", []):
|
||||||
self.paths_list.addItem(str(path))
|
self.paths_list.addItem(str(path))
|
||||||
|
|
||||||
|
# Apply URLs
|
||||||
self.urls_list.clear()
|
self.urls_list.clear()
|
||||||
for url in config_data.get("allowed_urls", []):
|
for url in config_data.get("allowed_urls", []):
|
||||||
self.urls_list.addItem(url)
|
self.urls_list.addItem(url)
|
||||||
|
|
||||||
|
# Apply logging settings
|
||||||
self.log_level_combo.setCurrentText(config_data.get("log_level", "INFO"))
|
self.log_level_combo.setCurrentText(config_data.get("log_level", "INFO"))
|
||||||
log_file = config_data.get("log_file")
|
log_file = config_data.get("log_file")
|
||||||
self.log_file_input.setText(str(log_file) if log_file else "")
|
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.width_spin.setValue(config_data.get("window_width", 800))
|
||||||
self.height_spin.setValue(config_data.get("window_height", 600))
|
self.height_spin.setValue(config_data.get("window_height", 600))
|
||||||
|
|
||||||
language = config_data.get("language", "auto")
|
def get_config_data(self) -> dict:
|
||||||
idx = self.language_combo.findData(language)
|
|
||||||
if idx >= 0:
|
|
||||||
self.language_combo.setCurrentIndex(idx)
|
|
||||||
|
|
||||||
def get_config_data(self) -> Dict[str, Any]:
|
|
||||||
"""Get updated configuration data from dialog.
|
"""Get updated configuration data from dialog.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -598,42 +608,29 @@ class SettingsDialog(QDialog):
|
||||||
Raises:
|
Raises:
|
||||||
ConfigurationError: If configuration is invalid
|
ConfigurationError: If configuration is invalid
|
||||||
"""
|
"""
|
||||||
url_mappings_table_count = (
|
|
||||||
self.url_mappings_table.rowCount() if self.url_mappings_table else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
config_data = {
|
config_data = {
|
||||||
"app_name": self.config.app_name,
|
"app_name": self.config.app_name,
|
||||||
"app_version": self.config.app_version,
|
"app_version": self.config.app_version,
|
||||||
"language": self.language_combo.currentData(),
|
|
||||||
"log_level": self.log_level_combo.currentText(),
|
"log_level": self.log_level_combo.currentText(),
|
||||||
"log_file": self.log_file_input.text() or None,
|
"log_file": self.log_file_input.text() or None,
|
||||||
"allowed_roots": [
|
"allowed_roots": [self.paths_list.item(i).text() for i in range(self.paths_list.count())],
|
||||||
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())],
|
"allowed_urls": [self.urls_list.item(i).text() for i in range(self.urls_list.count())],
|
||||||
"webapp_url": self.webapp_url_input.text().strip(),
|
"webapp_url": self.webapp_url_input.text().strip(),
|
||||||
"url_mappings": [
|
"url_mappings": [
|
||||||
{
|
{
|
||||||
"url_prefix": (
|
"url_prefix": self.url_mappings_table.item(i, 0).text(),
|
||||||
self.url_mappings_table.item(i, 0).text() # type: ignore
|
"local_path": self.url_mappings_table.item(i, 1).text()
|
||||||
if self.url_mappings_table.item(i, 0)
|
|
||||||
else ""
|
|
||||||
),
|
|
||||||
"local_path": (
|
|
||||||
self.url_mappings_table.item(i, 1).text() # type: ignore
|
|
||||||
if self.url_mappings_table.item(i, 1)
|
|
||||||
else ""
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
for i in range(url_mappings_table_count)
|
for i in range(self.url_mappings_table.rowCount())
|
||||||
],
|
],
|
||||||
"window_width": self.width_spin.value(),
|
"window_width": self.width_spin.value(),
|
||||||
"window_height": self.height_spin.value(),
|
"window_height": self.height_spin.value(),
|
||||||
"enable_logging": self.config.enable_logging,
|
"enable_logging": self.config.enable_logging,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Validate
|
||||||
ConfigValidator.validate_or_raise(config_data)
|
ConfigValidator.validate_or_raise(config_data)
|
||||||
|
|
||||||
return config_data
|
return config_data
|
||||||
|
|
||||||
def _show_error(self, message: str) -> None:
|
def _show_error(self, message: str) -> None:
|
||||||
|
|
@ -643,5 +640,4 @@ class SettingsDialog(QDialog):
|
||||||
message: Error message
|
message: Error message
|
||||||
"""
|
"""
|
||||||
from PySide6.QtWidgets import QMessageBox
|
from PySide6.QtWidgets import QMessageBox
|
||||||
|
QMessageBox.critical(self, "Error", message)
|
||||||
QMessageBox.critical(self, tr("dialog.error.title"), message)
|
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,6 @@ from PySide6.QtWidgets import (
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
)
|
)
|
||||||
|
|
||||||
from webdrop_bridge.utils.i18n import tr
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -43,7 +41,7 @@ class CheckingDialog(QDialog):
|
||||||
parent: Parent widget
|
parent: Parent widget
|
||||||
"""
|
"""
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle(tr("update.checking.title"))
|
self.setWindowTitle("Checking for Updates")
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.setMinimumWidth(300)
|
self.setMinimumWidth(300)
|
||||||
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
|
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
|
||||||
|
|
@ -51,7 +49,7 @@ class CheckingDialog(QDialog):
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
# Status label
|
# Status label
|
||||||
self.label = QLabel(tr("update.checking.label"))
|
self.label = QLabel("Checking for updates...")
|
||||||
layout.addWidget(self.label)
|
layout.addWidget(self.label)
|
||||||
|
|
||||||
# Animated progress bar
|
# Animated progress bar
|
||||||
|
|
@ -60,7 +58,7 @@ class CheckingDialog(QDialog):
|
||||||
layout.addWidget(self.progress)
|
layout.addWidget(self.progress)
|
||||||
|
|
||||||
# Timeout info
|
# Timeout info
|
||||||
info_label = QLabel(tr("update.checking.timeout_info"))
|
info_label = QLabel("This may take up to 10 seconds")
|
||||||
info_label.setStyleSheet("color: gray; font-size: 11px;")
|
info_label.setStyleSheet("color: gray; font-size: 11px;")
|
||||||
layout.addWidget(info_label)
|
layout.addWidget(info_label)
|
||||||
|
|
||||||
|
|
@ -80,6 +78,7 @@ class UpdateAvailableDialog(QDialog):
|
||||||
# Signals
|
# Signals
|
||||||
update_now = Signal()
|
update_now = Signal()
|
||||||
update_later = Signal()
|
update_later = Signal()
|
||||||
|
skip_version = Signal()
|
||||||
|
|
||||||
def __init__(self, version: str, changelog: str, parent=None):
|
def __init__(self, version: str, changelog: str, parent=None):
|
||||||
"""Initialize update available dialog.
|
"""Initialize update available dialog.
|
||||||
|
|
@ -90,7 +89,7 @@ class UpdateAvailableDialog(QDialog):
|
||||||
parent: Parent widget
|
parent: Parent widget
|
||||||
"""
|
"""
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle(tr("update.available.title"))
|
self.setWindowTitle("Update Available")
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.setMinimumWidth(400)
|
self.setMinimumWidth(400)
|
||||||
self.setMinimumHeight(300)
|
self.setMinimumHeight(300)
|
||||||
|
|
@ -98,12 +97,12 @@ class UpdateAvailableDialog(QDialog):
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
# Header
|
# Header
|
||||||
header = QLabel(tr("update.available.header", version=version))
|
header = QLabel(f"WebDrop Bridge v{version} is available")
|
||||||
header.setStyleSheet("font-weight: bold; font-size: 14px;")
|
header.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||||
layout.addWidget(header)
|
layout.addWidget(header)
|
||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
changelog_label = QLabel(tr("update.available.changelog_label"))
|
changelog_label = QLabel("Release Notes:")
|
||||||
changelog_label.setStyleSheet("font-weight: bold; margin-top: 10px;")
|
changelog_label.setStyleSheet("font-weight: bold; margin-top: 10px;")
|
||||||
layout.addWidget(changelog_label)
|
layout.addWidget(changelog_label)
|
||||||
|
|
||||||
|
|
@ -115,14 +114,18 @@ class UpdateAvailableDialog(QDialog):
|
||||||
# Buttons
|
# Buttons
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
|
|
||||||
self.update_now_btn = QPushButton(tr("update.available.update_now_btn"))
|
self.update_now_btn = QPushButton("Update Now")
|
||||||
self.update_now_btn.clicked.connect(self._on_update_now)
|
self.update_now_btn.clicked.connect(self._on_update_now)
|
||||||
button_layout.addWidget(self.update_now_btn)
|
button_layout.addWidget(self.update_now_btn)
|
||||||
|
|
||||||
self.update_later_btn = QPushButton(tr("update.available.later_btn"))
|
self.update_later_btn = QPushButton("Later")
|
||||||
self.update_later_btn.clicked.connect(self._on_update_later)
|
self.update_later_btn.clicked.connect(self._on_update_later)
|
||||||
button_layout.addWidget(self.update_later_btn)
|
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)
|
layout.addLayout(button_layout)
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
|
@ -136,6 +139,11 @@ class UpdateAvailableDialog(QDialog):
|
||||||
self.update_later.emit()
|
self.update_later.emit()
|
||||||
self.reject()
|
self.reject()
|
||||||
|
|
||||||
|
def _on_skip(self):
|
||||||
|
"""Handle skip version button click."""
|
||||||
|
self.skip_version.emit()
|
||||||
|
self.reject()
|
||||||
|
|
||||||
|
|
||||||
class DownloadingDialog(QDialog):
|
class DownloadingDialog(QDialog):
|
||||||
"""Dialog shown while downloading the update.
|
"""Dialog shown while downloading the update.
|
||||||
|
|
@ -155,7 +163,7 @@ class DownloadingDialog(QDialog):
|
||||||
parent: Parent widget
|
parent: Parent widget
|
||||||
"""
|
"""
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle(tr("update.downloading.title"))
|
self.setWindowTitle("Downloading Update")
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.setMinimumWidth(350)
|
self.setMinimumWidth(350)
|
||||||
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
|
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
|
||||||
|
|
@ -163,12 +171,12 @@ class DownloadingDialog(QDialog):
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
# Header
|
# Header
|
||||||
header = QLabel(tr("update.downloading.header"))
|
header = QLabel("Downloading update...")
|
||||||
header.setStyleSheet("font-weight: bold;")
|
header.setStyleSheet("font-weight: bold;")
|
||||||
layout.addWidget(header)
|
layout.addWidget(header)
|
||||||
|
|
||||||
# File label
|
# File label
|
||||||
self.file_label = QLabel(tr("update.downloading.preparing"))
|
self.file_label = QLabel("Preparing download")
|
||||||
layout.addWidget(self.file_label)
|
layout.addWidget(self.file_label)
|
||||||
|
|
||||||
# Progress bar
|
# Progress bar
|
||||||
|
|
@ -184,7 +192,7 @@ class DownloadingDialog(QDialog):
|
||||||
layout.addWidget(self.size_label)
|
layout.addWidget(self.size_label)
|
||||||
|
|
||||||
# Cancel button
|
# Cancel button
|
||||||
self.cancel_btn = QPushButton(tr("update.downloading.cancel_btn"))
|
self.cancel_btn = QPushButton("Cancel")
|
||||||
self.cancel_btn.clicked.connect(self._on_cancel)
|
self.cancel_btn.clicked.connect(self._on_cancel)
|
||||||
layout.addWidget(self.cancel_btn)
|
layout.addWidget(self.cancel_btn)
|
||||||
|
|
||||||
|
|
@ -212,7 +220,7 @@ class DownloadingDialog(QDialog):
|
||||||
Args:
|
Args:
|
||||||
filename: Name of file being downloaded
|
filename: Name of file being downloaded
|
||||||
"""
|
"""
|
||||||
self.file_label.setText(tr("update.downloading.filename", filename=filename))
|
self.file_label.setText(f"Downloading: {filename}")
|
||||||
|
|
||||||
def _on_cancel(self):
|
def _on_cancel(self):
|
||||||
"""Handle cancel button click."""
|
"""Handle cancel button click."""
|
||||||
|
|
@ -238,23 +246,26 @@ class InstallDialog(QDialog):
|
||||||
parent: Parent widget
|
parent: Parent widget
|
||||||
"""
|
"""
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle(tr("update.install.title"))
|
self.setWindowTitle("Install Update")
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.setMinimumWidth(350)
|
self.setMinimumWidth(350)
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
# Header
|
# Header
|
||||||
header = QLabel(tr("update.install.header"))
|
header = QLabel("Ready to Install")
|
||||||
header.setStyleSheet("font-weight: bold; font-size: 14px;")
|
header.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||||
layout.addWidget(header)
|
layout.addWidget(header)
|
||||||
|
|
||||||
# Message
|
# Message
|
||||||
message = QLabel(tr("update.install.message"))
|
message = QLabel("The update is ready to install. The application will restart.")
|
||||||
layout.addWidget(message)
|
layout.addWidget(message)
|
||||||
|
|
||||||
# Warning
|
# Warning
|
||||||
warning = QLabel(tr("update.install.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.setStyleSheet("background-color: #fff3cd; padding: 10px; border-radius: 4px;")
|
||||||
warning.setWordWrap(True)
|
warning.setWordWrap(True)
|
||||||
layout.addWidget(warning)
|
layout.addWidget(warning)
|
||||||
|
|
@ -262,12 +273,12 @@ class InstallDialog(QDialog):
|
||||||
# Buttons
|
# Buttons
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
|
|
||||||
self.install_btn = QPushButton(tr("update.install.now_btn"))
|
self.install_btn = QPushButton("Install Now")
|
||||||
self.install_btn.setStyleSheet("background-color: #28a745; color: white;")
|
self.install_btn.setStyleSheet("background-color: #28a745; color: white;")
|
||||||
self.install_btn.clicked.connect(self._on_install)
|
self.install_btn.clicked.connect(self._on_install)
|
||||||
button_layout.addWidget(self.install_btn)
|
button_layout.addWidget(self.install_btn)
|
||||||
|
|
||||||
self.cancel_btn = QPushButton(tr("update.install.cancel_btn"))
|
self.cancel_btn = QPushButton("Cancel")
|
||||||
self.cancel_btn.clicked.connect(self.reject)
|
self.cancel_btn.clicked.connect(self.reject)
|
||||||
button_layout.addWidget(self.cancel_btn)
|
button_layout.addWidget(self.cancel_btn)
|
||||||
|
|
||||||
|
|
@ -293,22 +304,22 @@ class NoUpdateDialog(QDialog):
|
||||||
parent: Parent widget
|
parent: Parent widget
|
||||||
"""
|
"""
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle(tr("update.no_update.title"))
|
self.setWindowTitle("No Updates Available")
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.setMinimumWidth(300)
|
self.setMinimumWidth(300)
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
# Message
|
# Message
|
||||||
message = QLabel(tr("update.no_update.message"))
|
message = QLabel("✓ You're using the latest version")
|
||||||
message.setStyleSheet("font-weight: bold; font-size: 12px; color: #28a745;")
|
message.setStyleSheet("font-weight: bold; font-size: 12px; color: #28a745;")
|
||||||
layout.addWidget(message)
|
layout.addWidget(message)
|
||||||
|
|
||||||
info = QLabel(tr("update.no_update.info"))
|
info = QLabel("WebDrop Bridge is up to date.")
|
||||||
layout.addWidget(info)
|
layout.addWidget(info)
|
||||||
|
|
||||||
# Close button
|
# Close button
|
||||||
close_btn = QPushButton(tr("update.no_update.ok_btn"))
|
close_btn = QPushButton("OK")
|
||||||
close_btn.clicked.connect(self.accept)
|
close_btn.clicked.connect(self.accept)
|
||||||
layout.addWidget(close_btn)
|
layout.addWidget(close_btn)
|
||||||
|
|
||||||
|
|
@ -334,14 +345,14 @@ class ErrorDialog(QDialog):
|
||||||
parent: Parent widget
|
parent: Parent widget
|
||||||
"""
|
"""
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle(tr("update.error.title"))
|
self.setWindowTitle("Update Failed")
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.setMinimumWidth(350)
|
self.setMinimumWidth(350)
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
# Header
|
# Header
|
||||||
header = QLabel(tr("update.error.header"))
|
header = QLabel("⚠️ Update Failed")
|
||||||
header.setStyleSheet("font-weight: bold; font-size: 14px; color: #dc3545;")
|
header.setStyleSheet("font-weight: bold; font-size: 14px; color: #dc3545;")
|
||||||
layout.addWidget(header)
|
layout.addWidget(header)
|
||||||
|
|
||||||
|
|
@ -353,7 +364,9 @@ class ErrorDialog(QDialog):
|
||||||
layout.addWidget(self.error_text)
|
layout.addWidget(self.error_text)
|
||||||
|
|
||||||
# Info message
|
# Info message
|
||||||
info = QLabel(tr("update.error.info"))
|
info = QLabel(
|
||||||
|
"Please try again or visit the website to download the update manually."
|
||||||
|
)
|
||||||
info.setWordWrap(True)
|
info.setWordWrap(True)
|
||||||
info.setStyleSheet("color: gray; font-size: 11px;")
|
info.setStyleSheet("color: gray; font-size: 11px;")
|
||||||
layout.addWidget(info)
|
layout.addWidget(info)
|
||||||
|
|
@ -361,15 +374,15 @@ class ErrorDialog(QDialog):
|
||||||
# Buttons
|
# Buttons
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
|
|
||||||
self.retry_btn = QPushButton(tr("update.error.retry_btn"))
|
self.retry_btn = QPushButton("Retry")
|
||||||
self.retry_btn.clicked.connect(self._on_retry)
|
self.retry_btn.clicked.connect(self._on_retry)
|
||||||
button_layout.addWidget(self.retry_btn)
|
button_layout.addWidget(self.retry_btn)
|
||||||
|
|
||||||
self.manual_btn = QPushButton(tr("update.error.manual_btn"))
|
self.manual_btn = QPushButton("Download Manually")
|
||||||
self.manual_btn.clicked.connect(self._on_manual)
|
self.manual_btn.clicked.connect(self._on_manual)
|
||||||
button_layout.addWidget(self.manual_btn)
|
button_layout.addWidget(self.manual_btn)
|
||||||
|
|
||||||
self.cancel_btn = QPushButton(tr("update.error.cancel_btn"))
|
self.cancel_btn = QPushButton("Cancel")
|
||||||
self.cancel_btn.clicked.connect(self.reject)
|
self.cancel_btn.clicked.connect(self.reject)
|
||||||
button_layout.addWidget(self.cancel_btn)
|
button_layout.addWidget(self.cancel_btn)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,292 +0,0 @@
|
||||||
"""Internationalization (i18n) support for WebDrop Bridge.
|
|
||||||
|
|
||||||
Provides a simple JSON-based translation system. Translation files are stored
|
|
||||||
in ``resources/translations/`` (e.g. ``en.json``, ``de.json``, ``fr.json``).
|
|
||||||
|
|
||||||
Usage::
|
|
||||||
|
|
||||||
from webdrop_bridge.utils.i18n import tr
|
|
||||||
|
|
||||||
# Simple lookup
|
|
||||||
self.setWindowTitle(tr("settings.title"))
|
|
||||||
|
|
||||||
# With named format arguments
|
|
||||||
label.setText(tr("status.opened", name="file.pdf"))
|
|
||||||
|
|
||||||
To add a new language, place a JSON file named ``<code>.json`` in
|
|
||||||
``resources/translations/`` and optionally add an entry to
|
|
||||||
:attr:`Translator.BUILTIN_LANGUAGES` for a nicer display name.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, Optional
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class Translator:
|
|
||||||
"""Manages translations for the application.
|
|
||||||
|
|
||||||
Loads translations from UTF-8 JSON files that use dot-notation string keys.
|
|
||||||
Falls back to the English translation (and ultimately to the bare key) when
|
|
||||||
a translation is missing.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
BUILTIN_LANGUAGES: Mapping of language code → display name for languages
|
|
||||||
that ship with the application. Add entries here when including new
|
|
||||||
translation files.
|
|
||||||
"""
|
|
||||||
|
|
||||||
#: Human-readable display names for supported language codes.
|
|
||||||
#: Unknown codes fall back to their uppercase code string.
|
|
||||||
BUILTIN_LANGUAGES: Dict[str, str] = {
|
|
||||||
"en": "English",
|
|
||||||
"de": "Deutsch",
|
|
||||||
"fr": "Français",
|
|
||||||
"it": "Italiano",
|
|
||||||
"ru": "Русский",
|
|
||||||
"zh": "中文",
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._language: str = "en"
|
|
||||||
self._translations: Dict[str, str] = {}
|
|
||||||
self._fallback: Dict[str, str] = {}
|
|
||||||
self._translations_dir: Optional[Path] = None
|
|
||||||
|
|
||||||
def initialize(self, language: str, translations_dir: Path) -> None:
|
|
||||||
"""Initialize the translator with a language and translations directory.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
language: Language code (e.g. ``"en"``, ``"de"``, ``"fr"``) or
|
|
||||||
``"auto"`` to detect from the system locale.
|
|
||||||
translations_dir: Directory containing the ``.json`` translation files.
|
|
||||||
"""
|
|
||||||
self._translations_dir = translations_dir
|
|
||||||
|
|
||||||
# Resolve "auto" to system locale
|
|
||||||
if language == "auto":
|
|
||||||
language = self._detect_system_language()
|
|
||||||
logger.debug(f"Auto-detected language: {language}")
|
|
||||||
|
|
||||||
# Load English as fallback first
|
|
||||||
en_path = translations_dir / "en.json"
|
|
||||||
if en_path.exists():
|
|
||||||
self._fallback = self._load_file(en_path)
|
|
||||||
logger.debug(f"Loaded English fallback translations ({len(self._fallback)} keys)")
|
|
||||||
else:
|
|
||||||
logger.warning(f"English translation file not found at {en_path}")
|
|
||||||
|
|
||||||
# Load requested language
|
|
||||||
self._language = language
|
|
||||||
if language != "en":
|
|
||||||
lang_path = translations_dir / f"{language}.json"
|
|
||||||
if lang_path.exists():
|
|
||||||
self._translations = self._load_file(lang_path)
|
|
||||||
logger.debug(f"Loaded '{language}' translations ({len(self._translations)} keys)")
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
f"Translation file not found for language '{language}', "
|
|
||||||
"falling back to English"
|
|
||||||
)
|
|
||||||
self._translations = {}
|
|
||||||
else:
|
|
||||||
self._translations = self._fallback
|
|
||||||
|
|
||||||
def tr(self, key: str, **kwargs: str) -> str:
|
|
||||||
"""Get translated string for the given key.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key: Translation key using dot-notation (e.g. ``"toolbar.home"``).
|
|
||||||
**kwargs: Named format arguments applied to the translated string.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Translated and formatted string. Returns the *key* itself when no
|
|
||||||
translation is found, so missing keys are always visible.
|
|
||||||
"""
|
|
||||||
text = self._translations.get(key) or self._fallback.get(key) or key
|
|
||||||
if kwargs:
|
|
||||||
try:
|
|
||||||
text = text.format(**kwargs)
|
|
||||||
except (KeyError, ValueError) as e:
|
|
||||||
logger.debug(f"Translation format error for key '{key}': {e}")
|
|
||||||
return text
|
|
||||||
|
|
||||||
def get_current_language(self) -> str:
|
|
||||||
"""Get the currently active language code (e.g. ``"de"``)."""
|
|
||||||
return self._language
|
|
||||||
|
|
||||||
def get_available_languages(self) -> Dict[str, str]:
|
|
||||||
"""Return available languages as ``{code: display_name}``.
|
|
||||||
|
|
||||||
Discovers language files at runtime so newly added JSON files are
|
|
||||||
automatically included without code changes.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Ordered dict mapping language code → human-readable display name.
|
|
||||||
"""
|
|
||||||
if self._translations_dir is None:
|
|
||||||
return {"en": "English"}
|
|
||||||
|
|
||||||
languages: Dict[str, str] = {}
|
|
||||||
for lang_file in sorted(self._translations_dir.glob("*.json")):
|
|
||||||
code = lang_file.stem
|
|
||||||
name = self.BUILTIN_LANGUAGES.get(code, code.upper())
|
|
||||||
languages[code] = name
|
|
||||||
return languages
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Private helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _load_file(self, path: Path) -> Dict[str, str]:
|
|
||||||
"""Load a JSON translation file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
path: Path to the UTF-8 encoded JSON translation file.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary of translation keys to translated strings, or an empty
|
|
||||||
dict when the file cannot be read or parsed.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
|
||||||
return json.load(f)
|
|
||||||
except (json.JSONDecodeError, IOError) as e:
|
|
||||||
logger.error(f"Failed to load translation file {path}: {e}")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def _detect_system_language(self) -> str:
|
|
||||||
"""Detect system language from locale or platform settings.
|
|
||||||
|
|
||||||
On Windows, attempts to read the UI language via the WinAPI before
|
|
||||||
falling back to the ``locale`` module.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Best-matching supported language code, or ``"en"`` as fallback.
|
|
||||||
"""
|
|
||||||
import locale
|
|
||||||
|
|
||||||
try:
|
|
||||||
lang_code: Optional[str] = None
|
|
||||||
|
|
||||||
if sys.platform.startswith("win"):
|
|
||||||
# Windows: use GetUserDefaultUILanguage for accuracy
|
|
||||||
try:
|
|
||||||
import ctypes
|
|
||||||
|
|
||||||
lcid = ctypes.windll.kernel32.GetUserDefaultUILanguage() # type: ignore[attr-defined]
|
|
||||||
# Subset of LCID → ISO 639-1 mappings
|
|
||||||
lcid_map: Dict[int, str] = {
|
|
||||||
0x0407: "de", # German (Germany)
|
|
||||||
0x0C07: "de", # German (Austria)
|
|
||||||
0x0807: "de", # German (Switzerland)
|
|
||||||
0x040C: "fr", # French (France)
|
|
||||||
0x080C: "fr", # French (Belgium)
|
|
||||||
0x0C0C: "fr", # French (Canada)
|
|
||||||
0x100C: "fr", # French (Switzerland)
|
|
||||||
0x0410: "it", # Italian (Italy)
|
|
||||||
0x0810: "it", # Italian (Switzerland)
|
|
||||||
0x0419: "ru", # Russian
|
|
||||||
0x0804: "zh", # Chinese Simplified
|
|
||||||
0x0404: "zh", # Chinese Traditional
|
|
||||||
0x0409: "en", # English (US)
|
|
||||||
0x0809: "en", # English (UK)
|
|
||||||
}
|
|
||||||
lang_code = lcid_map.get(lcid)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not lang_code:
|
|
||||||
raw = locale.getdefaultlocale()[0] or ""
|
|
||||||
lang_code = raw.split("_")[0].lower() if raw else None
|
|
||||||
|
|
||||||
if lang_code and lang_code in self.BUILTIN_LANGUAGES:
|
|
||||||
return lang_code
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Language auto-detection failed: {e}")
|
|
||||||
|
|
||||||
return "en"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Module-level singleton and public API
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_translator = Translator()
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_initialized() -> None:
|
|
||||||
"""Initialize translator lazily with default settings if needed."""
|
|
||||||
if _translator._translations_dir is not None: # type: ignore[attr-defined]
|
|
||||||
return
|
|
||||||
_translator.initialize("en", get_translations_dir())
|
|
||||||
|
|
||||||
|
|
||||||
def initialize(language: str, translations_dir: Path) -> None:
|
|
||||||
"""Initialize the global translator.
|
|
||||||
|
|
||||||
Should be called **once at application startup**, before any UI is shown.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
language: Language code (e.g. ``"de"``) or ``"auto"`` for system
|
|
||||||
locale detection.
|
|
||||||
translations_dir: Directory containing the ``.json`` translation files.
|
|
||||||
"""
|
|
||||||
_translator.initialize(language, translations_dir)
|
|
||||||
|
|
||||||
|
|
||||||
def tr(key: str, **kwargs: str) -> str:
|
|
||||||
"""Translate a string by key.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key: Translation key (e.g. ``"toolbar.home"``).
|
|
||||||
**kwargs: Named format arguments (e.g. ``name="file.pdf"``).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Translated string with any format substitutions applied.
|
|
||||||
"""
|
|
||||||
_ensure_initialized()
|
|
||||||
text = _translator.tr(key, **kwargs)
|
|
||||||
|
|
||||||
# If lookup failed and translator points to a non-default directory (e.g. tests
|
|
||||||
# overriding translator state), retry from default bundled translations.
|
|
||||||
if text == key:
|
|
||||||
default_dir = get_translations_dir()
|
|
||||||
current_dir = _translator._translations_dir # type: ignore[attr-defined]
|
|
||||||
if current_dir != default_dir:
|
|
||||||
_translator.initialize("en", default_dir)
|
|
||||||
text = _translator.tr(key, **kwargs)
|
|
||||||
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_language() -> str:
|
|
||||||
"""Return the currently active language code (e.g. ``"de"``)."""
|
|
||||||
return _translator.get_current_language()
|
|
||||||
|
|
||||||
|
|
||||||
def get_available_languages() -> Dict[str, str]:
|
|
||||||
"""Return all available languages as ``{code: display_name}``."""
|
|
||||||
_ensure_initialized()
|
|
||||||
return _translator.get_available_languages()
|
|
||||||
|
|
||||||
|
|
||||||
def get_translations_dir() -> Path:
|
|
||||||
"""Resolve the translations directory for the current runtime context.
|
|
||||||
|
|
||||||
Handles development mode, PyInstaller bundles, and MSI installations
|
|
||||||
by searching the known candidate paths in order.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to the ``resources/translations`` directory.
|
|
||||||
"""
|
|
||||||
if hasattr(sys, "_MEIPASS"):
|
|
||||||
# PyInstaller bundle
|
|
||||||
return Path(sys._MEIPASS) / "resources" / "translations" # type: ignore[attr-defined]
|
|
||||||
# Development mode or installed Python package
|
|
||||||
return Path(__file__).parent.parent.parent.parent / "resources" / "translations"
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
"""Tests for brand-aware build configuration helpers."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
BUILD_SCRIPTS_DIR = Path(__file__).resolve().parents[2] / "build" / "scripts"
|
|
||||||
if str(BUILD_SCRIPTS_DIR) not in sys.path:
|
|
||||||
sys.path.insert(0, str(BUILD_SCRIPTS_DIR))
|
|
||||||
|
|
||||||
from brand_config import (
|
|
||||||
DEFAULT_BRAND_ID,
|
|
||||||
collect_local_release_data,
|
|
||||||
generate_release_manifest,
|
|
||||||
load_brand_config,
|
|
||||||
merge_release_manifests,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_agravity_brand_config():
|
|
||||||
"""Test loading the Agravity brand manifest."""
|
|
||||||
brand = load_brand_config("agravity")
|
|
||||||
|
|
||||||
assert brand.brand_id == "agravity"
|
|
||||||
assert brand.display_name == "Agravity Bridge"
|
|
||||||
assert brand.asset_prefix == "AgravityBridge"
|
|
||||||
assert brand.exe_name == "AgravityBridge"
|
|
||||||
assert brand.toolbar_icon_home == "resources/icons/home.ico"
|
|
||||||
assert brand.toolbar_icon_reload == "resources/icons/reload.ico"
|
|
||||||
assert brand.toolbar_icon_open == "resources/icons/open.ico"
|
|
||||||
assert brand.toolbar_icon_openwith == "resources/icons/openwith.ico"
|
|
||||||
assert brand.windows_installer_name("0.8.4") == "AgravityBridge-0.8.4-win-x64.msi"
|
|
||||||
|
|
||||||
|
|
||||||
def test_generate_release_manifest_for_agravity(tmp_path):
|
|
||||||
"""Test generating a shared release manifest from local artifacts."""
|
|
||||||
project_root = tmp_path
|
|
||||||
(project_root / "build" / "brands").mkdir(parents=True)
|
|
||||||
(project_root / "build" / "dist" / "windows" / "agravity").mkdir(parents=True)
|
|
||||||
(project_root / "build" / "dist" / "macos" / "agravity").mkdir(parents=True)
|
|
||||||
|
|
||||||
source_manifest = Path(__file__).resolve().parents[2] / "build" / "brands" / "agravity.json"
|
|
||||||
(project_root / "build" / "brands" / "agravity.json").write_text(
|
|
||||||
source_manifest.read_text(encoding="utf-8"),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
win_installer = (
|
|
||||||
project_root
|
|
||||||
/ "build"
|
|
||||||
/ "dist"
|
|
||||||
/ "windows"
|
|
||||||
/ "agravity"
|
|
||||||
/ "AgravityBridge-0.8.4-win-x64.msi"
|
|
||||||
)
|
|
||||||
win_installer.write_bytes(b"msi")
|
|
||||||
(win_installer.parent / f"{win_installer.name}.sha256").write_text("abc", encoding="utf-8")
|
|
||||||
|
|
||||||
mac_installer = (
|
|
||||||
project_root
|
|
||||||
/ "build"
|
|
||||||
/ "dist"
|
|
||||||
/ "macos"
|
|
||||||
/ "agravity"
|
|
||||||
/ "AgravityBridge-0.8.4-macos-universal.dmg"
|
|
||||||
)
|
|
||||||
mac_installer.write_bytes(b"dmg")
|
|
||||||
(mac_installer.parent / f"{mac_installer.name}.sha256").write_text("def", encoding="utf-8")
|
|
||||||
|
|
||||||
output_path = project_root / "build" / "dist" / "release-manifest.json"
|
|
||||||
generate_release_manifest(
|
|
||||||
"0.8.4",
|
|
||||||
["agravity"],
|
|
||||||
output_path=output_path,
|
|
||||||
root=project_root,
|
|
||||||
)
|
|
||||||
|
|
||||||
manifest = json.loads(output_path.read_text(encoding="utf-8"))
|
|
||||||
assert manifest["version"] == "0.8.4"
|
|
||||||
assert (
|
|
||||||
manifest["brands"]["agravity"]["windows-x64"]["installer"]
|
|
||||||
== "AgravityBridge-0.8.4-win-x64.msi"
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
manifest["brands"]["agravity"]["macos-universal"]["installer"]
|
|
||||||
== "AgravityBridge-0.8.4-macos-universal.dmg"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_collect_local_release_data_includes_default_brand(tmp_path):
|
|
||||||
"""Test discovering local artifacts for the default Windows build."""
|
|
||||||
project_root = tmp_path
|
|
||||||
installer_dir = project_root / "build" / "dist" / "windows" / DEFAULT_BRAND_ID
|
|
||||||
installer_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
installer = installer_dir / "WebDropBridge-0.8.4-win-x64.msi"
|
|
||||||
installer.write_bytes(b"msi")
|
|
||||||
checksum = installer_dir / f"{installer.name}.sha256"
|
|
||||||
checksum.write_text("abc", encoding="utf-8")
|
|
||||||
|
|
||||||
data = collect_local_release_data("0.8.4", platform="windows", root=project_root)
|
|
||||||
|
|
||||||
assert data["brands"] == [DEFAULT_BRAND_ID]
|
|
||||||
assert str(installer) in data["artifacts"]
|
|
||||||
assert str(checksum) in data["artifacts"]
|
|
||||||
assert (
|
|
||||||
data["manifest"]["brands"][DEFAULT_BRAND_ID]["windows-x64"]["installer"] == installer.name
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_release_manifests_preserves_existing_platforms():
|
|
||||||
"""Test merging platform-specific manifest entries from separate upload runs."""
|
|
||||||
base_manifest = {
|
|
||||||
"version": "0.8.4",
|
|
||||||
"channel": "stable",
|
|
||||||
"brands": {
|
|
||||||
"agravity": {
|
|
||||||
"windows-x64": {
|
|
||||||
"installer": "AgravityBridge-0.8.4-win-x64.msi",
|
|
||||||
"checksum": "AgravityBridge-0.8.4-win-x64.msi.sha256",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
overlay_manifest = {
|
|
||||||
"version": "0.8.4",
|
|
||||||
"channel": "stable",
|
|
||||||
"brands": {
|
|
||||||
"agravity": {
|
|
||||||
"macos-universal": {
|
|
||||||
"installer": "AgravityBridge-0.8.4-macos-universal.dmg",
|
|
||||||
"checksum": "AgravityBridge-0.8.4-macos-universal.dmg.sha256",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
merged = merge_release_manifests(base_manifest, overlay_manifest)
|
|
||||||
|
|
||||||
assert (
|
|
||||||
merged["brands"]["agravity"]["windows-x64"]["installer"]
|
|
||||||
== "AgravityBridge-0.8.4-win-x64.msi"
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
merged["brands"]["agravity"]["macos-universal"]["installer"]
|
|
||||||
== "AgravityBridge-0.8.4-macos-universal.dmg"
|
|
||||||
)
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"""Unit tests for configuration system."""
|
"""Unit tests for configuration system."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -16,19 +15,7 @@ def clear_env():
|
||||||
|
|
||||||
# Clear relevant variables
|
# Clear relevant variables
|
||||||
for key in list(os.environ.keys()):
|
for key in list(os.environ.keys()):
|
||||||
if key.startswith(
|
if key.startswith(('APP_', 'LOG_', 'ALLOWED_', 'WEBAPP_', 'WINDOW_', 'ENABLE_')):
|
||||||
(
|
|
||||||
"APP_",
|
|
||||||
"LOG_",
|
|
||||||
"ALLOWED_",
|
|
||||||
"WEBAPP_",
|
|
||||||
"WINDOW_",
|
|
||||||
"ENABLE_",
|
|
||||||
"BRAND_",
|
|
||||||
"UPDATE_",
|
|
||||||
"LANGUAGE",
|
|
||||||
)
|
|
||||||
):
|
|
||||||
del os.environ[key]
|
del os.environ[key]
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
@ -77,28 +64,6 @@ class TestConfigFromEnv:
|
||||||
assert config.window_width == 1200
|
assert config.window_width == 1200
|
||||||
assert config.window_height == 800
|
assert config.window_height == 800
|
||||||
|
|
||||||
def test_from_env_with_branding_values(self, tmp_path):
|
|
||||||
"""Test loading branding and update metadata from environment."""
|
|
||||||
env_file = tmp_path / ".env"
|
|
||||||
root1 = tmp_path / "root1"
|
|
||||||
root1.mkdir()
|
|
||||||
env_file.write_text(
|
|
||||||
f"BRAND_ID=agravity\n"
|
|
||||||
f"APP_CONFIG_DIR_NAME=agravity_bridge\n"
|
|
||||||
f"UPDATE_REPO=HIM-public/webdrop-bridge\n"
|
|
||||||
f"UPDATE_CHANNEL=stable\n"
|
|
||||||
f"UPDATE_MANIFEST_NAME=release-manifest.json\n"
|
|
||||||
f"ALLOWED_ROOTS={root1}\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
config = Config.from_env(str(env_file))
|
|
||||||
|
|
||||||
assert config.brand_id == "agravity"
|
|
||||||
assert config.config_dir_name == "agravity_bridge"
|
|
||||||
assert config.update_repo == "HIM-public/webdrop-bridge"
|
|
||||||
assert config.update_channel == "stable"
|
|
||||||
assert config.update_manifest_name == "release-manifest.json"
|
|
||||||
|
|
||||||
def test_from_env_with_defaults(self, tmp_path):
|
def test_from_env_with_defaults(self, tmp_path):
|
||||||
"""Test loading config uses defaults when env vars not set."""
|
"""Test loading config uses defaults when env vars not set."""
|
||||||
# Create empty .env file
|
# Create empty .env file
|
||||||
|
|
@ -108,11 +73,8 @@ class TestConfigFromEnv:
|
||||||
config = Config.from_env(str(env_file))
|
config = Config.from_env(str(env_file))
|
||||||
|
|
||||||
assert config.app_name == "WebDrop Bridge"
|
assert config.app_name == "WebDrop Bridge"
|
||||||
assert config.brand_id == "webdrop_bridge"
|
|
||||||
assert config.config_dir_name == "webdrop_bridge"
|
|
||||||
# Version should come from __init__.py (dynamic, not hardcoded)
|
# Version should come from __init__.py (dynamic, not hardcoded)
|
||||||
from webdrop_bridge import __version__
|
from webdrop_bridge import __version__
|
||||||
|
|
||||||
assert config.app_version == __version__
|
assert config.app_version == __version__
|
||||||
assert config.log_level == "INFO"
|
assert config.log_level == "INFO"
|
||||||
assert config.window_width == 1024
|
assert config.window_width == 1024
|
||||||
|
|
@ -225,30 +187,3 @@ class TestConfigValidation:
|
||||||
config = Config.from_env(str(env_file))
|
config = Config.from_env(str(env_file))
|
||||||
|
|
||||||
assert config.allowed_urls == ["example.com", "test.org"]
|
assert config.allowed_urls == ["example.com", "test.org"]
|
||||||
|
|
||||||
def test_brand_specific_default_paths(self):
|
|
||||||
"""Test brand-specific config and log directories."""
|
|
||||||
config_path = Config.get_default_config_path("agravity_bridge")
|
|
||||||
log_path = Config.get_default_log_path("agravity_bridge")
|
|
||||||
|
|
||||||
assert config_path.parts[-2:] == ("agravity_bridge", "config.json")
|
|
||||||
assert log_path.parts[-2:] == ("logs", "agravity_bridge.log")
|
|
||||||
|
|
||||||
|
|
||||||
class TestBootstrapEnvLoading:
|
|
||||||
"""Test bootstrap .env loading behavior for packaged builds."""
|
|
||||||
|
|
||||||
def test_load_bootstrap_env_reads_meipass_dotenv(self, tmp_path, monkeypatch):
|
|
||||||
"""Packaged app should load .env from PyInstaller runtime directory."""
|
|
||||||
meipass_dir = tmp_path / "runtime"
|
|
||||||
meipass_dir.mkdir(parents=True)
|
|
||||||
env_path = meipass_dir / ".env"
|
|
||||||
env_path.write_text("APP_NAME=Agravity Bridge\n", encoding="utf-8")
|
|
||||||
|
|
||||||
monkeypatch.setattr(sys, "frozen", True, raising=False)
|
|
||||||
monkeypatch.setattr(sys, "_MEIPASS", str(meipass_dir), raising=False)
|
|
||||||
|
|
||||||
loaded_path = Config.load_bootstrap_env()
|
|
||||||
|
|
||||||
assert loaded_path == env_path
|
|
||||||
assert os.getenv("APP_NAME") == "Agravity Bridge"
|
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,6 @@ class TestDragInterceptorValidation:
|
||||||
mock_drag_instance = MagicMock()
|
mock_drag_instance = MagicMock()
|
||||||
# Simulate successful copy action
|
# Simulate successful copy action
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
|
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
|
||||||
mock_drag.return_value = mock_drag_instance
|
mock_drag.return_value = mock_drag_instance
|
||||||
|
|
||||||
|
|
@ -137,7 +136,7 @@ class TestDragInterceptorAzureURL:
|
||||||
url_mappings=[
|
url_mappings=[
|
||||||
URLMapping(
|
URLMapping(
|
||||||
url_prefix="https://devagravitystg.file.core.windows.net/devagravitysync/",
|
url_prefix="https://devagravitystg.file.core.windows.net/devagravitysync/",
|
||||||
local_path=str(tmp_path),
|
local_path=str(tmp_path)
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
check_file_exists=True,
|
check_file_exists=True,
|
||||||
|
|
@ -151,7 +150,6 @@ class TestDragInterceptorAzureURL:
|
||||||
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
|
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
|
||||||
mock_drag_instance = MagicMock()
|
mock_drag_instance = MagicMock()
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
|
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
|
||||||
mock_drag.return_value = mock_drag_instance
|
mock_drag.return_value = mock_drag_instance
|
||||||
|
|
||||||
|
|
@ -198,7 +196,6 @@ class TestDragInterceptorSignals:
|
||||||
interceptor.drag_started.connect(lambda src, path: signal_spy.append((src, path)))
|
interceptor.drag_started.connect(lambda src, path: signal_spy.append((src, path)))
|
||||||
|
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
|
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
|
||||||
mock_drag_instance = MagicMock()
|
mock_drag_instance = MagicMock()
|
||||||
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
|
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
|
||||||
|
|
@ -238,234 +235,3 @@ class TestDragInterceptorSignals:
|
||||||
# Verify result and signal emission
|
# Verify result and signal emission
|
||||||
assert result is False
|
assert result is False
|
||||||
assert len(signal_spy) == 1
|
assert len(signal_spy) == 1
|
||||||
|
|
||||||
|
|
||||||
class TestDragInterceptorMultipleDrags:
|
|
||||||
"""Test multiple file drag support."""
|
|
||||||
|
|
||||||
def test_handle_drag_with_list_single_item(self, qtbot, tmp_path):
|
|
||||||
"""Test handle_drag with list containing single file path."""
|
|
||||||
test_file = tmp_path / "test.txt"
|
|
||||||
test_file.write_text("content")
|
|
||||||
|
|
||||||
config = Config(
|
|
||||||
app_name="Test",
|
|
||||||
app_version="1.0.0",
|
|
||||||
log_level="INFO",
|
|
||||||
log_file=None,
|
|
||||||
allowed_roots=[tmp_path],
|
|
||||||
allowed_urls=[],
|
|
||||||
webapp_url="https://test.com/",
|
|
||||||
url_mappings=[],
|
|
||||||
check_file_exists=True,
|
|
||||||
)
|
|
||||||
interceptor = DragInterceptor(config)
|
|
||||||
|
|
||||||
from PySide6.QtCore import Qt
|
|
||||||
|
|
||||||
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
|
|
||||||
mock_drag_instance = MagicMock()
|
|
||||||
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
|
|
||||||
mock_drag.return_value = mock_drag_instance
|
|
||||||
|
|
||||||
result = interceptor.handle_drag([str(test_file)])
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
def test_handle_drag_with_multiple_files(self, qtbot, tmp_path):
|
|
||||||
"""Test handle_drag with list of multiple file paths."""
|
|
||||||
# Create multiple test files
|
|
||||||
test_file1 = tmp_path / "test1.txt"
|
|
||||||
test_file1.write_text("content1")
|
|
||||||
test_file2 = tmp_path / "test2.txt"
|
|
||||||
test_file2.write_text("content2")
|
|
||||||
test_file3 = tmp_path / "test3.txt"
|
|
||||||
test_file3.write_text("content3")
|
|
||||||
|
|
||||||
config = Config(
|
|
||||||
app_name="Test",
|
|
||||||
app_version="1.0.0",
|
|
||||||
log_level="INFO",
|
|
||||||
log_file=None,
|
|
||||||
allowed_roots=[tmp_path],
|
|
||||||
allowed_urls=[],
|
|
||||||
webapp_url="https://test.com/",
|
|
||||||
url_mappings=[],
|
|
||||||
check_file_exists=True,
|
|
||||||
)
|
|
||||||
interceptor = DragInterceptor(config)
|
|
||||||
|
|
||||||
from PySide6.QtCore import Qt
|
|
||||||
|
|
||||||
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
|
|
||||||
mock_drag_instance = MagicMock()
|
|
||||||
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
|
|
||||||
mock_drag.return_value = mock_drag_instance
|
|
||||||
|
|
||||||
result = interceptor.handle_drag(
|
|
||||||
[
|
|
||||||
str(test_file1),
|
|
||||||
str(test_file2),
|
|
||||||
str(test_file3),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
def test_handle_drag_with_multiple_azure_urls(self, qtbot, tmp_path):
|
|
||||||
"""Test handle_drag with list of multiple Azure URLs."""
|
|
||||||
from webdrop_bridge.config import URLMapping
|
|
||||||
|
|
||||||
config = Config(
|
|
||||||
app_name="Test",
|
|
||||||
app_version="1.0.0",
|
|
||||||
log_level="INFO",
|
|
||||||
log_file=None,
|
|
||||||
allowed_roots=[tmp_path],
|
|
||||||
allowed_urls=[],
|
|
||||||
webapp_url="https://test.com/",
|
|
||||||
url_mappings=[
|
|
||||||
URLMapping(
|
|
||||||
url_prefix="https://produktagravitystg.file.core.windows.net/produktagravitysync/",
|
|
||||||
local_path=str(tmp_path),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
check_file_exists=False, # Don't check file existence for this test
|
|
||||||
)
|
|
||||||
interceptor = DragInterceptor(config)
|
|
||||||
|
|
||||||
# Multiple Azure URLs (as would be in a multi-drag)
|
|
||||||
azure_urls = [
|
|
||||||
"https://produktagravitystg.file.core.windows.net/produktagravitysync/axtZdPVjs5iUaKU2muKMFN1WZ/igkjieyjcko.jpg",
|
|
||||||
"https://produktagravitystg.file.core.windows.net/produktagravitysync/aWd7mDjnsm2w0PHU9AryQBYz2/457101023fd46d673e2ce6642f78fb0d62736f0f06c7.jpg",
|
|
||||||
]
|
|
||||||
|
|
||||||
from PySide6.QtCore import Qt
|
|
||||||
|
|
||||||
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
|
|
||||||
mock_drag_instance = MagicMock()
|
|
||||||
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
|
|
||||||
mock_drag.return_value = mock_drag_instance
|
|
||||||
|
|
||||||
result = interceptor.handle_drag(azure_urls)
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
# Verify QDrag.exec was called (meaning drag was set up correctly)
|
|
||||||
mock_drag_instance.exec.assert_called_once()
|
|
||||||
|
|
||||||
def test_handle_drag_mixed_urls_and_paths(self, qtbot, tmp_path):
|
|
||||||
"""Test handle_drag with mixed Azure URLs and local paths."""
|
|
||||||
from webdrop_bridge.config import URLMapping
|
|
||||||
|
|
||||||
# Create test file
|
|
||||||
test_file = tmp_path / "local_file.txt"
|
|
||||||
test_file.write_text("local content")
|
|
||||||
|
|
||||||
config = Config(
|
|
||||||
app_name="Test",
|
|
||||||
app_version="1.0.0",
|
|
||||||
log_level="INFO",
|
|
||||||
log_file=None,
|
|
||||||
allowed_roots=[tmp_path],
|
|
||||||
allowed_urls=[],
|
|
||||||
webapp_url="https://test.com/",
|
|
||||||
url_mappings=[
|
|
||||||
URLMapping(
|
|
||||||
url_prefix="https://devagravitystg.file.core.windows.net/devagravitysync/",
|
|
||||||
local_path=str(tmp_path),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
check_file_exists=False, # Don't check existence for remote files
|
|
||||||
)
|
|
||||||
interceptor = DragInterceptor(config)
|
|
||||||
|
|
||||||
mixed_items = [
|
|
||||||
str(test_file), # local path
|
|
||||||
"https://devagravitystg.file.core.windows.net/devagravitysync/remote.jpg", # Azure URL
|
|
||||||
]
|
|
||||||
|
|
||||||
from PySide6.QtCore import Qt
|
|
||||||
|
|
||||||
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
|
|
||||||
mock_drag_instance = MagicMock()
|
|
||||||
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
|
|
||||||
mock_drag.return_value = mock_drag_instance
|
|
||||||
|
|
||||||
result = interceptor.handle_drag(mixed_items)
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
def test_handle_drag_multiple_empty_list(self, qtbot, test_config):
|
|
||||||
"""Test handle_drag with empty list fails."""
|
|
||||||
interceptor = DragInterceptor(test_config)
|
|
||||||
|
|
||||||
with qtbot.waitSignal(interceptor.drag_failed):
|
|
||||||
result = interceptor.handle_drag([])
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
def test_handle_drag_multiple_one_invalid_fails(self, qtbot, tmp_path):
|
|
||||||
"""Test handle_drag with multiple files fails if one is invalid."""
|
|
||||||
test_file1 = tmp_path / "test1.txt"
|
|
||||||
test_file1.write_text("content1")
|
|
||||||
|
|
||||||
config = Config(
|
|
||||||
app_name="Test",
|
|
||||||
app_version="1.0.0",
|
|
||||||
log_level="INFO",
|
|
||||||
log_file=None,
|
|
||||||
allowed_roots=[tmp_path],
|
|
||||||
allowed_urls=[],
|
|
||||||
webapp_url="https://test.com/",
|
|
||||||
url_mappings=[],
|
|
||||||
check_file_exists=True,
|
|
||||||
)
|
|
||||||
interceptor = DragInterceptor(config)
|
|
||||||
|
|
||||||
# One valid, one invalid
|
|
||||||
files = [
|
|
||||||
str(test_file1),
|
|
||||||
"/etc/passwd", # Invalid - outside allowed roots
|
|
||||||
]
|
|
||||||
|
|
||||||
with qtbot.waitSignal(interceptor.drag_failed):
|
|
||||||
result = interceptor.handle_drag(files)
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
def test_handle_drag_multiple_signal_with_pipes(self, qtbot, tmp_path):
|
|
||||||
"""Test drag_started signal contains pipe-separated paths for multiple files."""
|
|
||||||
test_file1 = tmp_path / "test1.txt"
|
|
||||||
test_file1.write_text("content1")
|
|
||||||
test_file2 = tmp_path / "test2.txt"
|
|
||||||
test_file2.write_text("content2")
|
|
||||||
|
|
||||||
config = Config(
|
|
||||||
app_name="Test",
|
|
||||||
app_version="1.0.0",
|
|
||||||
log_level="INFO",
|
|
||||||
log_file=None,
|
|
||||||
allowed_roots=[tmp_path],
|
|
||||||
allowed_urls=[],
|
|
||||||
webapp_url="https://test.com/",
|
|
||||||
url_mappings=[],
|
|
||||||
check_file_exists=True,
|
|
||||||
)
|
|
||||||
interceptor = DragInterceptor(config)
|
|
||||||
|
|
||||||
signal_spy = []
|
|
||||||
interceptor.drag_started.connect(lambda src, path: signal_spy.append((src, path)))
|
|
||||||
|
|
||||||
from PySide6.QtCore import Qt
|
|
||||||
|
|
||||||
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
|
|
||||||
mock_drag_instance = MagicMock()
|
|
||||||
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
|
|
||||||
mock_drag.return_value = mock_drag_instance
|
|
||||||
|
|
||||||
result = interceptor.handle_drag([str(test_file1), str(test_file2)])
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
assert len(signal_spy) == 1
|
|
||||||
# Multiple paths should be separated by " | "
|
|
||||||
assert " | " in signal_spy[0][1]
|
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
"""Unit tests for i18n translation helper."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from webdrop_bridge.utils import i18n
|
|
||||||
|
|
||||||
|
|
||||||
class TestI18n:
|
|
||||||
"""Tests for translation lookup and fallback behavior."""
|
|
||||||
|
|
||||||
def test_tr_lazy_initialization_uses_english_defaults(self):
|
|
||||||
"""Translator should lazily initialize and resolve known keys."""
|
|
||||||
# Force a fresh singleton state for this test.
|
|
||||||
i18n._translator = i18n.Translator() # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
assert i18n.tr("settings.title") == "Settings"
|
|
||||||
|
|
||||||
def test_initialize_with_language_falls_back_to_english(self, tmp_path: Path):
|
|
||||||
"""Missing keys in selected language should fall back to English."""
|
|
||||||
translations = tmp_path / "translations"
|
|
||||||
translations.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
(translations / "en.json").write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"greeting": "Hello {name}",
|
|
||||||
"settings.title": "Settings",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
(translations / "de.json").write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"settings.title": "Einstellungen",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
i18n._translator = i18n.Translator() # type: ignore[attr-defined]
|
|
||||||
i18n.initialize("de", translations)
|
|
||||||
|
|
||||||
assert i18n.tr("settings.title") == "Einstellungen"
|
|
||||||
assert i18n.tr("greeting", name="Alex") == "Hello Alex"
|
|
||||||
|
|
||||||
def test_get_available_languages_reads_translation_files(self, tmp_path: Path):
|
|
||||||
"""Available languages should be discovered from JSON files."""
|
|
||||||
translations = tmp_path / "translations"
|
|
||||||
translations.mkdir(parents=True, exist_ok=True)
|
|
||||||
(translations / "en.json").write_text("{}", encoding="utf-8")
|
|
||||||
(translations / "fr.json").write_text("{}", encoding="utf-8")
|
|
||||||
|
|
||||||
i18n._translator = i18n.Translator() # type: ignore[attr-defined]
|
|
||||||
i18n.initialize("en", translations)
|
|
||||||
|
|
||||||
available = i18n.get_available_languages()
|
|
||||||
assert "en" in available
|
|
||||||
assert "fr" in available
|
|
||||||
|
|
@ -82,6 +82,136 @@ class TestMainWindowInitialization:
|
||||||
assert window.drag_interceptor is not None
|
assert window.drag_interceptor is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestMainWindowNavigation:
|
||||||
|
"""Test navigation toolbar and functionality."""
|
||||||
|
|
||||||
|
def test_navigation_toolbar_created(self, qtbot, sample_config):
|
||||||
|
"""Test navigation toolbar is created."""
|
||||||
|
window = MainWindow(sample_config)
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
|
||||||
|
toolbars = window.findChildren(QToolBar)
|
||||||
|
assert len(toolbars) > 0
|
||||||
|
|
||||||
|
def test_navigation_toolbar_not_movable(self, qtbot, sample_config):
|
||||||
|
"""Test navigation toolbar is not movable (locked for Kiosk-mode)."""
|
||||||
|
window = MainWindow(sample_config)
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
|
||||||
|
toolbar = window.findChild(QToolBar)
|
||||||
|
assert toolbar is not None
|
||||||
|
assert not toolbar.isMovable()
|
||||||
|
|
||||||
|
def test_navigate_home(self, qtbot, sample_config):
|
||||||
|
"""Test home button navigation."""
|
||||||
|
window = MainWindow(sample_config)
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
|
||||||
|
with patch.object(window.web_view, "load") as mock_load:
|
||||||
|
window._navigate_home()
|
||||||
|
mock_load.assert_called_once()
|
||||||
|
|
||||||
|
def test_navigate_home_with_http_url(self, qtbot, tmp_path):
|
||||||
|
"""Test home navigation with HTTP URL."""
|
||||||
|
config = Config(
|
||||||
|
app_name="Test",
|
||||||
|
app_version="1.0.0",
|
||||||
|
log_level="INFO",
|
||||||
|
log_file=None,
|
||||||
|
allowed_roots=[tmp_path],
|
||||||
|
allowed_urls=[],
|
||||||
|
webapp_url="http://localhost:8000",
|
||||||
|
window_width=800,
|
||||||
|
window_height=600,
|
||||||
|
enable_logging=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
window = MainWindow(config)
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
|
||||||
|
with patch.object(window.web_view, "load") as mock_load:
|
||||||
|
window._navigate_home()
|
||||||
|
|
||||||
|
# Verify load was called with HTTP URL
|
||||||
|
call_args = mock_load.call_args
|
||||||
|
url = call_args[0][0]
|
||||||
|
assert url.scheme() == "http"
|
||||||
|
|
||||||
|
def test_navigate_home_with_file_url(self, qtbot, sample_config):
|
||||||
|
"""Test home navigation with file:// URL."""
|
||||||
|
window = MainWindow(sample_config)
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
|
||||||
|
with patch.object(window.web_view, "load") as mock_load:
|
||||||
|
window._navigate_home()
|
||||||
|
|
||||||
|
call_args = mock_load.call_args
|
||||||
|
url = call_args[0][0]
|
||||||
|
assert url.scheme() == "file"
|
||||||
|
|
||||||
|
|
||||||
|
class TestMainWindowWebAppLoading:
|
||||||
|
"""Test web application loading."""
|
||||||
|
|
||||||
|
def test_load_local_webapp_file(self, qtbot, sample_config):
|
||||||
|
"""Test loading local webapp file."""
|
||||||
|
window = MainWindow(sample_config)
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
|
||||||
|
# Window should load without errors
|
||||||
|
assert window.web_view is not None
|
||||||
|
|
||||||
|
def test_load_remote_webapp_url(self, qtbot, tmp_path):
|
||||||
|
"""Test loading remote webapp URL."""
|
||||||
|
config = Config(
|
||||||
|
app_name="Test",
|
||||||
|
app_version="1.0.0",
|
||||||
|
log_level="INFO",
|
||||||
|
log_file=None,
|
||||||
|
allowed_roots=[tmp_path],
|
||||||
|
allowed_urls=["localhost"],
|
||||||
|
webapp_url="http://localhost:3000",
|
||||||
|
window_width=800,
|
||||||
|
window_height=600,
|
||||||
|
enable_logging=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
window = MainWindow(config)
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
|
||||||
|
assert window.web_view is not None
|
||||||
|
|
||||||
|
def test_load_nonexistent_file_shows_welcome_page(self, qtbot, tmp_path):
|
||||||
|
"""Test loading nonexistent file shows welcome page HTML."""
|
||||||
|
config = Config(
|
||||||
|
app_name="Test",
|
||||||
|
app_version="1.0.0",
|
||||||
|
log_level="INFO",
|
||||||
|
log_file=None,
|
||||||
|
allowed_roots=[tmp_path],
|
||||||
|
allowed_urls=[],
|
||||||
|
webapp_url="/nonexistent/file.html",
|
||||||
|
window_width=800,
|
||||||
|
window_height=600,
|
||||||
|
enable_logging=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(config, "webapp_url", "/nonexistent/file.html"):
|
||||||
|
window = MainWindow(config)
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
window.web_view, "setHtml"
|
||||||
|
) as mock_set_html:
|
||||||
|
window._load_webapp()
|
||||||
|
mock_set_html.assert_called_once()
|
||||||
|
|
||||||
|
# Verify welcome page is shown instead of error
|
||||||
|
call_args = mock_set_html.call_args[0][0]
|
||||||
|
assert "WebDrop Bridge" in call_args
|
||||||
|
assert "Application Ready" in call_args
|
||||||
|
|
||||||
|
|
||||||
class TestMainWindowDragIntegration:
|
class TestMainWindowDragIntegration:
|
||||||
"""Test drag-and-drop integration."""
|
"""Test drag-and-drop integration."""
|
||||||
|
|
||||||
|
|
@ -101,7 +231,9 @@ class TestMainWindowDragIntegration:
|
||||||
assert window.drag_interceptor.drag_started is not None
|
assert window.drag_interceptor.drag_started is not None
|
||||||
assert window.drag_interceptor.drag_failed is not None
|
assert window.drag_interceptor.drag_failed is not None
|
||||||
|
|
||||||
def test_handle_drag_delegates_to_interceptor(self, qtbot, sample_config, tmp_path):
|
def test_handle_drag_delegates_to_interceptor(
|
||||||
|
self, qtbot, sample_config, tmp_path
|
||||||
|
):
|
||||||
"""Test drag handling delegates to interceptor."""
|
"""Test drag handling delegates to interceptor."""
|
||||||
from PySide6.QtCore import QCoreApplication
|
from PySide6.QtCore import QCoreApplication
|
||||||
|
|
||||||
|
|
@ -112,7 +244,9 @@ class TestMainWindowDragIntegration:
|
||||||
test_file = sample_config.allowed_roots[0] / "test.txt"
|
test_file = sample_config.allowed_roots[0] / "test.txt"
|
||||||
test_file.write_text("test")
|
test_file.write_text("test")
|
||||||
|
|
||||||
with patch.object(window.drag_interceptor, "handle_drag") as mock_drag:
|
with patch.object(
|
||||||
|
window.drag_interceptor, "handle_drag"
|
||||||
|
) as mock_drag:
|
||||||
mock_drag.return_value = True
|
mock_drag.return_value = True
|
||||||
# Call through bridge
|
# Call through bridge
|
||||||
window._drag_bridge.start_file_drag(str(test_file))
|
window._drag_bridge.start_file_drag(str(test_file))
|
||||||
|
|
@ -142,7 +276,9 @@ class TestMainWindowDragIntegration:
|
||||||
class TestMainWindowURLWhitelist:
|
class TestMainWindowURLWhitelist:
|
||||||
"""Test URL whitelisting integration."""
|
"""Test URL whitelisting integration."""
|
||||||
|
|
||||||
def test_restricted_web_view_receives_allowed_urls(self, qtbot, sample_config):
|
def test_restricted_web_view_receives_allowed_urls(
|
||||||
|
self, qtbot, sample_config
|
||||||
|
):
|
||||||
"""Test RestrictedWebEngineView receives allowed URLs from config."""
|
"""Test RestrictedWebEngineView receives allowed URLs from config."""
|
||||||
window = MainWindow(sample_config)
|
window = MainWindow(sample_config)
|
||||||
qtbot.addWidget(window)
|
qtbot.addWidget(window)
|
||||||
|
|
@ -150,73 +286,268 @@ class TestMainWindowURLWhitelist:
|
||||||
# web_view should have allowed_urls configured
|
# web_view should have allowed_urls configured
|
||||||
assert window.web_view.allowed_urls == sample_config.allowed_urls
|
assert window.web_view.allowed_urls == sample_config.allowed_urls
|
||||||
|
|
||||||
|
def test_empty_allowed_urls_list(self, qtbot, tmp_path):
|
||||||
class TestMainWindowOpenWith:
|
"""Test with empty allowed URLs (no restriction)."""
|
||||||
"""Test Open With chooser behavior."""
|
config = Config(
|
||||||
|
app_name="Test",
|
||||||
def test_open_with_app_chooser_windows(self, qtbot, sample_config):
|
app_version="1.0.0",
|
||||||
"""Windows should use ShellExecuteW with the openas verb."""
|
log_level="INFO",
|
||||||
window = MainWindow(sample_config)
|
log_file=None,
|
||||||
qtbot.addWidget(window)
|
allowed_roots=[tmp_path],
|
||||||
|
allowed_urls=[], # Empty = no restriction
|
||||||
test_file = sample_config.allowed_roots[0] / "open_with_test.txt"
|
webapp_url="http://localhost",
|
||||||
test_file.write_text("test")
|
window_width=800,
|
||||||
|
window_height=600,
|
||||||
with patch("webdrop_bridge.ui.main_window.sys.platform", "win32"):
|
enable_logging=False,
|
||||||
with patch("ctypes.windll.shell32.ShellExecuteW", return_value=33) as mock_shell:
|
|
||||||
assert window._open_with_app_chooser(str(test_file)) is True
|
|
||||||
mock_shell.assert_called_once_with(
|
|
||||||
None,
|
|
||||||
"openas",
|
|
||||||
str(test_file),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
1,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_open_with_app_chooser_windows_shellexecute_failure(self, qtbot, sample_config):
|
window = MainWindow(config)
|
||||||
"""Windows should fall back to OpenAs_RunDLL when ShellExecuteW fails."""
|
qtbot.addWidget(window)
|
||||||
|
|
||||||
|
assert window.web_view.allowed_urls == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestMainWindowSignals:
|
||||||
|
"""Test signal connections."""
|
||||||
|
|
||||||
|
def test_drag_started_signal_connection(self, qtbot, sample_config):
|
||||||
|
"""Test drag_started signal is connected to handler."""
|
||||||
window = MainWindow(sample_config)
|
window = MainWindow(sample_config)
|
||||||
qtbot.addWidget(window)
|
qtbot.addWidget(window)
|
||||||
|
|
||||||
test_file = sample_config.allowed_roots[0] / "open_with_fallback.txt"
|
with patch.object(window, "_on_drag_started") as mock_handler:
|
||||||
test_file.write_text("test")
|
window.drag_interceptor.drag_started.emit(["/path/to/file"])
|
||||||
|
mock_handler.assert_called_once()
|
||||||
|
|
||||||
with patch("webdrop_bridge.ui.main_window.sys.platform", "win32"):
|
def test_drag_failed_signal_connection(self, qtbot, sample_config):
|
||||||
with patch("ctypes.windll.shell32.ShellExecuteW", return_value=31):
|
"""Test drag_failed signal is connected to handler."""
|
||||||
with patch("webdrop_bridge.ui.main_window.subprocess.Popen") as mock_popen:
|
window = MainWindow(sample_config)
|
||||||
assert window._open_with_app_chooser(str(test_file)) is True
|
qtbot.addWidget(window)
|
||||||
mock_popen.assert_called_once_with(
|
|
||||||
["rundll32.exe", "shell32.dll,OpenAs_RunDLL", str(test_file)]
|
with patch.object(window, "_on_drag_failed") as mock_handler:
|
||||||
|
window.drag_interceptor.drag_failed.emit("Error message")
|
||||||
|
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."""
|
||||||
|
|
||||||
|
def test_stylesheet_loading_gracefully_handles_missing_file(
|
||||||
|
self, qtbot, sample_config
|
||||||
|
):
|
||||||
|
"""Test missing stylesheet doesn't crash application."""
|
||||||
|
window = MainWindow(sample_config)
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
|
||||||
|
# Should not raise even if stylesheet missing
|
||||||
|
window._apply_stylesheet()
|
||||||
|
|
||||||
|
def test_stylesheet_loading_with_nonexistent_file(
|
||||||
|
self, qtbot, sample_config
|
||||||
|
):
|
||||||
|
"""Test stylesheet loading with nonexistent file path."""
|
||||||
|
window = MainWindow(sample_config)
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
|
||||||
|
with patch("builtins.open", side_effect=OSError("File not found")):
|
||||||
|
# Should handle gracefully
|
||||||
|
window._apply_stylesheet()
|
||||||
|
|
||||||
|
|
||||||
|
class TestMainWindowCloseEvent:
|
||||||
|
"""Test window close handling."""
|
||||||
|
|
||||||
|
def test_close_event_accepted(self, qtbot, sample_config):
|
||||||
|
"""Test close event is accepted."""
|
||||||
|
window = MainWindow(sample_config)
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
|
||||||
|
from PySide6.QtGui import QCloseEvent
|
||||||
|
|
||||||
|
event = QCloseEvent()
|
||||||
|
window.closeEvent(event)
|
||||||
|
|
||||||
|
assert event.isAccepted()
|
||||||
|
|
||||||
|
|
||||||
|
class TestMainWindowIntegration:
|
||||||
|
"""Integration tests for MainWindow with all components."""
|
||||||
|
|
||||||
|
def test_full_initialization_flow(self, qtbot, sample_config):
|
||||||
|
"""Test complete initialization flow."""
|
||||||
|
window = MainWindow(sample_config)
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
|
||||||
|
# Verify all components initialized
|
||||||
|
assert window.web_view is not None
|
||||||
|
assert window.drag_interceptor is not None
|
||||||
|
assert window.config == sample_config
|
||||||
|
|
||||||
|
# Verify toolbar exists
|
||||||
|
toolbars = window.findChildren(QToolBar)
|
||||||
|
assert len(toolbars) > 0
|
||||||
|
|
||||||
|
def test_window_with_multiple_allowed_roots(self, qtbot, tmp_path):
|
||||||
|
"""Test MainWindow with multiple allowed root directories."""
|
||||||
|
root1 = tmp_path / "root1"
|
||||||
|
root2 = tmp_path / "root2"
|
||||||
|
root1.mkdir()
|
||||||
|
root2.mkdir()
|
||||||
|
|
||||||
|
webapp_file = tmp_path / "index.html"
|
||||||
|
webapp_file.write_text("<html></html>")
|
||||||
|
|
||||||
|
config = Config(
|
||||||
|
app_name="Test",
|
||||||
|
app_version="1.0.0",
|
||||||
|
log_level="INFO",
|
||||||
|
log_file=None,
|
||||||
|
allowed_roots=[root1, root2],
|
||||||
|
allowed_urls=[],
|
||||||
|
webapp_url=str(webapp_file),
|
||||||
|
window_width=800,
|
||||||
|
window_height=600,
|
||||||
|
enable_logging=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_open_with_app_chooser_missing_file(self, qtbot, sample_config):
|
window = MainWindow(config)
|
||||||
"""Missing files should fail before platform-specific invocation."""
|
|
||||||
window = MainWindow(sample_config)
|
|
||||||
qtbot.addWidget(window)
|
qtbot.addWidget(window)
|
||||||
|
|
||||||
with patch("webdrop_bridge.ui.main_window.sys.platform", "win32"):
|
# Verify validator has both roots
|
||||||
assert window._open_with_app_chooser("C:/tmp/does_not_exist.txt") is False
|
assert window.drag_interceptor._validator is not None
|
||||||
|
assert len(
|
||||||
|
window.drag_interceptor._validator.allowed_roots
|
||||||
|
) == 2
|
||||||
|
|
||||||
def test_open_with_app_chooser_macos_success(self, qtbot, sample_config):
|
def test_window_with_url_whitelist(self, qtbot, tmp_path):
|
||||||
"""macOS should return True when osascript exits successfully."""
|
"""Test MainWindow respects URL whitelist."""
|
||||||
window = MainWindow(sample_config)
|
config = Config(
|
||||||
|
app_name="Test",
|
||||||
|
app_version="1.0.0",
|
||||||
|
log_level="INFO",
|
||||||
|
log_file=None,
|
||||||
|
allowed_roots=[tmp_path],
|
||||||
|
allowed_urls=["*.example.com", "localhost"],
|
||||||
|
webapp_url="http://localhost",
|
||||||
|
window_width=800,
|
||||||
|
window_height=600,
|
||||||
|
enable_logging=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
window = MainWindow(config)
|
||||||
qtbot.addWidget(window)
|
qtbot.addWidget(window)
|
||||||
|
|
||||||
test_file = sample_config.allowed_roots[0] / "open_with_macos.txt"
|
# Verify whitelist is set
|
||||||
test_file.write_text("test")
|
assert window.web_view.allowed_urls == ["*.example.com", "localhost"]
|
||||||
|
|
||||||
class _Result:
|
|
||||||
returncode = 0
|
|
||||||
|
|
||||||
with patch("webdrop_bridge.ui.main_window.sys.platform", "darwin"):
|
|
||||||
with patch("webdrop_bridge.ui.main_window.subprocess.run", return_value=_Result()):
|
|
||||||
assert window._open_with_app_chooser(str(test_file)) is True
|
|
||||||
|
|
||||||
def test_open_with_app_chooser_unsupported_platform(self, qtbot, sample_config):
|
|
||||||
"""Unsupported platforms should return False."""
|
|
||||||
window = MainWindow(sample_config)
|
|
||||||
qtbot.addWidget(window)
|
|
||||||
|
|
||||||
with patch("webdrop_bridge.ui.main_window.sys.platform", "linux"):
|
|
||||||
assert window._open_with_app_chooser("/tmp/test.txt") is False
|
|
||||||
|
|
|
||||||
|
|
@ -8,20 +8,6 @@ from PySide6.QtWebEngineCore import QWebEngineNavigationRequest
|
||||||
from webdrop_bridge.ui.restricted_web_view import RestrictedWebEngineView
|
from webdrop_bridge.ui.restricted_web_view import RestrictedWebEngineView
|
||||||
|
|
||||||
|
|
||||||
def _create_mock_request(url: str) -> MagicMock:
|
|
||||||
"""Create properly mocked navigation request.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: URL string to mock
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Properly mocked QWebEngineNavigationRequest
|
|
||||||
"""
|
|
||||||
request = MagicMock(spec=QWebEngineNavigationRequest)
|
|
||||||
request.url = MagicMock(return_value=QUrl(url))
|
|
||||||
return request
|
|
||||||
|
|
||||||
|
|
||||||
class TestRestrictedWebEngineView:
|
class TestRestrictedWebEngineView:
|
||||||
"""Test URL whitelist enforcement."""
|
"""Test URL whitelist enforcement."""
|
||||||
|
|
||||||
|
|
@ -30,7 +16,8 @@ class TestRestrictedWebEngineView:
|
||||||
view = RestrictedWebEngineView([])
|
view = RestrictedWebEngineView([])
|
||||||
|
|
||||||
# Mock navigation request
|
# Mock navigation request
|
||||||
request = _create_mock_request("https://example.com/page")
|
request = MagicMock(spec=QWebEngineNavigationRequest)
|
||||||
|
request.url = QUrl("https://example.com/page")
|
||||||
|
|
||||||
# Should not reject any URL
|
# Should not reject any URL
|
||||||
view._on_navigation_requested(request)
|
view._on_navigation_requested(request)
|
||||||
|
|
@ -40,7 +27,8 @@ class TestRestrictedWebEngineView:
|
||||||
"""Test that None allowed_urls means no restrictions."""
|
"""Test that None allowed_urls means no restrictions."""
|
||||||
view = RestrictedWebEngineView(None)
|
view = RestrictedWebEngineView(None)
|
||||||
|
|
||||||
request = _create_mock_request("https://blocked.com/page")
|
request = MagicMock(spec=QWebEngineNavigationRequest)
|
||||||
|
request.url = QUrl("https://blocked.com/page")
|
||||||
|
|
||||||
view._on_navigation_requested(request)
|
view._on_navigation_requested(request)
|
||||||
request.reject.assert_not_called()
|
request.reject.assert_not_called()
|
||||||
|
|
@ -49,7 +37,8 @@ class TestRestrictedWebEngineView:
|
||||||
"""Test exact domain matching."""
|
"""Test exact domain matching."""
|
||||||
view = RestrictedWebEngineView(["example.com"])
|
view = RestrictedWebEngineView(["example.com"])
|
||||||
|
|
||||||
request = _create_mock_request("https://example.com/page")
|
request = MagicMock(spec=QWebEngineNavigationRequest)
|
||||||
|
request.url = QUrl("https://example.com/page")
|
||||||
|
|
||||||
view._on_navigation_requested(request)
|
view._on_navigation_requested(request)
|
||||||
request.reject.assert_not_called()
|
request.reject.assert_not_called()
|
||||||
|
|
@ -58,7 +47,8 @@ class TestRestrictedWebEngineView:
|
||||||
"""Test that mismatched domains are rejected."""
|
"""Test that mismatched domains are rejected."""
|
||||||
view = RestrictedWebEngineView(["example.com"])
|
view = RestrictedWebEngineView(["example.com"])
|
||||||
|
|
||||||
request = _create_mock_request("https://other.com/page")
|
request = MagicMock(spec=QWebEngineNavigationRequest)
|
||||||
|
request.url = QUrl("https://other.com/page")
|
||||||
|
|
||||||
with patch("webdrop_bridge.ui.restricted_web_view.QDesktopServices"):
|
with patch("webdrop_bridge.ui.restricted_web_view.QDesktopServices"):
|
||||||
view._on_navigation_requested(request)
|
view._on_navigation_requested(request)
|
||||||
|
|
@ -68,7 +58,8 @@ class TestRestrictedWebEngineView:
|
||||||
"""Test wildcard pattern matching."""
|
"""Test wildcard pattern matching."""
|
||||||
view = RestrictedWebEngineView(["*.example.com"])
|
view = RestrictedWebEngineView(["*.example.com"])
|
||||||
|
|
||||||
request = _create_mock_request("https://sub.example.com/page")
|
request = MagicMock(spec=QWebEngineNavigationRequest)
|
||||||
|
request.url = QUrl("https://sub.example.com/page")
|
||||||
|
|
||||||
view._on_navigation_requested(request)
|
view._on_navigation_requested(request)
|
||||||
request.reject.assert_not_called()
|
request.reject.assert_not_called()
|
||||||
|
|
@ -77,7 +68,8 @@ class TestRestrictedWebEngineView:
|
||||||
"""Test that non-matching wildcard patterns are rejected."""
|
"""Test that non-matching wildcard patterns are rejected."""
|
||||||
view = RestrictedWebEngineView(["*.example.com"])
|
view = RestrictedWebEngineView(["*.example.com"])
|
||||||
|
|
||||||
request = _create_mock_request("https://example.org/page")
|
request = MagicMock(spec=QWebEngineNavigationRequest)
|
||||||
|
request.url = QUrl("https://example.org/page")
|
||||||
|
|
||||||
with patch("webdrop_bridge.ui.restricted_web_view.QDesktopServices"):
|
with patch("webdrop_bridge.ui.restricted_web_view.QDesktopServices"):
|
||||||
view._on_navigation_requested(request)
|
view._on_navigation_requested(request)
|
||||||
|
|
@ -87,7 +79,8 @@ class TestRestrictedWebEngineView:
|
||||||
"""Test that localhost is allowed."""
|
"""Test that localhost is allowed."""
|
||||||
view = RestrictedWebEngineView(["localhost"])
|
view = RestrictedWebEngineView(["localhost"])
|
||||||
|
|
||||||
request = _create_mock_request("http://localhost:8000/page")
|
request = MagicMock(spec=QWebEngineNavigationRequest)
|
||||||
|
request.url = QUrl("http://localhost:8000/page")
|
||||||
|
|
||||||
view._on_navigation_requested(request)
|
view._on_navigation_requested(request)
|
||||||
request.reject.assert_not_called()
|
request.reject.assert_not_called()
|
||||||
|
|
@ -96,7 +89,8 @@ class TestRestrictedWebEngineView:
|
||||||
"""Test that file:// URLs are always allowed."""
|
"""Test that file:// URLs are always allowed."""
|
||||||
view = RestrictedWebEngineView(["example.com"])
|
view = RestrictedWebEngineView(["example.com"])
|
||||||
|
|
||||||
request = _create_mock_request("file:///var/www/index.html")
|
request = MagicMock(spec=QWebEngineNavigationRequest)
|
||||||
|
request.url = QUrl("file:///var/www/index.html")
|
||||||
|
|
||||||
view._on_navigation_requested(request)
|
view._on_navigation_requested(request)
|
||||||
request.reject.assert_not_called()
|
request.reject.assert_not_called()
|
||||||
|
|
@ -106,17 +100,20 @@ class TestRestrictedWebEngineView:
|
||||||
view = RestrictedWebEngineView(["example.com", "test.org"])
|
view = RestrictedWebEngineView(["example.com", "test.org"])
|
||||||
|
|
||||||
# First allowed URL
|
# First allowed URL
|
||||||
request1 = _create_mock_request("https://example.com/page")
|
request1 = MagicMock(spec=QWebEngineNavigationRequest)
|
||||||
|
request1.url = QUrl("https://example.com/page")
|
||||||
view._on_navigation_requested(request1)
|
view._on_navigation_requested(request1)
|
||||||
request1.reject.assert_not_called()
|
request1.reject.assert_not_called()
|
||||||
|
|
||||||
# Second allowed URL
|
# Second allowed URL
|
||||||
request2 = _create_mock_request("https://test.org/page")
|
request2 = MagicMock(spec=QWebEngineNavigationRequest)
|
||||||
|
request2.url = QUrl("https://test.org/page")
|
||||||
view._on_navigation_requested(request2)
|
view._on_navigation_requested(request2)
|
||||||
request2.reject.assert_not_called()
|
request2.reject.assert_not_called()
|
||||||
|
|
||||||
# Non-allowed URL
|
# Non-allowed URL
|
||||||
request3 = _create_mock_request("https://blocked.com/page")
|
request3 = MagicMock(spec=QWebEngineNavigationRequest)
|
||||||
|
request3.url = QUrl("https://blocked.com/page")
|
||||||
with patch("webdrop_bridge.ui.restricted_web_view.QDesktopServices"):
|
with patch("webdrop_bridge.ui.restricted_web_view.QDesktopServices"):
|
||||||
view._on_navigation_requested(request3)
|
view._on_navigation_requested(request3)
|
||||||
request3.reject.assert_called_once()
|
request3.reject.assert_called_once()
|
||||||
|
|
@ -125,13 +122,15 @@ class TestRestrictedWebEngineView:
|
||||||
"""Test that rejected URLs open in system browser."""
|
"""Test that rejected URLs open in system browser."""
|
||||||
view = RestrictedWebEngineView(["allowed.com"])
|
view = RestrictedWebEngineView(["allowed.com"])
|
||||||
|
|
||||||
request = _create_mock_request("https://blocked.com/page")
|
request = MagicMock(spec=QWebEngineNavigationRequest)
|
||||||
|
request.url = QUrl("https://blocked.com/page")
|
||||||
|
|
||||||
with patch("webdrop_bridge.ui.restricted_web_view.QDesktopServices.openUrl") as mock_open:
|
with patch(
|
||||||
|
"webdrop_bridge.ui.restricted_web_view.QDesktopServices.openUrl"
|
||||||
|
) as mock_open:
|
||||||
view._on_navigation_requested(request)
|
view._on_navigation_requested(request)
|
||||||
request.reject.assert_called_once()
|
request.reject.assert_called_once()
|
||||||
# Check that openUrl was called with a QUrl
|
mock_open.assert_called_once_with(request.url)
|
||||||
mock_open.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
class TestURLAllowedLogic:
|
class TestURLAllowedLogic:
|
||||||
|
|
|
||||||
|
|
@ -44,56 +44,42 @@ class TestSettingsDialogInitialization:
|
||||||
qtbot.addWidget(dialog)
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
assert dialog.tabs is not None
|
assert dialog.tabs is not None
|
||||||
assert dialog.tabs.count() == 7 # General + previous 6 tabs
|
assert dialog.tabs.count() == 5 # Paths, URLs, Logging, Window, Profiles
|
||||||
|
|
||||||
def test_dialog_has_general_tab(self, qtbot, sample_config):
|
|
||||||
"""Test General tab exists."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
assert dialog.tabs.tabText(0) == "General"
|
|
||||||
|
|
||||||
def test_dialog_has_web_source_tab(self, qtbot, sample_config):
|
|
||||||
"""Test Web Source tab exists."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
assert dialog.tabs.tabText(1) == "Web Source"
|
|
||||||
|
|
||||||
def test_dialog_has_paths_tab(self, qtbot, sample_config):
|
def test_dialog_has_paths_tab(self, qtbot, sample_config):
|
||||||
"""Test Paths tab exists."""
|
"""Test Paths tab exists."""
|
||||||
dialog = SettingsDialog(sample_config)
|
dialog = SettingsDialog(sample_config)
|
||||||
qtbot.addWidget(dialog)
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
assert dialog.tabs.tabText(2) == "Paths"
|
assert dialog.tabs.tabText(0) == "Paths"
|
||||||
|
|
||||||
def test_dialog_has_urls_tab(self, qtbot, sample_config):
|
def test_dialog_has_urls_tab(self, qtbot, sample_config):
|
||||||
"""Test URLs tab exists."""
|
"""Test URLs tab exists."""
|
||||||
dialog = SettingsDialog(sample_config)
|
dialog = SettingsDialog(sample_config)
|
||||||
qtbot.addWidget(dialog)
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
assert dialog.tabs.tabText(3) == "URLs"
|
assert dialog.tabs.tabText(1) == "URLs"
|
||||||
|
|
||||||
def test_dialog_has_logging_tab(self, qtbot, sample_config):
|
def test_dialog_has_logging_tab(self, qtbot, sample_config):
|
||||||
"""Test Logging tab exists."""
|
"""Test Logging tab exists."""
|
||||||
dialog = SettingsDialog(sample_config)
|
dialog = SettingsDialog(sample_config)
|
||||||
qtbot.addWidget(dialog)
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
assert dialog.tabs.tabText(4) == "Logging"
|
assert dialog.tabs.tabText(2) == "Logging"
|
||||||
|
|
||||||
def test_dialog_has_window_tab(self, qtbot, sample_config):
|
def test_dialog_has_window_tab(self, qtbot, sample_config):
|
||||||
"""Test Window tab exists."""
|
"""Test Window tab exists."""
|
||||||
dialog = SettingsDialog(sample_config)
|
dialog = SettingsDialog(sample_config)
|
||||||
qtbot.addWidget(dialog)
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
assert dialog.tabs.tabText(5) == "Window"
|
assert dialog.tabs.tabText(3) == "Window"
|
||||||
|
|
||||||
def test_dialog_has_profiles_tab(self, qtbot, sample_config):
|
def test_dialog_has_profiles_tab(self, qtbot, sample_config):
|
||||||
"""Test Profiles tab exists."""
|
"""Test Profiles tab exists."""
|
||||||
dialog = SettingsDialog(sample_config)
|
dialog = SettingsDialog(sample_config)
|
||||||
qtbot.addWidget(dialog)
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
assert dialog.tabs.tabText(6) == "Profiles"
|
assert dialog.tabs.tabText(4) == "Profiles"
|
||||||
|
|
||||||
|
|
||||||
class TestPathsTab:
|
class TestPathsTab:
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,13 @@ class TestUpdateAvailableDialog:
|
||||||
with qtbot.waitSignal(dialog.update_later):
|
with qtbot.waitSignal(dialog.update_later):
|
||||||
dialog.update_later_btn.click()
|
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:
|
class TestDownloadingDialog:
|
||||||
"""Tests for DownloadingDialog."""
|
"""Tests for DownloadingDialog."""
|
||||||
|
|
|
||||||
|
|
@ -16,17 +16,6 @@ def update_manager(tmp_path):
|
||||||
return UpdateManager(current_version="0.0.1", config_dir=tmp_path)
|
return UpdateManager(current_version="0.0.1", config_dir=tmp_path)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def agravity_update_manager(tmp_path):
|
|
||||||
"""Create a brand-aware UpdateManager instance for Agravity Bridge."""
|
|
||||||
return UpdateManager(
|
|
||||||
current_version="0.0.1",
|
|
||||||
config_dir=tmp_path,
|
|
||||||
brand_id="agravity",
|
|
||||||
update_channel="stable",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_release():
|
def sample_release():
|
||||||
"""Sample release data from API."""
|
"""Sample release data from API."""
|
||||||
|
|
@ -177,7 +166,9 @@ class TestCheckForUpdates:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch.object(UpdateManager, "_fetch_release")
|
@patch.object(UpdateManager, "_fetch_release")
|
||||||
async def test_check_for_updates_no_update(self, mock_fetch, update_manager):
|
async def test_check_for_updates_no_update(
|
||||||
|
self, mock_fetch, update_manager
|
||||||
|
):
|
||||||
"""Test no update available."""
|
"""Test no update available."""
|
||||||
mock_fetch.return_value = {
|
mock_fetch.return_value = {
|
||||||
"tag_name": "v0.0.1",
|
"tag_name": "v0.0.1",
|
||||||
|
|
@ -193,7 +184,9 @@ class TestCheckForUpdates:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch.object(UpdateManager, "_fetch_release")
|
@patch.object(UpdateManager, "_fetch_release")
|
||||||
async def test_check_for_updates_uses_cache(self, mock_fetch, update_manager, sample_release):
|
async def test_check_for_updates_uses_cache(
|
||||||
|
self, mock_fetch, update_manager, sample_release
|
||||||
|
):
|
||||||
"""Test cache is used on subsequent calls."""
|
"""Test cache is used on subsequent calls."""
|
||||||
mock_fetch.return_value = sample_release
|
mock_fetch.return_value = sample_release
|
||||||
|
|
||||||
|
|
@ -214,7 +207,9 @@ class TestDownloading:
|
||||||
"""Test update downloading."""
|
"""Test update downloading."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_download_update_success(self, update_manager, tmp_path):
|
async def test_download_update_success(
|
||||||
|
self, update_manager, tmp_path
|
||||||
|
):
|
||||||
"""Test successful update download."""
|
"""Test successful update download."""
|
||||||
# Create release with .msi asset
|
# Create release with .msi asset
|
||||||
release_data = {
|
release_data = {
|
||||||
|
|
@ -242,7 +237,9 @@ class TestDownloading:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch.object(UpdateManager, "_download_file")
|
@patch.object(UpdateManager, "_download_file")
|
||||||
async def test_download_update_no_installer(self, mock_download, update_manager):
|
async def test_download_update_no_installer(
|
||||||
|
self, mock_download, update_manager
|
||||||
|
):
|
||||||
"""Test download fails when no installer in release."""
|
"""Test download fails when no installer in release."""
|
||||||
release_data = {
|
release_data = {
|
||||||
"tag_name": "v0.0.2",
|
"tag_name": "v0.0.2",
|
||||||
|
|
@ -263,143 +260,6 @@ class TestDownloading:
|
||||||
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_download_update_uses_release_manifest(self, agravity_update_manager, tmp_path):
|
|
||||||
"""Test branded download selection from a shared release manifest."""
|
|
||||||
release = Release(
|
|
||||||
tag_name="v0.0.2",
|
|
||||||
name="WebDropBridge v0.0.2",
|
|
||||||
version="0.0.2",
|
|
||||||
body="Release notes",
|
|
||||||
assets=[
|
|
||||||
{
|
|
||||||
"name": "AgravityBridge-0.0.2-win-x64.msi",
|
|
||||||
"browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "AgravityBridge-0.0.2-win-x64.msi.sha256",
|
|
||||||
"browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi.sha256",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "OtherBridge-0.0.2-win-x64.msi",
|
|
||||||
"browser_download_url": "https://example.com/OtherBridge-0.0.2-win-x64.msi",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "release-manifest.json",
|
|
||||||
"browser_download_url": "https://example.com/release-manifest.json",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
published_at="2026-01-29T10:00:00Z",
|
|
||||||
)
|
|
||||||
|
|
||||||
manifest = {
|
|
||||||
"version": "0.0.2",
|
|
||||||
"channel": "stable",
|
|
||||||
"brands": {
|
|
||||||
"agravity": {
|
|
||||||
"windows-x64": {
|
|
||||||
"installer": "AgravityBridge-0.0.2-win-x64.msi",
|
|
||||||
"checksum": "AgravityBridge-0.0.2-win-x64.msi.sha256",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.object(UpdateManager, "_download_json_asset", return_value=manifest),
|
|
||||||
patch.object(UpdateManager, "_download_file", return_value=True) as mock_download,
|
|
||||||
):
|
|
||||||
result = await agravity_update_manager.download_update(release, tmp_path)
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert result.name == "AgravityBridge-0.0.2-win-x64.msi"
|
|
||||||
mock_download.assert_called_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_download_update_falls_back_to_brand_prefix_without_manifest(
|
|
||||||
self, agravity_update_manager, tmp_path
|
|
||||||
):
|
|
||||||
"""Test branded download selection still works when the manifest is unavailable."""
|
|
||||||
release = Release(
|
|
||||||
tag_name="v0.0.2",
|
|
||||||
name="WebDropBridge v0.0.2",
|
|
||||||
version="0.0.2",
|
|
||||||
body="Release notes",
|
|
||||||
assets=[
|
|
||||||
{
|
|
||||||
"name": "WebDropBridge-0.0.2-win-x64.msi",
|
|
||||||
"browser_download_url": "https://example.com/WebDropBridge-0.0.2-win-x64.msi",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "AgravityBridge-0.0.2-win-x64.msi",
|
|
||||||
"browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "AgravityBridge-0.0.2-win-x64.msi.sha256",
|
|
||||||
"browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi.sha256",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
published_at="2026-01-29T10:00:00Z",
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch.object(UpdateManager, "_download_file", return_value=True) as mock_download:
|
|
||||||
result = await agravity_update_manager.download_update(release, tmp_path)
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert result.name == "AgravityBridge-0.0.2-win-x64.msi"
|
|
||||||
mock_download.assert_called_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_verify_checksum_uses_release_manifest(self, agravity_update_manager, tmp_path):
|
|
||||||
"""Test branded checksum selection from a shared release manifest."""
|
|
||||||
test_file = tmp_path / "AgravityBridge-0.0.2-win-x64.msi"
|
|
||||||
test_file.write_bytes(b"test content")
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
checksum = hashlib.sha256(b"test content").hexdigest()
|
|
||||||
release = Release(
|
|
||||||
tag_name="v0.0.2",
|
|
||||||
name="WebDropBridge v0.0.2",
|
|
||||||
version="0.0.2",
|
|
||||||
body="Release notes",
|
|
||||||
assets=[
|
|
||||||
{
|
|
||||||
"name": "AgravityBridge-0.0.2-win-x64.msi",
|
|
||||||
"browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "AgravityBridge-0.0.2-win-x64.msi.sha256",
|
|
||||||
"browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi.sha256",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "release-manifest.json",
|
|
||||||
"browser_download_url": "https://example.com/release-manifest.json",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
published_at="2026-01-29T10:00:00Z",
|
|
||||||
)
|
|
||||||
manifest = {
|
|
||||||
"version": "0.0.2",
|
|
||||||
"channel": "stable",
|
|
||||||
"brands": {
|
|
||||||
"agravity": {
|
|
||||||
"windows-x64": {
|
|
||||||
"installer": "AgravityBridge-0.0.2-win-x64.msi",
|
|
||||||
"checksum": "AgravityBridge-0.0.2-win-x64.msi.sha256",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.object(UpdateManager, "_download_json_asset", return_value=manifest),
|
|
||||||
patch.object(UpdateManager, "_download_checksum", return_value=checksum),
|
|
||||||
):
|
|
||||||
result = await agravity_update_manager.verify_checksum(test_file, release)
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestChecksumVerification:
|
class TestChecksumVerification:
|
||||||
"""Test checksum verification."""
|
"""Test checksum verification."""
|
||||||
|
|
@ -410,8 +270,8 @@ class TestChecksumVerification:
|
||||||
self, mock_download_checksum, update_manager, sample_release, tmp_path
|
self, mock_download_checksum, update_manager, sample_release, tmp_path
|
||||||
):
|
):
|
||||||
"""Test successful checksum verification."""
|
"""Test successful checksum verification."""
|
||||||
# File must match the asset name so the .sha256 lookup succeeds
|
# Create test file
|
||||||
test_file = tmp_path / "WebDropBridge.exe"
|
test_file = tmp_path / "test.exe"
|
||||||
test_file.write_bytes(b"test content")
|
test_file.write_bytes(b"test content")
|
||||||
|
|
||||||
# Calculate actual checksum
|
# Calculate actual checksum
|
||||||
|
|
@ -431,8 +291,7 @@ class TestChecksumVerification:
|
||||||
self, mock_download_checksum, update_manager, sample_release, tmp_path
|
self, mock_download_checksum, update_manager, sample_release, tmp_path
|
||||||
):
|
):
|
||||||
"""Test checksum verification fails on mismatch."""
|
"""Test checksum verification fails on mismatch."""
|
||||||
# File must match the asset name so the .sha256 lookup succeeds
|
test_file = tmp_path / "test.exe"
|
||||||
test_file = tmp_path / "WebDropBridge.exe"
|
|
||||||
test_file.write_bytes(b"test content")
|
test_file.write_bytes(b"test content")
|
||||||
|
|
||||||
# Return wrong checksum
|
# Return wrong checksum
|
||||||
|
|
@ -444,7 +303,9 @@ class TestChecksumVerification:
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_verify_checksum_no_checksum_file(self, update_manager, tmp_path):
|
async def test_verify_checksum_no_checksum_file(
|
||||||
|
self, update_manager, tmp_path
|
||||||
|
):
|
||||||
"""Test verification skipped when no checksum file in release."""
|
"""Test verification skipped when no checksum file in release."""
|
||||||
test_file = tmp_path / "test.exe"
|
test_file = tmp_path / "test.exe"
|
||||||
test_file.write_bytes(b"test content")
|
test_file.write_bytes(b"test content")
|
||||||
|
|
@ -475,7 +336,9 @@ class TestInstallation:
|
||||||
|
|
||||||
@patch("subprocess.Popen")
|
@patch("subprocess.Popen")
|
||||||
@patch("platform.system")
|
@patch("platform.system")
|
||||||
def test_install_update_windows(self, mock_platform, mock_popen, update_manager, tmp_path):
|
def test_install_update_windows(
|
||||||
|
self, mock_platform, mock_popen, update_manager, tmp_path
|
||||||
|
):
|
||||||
"""Test installation on Windows."""
|
"""Test installation on Windows."""
|
||||||
mock_platform.return_value = "Windows"
|
mock_platform.return_value = "Windows"
|
||||||
installer = tmp_path / "WebDropBridge.msi"
|
installer = tmp_path / "WebDropBridge.msi"
|
||||||
|
|
@ -488,7 +351,9 @@ class TestInstallation:
|
||||||
|
|
||||||
@patch("subprocess.Popen")
|
@patch("subprocess.Popen")
|
||||||
@patch("platform.system")
|
@patch("platform.system")
|
||||||
def test_install_update_macos(self, mock_platform, mock_popen, update_manager, tmp_path):
|
def test_install_update_macos(
|
||||||
|
self, mock_platform, mock_popen, update_manager, tmp_path
|
||||||
|
):
|
||||||
"""Test installation on macOS."""
|
"""Test installation on macOS."""
|
||||||
mock_platform.return_value = "Darwin"
|
mock_platform.return_value = "Darwin"
|
||||||
installer = tmp_path / "WebDropBridge.dmg"
|
installer = tmp_path / "WebDropBridge.dmg"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue