Compare commits

..

57 commits
v0.6.2 ... main

Author SHA1 Message Date
a135dd0d96 bump to version 0.8.6
Some checks failed
Tests & Quality Checks / Test on Python 3.11 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.11-1 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12-1 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.10 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.11-2 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12-2 (push) Has been cancelled
Tests & Quality Checks / Build Artifacts (push) Has been cancelled
Tests & Quality Checks / Build Artifacts-1 (push) Has been cancelled
2026-03-12 14:12:27 +01:00
25ebe5d7ea feat: Add fallback mechanism for branded download selection when manifest is unavailable 2026-03-12 14:09:11 +01:00
1de604e7e2 feat: Enhance asset upload process with Python script for reliability and retry logic 2026-03-12 13:59:33 +01:00
ef96184dc3 fix: Rename WiX object file and correct source directory path for Heat-generated files
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-12 11:23:58 +01:00
5cb3bf9f76 feat: Update product name format to include version in Wix configuration
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-12 11:06:53 +01:00
8e07e7e855 bump to version 0.8.5
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-12 11:02:32 +01:00
093b196d0d feat: Enhance update manager to handle UTF-8 BOM and improve asset resolution logic 2026-03-12 11:00:43 +01:00
44124595d8 feat: Add documentation for architecture and translations
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-12 09:22:07 +01:00
c80128118e cleanup
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-12 09:18:06 +01:00
df76cb9b36 feat: Add toolbar icon configuration and update handling for Agravity
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-12 09:07:14 +01:00
eab1009d8c Add brand-specific update channel and environment configuration
- Updated `brand_config.py` to include `WEBDROP_UPDATE_CHANNEL` in the environment variables.
- Enhanced `build_macos.sh` to create a bundled `.env` file with brand-specific defaults, including the update channel.
- Implemented a method in `build_windows.py` to create a bundled `.env` file for Windows builds, incorporating brand-specific runtime defaults.
- Modified `config.py` to ensure the application can locate the `.env` file in various installation scenarios.
- Added unit tests in `test_config.py` to verify the loading of the bootstrap `.env` from the PyInstaller runtime directory.
- Generated new WiX object and script files for the Windows installer, including application shortcuts and registry entries.
2026-03-12 09:04:27 +01:00
de6e9838e5 bump to version 0.8.4 2026-03-12 08:39:51 +01:00
67bfe4a600 Enhance branding and release workflows
- Updated README.md to include a reference to branding and releases documentation.
- Modified brand_config.py to support multi-brand packaging, including functions for collecting local release data and merging release manifests.
- Adjusted build_macos.sh to set a default brand if none is specified and updated DMG naming conventions.
- Enhanced create_release.ps1 and create_release.sh scripts to support dry-run functionality and improved artifact handling.
- Added a new template for brand configuration in build/brands/template.jsonc.
- Created comprehensive branding and releases documentation in docs/BRANDING_AND_RELEASES.md.
- Added unit tests for new branding functionalities in test_brand_config.py.
2026-03-12 08:38:40 +01:00
fd69996c53 feat: Implement brand-aware release creation for Agravity
Some checks failed
Tests & Quality Checks / Test on Python 3.11 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.11-1 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12-1 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.10 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.11-2 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12-2 (push) Has been cancelled
Tests & Quality Checks / Build Artifacts (push) Has been cancelled
Tests & Quality Checks / Build Artifacts-1 (push) Has been cancelled
- Added support for multiple brands in release scripts, allowing for branded artifacts.
- Introduced brand configuration management with JSON files for each brand.
- Created a new `brand_config.py` script to handle brand-specific logic and asset resolution.
- Updated `create_release.ps1` and `create_release.sh` scripts to utilize brand configurations and generate release manifests.
- Added unit tests for brand configuration loading and release manifest generation.
- Introduced `agravity` brand with its specific configuration in `agravity.json`.
2026-03-10 16:18:28 +01:00
b988532aaa feat: implement brand-specific configuration and update management for Agravity Bridge 2026-03-10 16:02:24 +01:00
baf56e040f fix(translations): improve Russian and Chinese translations for clarity and consistency
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-10 15:04:40 +01:00
b4c8692738 feat: add Italian, Russian, and Chinese translations for UI elements and dialogs
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-10 15:01:37 +01:00
a48cc01254 feat: add language change handling and prompts for restart in settings
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-10 14:38:17 +01:00
7daec731ca Add internationalization support with English and French translations
- Introduced a new i18n module for managing translations using JSON files.
- Added English (en.json) and French (fr.json) translation files for UI elements.
- Implemented lazy initialization of the translator to load translations on demand.
- Added unit tests for translation lookup, fallback behavior, and available languages detection.
2026-03-10 14:32:38 +01:00
fd0482ed2d installer creation files
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-10 13:27:10 +01:00
10ab3a418a bump version to 0.8.3 2026-03-10 13:19:29 +01:00
856aec65de feat: ensure toolbar icons are included in MSI bundle during build process
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-10 13:17:35 +01:00
a261de3460 feat: implement Open With functionality for file chooser integration 2026-03-10 13:16:27 +01:00
939c2f896f Refactor code structure for improved readability and maintainability 2026-03-10 12:52:12 +01:00
e84f7bbd66 Bump version to 0.8.2
Some checks failed
Tests & Quality Checks / Test on Python 3.11 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.11-1 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12-1 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.10 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.11-2 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12-2 (push) Has been cancelled
Tests & Quality Checks / Build Artifacts (push) Has been cancelled
Tests & Quality Checks / Build Artifacts-1 (push) Has been cancelled
2026-03-04 17:13:40 +01:00
e3fae14c69 feat: add Developer Tools integration with shortcut for enhanced debugging 2026-03-04 16:56:38 +01:00
44dbc9b2e5 feat: disable touch events in Qt WebEngineView for improved hover effect handling 2026-03-04 15:39:15 +01:00
3804f90bc6 Bump version to 0.8.1
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-04 15:13:02 +01:00
ed2298539d Bump version to 0.8.0 for upcoming release
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-04 14:56:17 +01:00
810baf65d9 feat: implement mouse event emulator for Qt WebEngineView to enhance hover effects
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-04 14:49:40 +01:00
c612072dc8 feat: enhance drag-and-drop functionality to support multiple file paths and URLs 2026-03-04 13:43:21 +01:00
1e848e84b2 feat: enhance settings dialog to handle domain changes with restart prompt and silent reload for path changes 2026-03-04 13:19:19 +01:00
3f7623f11c feat: add settings dialog enhancements for webapp URL change detection and restart handling 2026-03-04 13:09:25 +01:00
695182c44f fix: enhance authorization token capture logic based on checkout feature status 2026-03-04 12:56:49 +01:00
308f77f84e fix: improve drag handler installation logic to prevent multiple installations and enhance retry mechanism 2026-03-04 12:52:16 +01:00
ced50dd1f6 fix: simplify drag interception logic by removing native DnD check and updating console messages 2026-03-04 11:57:17 +01:00
705969cdba feat: enhance web engine view with profile isolation and add cache clearing functionality
Some checks failed
Tests & Quality Checks / Test on Python 3.11 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.11-1 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12-1 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.10 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.11-2 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12-2 (push) Has been cancelled
Tests & Quality Checks / Build Artifacts (push) Has been cancelled
Tests & Quality Checks / Build Artifacts-1 (push) Has been cancelled
2026-03-03 10:22:14 +01:00
ba0594c260 fix: standardize profile directory naming from ".webdrop-bridge" and "WebDropBridge" to ".webdrop_bridge"
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-03 09:44:29 +01:00
87884935c9 feat: implement package manager support for Windows and macOS, including Chocolatey and Homebrew configurations
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-03 09:33:06 +01:00
1dcce081f1 feat: add installation scripts and update documentation for downloading WebDrop Bridge releases
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-03 09:23:09 +01:00
6d052e221b chore: update DEVELOPMENT_PLAN.md with latest status and deliverables
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-03 09:05:00 +01:00
55da804e3e cleanup
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-03 08:57:28 +01:00
f0bab2afa5 update documentation
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-03 08:41:28 +01:00
4cc158a791 cleanup
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-03 08:34:27 +01:00
910ac3390a Bump version to 0.7.1
Some checks failed
Tests & Quality Checks / Test on Python 3.11 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.11-1 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12-1 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.10 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.11-2 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12-2 (push) Has been cancelled
Tests & Quality Checks / Build Artifacts (push) Has been cancelled
Tests & Quality Checks / Build Artifacts-1 (push) Has been cancelled
2026-02-25 16:27:20 +01:00
530e7f92a3 feat: add OpenDropZone widget for opening files with default applications via drag-and-drop
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-02-25 16:16:52 +01:00
f7111896b5 feat: enhance dragstart interception with native DnD disable check via URL param 2026-02-25 15:45:26 +01:00
4b252da572 Refactor code structure for improved readability and maintainability
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-02-25 15:34:22 +01:00
9795423646 Bump version to 0.7.0 and update changelog
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-02-25 15:30:05 +01:00
9609a12ae7 Bump version to 0.6.5 and enhance update download functionality
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
- Updated version number in __init__.py to 0.6.5.
- Modified the download_update method in updater.py to accept a progress_callback for tracking download progress.
- Implemented chunked downloading in _download_file to report progress via the callback.
- Adjusted installer launching logic in updater.py to handle MSI files correctly using msiexec.
- Connected download progress signal in main_window.py to update the downloading dialog.
2026-02-25 15:26:02 +01:00
fba25534d9 chore: update version to 0.6.4 and add log file opening functionality in main window 2026-02-25 15:09:22 +01:00
bbf5e9f875 fix: correct capitalization of "hörl" in About dialog 2026-02-25 15:04:12 +01:00
239438dddb Bump version to 0.6.3 2026-02-25 15:03:30 +01:00
025e9c888c Refactor Windows build script for improved readability and consistency
- Cleaned up whitespace and formatting in build_windows.py for better readability.
- Consolidated environment variable setup for stdout and stderr.
- Streamlined subprocess command calls by removing unnecessary line breaks.
- Enhanced error handling and logging for better debugging.
- Updated comments for clarity and consistency.

Update updater.py to improve checksum verification logic

- Modified checksum verification to prioritize specific .sha256 files matching installer names.
- Added fallback logic to check for any .sha256 file if no specific match is found.

Enhance update manager UI with download progress dialog

- Introduced DownloadingDialog to provide feedback during update downloads.
- Updated MainWindow to manage the new downloading dialog and handle its lifecycle.
- Removed the skip version functionality from the update dialog as per new requirements.

Refactor update manager UI tests for clarity and maintainability

- Removed tests related to the skip version functionality.
- Updated test cases to reflect changes in the update manager UI.
- Ensured all tests are aligned with the new dialog structure and signal emissions.
2026-02-25 14:38:33 +01:00
88d9f200ab feat: update Windows installer to include WixUtilExtension and close running application
chore: bump version to 0.6.2

fix: improve update manager logic for caching and fetching releases
2026-02-25 14:08:41 +01:00
cbd1f3f77c Add enable_checkout configuration option and update drag handling logic 2026-02-25 13:34:37 +01:00
986793632e Refactor SettingsDialog for improved readability and maintainability
- Cleaned up whitespace and formatting throughout the settings_dialog.py file.
- Enhanced type hints for better clarity and type checking.
- Consolidated URL mapping handling in get_config_data method.
- Improved error handling and logging for configuration operations.
- Added comments for better understanding of the code structure and functionality.
2026-02-25 13:26:46 +01:00
71 changed files with 8118 additions and 14996 deletions

View file

@ -2,7 +2,7 @@
# Application
APP_NAME=WebDrop Bridge
APP_VERSION=0.6.0
APP_VERSION=0.8.6
# Web App
WEBAPP_URL=file:///./webapp/index.html

View file

@ -19,16 +19,18 @@ WebDrop Bridge is a professional Qt-based desktop application (v0.5.0) that conv
| File | Purpose |
|------|---------|
| `src/webdrop_bridge/__init__.py` | Package info, version (0.5.0) |
| `src/webdrop_bridge/__init__.py` | Package info, version (0.7.1) |
| `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/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/config_manager.py` | File-based config loading and caching |
| `src/webdrop_bridge/core/config_manager.py` | Configuration validation, profiles, import/export |
| `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/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/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/update_manager_ui.py` | Update check UI and dialogs |
| `src/webdrop_bridge/utils/logging.py` | Logging configuration (console + file) |
@ -254,6 +256,6 @@ git push origin feature/my-feature
---
**Current Status**: Phase 4 Complete (Jan 29, 2026) - Phase 5 (Release Candidates) Planned
**Version**: 0.5.0
**Last Updated**: February 18, 2026
**Current Status**: Phase 4 Complete - Phase 5 (Release Candidates) In Progress
**Version**: 0.7.1
**Last Updated**: March 3, 2026

6
.gitignore vendored
View file

@ -143,6 +143,12 @@ ehthumbs.db
# Build outputs
build/dist/
build/build_output.log
build/test.txt
build/*.wixobj
build/*.wixpdb
build/*_Files.wxs
build/*.generated.wxs
*.msi
*.exe
*.dmg

View file

@ -0,0 +1 @@
{"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"}}

View file

@ -1,232 +0,0 @@
## [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)

View file

@ -1,8 +1,8 @@
# WebDrop Bridge - Professional Development Plan
**Version**: 1.0
**Last Updated**: February 18, 2026
**Status**: Phase 4 Complete - Phase 5 (Release Candidates) Planned
**Last Updated**: March 3, 2026
**Status**: Phase 4 Complete - Phase 5 (Release Candidates) In Progress
## Executive Summary
@ -131,9 +131,9 @@ def setup_logging(
```
**Deliverables:**
- [ ] `src/webdrop_bridge/utils/logging.py` - Logging utilities
- [ ] Logs directory with `.gitkeep`
- [ ] Log rotation policy
- [x] `src/webdrop_bridge/utils/logging.py` - Logging utilities
- [x] Logs directory with `.gitkeep`
- [x] Log rotation policy
**Acceptance Criteria:**
- Logs written to `logs/webdrop_bridge.log`
@ -189,9 +189,9 @@ class PathValidator:
```
**Deliverables:**
- [ ] `src/webdrop_bridge/core/validator.py` - Path validation
- [ ] Unit tests for `PathValidator`
- [ ] Security documentation
- [x] `src/webdrop_bridge/core/validator.py` - Path validation
- [x] Unit tests for `PathValidator`
- [x] Security documentation
**Acceptance Criteria:**
- All paths resolved to absolute
@ -251,9 +251,9 @@ class DragInterceptor(QWidget):
```
**Deliverables:**
- [ ] `src/webdrop_bridge/core/drag_interceptor.py` - Drag handling
- [ ] Unit tests with mocking
- [ ] Platform-specific tests (Windows/macOS)
- [x] `src/webdrop_bridge/core/drag_interceptor.py` - Drag handling
- [x] Unit tests with mocking
- [x] Platform-specific tests (Windows/macOS)
**Acceptance Criteria:**
- Drag events properly intercepted
@ -510,7 +510,8 @@ if __name__ == "__main__":
### 2.2 Integration Tests
**Files to create:**
**Files created:**
- [x] `tests/integration/test_update_flow.py`
- [ ] `tests/integration/test_drag_workflow.py`
- [ ] `tests/integration/test_webapp_loading.py`
- [ ] `tests/integration/test_end_to_end.py`
@ -626,8 +627,8 @@ export APPLE_TEAM_ID="XXXXXXXXXX"
```
**Acceptance Criteria:**
- [ ] .app bundle builds successfully
- [ ] DMG image creates without errors
- [x] .app bundle builds successfully
- [x] DMG image creates without errors
- [ ] DMG mounts and shows contents properly
- [ ] Code signing works
- [ ] Notarization passes
@ -1196,6 +1197,27 @@ 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
**Options:**
@ -1212,13 +1234,14 @@ February 2026
## Current Phase
Phase 4 Complete - Professional Features & Auto-Update system fully implemented (Feb 18, 2026).
Phase 4 Complete - Professional Features & Auto-Update system fully implemented.
**Current Status**: Phase 5 (Release Candidates) In Progress (as of March 3, 2026)
**Phase 4 Completion Summary:**
- ✅ Phase 4.1: Auto-Update System with Forgejo integration (76 tests)
- ✅ Phase 4.2: Enhanced Logging & Monitoring (20 tests)
- ✅ Phase 4.3: Advanced Configuration & Settings UI (43 tests)
- ✅ Total Phase 4: 139 tests passing, 90%+ coverage
- ✅ Total Phase 4: 139 tests passing, 85%+ code coverage
**MSI Update Support (Feb 20, 2026):**
- ✅ Added `<MajorUpgrade />` element to WiX configuration (build/WebDropBridge.wxs)
@ -1226,32 +1249,50 @@ Phase 4 Complete - Professional Features & Auto-Update system fully implemented
- ✅ Implemented EXE version information setting in build script (build/scripts/build_windows.py)
- ✅ Added pefile dependency for version injection
- 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:**
- Version: 1.0.0 (released Jan 28, 2026)
- Phase 1-3: Complete (core features, testing, build system)
- Phase 4: Complete (auto-update, logging, configuration)
- Phase 5: Ready to begin (Release candidates & final polish)
- **Version**: 0.7.1 (current development version)
- **Phase 1-3**: Complete (core features, testing, build system)
- **Phase 4**: Complete (auto-update, logging, configuration)
- **Phase 5**: In Progress (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
1. **Phase 5 - Release Candidates**:
- Build release candidates (v1.0.0-rc1, rc2, rc3)
1. **Phase 5 - Release Candidates** (Current):
- Build release candidates (v1.0.0 or higher)
- Cross-platform testing on Windows 10/11, macOS 12-14
- Security hardening and final audit
- 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**:
- Run full test suite on both platforms
- User acceptance testing
- Documentation review
- User acceptance testing with real-world scenarios
- Package manager installation testing
- Documentation review and finalization
3. **Finalization**:
- Code signing for Windows MSI (optional)
- Apple notarization for macOS DMG (future)
- Create stable v1.0.0 release
- Publish to Forgejo Packages
- Announce stable release v1.0.0
- Publish installers to Forgejo Packages
- Publish to Chocolatey (community or internal)
- Create and publish Homebrew tap
- Enable auto-update system for users
---

View file

@ -70,46 +70,6 @@ webdrop-bridge/
└── 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
### Running Tests
@ -148,11 +108,112 @@ tox -e type
tox
```
### Building
### Installing from Release (wget)
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
# Windows MSI
python build/scripts/build_windows.py
python build/scripts/build_windows.py --msi
# macOS DMG
bash build/scripts/build_macos.sh
@ -235,8 +296,6 @@ Edit as needed:
## Next Steps
**Phase 4 is complete!** Here's what you can do:
### To Run the Application
```bash
# Run the full application (requires config)
@ -255,31 +314,12 @@ pytest --cov=src/webdrop_bridge tests
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
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`
**Review** [CONTRIBUTING.md](CONTRIBUTING.md)
## Getting Help
- 📖 **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

View file

@ -38,6 +38,47 @@ WebDrop Bridge embeds a web application in a Qt container with full filesystem a
- Windows 10/11
- 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
```bash
@ -102,6 +143,11 @@ webdrop-bridge/
└── README.md # This file
```
## Documentation
- [Architecture Guide](docs/ARCHITECTURE.md)
- [Translations Guide (i18n)](docs/TRANSLATIONS_GUIDE.md)
## Architecture
```
@ -142,7 +188,7 @@ Launch the application and access the Settings menu to configure:
- **Window Tab** - Configure window dimensions
- **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
Create a `.env` file in the project root. Available settings:
@ -213,10 +259,6 @@ 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).
## Changelog
See [CHANGELOG.md](CHANGELOG.md) for release notes.
## Building Installers
### Windows MSI Installer
@ -317,37 +359,6 @@ MIT License - see [LICENSE](LICENSE) file for details
- Inspired by professional desktop integration practices
- Special thanks to the Qt community
## Development Status
**Current Phase**: Phase 4 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
- 📖 [Documentation](https://git.him-tools.de/HIM-public/webdrop-bridge/wiki)

View file

@ -1,543 +0,0 @@
# 🎉 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*

File diff suppressed because one or more lines are too long

View file

@ -1,29 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui">
<Product Id="*" Name="WebDrop Bridge" Language="1033" Version="0.6.0"
Manufacturer="HIM-Tools"
UpgradeCode="12345678-1234-1234-1234-123456789012">
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui"
xmlns:util="http://schemas.microsoft.com/wix/UtilExtension">
<Product Id="*" Name="{product_name_with_version}" Language="1033" Version="{version}"
Manufacturer="{manufacturer}"
UpgradeCode="{upgrade_code}">
<Package InstallerVersion="200" Compressed="yes" InstallScope="perMachine" Platform="x64" />
<Media Id="1" Cabinet="WebDropBridge.cab" EmbedCab="yes" />
<Media Id="1" Cabinet="{asset_prefix}.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" />
<Icon Id="AppIcon.ico" SourceFile="{icon_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" />
<WixVariable Id="WixUIDialogBmp" Value="{dialog_bmp}" />
<WixVariable Id="WixUIBannerBmp" Value="{banner_bmp}" />
<WixVariable Id="WixUILicenseRtf" Value="{license_rtf}" />
<!-- Installation UI dialogs -->
<UIRef Id="WixUI_InstallDir" />
<UIRef Id="WixUI_ErrorProgressText" />
<Feature Id="ProductFeature" Title="WebDrop Bridge" Level="1">
<!-- Close running application before installation -->
<util:CloseApplication
Target="{exe_name}.exe"
CloseMessage="yes"
RebootPrompt="no"
ElevatedCloseMessage="no" />
<Feature Id="ProductFeature" Title="{product_name}" Level="1">
<ComponentGroupRef Id="AppFiles" />
<ComponentRef Id="ProgramMenuShortcut" />
<ComponentRef Id="DesktopShortcut" />
@ -31,10 +39,10 @@
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="ProgramFiles64Folder">
<Directory Id="INSTALLFOLDER" Name="WebDrop Bridge" />
<Directory Id="INSTALLFOLDER" Name="{install_dir_name}" />
</Directory>
<Directory Id="ProgramMenuFolder">
<Directory Id="ApplicationProgramsFolder" Name="WebDrop Bridge"/>
<Directory Id="ApplicationProgramsFolder" Name="{product_name}"/>
</Directory>
<Directory Id="DesktopFolder" />
</Directory>
@ -42,16 +50,16 @@
<DirectoryRef Id="ApplicationProgramsFolder">
<Component Id="ProgramMenuShortcut" Guid="*">
<Shortcut Id="ApplicationStartMenuShortcut"
Name="WebDrop Bridge"
Description="Web Drag-and-Drop Bridge"
Target="[INSTALLFOLDER]WebDropBridge.exe"
Name="{product_name}"
Description="{shortcut_description}"
Target="[INSTALLFOLDER]{exe_name}.exe"
Icon="AppIcon.ico"
IconIndex="0"
WorkingDirectory="INSTALLFOLDER" />
<RemoveFolder Id="ApplicationProgramsFolderRemove"
On="uninstall" />
<RegistryValue Root="HKCU"
Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\WebDropBridge"
Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\{exe_name}"
Name="installed"
Type="integer"
Value="1"
@ -62,14 +70,14 @@
<DirectoryRef Id="DesktopFolder">
<Component Id="DesktopShortcut" Guid="*">
<Shortcut Id="DesktopApplicationShortcut"
Name="WebDrop Bridge"
Description="Web Drag-and-Drop Bridge"
Target="[INSTALLFOLDER]WebDropBridge.exe"
Name="{product_name}"
Description="{shortcut_description}"
Target="[INSTALLFOLDER]{exe_name}.exe"
Icon="AppIcon.ico"
IconIndex="0"
WorkingDirectory="INSTALLFOLDER" />
<RegistryValue Root="HKCU"
Key="Software\WebDropBridge"
Key="Software\{exe_name}"
Name="DesktopShortcut"
Type="integer"
Value="1"

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
{
"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"
}

View file

@ -0,0 +1,24 @@
{
// 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"
}

View file

@ -0,0 +1,47 @@
$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
}
}

View file

@ -0,0 +1,38 @@
$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: $_"
}

View file

@ -0,0 +1,28 @@
<?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>

View file

@ -0,0 +1,48 @@
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

View file

@ -0,0 +1,126 @@
# 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/)

197
build/scripts/README.md Normal file
View file

@ -0,0 +1,197 @@
# 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)

View file

@ -0,0 +1,397 @@
"""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())

View file

@ -28,10 +28,13 @@ DIST_DIR="$BUILD_DIR/dist/macos"
TEMP_BUILD="$BUILD_DIR/temp/macos"
SPECS_DIR="$BUILD_DIR/specs"
SPEC_FILE="$BUILD_DIR/webdrop_bridge.spec"
BRAND_HELPER="$BUILD_DIR/scripts/brand_config.py"
BRAND=""
APP_NAME="WebDropBridge"
DMG_VOLUME_NAME="WebDrop Bridge"
VERSION="1.0.0"
BUNDLE_IDENTIFIER="de.him_tools.webdrop-bridge"
VERSION=""
# Default .env file
ENV_FILE="$PROJECT_ROOT/.env"
@ -54,6 +57,10 @@ while [[ $# -gt 0 ]]; do
ENV_FILE="$2"
shift 2
;;
--brand)
BRAND="$2"
shift 2
;;
*)
echo "Unknown option: $1"
exit 1
@ -70,6 +77,23 @@ fi
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
RED='\033[0;31m'
GREEN='\033[0;32m'
@ -176,8 +200,27 @@ build_executable() {
log_info "Building macOS executable with PyInstaller..."
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 WEBDROP_ENV_FILE="$ENV_FILE"
export WEBDROP_ENV_FILE="$BUNDLED_ENV_FILE"
export WEBDROP_VERSION="$VERSION"
export WEBDROP_BUNDLE_ID="$BUNDLE_IDENTIFIER"
python3 -m PyInstaller \
--distpath="$DIST_DIR" \
@ -199,7 +242,7 @@ create_dmg() {
log_info "Creating DMG package..."
echo ""
DMG_FILE="$DIST_DIR/${APP_NAME}-${VERSION}.dmg"
DMG_FILE="$DIST_DIR/${APP_NAME}-${VERSION}-macos-universal.dmg"
# Remove existing DMG
if [ -f "$DMG_FILE" ]; then
@ -252,6 +295,8 @@ create_dmg() {
SIZE=$(du -h "$DMG_FILE" | cut -f1)
log_success "DMG created successfully"
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 ""
}

View file

@ -27,13 +27,10 @@ from typing import Optional
if sys.platform == "win32":
os.environ["PYTHONIOENCODING"] = "utf-8"
import io
# Reconfigure stdout/stderr for UTF-8 output
sys.stdout = io.TextIOWrapper(
sys.stdout.buffer, encoding="utf-8", errors="replace"
)
sys.stderr = io.TextIOWrapper(
sys.stderr.buffer, encoding="utf-8", errors="replace"
)
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
import subprocess
import shutil
@ -41,14 +38,17 @@ import argparse
from pathlib import Path
from datetime import datetime
from dotenv import dotenv_values
# Import shared version utilities
from brand_config import load_brand_config
from sync_version import get_current_version, do_sync_version
class WindowsBuilder:
"""Build Windows installer using PyInstaller."""
def __init__(self, env_file: Path | None = None):
def __init__(self, env_file: Path | None = None, brand: str | None = None):
"""Initialize builder paths.
Args:
@ -56,10 +56,12 @@ class WindowsBuilder:
If that doesn't exist, raises error.
"""
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.dist_dir = self.build_dir / "dist" / "windows"
self.temp_dir = self.build_dir / "temp" / "windows"
self.dist_dir = self.build_dir / "dist" / "windows" / self.brand.brand_id
self.temp_dir = self.build_dir / "temp" / "windows" / self.brand.brand_id
self.spec_file = self.build_dir / "webdrop_bridge.spec"
self.wix_template = self.build_dir / "WebDropBridge.wxs"
self.version = get_current_version()
# Validate and set env file
@ -77,6 +79,7 @@ class WindowsBuilder:
self.env_file = 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:
"""Get version from __init__.py.
@ -94,6 +97,48 @@ class WindowsBuilder:
shutil.rmtree(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:
"""Build executable using PyInstaller."""
print("\n🔨 Building Windows executable with PyInstaller...")
@ -118,21 +163,25 @@ class WindowsBuilder:
# Set environment variable for spec file to use
env = os.environ.copy()
env["WEBDROP_ENV_FILE"] = str(self.env_file)
env["WEBDROP_ENV_FILE"] = str(self._create_bundled_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:
print("❌ PyInstaller build failed")
return False
# Check if executable exists (now in WebDropBridge/WebDropBridge.exe due to COLLECT)
exe_path = self.dist_dir / "WebDropBridge" / "WebDropBridge.exe"
# Check if executable exists (inside the COLLECT directory)
exe_path = self.dist_dir / self.brand.exe_name / f"{self.brand.exe_name}.exe"
if not exe_path.exists():
print(f"❌ Executable not found at {exe_path}")
return False
@ -141,7 +190,11 @@ class WindowsBuilder:
print(f"📦 Output: {exe_path}")
# Calculate total dist size
total_size = sum(f.stat().st_size for f in self.dist_dir.glob("WebDropBridge/**/*") if f.is_file())
total_size = sum(
f.stat().st_size
for f in self.dist_dir.glob(f"{self.brand.exe_name}/**/*")
if f.is_file()
)
if total_size > 0:
print(f" Total size: {total_size / 1024 / 1024:.1f} MB")
@ -249,9 +302,13 @@ class WindowsBuilder:
if not self._create_wix_source():
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
print(f" Harvesting application files...")
dist_folder = self.dist_dir / "WebDropBridge"
dist_folder = self.dist_dir / self.brand.exe_name
if not dist_folder.exists():
print(f"❌ Distribution folder not found: {dist_folder}")
return False
@ -263,12 +320,15 @@ class WindowsBuilder:
str(heat_exe),
"dir",
str(dist_folder),
"-cg", "AppFiles",
"-dr", "INSTALLFOLDER",
"-cg",
"AppFiles",
"-dr",
"INSTALLFOLDER",
"-sfrag",
"-srd",
"-gg",
"-o", str(harvest_file),
"-o",
str(harvest_file),
]
result = subprocess.run(heat_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
@ -283,27 +343,28 @@ class WindowsBuilder:
if harvest_file.exists():
content = harvest_file.read_text()
# 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)
print(f" ✓ Marked components as 64-bit")
# Compile both WiX files
wix_obj = self.build_dir / "WebDropBridge.wixobj"
wix_obj = self.build_dir / "WebDropBridge.generated.wixobj"
wix_files_obj = self.build_dir / "WebDropBridge_Files.wixobj"
msi_output = self.dist_dir / f"WebDropBridge-{self.version}-Setup.msi"
msi_output = self.dist_dir / self.brand.windows_installer_name(self.version)
# Run candle compiler - make sure to use correct source directory
candle_cmd = [
str(candle_exe),
"-ext", "WixUIExtension",
"-ext",
"WixUIExtension",
"-ext",
"WixUtilExtension",
f"-dDistDir={self.dist_dir}",
f"-dSourceDir={self.dist_dir}\\WebDropBridge", # Set SourceDir for Heat-generated files
f"-dSourceDir={self.dist_dir / self.brand.exe_name}", # Set SourceDir for Heat-generated files
f"-dResourcesDir={self.project_root}\\resources", # Set ResourcesDir for branding assets
"-o", str(self.build_dir) + "\\",
str(self.build_dir / "WebDropBridge.wxs"),
"-o",
str(self.build_dir) + "\\",
str(self.build_dir / "WebDropBridge.generated.wxs"),
]
if harvest_file.exists():
@ -318,9 +379,14 @@ class WindowsBuilder:
# Link MSI - include both obj files if harvest was successful
light_cmd = [
str(light_exe),
"-ext", "WixUIExtension",
"-b", str(self.dist_dir / "WebDropBridge"), # Base path for source files
"-o", str(msi_output),
"-ext",
"WixUIExtension",
"-ext",
"WixUtilExtension",
"-b",
str(self.dist_dir / self.brand.exe_name), # Base path for source files
"-o",
str(msi_output),
str(wix_obj),
]
@ -328,7 +394,9 @@ class WindowsBuilder:
light_cmd.append(str(wix_files_obj))
print(f" Linking MSI installer...")
result = subprocess.run(light_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
result = subprocess.run(
light_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
if result.returncode != 0:
print("❌ MSI linking failed")
if result.stdout:
@ -344,103 +412,76 @@ class WindowsBuilder:
print("✅ MSI installer created successfully")
print(f"📦 Output: {msi_output}")
print(f" Size: {msi_output.stat().st_size / 1024 / 1024:.1f} MB")
self.generate_checksum(msi_output)
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:
"""Create WiX source file for MSI generation.
Creates per-machine installation (Program Files).
Installation requires admin rights, but the app does not.
"""
wix_content = f'''<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui">
<Product Id="*" Name="WebDrop Bridge" Language="1033" Version="{self.version}"
Manufacturer="HIM-Tools"
UpgradeCode="12345678-1234-1234-1234-123456789012">
wix_template = self.wix_template.read_text(encoding="utf-8")
wix_content = wix_template.format(
product_name=self.brand.display_name,
product_name_with_version=f"{self.brand.display_name} v{self.version}",
version=self.version,
manufacturer=self.brand.manufacturer,
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,
)
<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 = self.build_dir / "WebDropBridge.generated.wxs"
wix_file.write_text(wix_content)
print(f" Created WiX source: {wix_file}")
return True
def _generate_file_elements(self, folder: Path, parent_dir_ref: str, parent_rel_path: str, indent: int = 8, file_counter: Optional[dict] = None) -> str:
def _generate_file_elements(
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.
Args:
@ -465,6 +506,7 @@ class WindowsBuilder:
if item.is_file():
# Create unique File element ID using hash of full path
import hashlib
path_hash = hashlib.md5(str(item).encode()).hexdigest()[:8]
file_id = f"File_{path_hash}"
file_path = str(item)
@ -472,10 +514,7 @@ class WindowsBuilder:
elif item.is_dir() and item.name != "__pycache__":
# Recursively add files from subdirectories
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:
elements.append(sub_elements)
@ -521,7 +560,7 @@ class WindowsBuilder:
print(" Skipping code signing")
return True
exe_path = self.dist_dir / "WebDropBridge.exe"
exe_path = self.dist_dir / self.brand.exe_name / f"{self.brand.exe_name}.exe"
cmd = [
signtool,
"sign",
@ -534,10 +573,7 @@ class WindowsBuilder:
str(exe_path),
]
result = subprocess.run(
cmd,
text=True
)
result = subprocess.run(cmd, text=True)
if result.returncode != 0:
print("❌ Code signing failed")
return False
@ -557,7 +593,7 @@ class WindowsBuilder:
"""
start_time = datetime.now()
print("=" * 60)
print("🚀 WebDrop Bridge Windows Build")
print(f"🚀 {self.brand.display_name} Windows Build")
print("=" * 60)
self.clean()
@ -584,9 +620,7 @@ class WindowsBuilder:
def main() -> int:
"""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(
"--msi",
action="store_true",
@ -603,6 +637,12 @@ def main() -> int:
default=None,
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()
@ -610,7 +650,7 @@ def main() -> int:
do_sync_version()
try:
builder = WindowsBuilder(env_file=args.env_file)
builder = WindowsBuilder(env_file=args.env_file, brand=args.brand)
except FileNotFoundError as e:
print(f"❌ Build failed: {e}")
return 1

View file

@ -1,70 +1,58 @@
# 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(
[Parameter(Mandatory=$false)]
[Parameter(Mandatory = $false)]
[string]$Version,
[Parameter(Mandatory=$false)]
[Parameter(Mandatory = $false)]
[string[]]$Brands,
[Parameter(Mandatory = $false)]
[string]$ForgejoUser,
[Parameter(Mandatory=$false)]
[Parameter(Mandatory = $false)]
[string]$ForgejoPW,
[switch]$ClearCredentials,
[switch]$SkipExe,
[switch]$DryRun,
[string]$ForgejoUrl = "https://git.him-tools.de",
[string]$Repo = "HIM-public/webdrop-bridge",
[string]$ExePath = "build\dist\windows\WebDropBridge\WebDropBridge.exe",
[string]$ChecksumPath = "build\dist\windows\WebDropBridge\WebDropBridge.exe.sha256"
[string]$Repo = "HIM-public/webdrop-bridge"
)
$ErrorActionPreference = "Stop"
# Get project root (PSScriptRoot is build/scripts, go up to project root with ..\..)
$projectRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..")
# Resolve file paths relative to project root
$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()
}
}
# Fall back to .env.example
if (Test-Path $envExampleFile) {
$content = Get-Content $envExampleFile -Raw
if ($content -match 'APP_VERSION=([^\r\n]+)') {
Write-Host "Version read from .env.example" -ForegroundColor Gray
return $matches[1].Trim()
}
}
Write-Host "ERROR: Could not find APP_VERSION in .env or .env.example" -ForegroundColor Red
exit 1
$pythonExe = Join-Path $projectRoot ".venv\Scripts\python.exe"
if (-not (Test-Path $pythonExe)) {
$pythonExe = "python"
}
$brandHelper = Join-Path $projectRoot "build\scripts\brand_config.py"
$manifestOutput = Join-Path $projectRoot "build\dist\release-manifest.json"
$localManifestPath = Join-Path $projectRoot "build\dist\release-manifest.local.json"
$existingManifestPath = Join-Path $projectRoot "build\dist\release-manifest.existing.json"
function Get-CurrentVersion {
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 {
$arguments = @($brandHelper, "local-release-data", "--platform", "windows", "--version", $Version)
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) {
Remove-Item env:FORGEJO_USER -ErrorAction SilentlyContinue
Remove-Item env:FORGEJO_PASS -ErrorAction SilentlyContinue
@ -72,191 +60,228 @@ if ($ClearCredentials) {
exit 0
}
# Get credentials from sources (in order of priority)
if (-not $Version) {
$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) {
$ForgejoUser = $env:FORGEJO_USER
}
if (-not $ForgejoPW) {
$ForgejoPW = $env:FORGEJO_PASS
}
# If still no credentials, prompt user interactively
if (-not $ForgejoUser -or -not $ForgejoPW) {
Write-Host "Forgejo credentials not found. Enter your credentials:" -ForegroundColor Yellow
if (-not $ForgejoUser) {
$ForgejoUser = Read-Host "Username"
}
if (-not $ForgejoPW) {
$securePass = Read-Host "Password" -AsSecureString
$ForgejoPW = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToCoTaskMemUnicode($securePass))
}
# Save credentials to environment for this session
$env:FORGEJO_USER = $ForgejoUser
$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}"))
$headers = @{
"Authorization" = "Basic $auth"
"Content-Type" = "application/json"
}
# Step 1: Create release
Write-Host "`nCreating release v$Version..." -ForegroundColor Yellow
$releaseLookupUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/tags/v$Version"
$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 = @{
tag_name = "v$Version"
name = "WebDropBridge v$Version"
body = $releaseBody
body = "Shared branded release for WebDrop Bridge v$Version"
draft = $false
prerelease = $false
} | ConvertTo-Json
try {
$response = Invoke-WebRequest -Uri $releaseUrl `
-Method POST `
-Headers $headers `
-Body $releaseData `
-TimeoutSec 30 `
-UseBasicParsing `
-ErrorAction Stop
$releaseInfo = $response.Content | ConvertFrom-Json
$releaseInfo = Invoke-RestMethod -Uri $releaseLookupUrl -Method GET -Headers $headers -TimeoutSec 30 -ErrorAction Stop
$releaseId = $releaseInfo.id
Write-Host "[OK] Using existing release (ID: $releaseId)" -ForegroundColor Green
}
catch {
$releaseInfo = Invoke-RestMethod -Uri $releaseUrl -Method POST -Headers $headers -Body $releaseData -TimeoutSec 30 -ErrorAction Stop
$releaseId = $releaseInfo.id
Write-Host "[OK] Release created (ID: $releaseId)" -ForegroundColor Green
}
catch {
Write-Host "ERROR creating release: $_" -ForegroundColor Red
exit 1
$assetMap = Get-AssetMap -Assets $releaseInfo.assets
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
}
# Setup curl authentication
$curlAuth = "$ForgejoUser`:$ForgejoPW"
$uploadUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/$releaseId/assets"
# 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)))
}
# Step 2: Upload MSI installer as primary artifact
Write-Host "`nUploading MSI installer (primary artifact)..." -ForegroundColor Yellow
$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 {
$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
$uploadUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/$releaseId/assets"
& $pythonExe $uploadScriptPath $uploadUrl @artifactsToUpload
if ($LASTEXITCODE -ne 0) {
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

View file

@ -1,31 +1,34 @@
#!/bin/bash
# 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
# Create or update a shared Forgejo release with branded macOS assets.
set -e
# Parse arguments
VERSION=""
FORGEJO_USER=""
FORGEJO_PASS=""
BRANDS=()
FORGEJO_USER="${FORGEJO_USER}"
FORGEJO_PASS="${FORGEJO_PASS}"
FORGEJO_URL="https://git.him-tools.de"
REPO="HIM-public/webdrop-bridge"
DMG_PATH="build/dist/macos/WebDropBridge.dmg"
CHECKSUM_PATH="build/dist/macos/WebDropBridge.dmg.sha256"
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
case $1 in
-v|--version) VERSION="$2"; shift 2;;
-u|--url) FORGEJO_URL="$2"; shift 2;;
--clear-credentials) CLEAR_CREDS=true; shift;;
*) echo "Unknown option: $1"; exit 1;;
-v|--version) VERSION="$2"; shift 2 ;;
-u|--url) FORGEJO_URL="$2"; shift 2 ;;
--brand) BRANDS+=("$2"); shift 2 ;;
--clear-credentials) CLEAR_CREDS=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
# Handle --clear-credentials flag
if [ "$CLEAR_CREDS" = true ]; then
unset FORGEJO_USER
unset FORGEJO_PASS
@ -33,127 +36,193 @@ if [ "$CLEAR_CREDS" = true ]; then
exit 0
fi
# Load credentials from environment
FORGEJO_USER="${FORGEJO_USER}"
FORGEJO_PASS="${FORGEJO_PASS}"
# Verify required parameters
if [ -z "$VERSION" ]; then
echo "ERROR: Version parameter required" >&2
echo "Usage: $0 -v VERSION [-u FORGEJO_URL]" >&2
echo "Example: $0 -v 1.0.0" >&2
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())")"
fi
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
fi
# If no credentials, prompt user interactively
if [ "$DRY_RUN" = true ]; then
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
echo "Forgejo credentials not found. Enter your credentials:"
if [ -z "$FORGEJO_USER" ]; then
read -p "Username: " FORGEJO_USER
read -r -p "Username: " FORGEJO_USER
fi
if [ -z "$FORGEJO_PASS" ]; then
read -sp "Password: " FORGEJO_PASS
read -r -s -p "Password: " FORGEJO_PASS
echo ""
fi
# Export for this session
export FORGEJO_USER
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
# 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)
# Step 1: Create release
echo ""
echo "Creating release v$VERSION..."
RELEASE_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases"
RELEASE_LOOKUP_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases/tags/v$VERSION"
RELEASE_DATA=$(cat <<EOF
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
{
"tag_name": "v$VERSION",
"name": "WebDropBridge v$VERSION",
"body": "WebDropBridge v$VERSION\n\nChecksum: $CHECKSUM",
"body": "Shared branded release for WebDrop Bridge v$VERSION",
"draft": false,
"prerelease": false
}
EOF
)
RESPONSE=$(curl -s -X POST \
HTTP_CODE=$(curl -s -o "$RELEASE_RESPONSE_FILE" -w "%{http_code}" -X POST \
-H "Authorization: Basic $BASIC_AUTH" \
-H "Content-Type: application/json" \
-d "$RELEASE_DATA" \
"$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
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
print(payload.get("id", ""))
PY
)
fi
fi
if [ -z "$RELEASE_ID" ]; then
echo "ERROR creating release:"
echo "$RESPONSE"
echo "ERROR creating or finding release"
cat "$RELEASE_RESPONSE_FILE"
exit 1
fi
echo "[OK] Release created (ID: $RELEASE_ID)"
MANIFEST_URL=$(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"))
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"
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
HTTP_CODE=$(curl -s -w "%{http_code}" -X POST \
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" \
-F "attachment=@$DMG_PATH" \
"$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 \
-H "Authorization: Basic $BASIC_AUTH" \
-F "attachment=@$ARTIFACT" \
"$UPLOAD_URL" \
-o /tmp/curl_response.txt)
if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 200 ]; then
echo "[OK] DMG uploaded"
else
echo "ERROR uploading DMG (HTTP $HTTP_CODE)"
if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 200 ]; then
echo "[OK] Uploaded $ASSET_NAME"
else
echo "ERROR uploading $ASSET_NAME (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
exit 1
fi
fi
done
echo ""
echo "[OK] Release complete!"

View file

@ -0,0 +1,192 @@
#!/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 ""

View file

@ -0,0 +1,208 @@
#!/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 ""

View file

@ -1 +0,0 @@
WebDropBridge.wxs

View file

@ -1,6 +1,12 @@
{
"app_name": "WebDrop Bridge",
"brand_id": "agravity",
"config_dir_name": "agravity_bridge",
"app_name": "Agravity Bridge",
"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_prefix": "https://devagravitystg.file.core.windows.net/devagravitysync/",
@ -18,5 +24,6 @@
"log_file": null,
"window_width": 1024,
"window_height": 768,
"enable_logging": true
"enable_logging": true,
"enable_checkout": false
}

View file

@ -1,268 +0,0 @@
# 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

View file

@ -1,5 +1,9 @@
# Architecture Guide
## Related Docs
- [Translations Guide (i18n)](TRANSLATIONS_GUIDE.md)
## High-Level Design
```
@ -36,10 +40,11 @@
**Key Components:**
- `validator.py`: Path validation against whitelist
- `drag_interceptor.py`: Drag event handling and conversion
- `config.py`: Configuration management
- `errors.py`: Custom exception classes
- `validator.py`: Path validation against whitelist with security checks
- `drag_interceptor.py`: Drag event handling and native drag operations
- `config_manager.py`: Configuration loading from files and caching
- `url_converter.py`: Azure Blob Storage URL → local path conversion
- `updater.py`: Update checking via Forgejo API
**Dependencies**: None (only stdlib + pathlib)
@ -49,9 +54,12 @@
**Key Components:**
- `main_window.py`: Main application window
- `widgets.py`: Reusable custom widgets
- `styles.py`: UI styling and themes
- `main_window.py`: Main application window with web engine integration
- `restricted_web_view.py`: Hardened QWebEngineView with security policies
- `settings_dialog.py`: Settings UI for configuration
- `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/
@ -61,9 +69,7 @@
**Key Components:**
- `logging.py`: Logging configuration
- `constants.py`: Application constants
- `helpers.py`: General-purpose helper functions
- `logging.py`: Logging configuration (console + file with rotation)
**Dependencies**: stdlib only
@ -72,34 +78,57 @@
### Drag-and-Drop Operation
```
User in Web App
User in Web App (browser)
[dragstart event] → JavaScript sets dataTransfer.text = "Z:\path\file.txt"
[dragstart event] → bridge_script_intercept.js detects drag
├─ Checks if content is convertible (file path or Azure URL)
├─ Calls window.bridge.start_file_drag(url)
└─ preventDefault() → Blocks normal browser drag
[dragend event] → Drag leaves WebEngine widget
JavaScript → QWebChannel Bridge
DragInterceptor.dragEnterEvent() triggered
_DragBridge.start_file_drag(path_text) [main_window.py]
├─ Defers execution via QTimer (drag manager safety)
└─ Calls DragInterceptor.handle_drag()
Extract text from QMimeData
DragInterceptor.handle_drag() [core/drag_interceptor.py]
├─ Check if Azure URL: Use URLConverter → local path
├─ Else: Treat as direct file path
└─ Validate with PathValidator
PathValidator.is_valid_file(path)
├─ is_allowed(path) → Check whitelist
└─ path.exists() and path.is_file() → File system check
PathValidator.validate(path)
├─ Resolve to absolute path
├─ Check file exists (if configured)
├─ Check is regular file (not directory)
└─ Check path within allowed_roots (whitelist)
If valid:
→ Create QUrl.fromLocalFile(path)
→ Create new QMimeData with URLs
→ QDrag.exec() → Native file drag
→ Create QMimeData with file URL
→ QDrag.exec(Qt.CopyAction) → Native file drag
→ Emit drag_started signal
If invalid:
event.ignore()
→ Log warning
Emit drag_failed signal with error
→ Log validation error
OS receives native file drag
InDesign/Word receives file handle
Target application (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
### Path Validation Strategy

View file

@ -0,0 +1,488 @@
# 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.

View file

@ -12,7 +12,7 @@ The configuration file must be named `.env` and contains settings like:
```dotenv
APP_NAME=WebDrop Bridge
APP_VERSION=0.1.0
APP_VERSION=0.7.1
WEBAPP_URL=https://example.com
ALLOWED_ROOTS=Z:/,C:/Users/Public
ALLOWED_URLS=

View file

@ -1,8 +1,28 @@
# Drag & Drop Problem Analysis - File Drop + Web App Popup
## Das Kernproblem
**Status**: Phase 1 (File Drop) ✅ Implemented | Phase 2 (Popup Trigger) ⏸️ Planned
**Last Updated**: March 3, 2026
**Ziel**: Bei ALT-Drag soll:
## 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
1. ✅ File gedroppt werden (Z:\ Laufwerk) → Native File-Drop
2. ✅ Web-App Popup erscheinen (Auschecken-Dialog) → Web-App Drop-Event
@ -250,28 +270,39 @@ Object.defineProperty(DataTransfer.prototype, 'types', {
## 📝 Empfehlung
**Sofortige Maßnahmen:**
### Current Status (as of March 2026)
1. ✅ **Lösung A Phase 1 ist bereits implementiert** (File-Drop funktioniert)
✅ **Phase 1 Complete:**
- 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.)
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?
### For Future Enhancement (Phase 2 - Popup Trigger)
3. 🛠️ **Popup-Trigger implementieren:**
- Sobald bekannt WIE Popup ausgelöst wird
- JavaScript-Funktion `trigger_checkout_popup()` erstellen
- Von Qt aus nach erfolgreichem Drop aufrufen
**If popup trigger integration is needed:**
4. 🧪 **Testen:**
- ALT-Drag eines Assets
- File-Drop sollte funktionieren
- Popup sollte erscheinen
1. 🔍 **Reverse-Engineering the Target Web App:**
- Identify how popups are triggered (API call, component method, event, etc.)
- Use browser DevTools:
- Network tab → Monitor API calls
- Console → Check for JavaScript errors/logs
- Elements → Inspect component structure
- Angular/Vue DevTools if applicable
**Fallback:**
Falls Reverse-Engineering zu komplex ist → **Lösung B** verwenden (Kein Drag, nur Copy nach Popup-Bestätigung)
2. 🛠️ **Implement Popup Trigger:**
- Create JavaScript hook function (e.g., `window.trigger_popup(assetId)`)
- 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.

View file

@ -0,0 +1,194 @@
# 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)

View file

@ -0,0 +1,391 @@
# 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`

205
docs/TRANSLATIONS_GUIDE.md Normal file
View file

@ -0,0 +1,205 @@
# 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

BIN
resources/icons/home.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

BIN
resources/icons/open.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

BIN
resources/icons/reload.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

View file

@ -0,0 +1,172 @@
{
"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}"
}

View file

@ -0,0 +1,172 @@
{
"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}"
}

View file

@ -0,0 +1,172 @@
{
"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}"
}

View file

@ -0,0 +1,172 @@
{
"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}"
}

View file

@ -0,0 +1,172 @@
{
"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}"
}

View file

@ -0,0 +1,172 @@
{
"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}"
}

View file

@ -1,6 +1,6 @@
"""WebDrop Bridge - Qt-based desktop application for intelligent drag-and-drop file handling."""
__version__ = "0.6.0"
__version__ = "0.8.6"
__author__ = "WebDrop Team"
__license__ = "MIT"

View file

@ -3,6 +3,7 @@
import json
import logging
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import List
@ -11,6 +12,13 @@ from dotenv import load_dotenv
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):
"""Raised when configuration is invalid."""
@ -58,6 +66,14 @@ class Config:
window_height: Initial window height in pixels
window_title: Main window title (default: "{app_name} v{app_version}")
enable_logging: Whether to write logs to file
enable_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:
ConfigurationError: If configuration values are invalid
@ -78,6 +94,14 @@ class Config:
window_height: int = 768
window_title: str = ""
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
def from_file(cls, config_path: Path) -> "Config":
@ -106,10 +130,7 @@ class Config:
# Parse URL 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", [])
]
@ -123,6 +144,9 @@ class Config:
elif not root.is_dir():
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
log_file = None
if data.get("enable_logging", True):
@ -131,10 +155,10 @@ class Config:
log_file = Path(log_file_str)
# If relative path, resolve relative to app data directory instead of cwd
if not log_file.is_absolute():
log_file = Config.get_default_log_dir() / log_file
log_file = Config.get_default_log_dir(config_dir_name) / log_file
else:
# Use default log path in app data
log_file = Config.get_default_log_path()
log_file = Config.get_default_log_path(config_dir_name)
app_name = data.get("app_name", "WebDrop Bridge")
stored_window_title = data.get("window_title", "")
@ -143,6 +167,7 @@ class Config:
# 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.
import re
version_pattern = re.compile(rf"^{re.escape(app_name)}\s+v[\d.]+$")
if stored_window_title and version_pattern.match(stored_window_title):
# Detected a default-pattern title with old version, regenerate
@ -170,6 +195,14 @@ class Config:
window_height=data.get("window_height", 768),
window_title=window_title,
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
@ -195,7 +228,10 @@ class Config:
app_name = os.getenv("APP_NAME", "WebDrop Bridge")
# Version always comes from __init__.py for consistency
from webdrop_bridge import __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_file_str = os.getenv("LOG_FILE", None)
@ -208,13 +244,18 @@ class Config:
default_title = f"{app_name} v{app_version}"
window_title = os.getenv("WINDOW_TITLE", default_title)
enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true"
enable_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
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
if log_level not in valid_levels:
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
@ -225,9 +266,7 @@ class Config:
if not root_path.exists():
logger.warning(f"Allowed root does not exist: {p.strip()}")
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:
allowed_roots.append(root_path)
except ConfigurationError:
@ -240,8 +279,7 @@ class Config:
# Validate window dimensions
if window_width <= 0 or window_height <= 0:
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
@ -251,20 +289,21 @@ class Config:
log_file = Path(log_file_str)
# If relative path, resolve relative to app data directory instead of cwd
if not log_file.is_absolute():
log_file = Config.get_default_log_dir() / log_file
log_file = Config.get_default_log_dir(config_dir_name) / log_file
else:
# Use default log path in app data
log_file = Config.get_default_log_path()
log_file = Config.get_default_log_path(config_dir_name)
# Validate webapp URL is not empty
if not webapp_url:
raise ConfigurationError("WEBAPP_URL cannot be empty")
# Parse allowed URLs (empty string = no restriction)
allowed_urls = [
url.strip() for url in allowed_urls_str.split(",")
if url.strip()
] if allowed_urls_str else []
allowed_urls = (
[url.strip() for url in allowed_urls_str.split(",") if url.strip()]
if allowed_urls_str
else []
)
# Parse URL mappings (Azure Blob Storage → Local Paths)
# Format: url_prefix1=local_path1;url_prefix2=local_path2
@ -282,10 +321,7 @@ class Config:
)
url_prefix, local_path_str = mapping.split("=", 1)
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:
raise ConfigurationError(
@ -305,6 +341,14 @@ class Config:
window_height=window_height,
window_title=window_title,
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:
@ -319,11 +363,7 @@ class Config:
"app_name": self.app_name,
"webapp_url": self.webapp_url,
"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_urls": self.allowed_urls,
@ -336,6 +376,14 @@ class Config:
"window_height": self.window_height,
"window_title": self.window_title,
"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)
@ -343,21 +391,72 @@ class Config:
json.dump(data, f, indent=2)
@staticmethod
def get_default_config_path() -> Path:
def load_bootstrap_env(env_file: str | None = None) -> Path | None:
"""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.
Returns:
Path to default config file in user's AppData/Roaming
"""
import platform
if platform.system() == "Windows":
base = Path.home() / "AppData" / "Roaming"
else:
base = Path.home() / ".config"
return base / "webdrop_bridge" / "config.json"
return base / (config_dir_name or Config.get_default_config_dir_name()) / "config.json"
@staticmethod
def get_default_log_dir() -> Path:
def get_default_log_dir(config_dir_name: str | None = None) -> Path:
"""Get the default directory for log files.
Always uses user's AppData directory to ensure permissions work
@ -367,25 +466,36 @@ class Config:
Path to default logs directory in user's AppData/Roaming
"""
import platform
if platform.system() == "Windows":
base = Path.home() / "AppData" / "Roaming"
else:
base = Path.home() / ".local" / "share"
return base / "webdrop_bridge" / "logs"
return base / (config_dir_name or Config.get_default_config_dir_name()) / "logs"
@staticmethod
def get_default_log_path() -> Path:
def get_default_log_path(config_dir_name: str | None = None) -> Path:
"""Get the default log file path.
Returns:
Path to default log file in user's AppData/Roaming/webdrop_bridge/logs
"""
return Config.get_default_log_dir() / "webdrop_bridge.log"
dir_name = config_dir_name or Config.get_default_config_dir_name()
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:
"""Return developer-friendly representation."""
return (
f"Config(app={self.app_name} v{self.app_version}, "
f"brand={self.brand_id}, "
f"log_level={self.log_level}, "
f"allowed_roots={len(self.allowed_roots)} dirs, "
f"window={self.window_width}x{self.window_height})"

View file

@ -53,7 +53,9 @@ class ConfigValidator:
# Check type
expected_type = rules.get("type")
if expected_type and not isinstance(value, expected_type):
errors.append(f"{field}: expected {expected_type.__name__}, got {type(value).__name__}")
errors.append(
f"{field}: expected {expected_type.__name__}, got {type(value).__name__}"
)
continue
# Check allowed values
@ -99,14 +101,13 @@ class ConfigValidator:
class ConfigProfile:
"""Manages named configuration profiles.
Profiles are stored in ~/.webdrop-bridge/profiles/ directory as JSON files.
Profiles are stored in the brand-specific app config directory.
"""
PROFILES_DIR = Path.home() / ".webdrop-bridge" / "profiles"
def __init__(self):
def __init__(self, config_dir_name: str = "webdrop_bridge") -> None:
"""Initialize profile manager."""
self.PROFILES_DIR.mkdir(parents=True, exist_ok=True)
self.profiles_dir = Config.get_default_config_path(config_dir_name).parent / "profiles"
self.profiles_dir.mkdir(parents=True, exist_ok=True)
def save_profile(self, profile_name: str, config: Config) -> Path:
"""Save configuration as a named profile.
@ -124,7 +125,7 @@ class ConfigProfile:
if not profile_name or "/" in profile_name or "\\" in profile_name:
raise ConfigurationError(f"Invalid profile name: {profile_name}")
profile_path = self.PROFILES_DIR / f"{profile_name}.json"
profile_path = self.profiles_dir / f"{profile_name}.json"
config_data = {
"app_name": config.app_name,
@ -158,7 +159,7 @@ class ConfigProfile:
Raises:
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():
raise ConfigurationError(f"Profile not found: {profile_name}")
@ -177,10 +178,10 @@ class ConfigProfile:
Returns:
List of profile names (without .json extension)
"""
if not self.PROFILES_DIR.exists():
if not self.profiles_dir.exists():
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:
"""Delete a profile.
@ -191,7 +192,7 @@ class ConfigProfile:
Raises:
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():
raise ConfigurationError(f"Profile not found: {profile_name}")

View file

@ -2,7 +2,7 @@
import logging
from pathlib import Path
from typing import List, Optional
from typing import List, Optional, Union
from PySide6.QtCore import QMimeData, Qt, QUrl, Signal
from PySide6.QtGui import QDrag
@ -21,14 +21,18 @@ class DragInterceptor(QWidget):
Intercepts drag events from web content, converts Azure Blob Storage URLs
to local paths, validates them, and initiates native Qt drag operations.
Supports both single and multiple file drag operations.
Signals:
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
(source_urls_or_paths: str, error_message: str)
"""
# Signals with string parameters
drag_started = Signal(str, str) # (url_or_path, local_path)
drag_failed = Signal(str, str) # (url_or_path, error_message)
drag_started = Signal(str, str) # (source_urls_or_paths, local_paths)
drag_failed = Signal(str, str) # (source_urls_or_paths, error_message)
def __init__(self, config: Config, parent: Optional[QWidget] = None):
"""Initialize the drag interceptor.
@ -40,45 +44,66 @@ class DragInterceptor(QWidget):
super().__init__(parent)
self.config = config
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)
def handle_drag(self, text: str) -> bool:
"""Handle drag event from web view.
def handle_drag(self, text_or_list: Union[str, List[str]]) -> bool:
"""Handle drag event from web view (single or multiple files).
Determines if the text is an Azure URL or file path, converts if needed,
Determines if the text/list contains Azure URLs or file paths, converts if needed,
validates, and initiates native drag operation.
Supports:
- Single string (backward compatible)
- List of strings (multiple drag support)
Args:
text: Azure Blob Storage URL or file path from web drag
text_or_list: Azure URL/file path (str) or list of URLs/paths (List[str])
Returns:
True if native drag was initiated, False otherwise
"""
if not text or not text.strip():
# Normalize input to list
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"
logger.warning(error_msg)
self.drag_failed.emit("", error_msg)
return False
text = text.strip()
logger.debug(f"Handling drag for text: {text}")
# Clean up text items
text_list = [str(t).strip() for t in text_list if str(t).strip()]
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
if self._url_converter.is_azure_url(text):
local_path = self._url_converter.convert_url_to_path(text)
if local_path is None:
error_msg = "No mapping found for URL"
logger.warning(f"{error_msg}: {text}")
error_msg = f"No mapping found for URL: {text}"
logger.warning(error_msg)
self.drag_failed.emit(text, error_msg)
return False
source_text = text
source_texts.append(text)
else:
# Treat as direct file path
local_path = Path(text)
source_text = text
source_texts.append(text)
# Validate the path
try:
@ -86,37 +111,56 @@ class DragInterceptor(QWidget):
except ValidationError as e:
error_msg = str(e)
logger.warning(f"Validation failed for {local_path}: {error_msg}")
self.drag_failed.emit(source_text, error_msg)
self.drag_failed.emit(text, error_msg)
return False
logger.info(f"Initiating drag for: {local_path}")
local_paths.append(local_path)
# Create native file drag
success = self._create_native_drag(local_path)
logger.info(
f"Initiating drag for {len(local_paths)} file(s): {[str(p) for p in local_paths]}"
)
# Create native file drag with all paths
success = self._create_native_drag(local_paths)
if success:
self.drag_started.emit(source_text, str(local_path))
source_str = " | ".join(source_texts) if len(source_texts) > 1 else source_texts[0]
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:
error_msg = "Failed to create native drag operation"
logger.error(error_msg)
self.drag_failed.emit(source_text, error_msg)
source_str = " | ".join(source_texts) if len(source_texts) > 1 else source_texts[0]
self.drag_failed.emit(source_str, error_msg)
return success
def _create_native_drag(self, file_path: Path) -> bool:
def _create_native_drag(self, file_paths: Union[Path, List[Path]]) -> bool:
"""Create a native file system drag operation.
Args:
file_path: Local file path to drag
file_paths: Single local file path or list of local file paths
Returns:
True if drag was created successfully
"""
try:
# Create MIME data with file URL
# Normalize to list
if isinstance(file_paths, Path):
paths_list = [file_paths]
else:
paths_list = list(file_paths)
# Create MIME data with file URLs
mime_data = QMimeData()
file_url = QUrl.fromLocalFile(str(file_path))
mime_data.setUrls([file_url])
file_urls = [QUrl.fromLocalFile(str(p)) for p in paths_list]
mime_data.setUrls(file_urls)
logger.debug(f"Creating drag with {len(file_urls)} file(s)")
# Create and execute drag
drag = QDrag(self)

View file

@ -5,9 +5,11 @@ verifying checksums from Forgejo releases.
"""
import asyncio
import fnmatch
import hashlib
import json
import logging
import platform
import socket
from dataclasses import dataclass
from datetime import datetime, timedelta
@ -34,7 +36,16 @@ class Release:
class UpdateManager:
"""Manages auto-updates via Forgejo releases API."""
def __init__(self, current_version: str, config_dir: Optional[Path] = None):
def __init__(
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.
Args:
@ -42,11 +53,12 @@ class UpdateManager:
config_dir: Directory for storing update cache. Defaults to temp.
"""
self.current_version = current_version
self.forgejo_url = "https://git.him-tools.de"
self.repo = "HIM-public/webdrop-bridge"
self.api_endpoint = (
f"{self.forgejo_url}/api/v1/repos/{self.repo}/releases/latest"
)
self.brand_id = brand_id
self.forgejo_url = forgejo_url.rstrip("/")
self.repo = repo
self.update_channel = update_channel
self.manifest_name = manifest_name
self.api_endpoint = f"{self.forgejo_url}/api/v1/repos/{self.repo}/releases/latest"
# Cache management
self.cache_dir = config_dir or Path.home() / ".webdrop-bridge"
@ -54,6 +66,150 @@ class UpdateManager:
self.cache_file = self.cache_dir / "update_check.json"
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]:
"""Parse semantic version string to tuple.
@ -147,43 +303,44 @@ class UpdateManager:
"""
logger.debug(f"check_for_updates() called, current version: {self.current_version}")
# Try cache first
logger.debug("Checking cache...")
# Only use cache when a pending update was already found (avoids
# showing the update dialog on every start). "No update" is never
# cached so that a freshly published release is visible immediately.
logger.debug("Checking cache for pending update...")
cached = self._load_cache()
if cached:
logger.debug("Found cached release")
release_data = cached.get("release")
if release_data:
version = release_data["tag_name"].lstrip("v")
if not self._is_newer_version(version):
logger.info("No newer version available (cached)")
return None
logger.debug(f"Cached pending update version: {version}")
if self._is_newer_version(version):
logger.info(f"Returning cached pending update: {version}")
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)
# Fetch from API
# Always fetch fresh from API so new releases are seen immediately
logger.debug("Fetching from API...")
try:
logger.info(f"Checking for updates from {self.api_endpoint}")
# Run in thread pool with aggressive timeout
loop = asyncio.get_event_loop()
response = await asyncio.wait_for(
loop.run_in_executor(
None, self._fetch_release
),
timeout=8 # Timeout after network call also has timeout
loop.run_in_executor(None, self._fetch_release),
timeout=8,
)
if not response:
return None
# Check if newer version
version = response["tag_name"].lstrip("v")
if not self._is_newer_version(version):
logger.info(f"Latest version {version} is not newer than {self.current_version}")
self._save_cache(response)
return None
# Cache the found update so repeated starts don't hammer the API
logger.info(f"New version available: {version}")
release = Release(**response)
self._save_cache(response)
@ -231,17 +388,15 @@ class UpdateManager:
except socket.timeout as e:
logger.error(f"Socket timeout (5s) connecting to {self.api_endpoint}")
return None
except TimeoutError as e:
logger.error(f"Timeout error: {e}")
return None
except Exception as e:
logger.error(f"Failed to fetch release: {type(e).__name__}: {e}")
import traceback
logger.debug(traceback.format_exc())
return None
async def download_update(
self, release: Release, output_dir: Optional[Path] = None
self, release: Release, output_dir: Optional[Path] = None, progress_callback=None
) -> Optional[Path]:
"""Download installer from release assets.
@ -256,12 +411,7 @@ class UpdateManager:
logger.error("No assets found in release")
return None
# Find .msi or .dmg file
installer_asset = None
for asset in release.assets:
if asset["name"].endswith((".msi", ".dmg")):
installer_asset = asset
break
installer_asset, _ = await self._resolve_release_assets(release)
if not installer_asset:
logger.error("No installer found in release assets")
@ -282,8 +432,9 @@ class UpdateManager:
self._download_file,
installer_asset["browser_download_url"],
output_file,
progress_callback,
),
timeout=300
timeout=300,
)
if success:
@ -302,12 +453,13 @@ class UpdateManager:
output_file.unlink()
return None
def _download_file(self, url: str, output_path: Path) -> bool:
def _download_file(self, url: str, output_path: Path, progress_callback=None) -> bool:
"""Download file from URL (blocking).
Args:
url: URL to download from
output_path: Path to save file
progress_callback: Optional callable(bytes_downloaded, total_bytes)
Returns:
True if successful, False otherwise
@ -315,17 +467,28 @@ class UpdateManager:
try:
logger.debug(f"Downloading from {url}")
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:
f.write(response.read())
while True:
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")
return True
except URLError as e:
logger.error(f"Download failed: {e}")
return False
async def verify_checksum(
self, file_path: Path, release: Release
) -> bool:
async def verify_checksum(self, file_path: Path, release: Release) -> bool:
"""Verify file checksum against release checksum file.
Args:
@ -335,12 +498,11 @@ class UpdateManager:
Returns:
True if checksum matches, False otherwise
"""
# Find .sha256 file in release assets
checksum_asset = None
for asset in release.assets:
if asset["name"].endswith(".sha256"):
checksum_asset = asset
break
installer_asset, checksum_asset = await self._resolve_release_assets(release)
installer_name = installer_asset["name"] if installer_asset else file_path.name
if not checksum_asset:
checksum_asset = self._find_asset(release.assets, f"{installer_name}.sha256")
if not checksum_asset:
logger.warning("No checksum file found in release")
@ -357,7 +519,7 @@ class UpdateManager:
self._download_checksum,
checksum_asset["browser_download_url"],
),
timeout=30
timeout=30,
)
if not checksum_content:
@ -377,9 +539,7 @@ class UpdateManager:
logger.info("Checksum verification passed")
return True
else:
logger.error(
f"Checksum mismatch: {file_checksum} != {expected_checksum}"
)
logger.error(f"Checksum mismatch: {file_checksum} != {expected_checksum}")
return False
except asyncio.TimeoutError:
@ -426,8 +586,11 @@ class UpdateManager:
import subprocess
if platform.system() == "Windows":
# Windows: Run MSI installer
# Windows: MSI files must be launched via msiexec
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)])
return True
elif platform.system() == "Darwin":

View file

@ -1,11 +1,22 @@
"""WebDrop Bridge - Application entry point."""
import os
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 webdrop_bridge.config import Config, ConfigurationError
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
@ -19,6 +30,8 @@ def main() -> int:
int: Exit code (0 for success, non-zero for error)
"""
try:
Config.load_bootstrap_env()
# Load configuration from file if it exists, otherwise from environment
config_path = Config.get_default_config_path()
if config_path.exists():
@ -41,6 +54,11 @@ def main() -> int:
logger.info(f"Starting {config.app_name} v{config.app_version}")
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:
print(f"Configuration error: {e}", file=sys.stderr)
return 1

View file

@ -11,13 +11,16 @@
console.log('%c[WebDrop Intercept] Script loaded - INTERCEPT_ENABLED=' + INTERCEPT_ENABLED, 'background: #2196F3; color: white; font-weight: bold; padding: 4px 8px;');
var currentDragUrl = null;
var currentDragUrls = []; // Array to support multiple URLs
var angularDragHandlers = [];
var originalAddEventListener = EventTarget.prototype.addEventListener;
var listenerPatchActive = true;
var dragHandlerInstalled = false;
// Capture Authorization token from XHR requests
// Capture Authorization token from XHR requests (only if checkout is enabled)
window.capturedAuthToken = null;
if (window.webdropConfig && window.webdropConfig.enableCheckout) {
console.log('[Intercept] Auth token capture enabled (checkout feature active)');
var originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.setRequestHeader = function(header, value) {
if (header === 'Authorization' && value.startsWith('Bearer ')) {
@ -26,11 +29,12 @@
}
return originalXHRSetRequestHeader.apply(this, arguments);
};
} else {
console.log('[Intercept] Auth token capture disabled (checkout feature inactive)');
}
// ============================================================================
// PART 1: Intercept Angular's dragstart listener registration
// ============================================================================
// Only patch addEventListener for dragstart events
// This minimizes impact on other event listeners (mouseover, mouseenter, etc.)
EventTarget.prototype.addEventListener = function(type, listener, options) {
if (listenerPatchActive && type === 'dragstart' && listener) {
// Store Angular's dragstart handler instead of registering it
@ -42,7 +46,8 @@
});
return; // Don't actually register it yet
}
// All other events: use original
// All other events (mouseover, mouseenter, mousedown, etc.): use original
// This is critical to ensure mouseover/hover events work properly
return originalAddEventListener.call(this, type, listener, options);
};
@ -54,8 +59,14 @@
DataTransfer.prototype.setData = function(format, data) {
if (format === 'text/plain' || format === 'text/uri-list') {
currentDragUrl = data;
console.log('%c[Intercept] Captured URL:', 'color: #4CAF50; font-weight: bold;', data.substring(0, 80));
// text/uri-list contains newline-separated URLs
// text/plain may be single URL or multiple newline-separated URLs
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);
};
@ -75,14 +86,20 @@
return;
}
// Stop intercepting addEventListener
listenerPatchActive = false;
// Only install once, even if called multiple times
if (dragHandlerInstalled) {
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
originalAddEventListener.call(document, 'dragstart', function(e) {
currentDragUrl = null; // Reset
console.log('%c[Intercept] dragstart', 'background: #FF9800; color: white; padding: 2px 6px;', 'ALT:', e.altKey);
currentDragUrls = []; // Reset
// Call Angular's handlers first to let them set the data
var handled = 0;
@ -99,32 +116,41 @@
}
}
console.log('[Intercept] Called', handled, 'Angular handlers, URL:', currentDragUrl ? currentDragUrl.substring(0, 60) : 'none');
console.log('[Intercept] Called', handled, 'Angular handlers, URLs:', currentDragUrls.length, 'URL(s)', currentDragUrls.length > 0 ? currentDragUrls[0].substring(0, 60) : 'none');
// NOW check if we should intercept
if (e.altKey && currentDragUrl) {
// Intercept any drag with URLs that match our configured mappings
if (currentDragUrls.length > 0) {
var shouldIntercept = false;
// Check against configured URL mappings
// Check each URL against configured URL mappings
// Intercept if ANY URL matches
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++) {
var mapping = window.webdropConfig.urlMappings[j];
if (currentDragUrl.toLowerCase().startsWith(mapping.url_prefix.toLowerCase())) {
if (dragUrl.toLowerCase().startsWith(mapping.url_prefix.toLowerCase())) {
shouldIntercept = true;
console.log('[Intercept] URL matches mapping for:', mapping.local_path);
console.log('[Intercept] URL #' + (k+1) + ' matches mapping for:', mapping.local_path);
break;
}
}
if (shouldIntercept) break;
}
} else {
// Fallback: Check for legacy Z: drive pattern if no config available
shouldIntercept = /^z:/i.test(currentDragUrl);
if (shouldIntercept) {
for (var k = 0; k < currentDragUrls.length; k++) {
if (/^z:/i.test(currentDragUrls[k])) {
shouldIntercept = true;
console.warn('[Intercept] Using fallback Z: drive pattern (no URL mappings configured)');
break;
}
}
}
if (shouldIntercept) {
console.log('%c[Intercept] PREVENTING browser drag, using Qt',
console.log('%c[Intercept] PREVENTING browser drag, using Qt for ' + currentDragUrls.length + ' file(s)',
'background: #F44336; color: white; font-weight: bold; padding: 4px 8px;');
e.preventDefault();
@ -132,14 +158,15 @@
ensureChannel(function() {
if (window.bridge && typeof window.bridge.start_file_drag === 'function') {
console.log('%c[Intercept] → Qt: start_file_drag', 'color: #9C27B0; font-weight: bold;');
window.bridge.start_file_drag(currentDragUrl);
console.log('%c[Intercept] → Qt: start_file_drag with ' + currentDragUrls.length + ' file(s)', 'color: #9C27B0; font-weight: bold;');
// Pass as JSON string to avoid Qt WebChannel array conversion issues
window.bridge.start_file_drag(JSON.stringify(currentDragUrls));
} else {
console.error('[Intercept] bridge.start_file_drag not available!');
}
});
currentDragUrl = null;
currentDragUrls = [];
return false;
}
}
@ -151,8 +178,23 @@
}
// Wait for Angular to register its listeners, then install our handler
// Start checking after 2 seconds (give Angular time to load on first page load)
setTimeout(installDragHandler, 2000);
// Start checking after 3 seconds (give Angular time to load), then retry for up to 30 seconds
var installRetries = 0;
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
@ -188,7 +230,7 @@
});
}
console.log('%c[WebDrop Intercept] Ready! ALT-drag will use Qt file drag.',
console.log('%c[WebDrop Intercept] Ready! URL-mapped drags will use Qt file drag.',
'background: #4CAF50; color: white; font-weight: bold; padding: 4px 8px;');
} catch(e) {
console.error('[WebDrop Intercept] FATAL ERROR in bridge script:', e);

View file

@ -0,0 +1,54 @@
"""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

View file

@ -0,0 +1,186 @@
// 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);
}
}
})();

View file

@ -1,21 +1,24 @@
"""Restricted web view with URL whitelist enforcement for Kiosk-mode."""
import fnmatch
import hashlib
import logging
from pathlib import Path
from typing import List, Optional, Union
from PySide6.QtCore import QStandardPaths, QUrl
from PySide6.QtGui import QDesktopServices
from PySide6.QtWebEngineCore import QWebEngineNavigationRequest, QWebEnginePage, QWebEngineProfile
from PySide6.QtWebEngineCore import (
QWebEngineNavigationRequest,
QWebEnginePage,
QWebEngineProfile,
QWebEngineSettings,
)
from PySide6.QtWebEngineWidgets import QWebEngineView
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
class CustomWebEnginePage(QWebEnginePage):
"""Custom page that handles new window requests for downloads."""
@ -108,21 +111,53 @@ class RestrictedWebEngineView(QWebEngineView):
If allowed_urls is empty, no restrictions are applied.
If allowed_urls is not empty, only matching URLs are loaded in the view.
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):
def __init__(self, allowed_urls: Optional[List[str]] = None, webapp_url: Optional[str] = None):
"""Initialize the restricted web view.
Args:
allowed_urls: List of allowed URL patterns (empty = no restriction)
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__()
self.allowed_urls = allowed_urls or []
self.webapp_url = webapp_url
# Create persistent profile for cookie and session storage
# Profile is unique per domain to prevent cache corruption
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
custom_page = CustomWebEnginePage(self.profile, self)
self.setPage(custom_page)
@ -131,6 +166,23 @@ class RestrictedWebEngineView(QWebEngineView):
"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
self.page().navigationRequested.connect(self._on_navigation_requested)
@ -141,6 +193,9 @@ class RestrictedWebEngineView(QWebEngineView):
authentication sessions (e.g., Microsoft login) to persist
across application restarts.
Each unique webapp domain gets its own profile to prevent
cache corruption from old domains affecting new domains.
Returns:
Configured QWebEngineProfile with persistent storage
"""
@ -149,14 +204,32 @@ class RestrictedWebEngineView(QWebEngineView):
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
profile_path = Path(app_data_dir) / "WebEngineProfile"
profile_path = Path(app_data_dir) / "webdrop_bridge" / profile_name
profile_path.mkdir(parents=True, exist_ok=True)
# Create persistent profile with custom storage location
# Using "WebDropBridge" as the profile name
# Note: No parent specified so we control the lifecycle
profile = QWebEngineProfile("WebDropBridge")
# Using unique profile name so different domains have isolated caches
profile = QWebEngineProfile(profile_name)
profile.setPersistentStoragePath(str(profile_path))
# Configure persistent cookies (critical for authentication)
@ -170,7 +243,8 @@ class RestrictedWebEngineView(QWebEngineView):
# Set cache size to 100 MB
profile.setHttpCacheMaximumSize(100 * 1024 * 1024)
logger.debug(f"Created persistent profile at: {profile_path}")
logger.debug(f"Created persistent profile '{profile_name}' at: {profile_path}")
logger.debug(f"Profile domain identifier: {domain}")
logger.debug("Cookies policy: ForcePersistentCookies")
logger.debug("HTTP cache: DiskHttpCache (100 MB)")
@ -204,7 +278,7 @@ class RestrictedWebEngineView(QWebEngineView):
- Exact domain matches: example.com
- Wildcard patterns: *.example.com
- Localhost variations: localhost, 127.0.0.1
- File URLs: file://...
- Internal/local URLs: file://, data:, about:, blob:, qrc:
Args:
url: QUrl to check
@ -216,8 +290,8 @@ class RestrictedWebEngineView(QWebEngineView):
host = url.host()
scheme = url.scheme()
# Allow file:// URLs (local webapp)
if scheme == "file":
# Allow internal browser/Qt schemes (never send these to the OS)
if scheme in ("file", "data", "about", "blob", "qrc"):
return True
# If no whitelist, allow all URLs
@ -242,3 +316,19 @@ class RestrictedWebEngineView(QWebEngineView):
return True
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")

View file

@ -2,10 +2,10 @@
import logging
from pathlib import Path
from typing import List, Optional
from typing import Any, Dict, Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
@ -13,7 +13,6 @@ from PySide6.QtWidgets import (
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QPushButton,
QSpinBox,
QTableWidget,
@ -25,23 +24,16 @@ from PySide6.QtWidgets import (
from webdrop_bridge.config import Config, ConfigurationError
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
logger = logging.getLogger(__name__)
class SettingsDialog(QDialog):
"""Dialog for managing application settings and configuration.
"""Dialog for managing application settings and configuration."""
Provides tabs for:
- Paths: Manage allowed root directories
- URLs: Manage allowed web URLs
- Logging: Configure logging settings
- Window: Manage window size and behavior
- Profiles: Save/load/delete configuration profiles
"""
def __init__(self, config: Config, parent=None):
def __init__(self, config: Config, parent: Optional[QWidget] = None):
"""Initialize the settings dialog.
Args:
@ -50,8 +42,8 @@ class SettingsDialog(QDialog):
"""
super().__init__(parent)
self.config = config
self.profile_manager = ConfigProfile()
self.setWindowTitle("Settings")
self.profile_manager = ConfigProfile(config.config_dir_name)
self.setWindowTitle(tr("settings.title"))
self.setGeometry(100, 100, 600, 500)
self.setup_ui()
@ -60,20 +52,16 @@ class SettingsDialog(QDialog):
"""Set up the dialog UI with tabs."""
layout = QVBoxLayout()
# Create tab widget
self.tabs = QTabWidget()
# Add tabs
self.tabs.addTab(self._create_web_source_tab(), "Web Source")
self.tabs.addTab(self._create_paths_tab(), "Paths")
self.tabs.addTab(self._create_urls_tab(), "URLs")
self.tabs.addTab(self._create_logging_tab(), "Logging")
self.tabs.addTab(self._create_window_tab(), "Window")
self.tabs.addTab(self._create_profiles_tab(), "Profiles")
self.tabs.addTab(self._create_general_tab(), tr("settings.tab.general"))
self.tabs.addTab(self._create_web_source_tab(), tr("settings.tab.web_source"))
self.tabs.addTab(self._create_paths_tab(), tr("settings.tab.paths"))
self.tabs.addTab(self._create_urls_tab(), tr("settings.tab.urls"))
self.tabs.addTab(self._create_logging_tab(), tr("settings.tab.logging"))
self.tabs.addTab(self._create_window_tab(), tr("settings.tab.window"))
self.tabs.addTab(self._create_profiles_tab(), tr("settings.tab.profiles"))
layout.addWidget(self.tabs)
# Add buttons
button_box = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
@ -84,31 +72,23 @@ class SettingsDialog(QDialog):
self.setLayout(layout)
def accept(self) -> None:
"""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.
"""
"""Handle OK button - save configuration changes to file."""
try:
# Get updated configuration data from UI
config_data = self.get_config_data()
# Convert URL mappings from dict to URLMapping objects
from webdrop_bridge.config import URLMapping
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"]
]
# Update the config object with new values
old_log_level = self.config.log_level
self.config.language = config_data["language"]
self.config.log_level = config_data["log_level"]
self.config.log_file = Path(config_data["log_file"]) if config_data["log_file"] else None
self.config.log_file = (
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_urls = config_data["allowed_urls"]
self.config.webapp_url = config_data["webapp_url"]
@ -116,25 +96,21 @@ class SettingsDialog(QDialog):
self.config.window_width = config_data["window_width"]
self.config.window_height = config_data["window_height"]
# Save to file (creates parent dirs if needed)
config_path = Config.get_default_config_path()
config_path = self.config.get_config_path()
self.config.to_file(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" 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:
logger.info(f"🔄 Updating log level: {old_log_level}{self.config.log_level}")
reconfigure_logging(
logger_name="webdrop_bridge",
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()
except ConfigurationError as e:
@ -144,38 +120,70 @@ class SettingsDialog(QDialog):
logger.error(f"Failed to save configuration: {e}", exc_info=True)
self._show_error(f"Failed to save configuration:\n\n{e}")
def _create_web_source_tab(self) -> QWidget:
"""Create web source configuration tab."""
from PySide6.QtWidgets import QTableWidget, QTableWidgetItem
def _create_general_tab(self) -> QWidget:
"""Create general settings tab with language selector."""
widget = QWidget()
layout = QVBoxLayout()
# Webapp URL configuration
layout.addWidget(QLabel("Web Application URL:"))
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:
"""Create web source configuration tab."""
widget = QWidget()
layout = QVBoxLayout()
layout.addWidget(QLabel(tr("settings.web_source.url_label")))
url_layout = QHBoxLayout()
self.webapp_url_input = QLineEdit()
self.webapp_url_input.setText(self.config.webapp_url)
self.webapp_url_input.setPlaceholderText("e.g., http://localhost:8080 or file:///./webapp/index.html")
self.webapp_url_input.setPlaceholderText(
"e.g., http://localhost:8080 or file:///./webapp/index.html"
)
url_layout.addWidget(self.webapp_url_input)
open_btn = QPushButton("Open")
open_btn = QPushButton(tr("settings.web_source.open_btn"))
open_btn.clicked.connect(self._open_webapp_url)
url_layout.addWidget(open_btn)
layout.addLayout(url_layout)
# URL Mappings (Azure Blob URL → Local Path)
layout.addWidget(QLabel("URL Mappings (Azure Blob Storage → Local Paths):"))
layout.addWidget(QLabel(tr("settings.web_source.url_mappings_label")))
# Create table for URL mappings
self.url_mappings_table = QTableWidget()
self.url_mappings_table.setColumnCount(2)
self.url_mappings_table.setHorizontalHeaderLabels(["URL Prefix", "Local Path"])
self.url_mappings_table.setHorizontalHeaderLabels(
[
tr("settings.web_source.col_url_prefix"),
tr("settings.web_source.col_local_path"),
]
)
self.url_mappings_table.horizontalHeader().setStretchLastSection(True)
# Populate from config
for mapping in self.config.url_mappings:
row = self.url_mappings_table.rowCount()
self.url_mappings_table.insertRow(row)
@ -184,18 +192,17 @@ class SettingsDialog(QDialog):
layout.addWidget(self.url_mappings_table)
# Buttons for URL mapping management
button_layout = QHBoxLayout()
add_mapping_btn = QPushButton("Add Mapping")
add_mapping_btn = QPushButton(tr("settings.web_source.add_mapping_btn"))
add_mapping_btn.clicked.connect(self._add_url_mapping)
button_layout.addWidget(add_mapping_btn)
edit_mapping_btn = QPushButton("Edit Selected")
edit_mapping_btn = QPushButton(tr("settings.web_source.edit_mapping_btn"))
edit_mapping_btn.clicked.connect(self._edit_url_mapping)
button_layout.addWidget(edit_mapping_btn)
remove_mapping_btn = QPushButton("Remove Selected")
remove_mapping_btn = QPushButton(tr("settings.web_source.remove_mapping_btn"))
remove_mapping_btn.clicked.connect(self._remove_url_mapping)
button_layout.addWidget(remove_mapping_btn)
@ -208,9 +215,10 @@ class SettingsDialog(QDialog):
def _open_webapp_url(self) -> None:
"""Open the webapp URL in the default browser."""
import webbrowser
url = self.webapp_url_input.text().strip()
if url:
# Handle file:// URLs
if not url:
return
try:
webbrowser.open(url)
except Exception as e:
@ -223,15 +231,15 @@ class SettingsDialog(QDialog):
url_prefix, ok1 = QInputDialog.getText(
self,
"Add URL Mapping",
"Enter Azure Blob Storage URL prefix:\n(e.g., https://myblob.blob.core.windows.net/container/)"
tr("settings.web_source.add_mapping_title"),
tr("settings.web_source.add_mapping_url_prompt"),
)
if ok1 and url_prefix:
local_path, ok2 = QInputDialog.getText(
self,
"Add URL Mapping",
"Enter local file system path:\n(e.g., C:\\Share or /mnt/share)"
tr("settings.web_source.add_mapping_title"),
tr("settings.web_source.add_mapping_path_prompt"),
)
if ok2 and local_path:
@ -246,25 +254,25 @@ class SettingsDialog(QDialog):
current_row = self.url_mappings_table.currentRow()
if current_row < 0:
self._show_error("Please select a mapping to edit")
self._show_error(tr("settings.web_source.select_mapping_to_edit"))
return
url_prefix = self.url_mappings_table.item(current_row, 0).text()
local_path = self.url_mappings_table.item(current_row, 1).text()
url_prefix = self.url_mappings_table.item(current_row, 0).text() # type: ignore
local_path = self.url_mappings_table.item(current_row, 1).text() # type: ignore
new_url_prefix, ok1 = QInputDialog.getText(
self,
"Edit URL Mapping",
"Enter Azure Blob Storage URL prefix:",
text=url_prefix
tr("settings.web_source.edit_mapping_title"),
tr("settings.web_source.edit_mapping_url_prompt"),
text=url_prefix,
)
if ok1 and new_url_prefix:
new_local_path, ok2 = QInputDialog.getText(
self,
"Edit URL Mapping",
"Enter local file system path:",
text=local_path
tr("settings.web_source.edit_mapping_title"),
tr("settings.web_source.edit_mapping_path_prompt"),
text=local_path,
)
if ok2 and new_local_path:
@ -282,22 +290,20 @@ class SettingsDialog(QDialog):
widget = QWidget()
layout = QVBoxLayout()
layout.addWidget(QLabel("Allowed root directories for file access:"))
layout.addWidget(QLabel(tr("settings.paths.label")))
# List widget for paths
self.paths_list = QListWidget()
for path in self.config.allowed_roots:
self.paths_list.addItem(str(path))
layout.addWidget(self.paths_list)
# Buttons for path management
button_layout = QHBoxLayout()
add_path_btn = QPushButton("Add Path")
add_path_btn = QPushButton(tr("settings.paths.add_btn"))
add_path_btn.clicked.connect(self._add_path)
button_layout.addWidget(add_path_btn)
remove_path_btn = QPushButton("Remove Selected")
remove_path_btn = QPushButton(tr("settings.paths.remove_btn"))
remove_path_btn.clicked.connect(self._remove_path)
button_layout.addWidget(remove_path_btn)
@ -312,22 +318,20 @@ class SettingsDialog(QDialog):
widget = QWidget()
layout = QVBoxLayout()
layout.addWidget(QLabel("Allowed web URLs (supports wildcards like http://*.example.com):"))
layout.addWidget(QLabel(tr("settings.urls.label")))
# List widget for URLs
self.urls_list = QListWidget()
for url in self.config.allowed_urls:
self.urls_list.addItem(url)
layout.addWidget(self.urls_list)
# Buttons for URL management
button_layout = QHBoxLayout()
add_url_btn = QPushButton("Add URL")
add_url_btn = QPushButton(tr("settings.urls.add_btn"))
add_url_btn.clicked.connect(self._add_url)
button_layout.addWidget(add_url_btn)
remove_url_btn = QPushButton("Remove Selected")
remove_url_btn = QPushButton(tr("settings.urls.remove_btn"))
remove_url_btn.clicked.connect(self._remove_url)
button_layout.addWidget(remove_url_btn)
@ -342,26 +346,22 @@ class SettingsDialog(QDialog):
widget = QWidget()
layout = QVBoxLayout()
# Log level selection
layout.addWidget(QLabel("Log Level:"))
from PySide6.QtWidgets import QComboBox
layout.addWidget(QLabel(tr("settings.logging.level_label")))
self.log_level_combo: QComboBox = self._create_log_level_widget()
layout.addWidget(self.log_level_combo)
# Log file path
layout.addWidget(QLabel("Log File (optional):"))
layout.addWidget(QLabel(tr("settings.logging.file_label")))
log_file_layout = QHBoxLayout()
self.log_file_input = QLineEdit()
self.log_file_input.setText(str(self.config.log_file) if self.config.log_file else "")
log_file_layout.addWidget(self.log_file_input)
browse_btn = QPushButton("Browse...")
browse_btn = QPushButton(tr("settings.logging.browse_btn"))
browse_btn.clicked.connect(self._browse_log_file)
log_file_layout.addWidget(browse_btn)
layout.addLayout(log_file_layout)
layout.addStretch()
widget.setLayout(layout)
return widget
@ -371,9 +371,8 @@ class SettingsDialog(QDialog):
widget = QWidget()
layout = QVBoxLayout()
# Window width
width_layout = QHBoxLayout()
width_layout.addWidget(QLabel("Window Width:"))
width_layout.addWidget(QLabel(tr("settings.window.width_label")))
self.width_spin = QSpinBox()
self.width_spin.setMinimum(400)
self.width_spin.setMaximum(5000)
@ -382,9 +381,8 @@ class SettingsDialog(QDialog):
width_layout.addStretch()
layout.addLayout(width_layout)
# Window height
height_layout = QHBoxLayout()
height_layout.addWidget(QLabel("Window Height:"))
height_layout.addWidget(QLabel(tr("settings.window.height_label")))
self.height_spin = QSpinBox()
self.height_spin.setMinimum(300)
self.height_spin.setMaximum(5000)
@ -402,38 +400,35 @@ class SettingsDialog(QDialog):
widget = QWidget()
layout = QVBoxLayout()
layout.addWidget(QLabel("Saved Configuration Profiles:"))
layout.addWidget(QLabel(tr("settings.profiles.label")))
# List of profiles
self.profiles_list = QListWidget()
self._refresh_profiles_list()
layout.addWidget(self.profiles_list)
# Profile management buttons
button_layout = QHBoxLayout()
save_profile_btn = QPushButton("Save as Profile")
save_profile_btn = QPushButton(tr("settings.profiles.save_btn"))
save_profile_btn.clicked.connect(self._save_profile)
button_layout.addWidget(save_profile_btn)
load_profile_btn = QPushButton("Load Profile")
load_profile_btn = QPushButton(tr("settings.profiles.load_btn"))
load_profile_btn.clicked.connect(self._load_profile)
button_layout.addWidget(load_profile_btn)
delete_profile_btn = QPushButton("Delete Profile")
delete_profile_btn = QPushButton(tr("settings.profiles.delete_btn"))
delete_profile_btn.clicked.connect(self._delete_profile)
button_layout.addWidget(delete_profile_btn)
layout.addLayout(button_layout)
# Export/Import buttons
export_layout = QHBoxLayout()
export_btn = QPushButton("Export Configuration")
export_btn = QPushButton(tr("settings.profiles.export_btn"))
export_btn.clicked.connect(self._export_config)
export_layout.addWidget(export_btn)
import_btn = QPushButton("Import Configuration")
import_btn = QPushButton(tr("settings.profiles.import_btn"))
import_btn.clicked.connect(self._import_config)
export_layout.addWidget(import_btn)
@ -443,10 +438,8 @@ class SettingsDialog(QDialog):
widget.setLayout(layout)
return widget
def _create_log_level_widget(self):
def _create_log_level_widget(self) -> QComboBox:
"""Create log level selection widget."""
from PySide6.QtWidgets import QComboBox
combo = QComboBox()
levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
combo.addItems(levels)
@ -455,7 +448,7 @@ class SettingsDialog(QDialog):
def _add_path(self) -> None:
"""Add a new allowed path."""
path = QFileDialog.getExistingDirectory(self, "Select Directory to Allow")
path = QFileDialog.getExistingDirectory(self, tr("settings.paths.select_dir_title"))
if path:
self.paths_list.addItem(path)
@ -469,9 +462,7 @@ class SettingsDialog(QDialog):
from PySide6.QtWidgets import QInputDialog
url, ok = QInputDialog.getText(
self,
"Add URL",
"Enter URL pattern (e.g., http://example.com or http://*.example.com):"
self, tr("settings.urls.add_title"), tr("settings.urls.add_prompt")
)
if ok and url:
self.urls_list.addItem(url)
@ -485,9 +476,9 @@ class SettingsDialog(QDialog):
"""Browse for log file location."""
file_path, _ = QFileDialog.getSaveFileName(
self,
"Select Log File",
tr("settings.logging.select_file_title"),
str(Path.home()),
"Log Files (*.log);;All Files (*)"
"Log Files (*.log);;All Files (*)",
)
if file_path:
self.log_file_input.setText(file_path)
@ -503,9 +494,7 @@ class SettingsDialog(QDialog):
from PySide6.QtWidgets import QInputDialog
profile_name, ok = QInputDialog.getText(
self,
"Save Profile",
"Enter profile name (e.g., work, personal):"
self, tr("settings.profiles.save_title"), tr("settings.profiles.save_prompt")
)
if ok and profile_name:
@ -519,7 +508,7 @@ class SettingsDialog(QDialog):
"""Load a saved profile."""
current_item = self.profiles_list.currentItem()
if not current_item:
self._show_error("Please select a profile to load")
self._show_error(tr("settings.profiles.select_to_load"))
return
profile_name = current_item.text()
@ -533,7 +522,7 @@ class SettingsDialog(QDialog):
"""Delete a saved profile."""
current_item = self.profiles_list.currentItem()
if not current_item:
self._show_error("Please select a profile to delete")
self._show_error(tr("settings.profiles.select_to_delete"))
return
profile_name = current_item.text()
@ -547,9 +536,9 @@ class SettingsDialog(QDialog):
"""Export configuration to file."""
file_path, _ = QFileDialog.getSaveFileName(
self,
"Export Configuration",
tr("settings.profiles.export_title"),
str(Path.home()),
"JSON Files (*.json);;All Files (*)"
"JSON Files (*.json);;All Files (*)",
)
if file_path:
@ -562,9 +551,9 @@ class SettingsDialog(QDialog):
"""Import configuration from file."""
file_path, _ = QFileDialog.getOpenFileName(
self,
"Import Configuration",
tr("settings.profiles.import_title"),
str(Path.home()),
"JSON Files (*.json);;All Files (*)"
"JSON Files (*.json);;All Files (*)",
)
if file_path:
@ -574,32 +563,33 @@ class SettingsDialog(QDialog):
except ConfigurationError as e:
self._show_error(f"Failed to import configuration: {e}")
def _apply_config_data(self, config_data: dict) -> None:
def _apply_config_data(self, config_data: Dict[str, Any]) -> None:
"""Apply imported configuration data to UI.
Args:
config_data: Configuration dictionary
"""
# Apply paths
self.paths_list.clear()
for path in config_data.get("allowed_roots", []):
self.paths_list.addItem(str(path))
# Apply URLs
self.urls_list.clear()
for url in config_data.get("allowed_urls", []):
self.urls_list.addItem(url)
# Apply logging settings
self.log_level_combo.setCurrentText(config_data.get("log_level", "INFO"))
log_file = config_data.get("log_file")
self.log_file_input.setText(str(log_file) if log_file else "")
# Apply window settings
self.width_spin.setValue(config_data.get("window_width", 800))
self.height_spin.setValue(config_data.get("window_height", 600))
def get_config_data(self) -> dict:
language = config_data.get("language", "auto")
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.
Returns:
@ -608,29 +598,42 @@ class SettingsDialog(QDialog):
Raises:
ConfigurationError: If configuration is invalid
"""
url_mappings_table_count = (
self.url_mappings_table.rowCount() if self.url_mappings_table else 0
)
config_data = {
"app_name": self.config.app_name,
"app_version": self.config.app_version,
"language": self.language_combo.currentData(),
"log_level": self.log_level_combo.currentText(),
"log_file": self.log_file_input.text() or None,
"allowed_roots": [self.paths_list.item(i).text() for i in range(self.paths_list.count())],
"allowed_roots": [
self.paths_list.item(i).text() for i in range(self.paths_list.count())
],
"allowed_urls": [self.urls_list.item(i).text() for i in range(self.urls_list.count())],
"webapp_url": self.webapp_url_input.text().strip(),
"url_mappings": [
{
"url_prefix": self.url_mappings_table.item(i, 0).text(),
"local_path": self.url_mappings_table.item(i, 1).text()
"url_prefix": (
self.url_mappings_table.item(i, 0).text() # type: ignore
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(self.url_mappings_table.rowCount())
for i in range(url_mappings_table_count)
],
"window_width": self.width_spin.value(),
"window_height": self.height_spin.value(),
"enable_logging": self.config.enable_logging,
}
# Validate
ConfigValidator.validate_or_raise(config_data)
return config_data
def _show_error(self, message: str) -> None:
@ -640,4 +643,5 @@ class SettingsDialog(QDialog):
message: Error message
"""
from PySide6.QtWidgets import QMessageBox
QMessageBox.critical(self, "Error", message)
QMessageBox.critical(self, tr("dialog.error.title"), message)

View file

@ -25,6 +25,8 @@ from PySide6.QtWidgets import (
QVBoxLayout,
)
from webdrop_bridge.utils.i18n import tr
logger = logging.getLogger(__name__)
@ -41,7 +43,7 @@ class CheckingDialog(QDialog):
parent: Parent widget
"""
super().__init__(parent)
self.setWindowTitle("Checking for Updates")
self.setWindowTitle(tr("update.checking.title"))
self.setModal(True)
self.setMinimumWidth(300)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
@ -49,7 +51,7 @@ class CheckingDialog(QDialog):
layout = QVBoxLayout()
# Status label
self.label = QLabel("Checking for updates...")
self.label = QLabel(tr("update.checking.label"))
layout.addWidget(self.label)
# Animated progress bar
@ -58,7 +60,7 @@ class CheckingDialog(QDialog):
layout.addWidget(self.progress)
# Timeout info
info_label = QLabel("This may take up to 10 seconds")
info_label = QLabel(tr("update.checking.timeout_info"))
info_label.setStyleSheet("color: gray; font-size: 11px;")
layout.addWidget(info_label)
@ -78,7 +80,6 @@ class UpdateAvailableDialog(QDialog):
# Signals
update_now = Signal()
update_later = Signal()
skip_version = Signal()
def __init__(self, version: str, changelog: str, parent=None):
"""Initialize update available dialog.
@ -89,7 +90,7 @@ class UpdateAvailableDialog(QDialog):
parent: Parent widget
"""
super().__init__(parent)
self.setWindowTitle("Update Available")
self.setWindowTitle(tr("update.available.title"))
self.setModal(True)
self.setMinimumWidth(400)
self.setMinimumHeight(300)
@ -97,12 +98,12 @@ class UpdateAvailableDialog(QDialog):
layout = QVBoxLayout()
# Header
header = QLabel(f"WebDrop Bridge v{version} is available")
header = QLabel(tr("update.available.header", version=version))
header.setStyleSheet("font-weight: bold; font-size: 14px;")
layout.addWidget(header)
# Changelog
changelog_label = QLabel("Release Notes:")
changelog_label = QLabel(tr("update.available.changelog_label"))
changelog_label.setStyleSheet("font-weight: bold; margin-top: 10px;")
layout.addWidget(changelog_label)
@ -114,18 +115,14 @@ class UpdateAvailableDialog(QDialog):
# Buttons
button_layout = QHBoxLayout()
self.update_now_btn = QPushButton("Update Now")
self.update_now_btn = QPushButton(tr("update.available.update_now_btn"))
self.update_now_btn.clicked.connect(self._on_update_now)
button_layout.addWidget(self.update_now_btn)
self.update_later_btn = QPushButton("Later")
self.update_later_btn = QPushButton(tr("update.available.later_btn"))
self.update_later_btn.clicked.connect(self._on_update_later)
button_layout.addWidget(self.update_later_btn)
self.skip_btn = QPushButton("Skip Version")
self.skip_btn.clicked.connect(self._on_skip)
button_layout.addWidget(self.skip_btn)
layout.addLayout(button_layout)
self.setLayout(layout)
@ -139,11 +136,6 @@ class UpdateAvailableDialog(QDialog):
self.update_later.emit()
self.reject()
def _on_skip(self):
"""Handle skip version button click."""
self.skip_version.emit()
self.reject()
class DownloadingDialog(QDialog):
"""Dialog shown while downloading the update.
@ -163,7 +155,7 @@ class DownloadingDialog(QDialog):
parent: Parent widget
"""
super().__init__(parent)
self.setWindowTitle("Downloading Update")
self.setWindowTitle(tr("update.downloading.title"))
self.setModal(True)
self.setMinimumWidth(350)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
@ -171,12 +163,12 @@ class DownloadingDialog(QDialog):
layout = QVBoxLayout()
# Header
header = QLabel("Downloading update...")
header = QLabel(tr("update.downloading.header"))
header.setStyleSheet("font-weight: bold;")
layout.addWidget(header)
# File label
self.file_label = QLabel("Preparing download")
self.file_label = QLabel(tr("update.downloading.preparing"))
layout.addWidget(self.file_label)
# Progress bar
@ -192,7 +184,7 @@ class DownloadingDialog(QDialog):
layout.addWidget(self.size_label)
# Cancel button
self.cancel_btn = QPushButton("Cancel")
self.cancel_btn = QPushButton(tr("update.downloading.cancel_btn"))
self.cancel_btn.clicked.connect(self._on_cancel)
layout.addWidget(self.cancel_btn)
@ -220,7 +212,7 @@ class DownloadingDialog(QDialog):
Args:
filename: Name of file being downloaded
"""
self.file_label.setText(f"Downloading: {filename}")
self.file_label.setText(tr("update.downloading.filename", filename=filename))
def _on_cancel(self):
"""Handle cancel button click."""
@ -246,26 +238,23 @@ class InstallDialog(QDialog):
parent: Parent widget
"""
super().__init__(parent)
self.setWindowTitle("Install Update")
self.setWindowTitle(tr("update.install.title"))
self.setModal(True)
self.setMinimumWidth(350)
layout = QVBoxLayout()
# Header
header = QLabel("Ready to Install")
header = QLabel(tr("update.install.header"))
header.setStyleSheet("font-weight: bold; font-size: 14px;")
layout.addWidget(header)
# Message
message = QLabel("The update is ready to install. The application will restart.")
message = QLabel(tr("update.install.message"))
layout.addWidget(message)
# Warning
warning = QLabel(
"⚠️ Please save any unsaved work before continuing.\n"
"The application will close and restart."
)
warning = QLabel(tr("update.install.warning"))
warning.setStyleSheet("background-color: #fff3cd; padding: 10px; border-radius: 4px;")
warning.setWordWrap(True)
layout.addWidget(warning)
@ -273,12 +262,12 @@ class InstallDialog(QDialog):
# Buttons
button_layout = QHBoxLayout()
self.install_btn = QPushButton("Install Now")
self.install_btn = QPushButton(tr("update.install.now_btn"))
self.install_btn.setStyleSheet("background-color: #28a745; color: white;")
self.install_btn.clicked.connect(self._on_install)
button_layout.addWidget(self.install_btn)
self.cancel_btn = QPushButton("Cancel")
self.cancel_btn = QPushButton(tr("update.install.cancel_btn"))
self.cancel_btn.clicked.connect(self.reject)
button_layout.addWidget(self.cancel_btn)
@ -304,22 +293,22 @@ class NoUpdateDialog(QDialog):
parent: Parent widget
"""
super().__init__(parent)
self.setWindowTitle("No Updates Available")
self.setWindowTitle(tr("update.no_update.title"))
self.setModal(True)
self.setMinimumWidth(300)
layout = QVBoxLayout()
# Message
message = QLabel("✓ You're using the latest version")
message = QLabel(tr("update.no_update.message"))
message.setStyleSheet("font-weight: bold; font-size: 12px; color: #28a745;")
layout.addWidget(message)
info = QLabel("WebDrop Bridge is up to date.")
info = QLabel(tr("update.no_update.info"))
layout.addWidget(info)
# Close button
close_btn = QPushButton("OK")
close_btn = QPushButton(tr("update.no_update.ok_btn"))
close_btn.clicked.connect(self.accept)
layout.addWidget(close_btn)
@ -345,14 +334,14 @@ class ErrorDialog(QDialog):
parent: Parent widget
"""
super().__init__(parent)
self.setWindowTitle("Update Failed")
self.setWindowTitle(tr("update.error.title"))
self.setModal(True)
self.setMinimumWidth(350)
layout = QVBoxLayout()
# Header
header = QLabel("⚠️ Update Failed")
header = QLabel(tr("update.error.header"))
header.setStyleSheet("font-weight: bold; font-size: 14px; color: #dc3545;")
layout.addWidget(header)
@ -364,9 +353,7 @@ class ErrorDialog(QDialog):
layout.addWidget(self.error_text)
# Info message
info = QLabel(
"Please try again or visit the website to download the update manually."
)
info = QLabel(tr("update.error.info"))
info.setWordWrap(True)
info.setStyleSheet("color: gray; font-size: 11px;")
layout.addWidget(info)
@ -374,15 +361,15 @@ class ErrorDialog(QDialog):
# Buttons
button_layout = QHBoxLayout()
self.retry_btn = QPushButton("Retry")
self.retry_btn = QPushButton(tr("update.error.retry_btn"))
self.retry_btn.clicked.connect(self._on_retry)
button_layout.addWidget(self.retry_btn)
self.manual_btn = QPushButton("Download Manually")
self.manual_btn = QPushButton(tr("update.error.manual_btn"))
self.manual_btn.clicked.connect(self._on_manual)
button_layout.addWidget(self.manual_btn)
self.cancel_btn = QPushButton("Cancel")
self.cancel_btn = QPushButton(tr("update.error.cancel_btn"))
self.cancel_btn.clicked.connect(self.reject)
button_layout.addWidget(self.cancel_btn)

View file

@ -0,0 +1,292 @@
"""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"

View file

@ -0,0 +1,147 @@
"""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"
)

View file

@ -1,6 +1,7 @@
"""Unit tests for configuration system."""
import os
import sys
import pytest
@ -15,7 +16,19 @@ def clear_env():
# Clear relevant variables
for key in list(os.environ.keys()):
if key.startswith(('APP_', 'LOG_', 'ALLOWED_', 'WEBAPP_', 'WINDOW_', 'ENABLE_')):
if key.startswith(
(
"APP_",
"LOG_",
"ALLOWED_",
"WEBAPP_",
"WINDOW_",
"ENABLE_",
"BRAND_",
"UPDATE_",
"LANGUAGE",
)
):
del os.environ[key]
yield
@ -64,6 +77,28 @@ class TestConfigFromEnv:
assert config.window_width == 1200
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):
"""Test loading config uses defaults when env vars not set."""
# Create empty .env file
@ -73,8 +108,11 @@ class TestConfigFromEnv:
config = Config.from_env(str(env_file))
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)
from webdrop_bridge import __version__
assert config.app_version == __version__
assert config.log_level == "INFO"
assert config.window_width == 1024
@ -187,3 +225,30 @@ class TestConfigValidation:
config = Config.from_env(str(env_file))
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"

View file

@ -82,6 +82,7 @@ class TestDragInterceptorValidation:
mock_drag_instance = MagicMock()
# Simulate successful copy action
from PySide6.QtCore import Qt
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
mock_drag.return_value = mock_drag_instance
@ -136,7 +137,7 @@ class TestDragInterceptorAzureURL:
url_mappings=[
URLMapping(
url_prefix="https://devagravitystg.file.core.windows.net/devagravitysync/",
local_path=str(tmp_path)
local_path=str(tmp_path),
)
],
check_file_exists=True,
@ -150,6 +151,7 @@ class TestDragInterceptorAzureURL:
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:
mock_drag_instance = MagicMock()
from PySide6.QtCore import Qt
mock_drag_instance.exec.return_value = Qt.DropAction.CopyAction
mock_drag.return_value = mock_drag_instance
@ -196,6 +198,7 @@ class TestDragInterceptorSignals:
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
@ -235,3 +238,234 @@ class TestDragInterceptorSignals:
# Verify result and signal emission
assert result is False
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]

60
tests/unit/test_i18n.py Normal file
View file

@ -0,0 +1,60 @@
"""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

View file

@ -82,136 +82,6 @@ class TestMainWindowInitialization:
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:
"""Test drag-and-drop integration."""
@ -231,9 +101,7 @@ class TestMainWindowDragIntegration:
assert window.drag_interceptor.drag_started 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."""
from PySide6.QtCore import QCoreApplication
@ -244,9 +112,7 @@ class TestMainWindowDragIntegration:
test_file = sample_config.allowed_roots[0] / "test.txt"
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
# Call through bridge
window._drag_bridge.start_file_drag(str(test_file))
@ -276,9 +142,7 @@ class TestMainWindowDragIntegration:
class TestMainWindowURLWhitelist:
"""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."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
@ -286,268 +150,73 @@ class TestMainWindowURLWhitelist:
# web_view should have allowed_urls configured
assert window.web_view.allowed_urls == sample_config.allowed_urls
def test_empty_allowed_urls_list(self, qtbot, tmp_path):
"""Test with empty allowed URLs (no restriction)."""
config = Config(
app_name="Test",
app_version="1.0.0",
log_level="INFO",
log_file=None,
allowed_roots=[tmp_path],
allowed_urls=[], # Empty = no restriction
webapp_url="http://localhost",
window_width=800,
window_height=600,
enable_logging=False,
class TestMainWindowOpenWith:
"""Test Open With chooser behavior."""
def test_open_with_app_chooser_windows(self, qtbot, sample_config):
"""Windows should use ShellExecuteW with the openas verb."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
test_file = sample_config.allowed_roots[0] / "open_with_test.txt"
test_file.write_text("test")
with patch("webdrop_bridge.ui.main_window.sys.platform", "win32"):
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,
)
window = MainWindow(config)
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."""
def test_open_with_app_chooser_windows_shellexecute_failure(self, qtbot, sample_config):
"""Windows should fall back to OpenAs_RunDLL when ShellExecuteW fails."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
with patch.object(window, "_on_drag_started") as mock_handler:
window.drag_interceptor.drag_started.emit(["/path/to/file"])
mock_handler.assert_called_once()
test_file = sample_config.allowed_roots[0] / "open_with_fallback.txt"
test_file.write_text("test")
def test_drag_failed_signal_connection(self, qtbot, sample_config):
"""Test drag_failed signal is connected to handler."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
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,
with patch("webdrop_bridge.ui.main_window.sys.platform", "win32"):
with patch("ctypes.windll.shell32.ShellExecuteW", return_value=31):
with patch("webdrop_bridge.ui.main_window.subprocess.Popen") as mock_popen:
assert window._open_with_app_chooser(str(test_file)) is True
mock_popen.assert_called_once_with(
["rundll32.exe", "shell32.dll,OpenAs_RunDLL", str(test_file)]
)
window = MainWindow(config)
def test_open_with_app_chooser_missing_file(self, qtbot, sample_config):
"""Missing files should fail before platform-specific invocation."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
# Verify validator has both roots
assert window.drag_interceptor._validator is not None
assert len(
window.drag_interceptor._validator.allowed_roots
) == 2
with patch("webdrop_bridge.ui.main_window.sys.platform", "win32"):
assert window._open_with_app_chooser("C:/tmp/does_not_exist.txt") is False
def test_window_with_url_whitelist(self, qtbot, tmp_path):
"""Test MainWindow respects URL whitelist."""
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)
def test_open_with_app_chooser_macos_success(self, qtbot, sample_config):
"""macOS should return True when osascript exits successfully."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
# Verify whitelist is set
assert window.web_view.allowed_urls == ["*.example.com", "localhost"]
test_file = sample_config.allowed_roots[0] / "open_with_macos.txt"
test_file.write_text("test")
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

View file

@ -8,6 +8,20 @@ from PySide6.QtWebEngineCore import QWebEngineNavigationRequest
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:
"""Test URL whitelist enforcement."""
@ -16,8 +30,7 @@ class TestRestrictedWebEngineView:
view = RestrictedWebEngineView([])
# Mock navigation request
request = MagicMock(spec=QWebEngineNavigationRequest)
request.url = QUrl("https://example.com/page")
request = _create_mock_request("https://example.com/page")
# Should not reject any URL
view._on_navigation_requested(request)
@ -27,8 +40,7 @@ class TestRestrictedWebEngineView:
"""Test that None allowed_urls means no restrictions."""
view = RestrictedWebEngineView(None)
request = MagicMock(spec=QWebEngineNavigationRequest)
request.url = QUrl("https://blocked.com/page")
request = _create_mock_request("https://blocked.com/page")
view._on_navigation_requested(request)
request.reject.assert_not_called()
@ -37,8 +49,7 @@ class TestRestrictedWebEngineView:
"""Test exact domain matching."""
view = RestrictedWebEngineView(["example.com"])
request = MagicMock(spec=QWebEngineNavigationRequest)
request.url = QUrl("https://example.com/page")
request = _create_mock_request("https://example.com/page")
view._on_navigation_requested(request)
request.reject.assert_not_called()
@ -47,8 +58,7 @@ class TestRestrictedWebEngineView:
"""Test that mismatched domains are rejected."""
view = RestrictedWebEngineView(["example.com"])
request = MagicMock(spec=QWebEngineNavigationRequest)
request.url = QUrl("https://other.com/page")
request = _create_mock_request("https://other.com/page")
with patch("webdrop_bridge.ui.restricted_web_view.QDesktopServices"):
view._on_navigation_requested(request)
@ -58,8 +68,7 @@ class TestRestrictedWebEngineView:
"""Test wildcard pattern matching."""
view = RestrictedWebEngineView(["*.example.com"])
request = MagicMock(spec=QWebEngineNavigationRequest)
request.url = QUrl("https://sub.example.com/page")
request = _create_mock_request("https://sub.example.com/page")
view._on_navigation_requested(request)
request.reject.assert_not_called()
@ -68,8 +77,7 @@ class TestRestrictedWebEngineView:
"""Test that non-matching wildcard patterns are rejected."""
view = RestrictedWebEngineView(["*.example.com"])
request = MagicMock(spec=QWebEngineNavigationRequest)
request.url = QUrl("https://example.org/page")
request = _create_mock_request("https://example.org/page")
with patch("webdrop_bridge.ui.restricted_web_view.QDesktopServices"):
view._on_navigation_requested(request)
@ -79,8 +87,7 @@ class TestRestrictedWebEngineView:
"""Test that localhost is allowed."""
view = RestrictedWebEngineView(["localhost"])
request = MagicMock(spec=QWebEngineNavigationRequest)
request.url = QUrl("http://localhost:8000/page")
request = _create_mock_request("http://localhost:8000/page")
view._on_navigation_requested(request)
request.reject.assert_not_called()
@ -89,8 +96,7 @@ class TestRestrictedWebEngineView:
"""Test that file:// URLs are always allowed."""
view = RestrictedWebEngineView(["example.com"])
request = MagicMock(spec=QWebEngineNavigationRequest)
request.url = QUrl("file:///var/www/index.html")
request = _create_mock_request("file:///var/www/index.html")
view._on_navigation_requested(request)
request.reject.assert_not_called()
@ -100,20 +106,17 @@ class TestRestrictedWebEngineView:
view = RestrictedWebEngineView(["example.com", "test.org"])
# First allowed URL
request1 = MagicMock(spec=QWebEngineNavigationRequest)
request1.url = QUrl("https://example.com/page")
request1 = _create_mock_request("https://example.com/page")
view._on_navigation_requested(request1)
request1.reject.assert_not_called()
# Second allowed URL
request2 = MagicMock(spec=QWebEngineNavigationRequest)
request2.url = QUrl("https://test.org/page")
request2 = _create_mock_request("https://test.org/page")
view._on_navigation_requested(request2)
request2.reject.assert_not_called()
# Non-allowed URL
request3 = MagicMock(spec=QWebEngineNavigationRequest)
request3.url = QUrl("https://blocked.com/page")
request3 = _create_mock_request("https://blocked.com/page")
with patch("webdrop_bridge.ui.restricted_web_view.QDesktopServices"):
view._on_navigation_requested(request3)
request3.reject.assert_called_once()
@ -122,15 +125,13 @@ class TestRestrictedWebEngineView:
"""Test that rejected URLs open in system browser."""
view = RestrictedWebEngineView(["allowed.com"])
request = MagicMock(spec=QWebEngineNavigationRequest)
request.url = QUrl("https://blocked.com/page")
request = _create_mock_request("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)
request.reject.assert_called_once()
mock_open.assert_called_once_with(request.url)
# Check that openUrl was called with a QUrl
mock_open.assert_called_once()
class TestURLAllowedLogic:

View file

@ -44,42 +44,56 @@ class TestSettingsDialogInitialization:
qtbot.addWidget(dialog)
assert dialog.tabs is not None
assert dialog.tabs.count() == 5 # Paths, URLs, Logging, Window, Profiles
assert dialog.tabs.count() == 7 # General + previous 6 tabs
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):
"""Test Paths tab exists."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog.tabs.tabText(0) == "Paths"
assert dialog.tabs.tabText(2) == "Paths"
def test_dialog_has_urls_tab(self, qtbot, sample_config):
"""Test URLs tab exists."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog.tabs.tabText(1) == "URLs"
assert dialog.tabs.tabText(3) == "URLs"
def test_dialog_has_logging_tab(self, qtbot, sample_config):
"""Test Logging tab exists."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog.tabs.tabText(2) == "Logging"
assert dialog.tabs.tabText(4) == "Logging"
def test_dialog_has_window_tab(self, qtbot, sample_config):
"""Test Window tab exists."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog.tabs.tabText(3) == "Window"
assert dialog.tabs.tabText(5) == "Window"
def test_dialog_has_profiles_tab(self, qtbot, sample_config):
"""Test Profiles tab exists."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog.tabs.tabText(4) == "Profiles"
assert dialog.tabs.tabText(6) == "Profiles"
class TestPathsTab:

View file

@ -87,13 +87,6 @@ class TestUpdateAvailableDialog:
with qtbot.waitSignal(dialog.update_later):
dialog.update_later_btn.click()
def test_signals_emitted_skip(self, qapp, qtbot):
"""Test skip version signal is emitted."""
dialog = UpdateAvailableDialog("0.0.2", "Changes")
with qtbot.waitSignal(dialog.skip_version):
dialog.skip_btn.click()
class TestDownloadingDialog:
"""Tests for DownloadingDialog."""

View file

@ -16,6 +16,17 @@ def update_manager(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
def sample_release():
"""Sample release data from API."""
@ -166,9 +177,7 @@ class TestCheckForUpdates:
@pytest.mark.asyncio
@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."""
mock_fetch.return_value = {
"tag_name": "v0.0.1",
@ -184,9 +193,7 @@ class TestCheckForUpdates:
@pytest.mark.asyncio
@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."""
mock_fetch.return_value = sample_release
@ -207,9 +214,7 @@ class TestDownloading:
"""Test update downloading."""
@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."""
# Create release with .msi asset
release_data = {
@ -237,9 +242,7 @@ class TestDownloading:
@pytest.mark.asyncio
@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."""
release_data = {
"tag_name": "v0.0.2",
@ -260,6 +263,143 @@ class TestDownloading:
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:
"""Test checksum verification."""
@ -270,8 +410,8 @@ class TestChecksumVerification:
self, mock_download_checksum, update_manager, sample_release, tmp_path
):
"""Test successful checksum verification."""
# Create test file
test_file = tmp_path / "test.exe"
# File must match the asset name so the .sha256 lookup succeeds
test_file = tmp_path / "WebDropBridge.exe"
test_file.write_bytes(b"test content")
# Calculate actual checksum
@ -291,7 +431,8 @@ class TestChecksumVerification:
self, mock_download_checksum, update_manager, sample_release, tmp_path
):
"""Test checksum verification fails on mismatch."""
test_file = tmp_path / "test.exe"
# File must match the asset name so the .sha256 lookup succeeds
test_file = tmp_path / "WebDropBridge.exe"
test_file.write_bytes(b"test content")
# Return wrong checksum
@ -303,9 +444,7 @@ class TestChecksumVerification:
assert result is False
@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_file = tmp_path / "test.exe"
test_file.write_bytes(b"test content")
@ -336,9 +475,7 @@ class TestInstallation:
@patch("subprocess.Popen")
@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."""
mock_platform.return_value = "Windows"
installer = tmp_path / "WebDropBridge.msi"
@ -351,9 +488,7 @@ class TestInstallation:
@patch("subprocess.Popen")
@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."""
mock_platform.return_value = "Darwin"
installer = tmp_path / "WebDropBridge.dmg"