Compare commits

...

10 commits
v0.9.0 ... main

Author SHA1 Message Date
ac10fdcbdd feat: Update documentation for version 0.9.1, including changelog, configuration, and package manager support
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-04-16 08:38:41 +02:00
1054266d0e feat: Update version to 0.9.1, enhance release notes generation, and add 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-04-15 17:00:28 +02:00
55f2ddf4b1 feat: Add branding import/export functionality and enhance settings dialog with new fields
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-04-15 16:26:38 +02:00
b826bd9b20 feat: Add delete and preview functionality for branding in settings dialog and update translations 2026-04-15 15:15:56 +02:00
e1dbc2ee84 feat: Update branding terminology and improve settings dialog for logo management 2026-04-15 14:27:56 +02:00
e52c09857f feat: Enhance branding management with editable fields and save functionality in settings dialog 2026-04-15 13:58:36 +02:00
fe341163e8 feat: Add branding change prompts and settings translations for multiple languages 2026-04-15 12:15:35 +02:00
2ecd299f31 feat: Add branding change prompts and corresponding translations for restart notifications 2026-04-15 11:49:09 +02:00
ca7105a6bc feat: Implement runtime branding management and add branding settings to UI 2026-04-15 11:01:49 +02:00
f022d984b6 feat: Update terminology from "Profiles" to "Setups" across translations and UI for clarity
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-04-15 09:52:40 +02:00
31 changed files with 1801 additions and 386 deletions

View file

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

15
CHANGELOG.md Normal file
View file

@ -0,0 +1,15 @@
# Changelog
All notable changes to this project will be documented in this file.
## [0.9.1] - 2026-04-15
### Changed
- Improved release publishing so release descriptions can be generated from the changelog.
- Updated the release workflow to use a clearer, user-facing summary for update information.
- Added "in App" Branding Management instead of using separated Brand Builds.
### Fixed
- Removed the generic placeholder release description from published releases.
- Added a reliable fallback message when no detailed notes are available.
- Instead of using "Profiles" and "Configurations" use "Setups" for more clarity.

View file

@ -36,7 +36,10 @@ Create a `config.json` file with the following structure:
"log_file": "logs/webdrop_bridge.log", "log_file": "logs/webdrop_bridge.log",
"window_width": 1024, "window_width": 1024,
"window_height": 768, "window_height": 768,
"enable_logging": true "enable_logging": true,
"language": "auto",
"active_branding_id": "default",
"brand_id": "agravity"
} }
``` ```
@ -99,6 +102,19 @@ Z:\aN5PysnXIuRECzcRbvHkjL7g0\Hintergrund_Agravity.png
- **`window_width`**, **`window_height`** (number): Initial window size in pixels - **`window_width`**, **`window_height`** (number): Initial window size in pixels
- Default: `1024` x `768` - Default: `1024` x `768`
### Language and Branding Settings
- **`language`** (string): UI language code
- Use `"auto"` to follow the system locale automatically
- Bundled translations currently include `en`, `de`, `fr`, `it`, `ru`, and `zh`
- **`active_branding_id`** (string): Runtime branding template selected in the Settings dialog
- Default: `"default"`
- Useful when switching between saved branding templates without rebuilding the app
- **`brand_id`** (string): Stable packaging/update identifier for branded variants
- Usually injected during packaging and normally left unchanged by end users
- **`log_level`** (string): Logging verbosity - **`log_level`** (string): Logging verbosity
- Options: `"DEBUG"`, `"INFO"`, `"WARNING"`, `"ERROR"`, `"CRITICAL"` - Options: `"DEBUG"`, `"INFO"`, `"WARNING"`, `"ERROR"`, `"CRITICAL"`
- Default: `"INFO"` - Default: `"INFO"`
@ -149,6 +165,14 @@ You can configure multiple Azure storage accounts:
} }
``` ```
## Configuration Priority
At startup, WebDrop Bridge first loads any bootstrap `.env` defaults and then prefers the persisted JSON config if it exists. This means:
1. packaged or development defaults can still come from `.env`,
2. the Settings dialog saves the active runtime configuration to JSON, and
3. the JSON file becomes the main configuration source after first save.
## Environment Variable Fallback ## Environment Variable Fallback
If no JSON config exists, WebDrop Bridge will load from `.env`: If no JSON config exists, WebDrop Bridge will load from `.env`:

View file

@ -9,7 +9,7 @@ Please be respectful and constructive in all interactions. We're building a welc
## Getting Started ## Getting Started
### Prerequisites ### Prerequisites
- Python 3.10+ - Python 3.9+
- Git - Git
- Familiarity with Qt/PySide6 or willingness to learn - Familiarity with Qt/PySide6 or willingness to learn

View file

@ -1051,7 +1051,7 @@ Help Menu
**Core:** **Core:**
- PySide6 6.6.0+ - PySide6 6.6.0+
- Python 3.10+ - Python 3.9+
**Optional:** **Optional:**
- PyInstaller (building) - PyInstaller (building)

View file

@ -143,19 +143,19 @@ brew upgrade webdrop-bridge
brew uninstall webdrop-bridge brew uninstall webdrop-bridge
``` ```
For more package manager details and internal hosting options, see [docs/PACKAGE_MANAGER_SUPPORT.md](../docs/PACKAGE_MANAGER_SUPPORT.md) 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) #### Simplest: Direct wget (if you know the version)
```bash ```bash
# Replace VERSION with release tag (e.g., v0.8.0) # Replace X.Y.Z with a release version (e.g., 0.9.1)
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/VERSION/WebDropBridge_Setup.msi wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/vX.Y.Z/WebDropBridge-X.Y.Z-win-x64.msi
# Real example - download v0.8.0 MSI # Real example - download v0.9.1 MSI
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.9.1/WebDropBridge-0.9.1-win-x64.msi
# macOS - download v0.8.0 DMG # macOS - download v0.9.1 DMG
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0/WebDropBridge_Setup.dmg wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.9.1/WebDropBridge-0.9.1-macos-universal.dmg
``` ```
#### Windows (PowerShell) - Full Control Script #### Windows (PowerShell) - Full Control Script
@ -168,7 +168,7 @@ wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0
.\build\scripts\download_release.ps1 -OutputDir "C:\Installers" .\build\scripts\download_release.ps1 -OutputDir "C:\Installers"
# Download specific version # Download specific version
.\build\scripts\download_release.ps1 -Version "0.8.0" .\build\scripts\download_release.ps1 -Version "0.9.1"
# Skip checksum verification # Skip checksum verification
.\build\scripts\download_release.ps1 -Verify $false .\build\scripts\download_release.ps1 -Verify $false
@ -186,7 +186,7 @@ wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0
./build/scripts/download_release.sh latest ~/Downloads ./build/scripts/download_release.sh latest ~/Downloads
# Download specific version # Download specific version
./build/scripts/download_release.sh 0.8.0 ./build/scripts/download_release.sh 0.9.1
# Skip checksum verification # Skip checksum verification
./build/scripts/download_release.sh latest --no-verify ./build/scripts/download_release.sh latest --no-verify

View file

@ -2,7 +2,7 @@
> Professional Qt-based desktop application for intelligent drag-and-drop file handling between web applications and desktop clients (InDesign, Word, Notepad++, etc.) > Professional Qt-based desktop application for intelligent drag-and-drop file handling between web applications and desktop clients (InDesign, Word, Notepad++, etc.)
![Status](https://img.shields.io/badge/Status-Phase%204%20Complete-green) ![License](https://img.shields.io/badge/License-MIT-blue) ![Python](https://img.shields.io/badge/Python-3.10%2B-blue) ![Status](https://img.shields.io/badge/Status-Phase%205%20RC%20In%20Progress-green) ![License](https://img.shields.io/badge/License-MIT-blue) ![Python](https://img.shields.io/badge/Python-3.9%2B-blue)
## Overview ## Overview
@ -19,23 +19,25 @@ WebDrop Bridge embeds a web application in a Qt container with full filesystem a
## Features ## Features
- ✅ **Qt-based Architecture** - Professional Windows support via PySide6 (macOS support planned) - ✅ **Qt-based Architecture** - Professional cross-platform desktop app via PySide6 for Windows and macOS
- ✅ **Embedded Web App** - QtWebEngine provides Chromium without browser limitations - ✅ **Embedded Web App** - QtWebEngine provides Chromium without browser limitations
- ✅ **Drag Interception** - Converts text paths to native file operations - ✅ **Drag Interception** - Converts text paths to native file operations
- ✅ **Path Whitelist** - Security-conscious file system access control - ✅ **Path Whitelist** - Security-conscious file system access control
- ✅ **Configuration Management** - Profile-based settings with validation - ✅ **Configuration Management** - JSON config, profile import/export, and validation
- ✅ **Settings Dialog** - Professional UI for path, URL, logging, and window configuration - ✅ **Runtime Branding** - Switch branding templates and packaged variants without code changes
- ✅ **Multilingual UI** - Built-in translations for English, German, French, Italian, Russian, and Chinese
- ✅ **Settings Dialog** - Language, branding, web source, path, URL, logging, and window configuration
- ✅ **Auto-Update System** - Automatic release detection via Forgejo API - ✅ **Auto-Update System** - Automatic release detection via Forgejo API
- ✅ **Professional Build Pipeline** - MSI for Windows, DMG for macOS - ✅ **Professional Build Pipeline** - MSI for Windows, DMG for macOS
- ✅ **Comprehensive Testing** - Unit, integration, and end-to-end tests (80%+ coverage) - ✅ **Comprehensive Testing** - Unit and integration coverage across core modules
- ✅ **Continuous Testing** - GitHub Actions test automation - ✅ **Continuous Testing** - Automated CI validation
- ✅ **Structured Logging** - File-based logging with configurable levels - ✅ **Structured Logging** - File-based logging with configurable levels
## Quick Start ## Quick Start
### Requirements ### Requirements
- Python 3.10+ - Python 3.9+
- Windows 10/11 - Windows 10/11 or macOS 12+
- 200 MB disk space (includes Chromium from PyInstaller) - 200 MB disk space (includes Chromium from PyInstaller)
### Installation from Pre-Built Release (Recommended) ### Installation from Pre-Built Release (Recommended)
@ -58,11 +60,11 @@ brew upgrade webdrop-bridge # Update to latest version
**Option 2: Direct wget (if you know the version)** **Option 2: Direct wget (if you know the version)**
```bash ```bash
# Replace VERSION with release tag (e.g., v0.8.0) # Replace X.Y.Z with a release version (e.g., 0.9.1)
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/VERSION/WebDropBridge_Setup.msi wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/vX.Y.Z/WebDropBridge-X.Y.Z-win-x64.msi
# Example for v0.8.0: # Example for v0.9.1:
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.9.1/WebDropBridge-0.9.1-win-x64.msi
``` ```
**Option 3: Automated script (auto-detects platform)** **Option 3: Automated script (auto-detects platform)**
@ -93,6 +95,7 @@ python -m venv venv
# Install dependencies # Install dependencies
pip install -r requirements.txt pip install -r requirements.txt
pip install -e .
# Run application # Run application
python -m webdrop_bridge.main python -m webdrop_bridge.main
@ -103,6 +106,7 @@ python -m webdrop_bridge.main
```bash ```bash
# Install development dependencies # Install development dependencies
pip install -r requirements-dev.txt pip install -r requirements-dev.txt
pip install -e .
# Run tests # Run tests
pytest tests -v pytest tests -v
@ -178,42 +182,36 @@ webdrop-bridge/
## Configuration ## Configuration
WebDrop Bridge supports two configuration methods: WebDrop Bridge supports persisted JSON configuration plus optional bootstrap environment defaults.
### 1. Settings Dialog (Recommended) ### 1. Settings Dialog / JSON Config (Recommended)
Launch the application and access the Settings menu to configure: Launch the application and access the Settings menu to configure:
- **Paths Tab** - Add/remove allowed root directories - **General Tab** - Select the UI language or follow the system locale automatically
- **URLs Tab** - Configure allowed web URLs (whitelist mode) - **Branding Tab** - Switch, import, export, and preview runtime branding templates
- **Logging Tab** - Set log level and file location - **Web Source Tab** - Configure the embedded web application URL
- **Window Tab** - Configure window dimensions - **Paths / URLs / Logging / Window Tabs** - Control filesystem access, allowed sites, log output, and initial window size
- **Profiles Tab** - Save/load/export-import configuration profiles - **Profiles Tab** - Save, load, import, and export complete configuration profiles
Profiles are saved in `~/.webdrop_bridge/profiles/` Saved settings are written to the brand-specific application config directory as `config.json`.
### 2. Environment Variables ### 2. Bootstrap Environment Variables (`.env`)
Create a `.env` file in the project root. Available settings: A `.env` file is still supported for local development and branded packaged defaults. It is used when no JSON config exists yet.
```bash ```bash
# Application
APP_NAME=WebDrop Bridge APP_NAME=WebDrop Bridge
APP_VERSION=1.0.0 BRAND_ID=webdrop_bridge
WEBAPP_URL=https://dev.agravity.io/
# Paths (comma-separated)
ALLOWED_ROOTS=Z:/,C:/Users/Public ALLOWED_ROOTS=Z:/,C:/Users/Public
# Web URLs (empty = no restriction, items = kiosk mode)
ALLOWED_URLS= ALLOWED_URLS=
LANGUAGE=auto
# Interface LOG_LEVEL=INFO
WEBAPP_URL=file:///./webapp/index.html
WINDOW_WIDTH=1024 WINDOW_WIDTH=1024
WINDOW_HEIGHT=768 WINDOW_HEIGHT=768
# Logging
LOG_LEVEL=INFO
ENABLE_LOGGING=true ENABLE_LOGGING=true
``` ```
For the full JSON structure and branding workflow, see [CONFIG_README.md](CONFIG_README.md) and [BRANDING_AND_RELEASES.md](docs/BRANDING_AND_RELEASES.md).
## Testing ## Testing
WebDrop Bridge includes comprehensive test coverage with unit, integration, and end-to-end tests. WebDrop Bridge includes comprehensive test coverage with unit, integration, and end-to-end tests.
@ -272,11 +270,20 @@ python build/scripts/build_windows.py --msi --code-sign
``` ```
Output: Output:
- Portable executable: `build/dist/windows/WebDropBridge/WebDropBridge.exe` (~195 MB) - Portable executable: `build/dist/windows/webdrop_bridge/WebDropBridge/WebDropBridge.exe`
- Professional MSI installer: `build/dist/windows/WebDropBridge-{version}-Setup.msi` - Professional MSI installer: `build/dist/windows/webdrop_bridge/WebDropBridge-<version>-win-x64.msi`
- SHA256 checksum: `build/dist/windows/WebDropBridge/WebDropBridge.exe.sha256` - SHA256 checksum: `build/dist/windows/webdrop_bridge/WebDropBridge-<version>-win-x64.msi.sha256`
**Note on macOS**: Build scripts exist for macOS (DMG generation), but have never been built or tested. macOS support is theoretical at this point. The Qt/PySide6 architecture should support macOS, but platform-specific testing and validation would be required. ### macOS DMG Installer
```bash
bash build/scripts/build_macos.sh
```
Output:
- Application bundle: `build/dist/macos/webdrop_bridge/WebDropBridge.app`
- DMG installer: `build/dist/macos/webdrop_bridge/WebDropBridge-<version>-macos-universal.dmg`
- SHA256 checksum: `build/dist/macos/webdrop_bridge/WebDropBridge-<version>-macos-universal.dmg.sha256`
### Creating Releases ### Creating Releases
@ -314,8 +321,8 @@ powershell -ExecutionPolicy Bypass -File build/scripts/create_release.ps1
## Troubleshooting ## Troubleshooting
### Application won't start ### Application won't start
- Ensure Python 3.10+ is installed - Ensure Python 3.9+ is installed
- Check `logs/webdrop_bridge.log` for errors - Check the application log in your platform-specific app data directory
- Verify all dependencies: `pip list` - Verify all dependencies: `pip list`
### Drag-and-drop not working ### Drag-and-drop not working
@ -332,10 +339,10 @@ powershell -ExecutionPolicy Bypass -File build/scripts/create_release.ps1
| Platform | Version | Status | Notes | | Platform | Version | Status | Notes |
|----------|---------|--------|-------| |----------|---------|--------|-------|
| Windows | 10, 11 | ✅ Full | Tested on x64, MSI installer support | | Windows | 10, 11 | ✅ Full | Primary target with MSI packaging and update support |
| macOS | 12+ | ⚠️ **Untested** | Possible via Qt/PySide6, but never built or tested. Theoretical support only. | | macOS | 12, 13, 14 | ✅ Supported | Universal DMG builds for Intel and Apple Silicon |
**Note**: WebDrop Bridge is currently developed and tested exclusively on Windows. While the Qt/PySide6 framework supports macOS, we cannot guarantee functionality without actual macOS testing and validation. Contributions for macOS support validation are welcome. **Note**: Release candidates currently target both Windows and macOS. For branded production releases, validate signing assets and installer behavior on the target platform before shipping.
## Contributing ## Contributing
@ -367,4 +374,4 @@ MIT License - see [LICENSE](LICENSE) file for details
--- ---
**Development Phase**: Phase 4 Complete | **Last Updated**: February 18, 2026 | **Current Version**: 1.0.0 | **Python**: 3.10+ | **Qt**: PySide6 (Qt 6) **Development Phase**: Phase 5 Release Candidates | **Last Updated**: April 16, 2026 | **Current Version**: 0.9.1 | **Python**: 3.9+ | **Qt**: PySide6 (Qt 6)

View file

@ -27,7 +27,7 @@ build/
2. **Get SHA256 checksum**: 2. **Get SHA256 checksum**:
```powershell ```powershell
certutil -hashfile build/dist/windows/WebDropBridge_Setup.msi SHA256 certutil -hashfile build/dist/windows/webdrop_bridge/WebDropBridge-<version>-win-x64.msi SHA256
``` ```
3. **Update package files**: 3. **Update package files**:
@ -42,7 +42,7 @@ build/
5. **Publish** (requires Chocolatey API key): 5. **Publish** (requires Chocolatey API key):
```powershell ```powershell
choco push webdrop-bridge.0.8.0.nupkg --api-key YOUR_KEY choco push webdrop-bridge.<version>.nupkg --api-key YOUR_KEY
``` ```
### Homebrew Formula (macOS) ### Homebrew Formula (macOS)
@ -54,7 +54,7 @@ build/
2. **Get SHA256 checksum**: 2. **Get SHA256 checksum**:
```bash ```bash
shasum -a 256 build/dist/macos/WebDropBridge_Setup.dmg shasum -a 256 build/dist/macos/webdrop_bridge/WebDropBridge-<version>-macos-universal.dmg
``` ```
3. **Update formula**: 3. **Update formula**:

View file

@ -75,8 +75,8 @@ The `download_release.ps1` (Windows) and `download_release.sh` (macOS/Linux) scr
```bash ```bash
# Download directly by version tag # 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.9.1/WebDropBridge-0.9.1-win-x64.msi
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0/WebDropBridge_Setup.dmg wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.9.1/WebDropBridge-0.9.1-macos-universal.dmg
``` ```
**If you need to auto-detect latest (with grep/cut, no jq needed)** **If you need to auto-detect latest (with grep/cut, no jq needed)**
@ -111,7 +111,7 @@ sha256sum -c installer.sha256
.\download_release.ps1 .\download_release.ps1
# Specific version to Downloads folder # Specific version to Downloads folder
.\download_release.ps1 -Version "0.8.0" -OutputDir "$env:USERPROFILE\Downloads" .\download_release.ps1 -Version "0.9.1" -OutputDir "$env:USERPROFILE\Downloads"
# Skip checksum verification # Skip checksum verification
.\download_release.ps1 -Verify $false .\download_release.ps1 -Verify $false
@ -124,7 +124,7 @@ sha256sum -c installer.sha256
./build/scripts/download_release.sh ./build/scripts/download_release.sh
# Specific version to Downloads # Specific version to Downloads
./build/scripts/download_release.sh 0.8.0 ~/Downloads ./build/scripts/download_release.sh 0.9.1 ~/Downloads
# Skip checksum verification # Skip checksum verification
./build/scripts/download_release.sh latest --no-verify ./build/scripts/download_release.sh latest --no-verify
@ -165,7 +165,7 @@ Automated release creation with versioning and asset uploads.
Manages consistent versioning across the project. Manages consistent versioning across the project.
```bash ```bash
python build/scripts/sync_version.py --version 0.8.0 python build/scripts/sync_version.py --version 0.9.1
``` ```
## Integration Flow ## Integration Flow

View file

@ -34,6 +34,18 @@ 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() 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-ReleaseNotes {
param([string]$Version)
$notes = & $pythonExe -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$projectRoot/build/scripts').resolve())); from version_utils import get_release_notes; print(get_release_notes('$Version'))"
if ($LASTEXITCODE -ne 0) {
return "## WebDrop Bridge v$Version`n`nThis release package contains the latest improvements, fixes, and installer updates for this version."
}
return ($notes | Out-String).Trim()
}
function Get-LocalReleaseData { function Get-LocalReleaseData {
$arguments = @($brandHelper, "local-release-data", "--platform", "windows", "--version", $Version) $arguments = @($brandHelper, "local-release-data", "--platform", "windows", "--version", $Version)
if ($Brands) { if ($Brands) {
@ -127,10 +139,11 @@ $headers = @{
$releaseLookupUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/tags/v$Version" $releaseLookupUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/tags/v$Version"
$releaseUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases" $releaseUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases"
$releaseBody = Get-ReleaseNotes -Version $Version
$releaseData = @{ $releaseData = @{
tag_name = "v$Version" tag_name = "v$Version"
name = "WebDropBridge v$Version" name = "WebDropBridge v$Version"
body = "Shared branded release for WebDrop Bridge v$Version" body = $releaseBody
draft = $false draft = $false
prerelease = $false prerelease = $false
} | ConvertTo-Json } | ConvertTo-Json

View file

@ -40,6 +40,10 @@ if [ -z "$VERSION" ]; then
VERSION="$(python3 -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$PROJECT_ROOT/build/scripts').resolve())); from version_utils import get_current_version; print(get_current_version())")" 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 fi
get_release_notes() {
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_release_notes; print(get_release_notes('$VERSION'))"
}
LOCAL_ARGS=("$BRAND_HELPER" "local-release-data" "--platform" "macos" "--version" "$VERSION") LOCAL_ARGS=("$BRAND_HELPER" "local-release-data" "--platform" "macos" "--version" "$VERSION")
if [ ${#BRANDS[@]} -gt 0 ]; then if [ ${#BRANDS[@]} -gt 0 ]; then
LOCAL_ARGS+=("--brands" "${BRANDS[@]}") LOCAL_ARGS+=("--brands" "${BRANDS[@]}")
@ -186,15 +190,19 @@ else
fi fi
if [ -z "$RELEASE_ID" ]; then if [ -z "$RELEASE_ID" ]; then
RELEASE_DATA=$(cat <<EOF RELEASE_BODY="$(get_release_notes)"
{ RELEASE_DATA=$(RELEASE_BODY="$RELEASE_BODY" VERSION="$VERSION" python3 - <<'PY'
"tag_name": "v$VERSION", import json
"name": "WebDropBridge v$VERSION", import os
"body": "Shared branded release for WebDrop Bridge v$VERSION",
"draft": false, print(json.dumps({
"prerelease": false "tag_name": f"v{os.environ['VERSION']}",
} "name": f"WebDropBridge v{os.environ['VERSION']}",
EOF "body": os.environ["RELEASE_BODY"],
"draft": False,
"prerelease": False,
}))
PY
) )
HTTP_CODE=$(curl -s -o "$RELEASE_RESPONSE_FILE" -w "%{http_code}" -X POST \ HTTP_CODE=$(curl -s -o "$RELEASE_RESPONSE_FILE" -w "%{http_code}" -X POST \
-H "Authorization: Basic $BASIC_AUTH" \ -H "Authorization: Basic $BASIC_AUTH" \

View file

@ -33,17 +33,68 @@ def get_current_version() -> str:
init_file = project_root / "src" / "webdrop_bridge" / "__init__.py" init_file = project_root / "src" / "webdrop_bridge" / "__init__.py"
if not init_file.exists(): if not init_file.exists():
raise FileNotFoundError( raise FileNotFoundError(f"Cannot find __init__.py at {init_file}")
f"Cannot find __init__.py at {init_file}"
)
content = init_file.read_text(encoding="utf-8") content = init_file.read_text(encoding="utf-8")
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content) match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
if not match: if not match:
raise ValueError( raise ValueError(
f"Could not find __version__ in {init_file}. " f"Could not find __version__ in {init_file}. " 'Expected: __version__ = "X.Y.Z"'
"Expected: __version__ = \"X.Y.Z\""
) )
return match.group(1) return match.group(1)
def extract_release_notes(changelog_content: str, version: str) -> str | None:
"""Extract the notes for a specific version from changelog content.
Args:
changelog_content: Full text of CHANGELOG.md
version: Version to extract, e.g. "0.9.1"
Returns:
The section content for that version, or None if not found.
"""
version_header = re.compile(
rf"^##\s*\[?{re.escape(version)}\]?(?:\s*-\s*.+)?\s*$",
re.MULTILINE,
)
match = version_header.search(changelog_content)
if not match:
return None
section_start = match.end()
next_header = re.search(r"^##\s+", changelog_content[section_start:], re.MULTILINE)
section_end = section_start + next_header.start() if next_header else len(changelog_content)
section = changelog_content[section_start:section_end].strip()
return section or None
def get_release_notes(version: str, project_root: Path | None = None) -> str:
"""Build a readable release body for publishing.
Prefers the matching version section from CHANGELOG.md. If no changelog
entry exists yet, falls back to a generic but user-facing description.
Args:
version: Release version string.
project_root: Optional project root override for testing.
Returns:
Release notes text suitable for Forgejo/GitHub release bodies.
"""
root = project_root or get_project_root()
changelog_file = root / "CHANGELOG.md"
if changelog_file.exists():
content = changelog_file.read_text(encoding="utf-8")
notes = extract_release_notes(content, version)
if notes:
return f"## WebDrop Bridge v{version}\n\n{notes}"
return (
f"## WebDrop Bridge v{version}\n\n"
"This release package contains the latest improvements, fixes, "
"and installer updates for this version."
)

View file

@ -1,33 +1,60 @@
# Configuration Management for Builds # Configuration Management for Builds
This document explains how configuration is handled when building executables and installers for WebDrop Bridge. This document explains how configuration and branding work for development builds, packaged installers, and installed applications.
## Overview ## Current Configuration Model
WebDrop Bridge uses `.env` files for runtime configuration. When building distributable packages (exe, MSI, or DMG), the `.env` file is **bundled into the application** so that users receive pre-configured settings. WebDrop Bridge now uses a **JSON-first runtime configuration** with optional `.env` bootstrap defaults:
## Configuration File 1. **Bootstrap `.env`** (optional)
- Loaded very early during startup
- Useful for packaged defaults such as `APP_NAME`, `BRAND_ID`, update channel, and default web source
- Commonly used by branded Windows/MSI and macOS/DMG builds
The configuration file must be named `.env` and contains settings like: 2. **Persisted JSON config** (preferred)
- Windows: `%APPDATA%\<config_dir_name>\config.json`
- macOS/Linux: `~/.config/<config_dir_name>/config.json`
- Created and maintained by the Settings dialog
- Takes precedence for day-to-day user settings after first launch
In practice, installers can ship with a curated `.env`, while user changes are saved into `config.json`.
## What Belongs Where?
### Use JSON config for:
- `url_mappings`
- `allowed_roots`
- `allowed_urls`
- window size and logging settings
- `language`
- `active_branding_id`
### Use `.env` bootstrap defaults for:
- `APP_NAME`
- `BRAND_ID`
- `APP_CONFIG_DIR_NAME`
- update channel and repository defaults
- packaged first-launch defaults for customer-specific builds
## Example Bootstrap `.env`
```dotenv ```dotenv
APP_NAME=WebDrop Bridge APP_NAME=WebDrop Bridge
APP_VERSION=0.7.1 BRAND_ID=webdrop_bridge
APP_CONFIG_DIR_NAME=webdrop_bridge
WEBAPP_URL=https://example.com WEBAPP_URL=https://example.com
ALLOWED_ROOTS=Z:/,C:/Users/Public ALLOWED_ROOTS=Z:/,C:/Users/Public
ALLOWED_URLS= ALLOWED_URLS=
LANGUAGE=auto
LOG_LEVEL=INFO LOG_LEVEL=INFO
LOG_FILE=logs/webdrop_bridge.log
ENABLE_LOGGING=true ENABLE_LOGGING=true
WINDOW_WIDTH=1024 WINDOW_WIDTH=1024
WINDOW_HEIGHT=768 WINDOW_HEIGHT=768
``` ```
See `.env.example` for a template with all available options.
## Building with Default Configuration ## Building with Default Configuration
If you want to use the project's `.env` file (in the project root), simply run: If you want to use the project's default `.env` file from the repository root:
### Windows ### Windows
```bash ```bash
@ -39,11 +66,11 @@ python build/scripts/build_windows.py --msi
bash build/scripts/build_macos.sh bash build/scripts/build_macos.sh
``` ```
**Important:** The build will **fail** if `.env` doesn't exist. This prevents accidentally shipping without configuration. > The build scripts currently expect a `.env` file to exist. This is intentional so packaged builds always have explicit bootstrap defaults.
## Building with Custom Configuration ## Building with Custom Customer Defaults
For different customers or deployments, you can specify a custom `.env` file: For customer-specific or branded releases, provide a different `.env` file during packaging:
### Windows ### Windows
```bash ```bash
@ -55,86 +82,73 @@ python build/scripts/build_windows.py --msi --env-file path/to/customer1.env
bash build/scripts/build_macos.sh --env-file path/to/customer1.env bash build/scripts/build_macos.sh --env-file path/to/customer1.env
``` ```
The custom `.env` file will be bundled into the executable and users will receive those pre-configured settings. This bundles those bootstrap defaults into the packaged app while still allowing the installed application to persist later changes in JSON.
## Example: Multi-Customer Setup ## Example: Multi-Customer Setup
If you have different customer configurations: ```text
```
webdrop_bridge/ webdrop_bridge/
├── .env # Default project configuration ├── .env
├── .env.example # Template
├── build/ ├── build/
│ └── scripts/ │ └── scripts/
│ ├── build_windows.py │ ├── build_windows.py
│ └── build_macos.sh │ └── build_macos.sh
├── customer_configs/ # Create this for customer-specific settings ├── customer_configs/
│ ├── acme_corp.env │ ├── acme_corp.env
│ ├── globex_corporation.env │ ├── globex_corporation.env
│ └── initech.env │ └── initech.env
└── ... └── config.example.json
``` ```
Then build for each customer: Then build per customer or brand:
```bash ```bash
# ACME Corp
python build/scripts/build_windows.py --msi --env-file customer_configs/acme_corp.env python build/scripts/build_windows.py --msi --env-file customer_configs/acme_corp.env
# Globex Corporation
python build/scripts/build_windows.py --msi --env-file customer_configs/globex_corporation.env python build/scripts/build_windows.py --msi --env-file customer_configs/globex_corporation.env
bash build/scripts/build_macos.sh --env-file customer_configs/initech.env
# Initech
python build/scripts/build_windows.py --msi --env-file customer_configs/initech.env
``` ```
Each MSI will include that customer's specific configuration (URLs, allowed paths, etc.). ## What Gets Bundled into Installers?
## What Gets Bundled During packaging, the supplied `.env` file is bundled so the application can resolve:
- app display name
- brand/config directory name
- update channel defaults
- initial web source and logging defaults
When building, the `.env` file is: After installation, the application normally saves user-controlled settings to the JSON config file in the app data directory.
1. ✅ Copied into the PyInstaller bundle
2. ✅ Extracted to the application's working directory when the app starts
3. ✅ Automatically loaded by `Config.from_env()` at startup
Users **do not** need to create their own `.env` files. ## Recommended Runtime Workflow
## After Installation 1. Package the app with the correct `.env` bootstrap defaults.
2. Launch the app once.
When users run the installed application: 3. Configure URLs, mappings, language, and branding in the Settings dialog.
1. The embedded `.env` is automatically available 4. Let the app save `config.json` in the brand-specific config directory.
2. Settings are loaded and applied 5. Reuse exported profiles or branding templates for future setups.
3. Users can optionally create a custom `.env` in the installation directory to override settings
This allows:
- **Pre-configured deployments** for your customers
- **Easy customization** by users (just edit the `.env` file)
- **No manual setup** required after installation
## Build Command Reference ## Build Command Reference
### Windows ### Windows
```bash ```bash
# Default (.env from project root) # Default build using the repository root .env
python build/scripts/build_windows.py --msi python build/scripts/build_windows.py --msi
# Custom .env file # Customer-specific defaults
python build/scripts/build_windows.py --msi --env-file customer_configs/acme.env python build/scripts/build_windows.py --msi --env-file customer_configs/acme.env
# Without MSI (just EXE) # Without MSI (just the packaged executable)
python build/scripts/build_windows.py python build/scripts/build_windows.py
# Sign executable (requires CODE_SIGN_CERT env var) # Sign executable (requires signing setup)
python build/scripts/build_windows.py --msi --code-sign python build/scripts/build_windows.py --msi --code-sign
``` ```
### macOS ### macOS
```bash ```bash
# Default (.env from project root) # Default build using the repository root .env
bash build/scripts/build_macos.sh bash build/scripts/build_macos.sh
# Custom .env file # Customer-specific defaults
bash build/scripts/build_macos.sh --env-file customer_configs/acme.env bash build/scripts/build_macos.sh --env-file customer_configs/acme.env
# Sign app (requires Apple developer certificate) # Sign app (requires Apple developer certificate)
@ -144,19 +158,11 @@ bash build/scripts/build_macos.sh --sign
bash build/scripts/build_macos.sh --notarize bash build/scripts/build_macos.sh --notarize
``` ```
## Configuration Validation ## Validation Notes
The build process validates that: The build process validates that:
1. ✅ The specified `.env` file exists 1. the specified `.env` file exists,
2. ✅ All required environment variables are present 2. packaging metadata can be resolved, and
3. ✅ Values are valid (LOG_LEVEL is valid, paths exist for ALLOWED_ROOTS, etc.) 3. the resulting installer assets are created successfully.
If validation fails, the build stops with a clear error message. If you need the full runtime JSON schema, see `CONFIG_README.md`.
## Version Management
The `APP_VERSION` is read from two places (in order):
1. `.env` file (if specified)
2. `src/webdrop_bridge/__init__.py` (as fallback)
This allows you to override the version per customer if needed.

View file

@ -20,7 +20,7 @@ webdrop_bridge/
python build/scripts/build_windows.py --msi python build/scripts/build_windows.py --msi
``` ```
**Result:** `WebDropBridge-x.x.x-Setup.msi` with your `.env` configuration bundled. **Result:** `WebDropBridge-<version>-win-x64.msi` with your packaged bootstrap defaults bundled.
--- ---
@ -47,7 +47,7 @@ webdrop_bridge/
**Customer Config Example:** `deploy/customer_configs/acme_corp.env` **Customer Config Example:** `deploy/customer_configs/acme_corp.env`
```dotenv ```dotenv
APP_NAME=WebDrop Bridge - ACME Corp Edition APP_NAME=WebDrop Bridge - ACME Corp Edition
APP_VERSION=1.0.0 BRAND_ID=acme_corp
WEBAPP_URL=https://acme-drop.example.com/drop WEBAPP_URL=https://acme-drop.example.com/drop
ALLOWED_ROOTS=Z:/acme_files/,C:/Users/Public/ACME ALLOWED_ROOTS=Z:/acme_files/,C:/Users/Public/ACME
LOG_LEVEL=INFO LOG_LEVEL=INFO
@ -72,9 +72,9 @@ python build/scripts/build_windows.py --msi --env-file deploy/customer_configs/i
python build/scripts/build_windows.py --msi --env-file deploy/customer_configs/wayne_enterprises.env python build/scripts/build_windows.py --msi --env-file deploy/customer_configs/wayne_enterprises.env
``` ```
**Result:** Four separate MSI files: **Result:** Four separate MSI files, for example:
- `WebDropBridge-1.0.0-Setup.msi` (ACME - says "ACME Corp Edition") - `WebDropBridge-<version>-win-x64.msi` (default brand)
- `WebDropBridge-1.0.0-Setup.msi` (Globex - say "Globex Edition") - `AcmeBridge-<version>-win-x64.msi` (if the customer build uses its own asset prefix)
- etc. - etc.
--- ---

View file

@ -20,16 +20,16 @@ WebDropBridge supports installation via package managers, making it easier for u
# 1. Build the Chocolatey package # 1. Build the Chocolatey package
cd build/chocolatey cd build/chocolatey
python ../../build/scripts/build_windows.py --msi python ../../build/scripts/build_windows.py --msi
certutil -hashfile "../../build/dist/windows/WebDropBridge_Setup.msi" SHA256 certutil -hashfile "../../build/dist/windows/webdrop_bridge/WebDropBridge-<version>-win-x64.msi" SHA256
# Update checksum in tools/chocolateyInstall.ps1 # Update checksum in tools/chocolateyInstall.ps1
choco pack webdrop-bridge.nuspec choco pack webdrop-bridge.nuspec
# 2. Share webdrop-bridge.0.8.0.nupkg # 2. Share webdrop-bridge.<version>.nupkg
# File share: \\server\packages\ # File share: \\server\packages\
# USB drive, email, Forgejo releases, etc. # USB drive, email, Forgejo releases, etc.
# 3. Users install it # 3. Users install it
# choco install webdrop-bridge.0.8.0.nupkg -s "\\server\packages" # choco install webdrop-bridge.<version>.nupkg -s "\\server\packages"
``` ```
**Advantages:** **Advantages:**
@ -56,21 +56,21 @@ choco pack webdrop-bridge.nuspec
python build/scripts/build_windows.py --msi python build/scripts/build_windows.py --msi
# 2. Calculate SHA256 checksum of the MSI # 2. Calculate SHA256 checksum of the MSI
certutil -hashfile "build/dist/windows/WebDropBridge_Setup.msi" SHA256 certutil -hashfile "build/dist/windows/webdrop_bridge/WebDropBridge-<version>-win-x64.msi" SHA256
# 3. Update the checksum in build/chocolatey/tools/chocolateyInstall.ps1 # 3. Update the checksum in build/chocolatey/tools/chocolateyInstall.ps1
# Replace: $Checksum = '' # Replace: $Checksum = ''
# With: $Checksum = 'YOUR_SHA256_HASH' # With: $Checksum = 'YOUR_SHA256_HASH'
# 4. Update version in chocolatey/webdrop-bridge.nuspec # 4. Update version in chocolatey/webdrop-bridge.nuspec
# <version>0.8.0</version> # <version>X.Y.Z</version>
# 5. Create the package # 5. Create the package
cd build/chocolatey cd build/chocolatey
choco pack webdrop-bridge.nuspec choco pack webdrop-bridge.nuspec
``` ```
This creates `webdrop-bridge.0.8.0.nupkg` This creates `webdrop-bridge.<version>.nupkg`
### Publishing to Chocolatey ### Publishing to Chocolatey
@ -83,7 +83,7 @@ Host on your own NuGet server (Azure Artifacts, Artifactory, ProGet, etc.):
choco source add -n=internal-repo -s "https://your-artifactory.internal/nuget/chocolatey/" choco source add -n=internal-repo -s "https://your-artifactory.internal/nuget/chocolatey/"
# Push package to internal repo # Push package to internal repo
nuget push webdrop-bridge.0.8.0.nupkg -Source https://your-artifactory.internal/nuget/chocolatey/ -ApiKey YOUR_API_KEY nuget push webdrop-bridge.<version>.nupkg -Source https://your-artifactory.internal/nuget/chocolatey/ -ApiKey YOUR_API_KEY
# Users install from internal repo (already configured) # Users install from internal repo (already configured)
choco install webdrop-bridge choco install webdrop-bridge
@ -95,7 +95,7 @@ If you want public distribution (requires community maintainer account):
```bash ```bash
# Push to community repo # Push to community repo
choco push webdrop-bridge.0.8.0.nupkg --api-key YOUR_CHOCOLATEY_API_KEY choco push webdrop-bridge.<version>.nupkg --api-key YOUR_CHOCOLATEY_API_KEY
``` ```
**Option 3: No Repository (Direct Distribution)** **Option 3: No Repository (Direct Distribution)**
@ -103,8 +103,8 @@ choco push webdrop-bridge.0.8.0.nupkg --api-key YOUR_CHOCOLATEY_API_KEY
Share the `.nupkg` file directly, users install locally: Share the `.nupkg` file directly, users install locally:
```powershell ```powershell
# User downloads webdrop-bridge.0.8.0.nupkg and runs: # User downloads webdrop-bridge.<version>.nupkg and runs:
choco install webdrop-bridge.0.8.0.nupkg -s C:\path\to\package\folder choco install webdrop-bridge.<version>.nupkg -s C:\path\to\package\folder
``` ```
### User Installation ### User Installation
@ -119,7 +119,7 @@ choco install webdrop-bridge
choco install webdrop-bridge choco install webdrop-bridge
# If distributing directly # If distributing directly
choco install webdrop-bridge.0.8.0.nupkg -s "\\network\share\packages" choco install webdrop-bridge.<version>.nupkg -s "\\network\share\packages"
``` ```
## Homebrew (macOS) ## Homebrew (macOS)
@ -183,11 +183,11 @@ Submit to `homebrew/casks` (requires more maintenance but no separate tap):
bash build/scripts/build_macos.sh bash build/scripts/build_macos.sh
# 2. Calculate SHA256 checksum # 2. Calculate SHA256 checksum
shasum -a 256 "build/dist/macos/WebDropBridge_Setup.dmg" shasum -a 256 "build/dist/macos/webdrop_bridge/WebDropBridge-<version>-macos-universal.dmg"
# 3. Update formula with checksum and URL # 3. Update formula with checksum and URL
# build/homebrew/webdrop-bridge.rb # build/homebrew/webdrop-bridge.rb
# - url: https://git.him-tools.de/...releases/download/vX.X.X/WebDropBridge_Setup.dmg # - url: https://git.him-tools.de/...releases/download/vX.Y.Z/WebDropBridge-X.Y.Z-macos-universal.dmg
# - sha256: YOUR_SHA256_HASH # - sha256: YOUR_SHA256_HASH
``` ```
@ -210,7 +210,7 @@ webdrop-bridge --version # If CLI exists, or check Applications folder
### Step 1: Build Release ### Step 1: Build Release
```bash ```bash
# Release v0.8.0 # Release vX.Y.Z
# Windows MSI # Windows MSI
python build/scripts/build_windows.py --msi python build/scripts/build_windows.py --msi
@ -224,8 +224,8 @@ bash build/scripts/build_macos.sh
Tag and upload installers to Forgejo: Tag and upload installers to Forgejo:
```bash ```bash
git tag -a v0.8.0 -m "Release 0.8.0" git tag -a vX.Y.Z -m "Release X.Y.Z"
git push upstream v0.8.0 git push upstream vX.Y.Z
# Upload MSI and DMG to Forgejo release page # Upload MSI and DMG to Forgejo release page
``` ```
@ -234,10 +234,10 @@ git push upstream v0.8.0
```bash ```bash
# Windows # Windows
certutil -hashfile WebDropBridge_Setup.msi SHA256 certutil -hashfile WebDropBridge-<version>-win-x64.msi SHA256
# macOS # macOS
shasum -a 256 WebDropBridge_Setup.dmg shasum -a 256 WebDropBridge-<version>-macos-universal.dmg
``` ```
### Step 4: Update Package Manager Files ### Step 4: Update Package Manager Files
@ -258,7 +258,7 @@ sha256 "MACOS_SHA256_HASH"
```powershell ```powershell
cd build/chocolatey cd build/chocolatey
choco pack choco pack
choco install webdrop-bridge.0.8.0.nupkg -s . choco install webdrop-bridge.<version>.nupkg -s .
``` ```
**Homebrew (with tap):** **Homebrew (with tap):**
@ -270,7 +270,7 @@ brew install ./build/homebrew/webdrop-bridge.rb
**Chocolatey:** **Chocolatey:**
```powershell ```powershell
choco push webdrop-bridge.0.8.0.nupkg --api-key YOUR_KEY choco push webdrop-bridge.<version>.nupkg --api-key YOUR_KEY
``` ```
**Homebrew:** **Homebrew:**
@ -343,7 +343,7 @@ This works for:
### Chocolatey Issues ### Chocolatey Issues
**Package won't install:** **Package won't install:**
- Verify checksum: `certutil -hashfile WebDropBridge_Setup.msi SHA256` - Verify checksum: `certutil -hashfile WebDropBridge-<version>-win-x64.msi SHA256`
- Check MSI exists at URL: `wget URL` - Check MSI exists at URL: `wget URL`
- Verify SHA256 matches in `chocolateyInstall.ps1` - Verify SHA256 matches in `chocolateyInstall.ps1`
@ -356,7 +356,7 @@ This works for:
**Formula won't install:** **Formula won't install:**
- Validate syntax: `brew audit --formula webdrop-bridge.rb` - Validate syntax: `brew audit --formula webdrop-bridge.rb`
- Check URL is accessible: `curl -I URL` - Check URL is accessible: `curl -I URL`
- Verify SHA256: `shasum -a 256 WebDropBridge_Setup.dmg` - Verify SHA256: `shasum -a 256 WebDropBridge-<version>-macos-universal.dmg`
**Upgrade fails:** **Upgrade fails:**
- Remove old version: `brew uninstall webdrop-bridge` - Remove old version: `brew uninstall webdrop-bridge`
@ -375,7 +375,7 @@ This works for:
1. **Easiest: Direct Distribution** 1. **Easiest: Direct Distribution**
- Share `.nupkg` file via file share or email - Share `.nupkg` file via file share or email
- Users: `choco install webdrop-bridge.0.8.0.nupkg -s "\\share\packages"` - Users: `choco install webdrop-bridge.<version>.nupkg -s "\\share\packages"`
- No infrastructure needed - No infrastructure needed
- No maintainer account required - No maintainer account required

View file

@ -58,6 +58,10 @@
"dialog.language_changed.msg": "Die Spracheinstellung wurde aktualisiert. Starten Sie jetzt neu, um die ausgew\u00e4hlte Sprache \u00fcberall anzuwenden.", "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_now": "Jetzt neu starten",
"dialog.language_changed.restart_later": "Sp\u00e4ter neu starten", "dialog.language_changed.restart_later": "Sp\u00e4ter neu starten",
"dialog.branding_changed.title": "Branding ge\u00e4ndert",
"dialog.branding_changed.msg": "Das aktive Branding wurde geändert. Starten Sie jetzt neu, damit die aktualisierte visuelle Identität überall angewendet wird.",
"dialog.branding_changed.restart_now": "Jetzt neu starten",
"dialog.branding_changed.restart_later": "Sp\u00e4ter neu starten",
"dialog.restart_failed.title": "Neustart fehlgeschlagen", "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.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.title": "Zeitüberschreitung bei der Update-Pr\u00fcfung",
@ -84,8 +88,28 @@
"settings.tab.urls": "URLs", "settings.tab.urls": "URLs",
"settings.tab.logging": "Protokollierung", "settings.tab.logging": "Protokollierung",
"settings.tab.window": "Fenster", "settings.tab.window": "Fenster",
"settings.tab.profiles": "Profile", "settings.tab.profiles": "Setups",
"settings.tab.general": "Allgemein", "settings.tab.general": "Allgemein",
"settings.tab.branding": "Branding",
"settings.branding.select_label": "Branding:",
"settings.branding.select_tooltip": "Wählen Sie das Branding, das beim Start automatisch geladen werden soll.",
"settings.branding.help_text": "Branding steuert Name sowie Logo/Icon der App. Änderungen sind klar von den gespeicherten Setups getrennt.",
"settings.branding.display_name_label": "Name:",
"settings.branding.app_name_label": "Anwendungsname:",
"settings.branding.window_title_label": "Fenstertitel (optional):",
"settings.branding.logo_path_label": "Logo/Icon-Datei (optional):",
"settings.branding.save_as_btn": "Branding speichern",
"settings.branding.export_btn": "Branding exportieren",
"settings.branding.import_btn": "Branding importieren",
"settings.branding.delete_btn": "Branding löschen",
"settings.branding.export_title": "Branding exportieren",
"settings.branding.import_title": "Branding importieren",
"settings.branding.preview_label": "Vorschau:",
"settings.branding.no_icon_selected": "Kein Icon ausgewählt",
"settings.branding.preview_default_name": "Default",
"settings.branding.save_as_title": "Branding speichern",
"settings.branding.save_as_prompt": "Name für das Branding eingeben:",
"settings.branding.restart_note": "Branding-Änderungen werden persistent gespeichert und nach einem Neustart vollständig angewendet.",
"settings.web_url.label": "Web-Anwendungs-URL:", "settings.web_url.label": "Web-Anwendungs-URL:",
"settings.web_url.placeholder": "z.B. http://localhost:8080 oder file:///./webapp/index.html", "settings.web_url.placeholder": "z.B. http://localhost:8080 oder file:///./webapp/index.html",
"settings.web_url.open_btn": "\u00d6ffnen", "settings.web_url.open_btn": "\u00d6ffnen",
@ -106,12 +130,22 @@
"settings.log_file.browse_btn": "Durchsuchen...", "settings.log_file.browse_btn": "Durchsuchen...",
"settings.window.width_label": "Fensterbreite:", "settings.window.width_label": "Fensterbreite:",
"settings.window.height_label": "Fensterh\u00f6he:", "settings.window.height_label": "Fensterh\u00f6he:",
"settings.profiles.label": "Gespeicherte Konfigurationsprofile:", "settings.profiles.label": "Gespeicherte Setups auf diesem Ger\u00e4t:",
"settings.profiles.save_btn": "Als Profil speichern", "settings.profiles.label_tooltip": "Gespeicherte Setups sind benannte Schnappsch\u00fcsse Ihrer aktuellen Einstellungen f\u00fcr den schnellen Wechsel auf diesem Ger\u00e4t.",
"settings.profiles.load_btn": "Profil laden", "settings.profiles.help_text": "Speichern Sie den aktuellen Stand als benanntes Setup f\u00fcr den schnellen Wechsel auf diesem Ger\u00e4t. Nutzen Sie Export/Import, wenn Sie eine Konfigurationsdatei sichern oder teilen m\u00f6chten.",
"settings.profiles.delete_btn": "Profil l\u00f6schen", "settings.profiles.list_tooltip": "Zeigt die auf diesem Ger\u00e4t verf\u00fcgbaren gespeicherten Setups.",
"settings.profiles.export_btn": "Konfiguration exportieren", "settings.profiles.save_btn": "Setup speichern",
"settings.profiles.import_btn": "Konfiguration importieren", "settings.profiles.save_tooltip": "Speichert die aktuellen Einstellungen als benanntes Setup auf diesem Ger\u00e4t.",
"settings.profiles.load_btn": "Setup laden",
"settings.profiles.load_tooltip": "L\u00e4dt das ausgew\u00e4hlte gespeicherte Setup in diesen Dialog.",
"settings.profiles.delete_btn": "Setup l\u00f6schen",
"settings.profiles.delete_tooltip": "L\u00f6scht das ausgew\u00e4hlte gespeicherte Setup von diesem Ger\u00e4t.",
"settings.profiles.transfer_label": "Aktuelle Einstellungen sichern oder teilen:",
"settings.profiles.transfer_tooltip": "Export erstellt eine JSON-Datei zum Sichern oder Teilen. Import liest eine solche Datei ein und wendet sie hier an.",
"settings.profiles.export_btn": "In Datei exportieren",
"settings.profiles.export_tooltip": "Exportiert die aktuellen Einstellungen als JSON-Datei zum Sichern oder Teilen.",
"settings.profiles.import_btn": "Aus Datei importieren",
"settings.profiles.import_tooltip": "Importiert Einstellungen aus einer JSON-Datei und wendet sie hier an.",
"settings.general.language_label": "Sprache:", "settings.general.language_label": "Sprache:",
"settings.general.language_auto": "Systemstandard (Auto)", "settings.general.language_auto": "Systemstandard (Auto)",
"settings.general.language_restart_note": "Sprach\u00e4nderung wirksam nach Neustart.", "settings.general.language_restart_note": "Sprach\u00e4nderung wirksam nach Neustart.",
@ -123,15 +157,15 @@
"settings.edit_mapping.path_prompt": "Lokalen Dateisystempfad eingeben:", "settings.edit_mapping.path_prompt": "Lokalen Dateisystempfad eingeben:",
"settings.add_url.title": "URL hinzuf\u00fcgen", "settings.add_url.title": "URL hinzuf\u00fcgen",
"settings.add_url.prompt": "URL-Muster eingeben (z.B. http://example.com oder http://*.example.com):", "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.title": "Setup speichern",
"settings.profile.save.prompt": "Profilnamen eingeben (z.B. Arbeit, Privat):", "settings.profile.save.prompt": "Namen für das Setup eingeben (z.B. Arbeit, Kunde A):",
"settings.select_directory.title": "Verzeichnis ausw\u00e4hlen", "settings.select_directory.title": "Verzeichnis ausw\u00e4hlen",
"settings.select_log_file.title": "Protokolldatei ausw\u00e4hlen", "settings.select_log_file.title": "Protokolldatei ausw\u00e4hlen",
"settings.export_config.title": "Konfiguration exportieren", "settings.export_config.title": "Einstellungen exportieren",
"settings.import_config.title": "Konfiguration importieren", "settings.import_config.title": "Einstellungen importieren",
"settings.error.select_mapping": "Bitte w\u00e4hlen Sie eine Zuordnung zur Bearbeitung aus", "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_load": "Bitte w\u00e4hlen Sie ein Setup zum Laden aus",
"settings.error.select_profile_delete": "Bitte w\u00e4hlen Sie ein Profil zum L\u00f6schen aus", "settings.error.select_profile_delete": "Bitte w\u00e4hlen Sie ein Setup zum L\u00f6schen aus",
"settings.web_source.url_label": "Webanwendungs-URL:", "settings.web_source.url_label": "Webanwendungs-URL:",
"settings.web_source.open_btn": "\u00d6ffnen", "settings.web_source.open_btn": "\u00d6ffnen",
"settings.web_source.url_mappings_label": "URL-Zuordnungen (Azure Blob Storage \u2192 Lokale Pfade):", "settings.web_source.url_mappings_label": "URL-Zuordnungen (Azure Blob Storage \u2192 Lokale Pfade):",
@ -154,12 +188,12 @@
"settings.paths.select_dir_title": "Verzeichnis ausw\u00e4hlen", "settings.paths.select_dir_title": "Verzeichnis ausw\u00e4hlen",
"settings.urls.add_title": "URL hinzuf\u00fcgen", "settings.urls.add_title": "URL hinzuf\u00fcgen",
"settings.urls.add_prompt": "URL-Muster eingeben (z.B. http://example.com oder http://*.example.com):", "settings.urls.add_prompt": "URL-Muster eingeben (z.B. http://example.com oder http://*.example.com):",
"settings.profiles.save_title": "Profil speichern", "settings.profiles.save_title": "Setup speichern",
"settings.profiles.save_prompt": "Profilnamen eingeben (z.B. Arbeit, Privat):", "settings.profiles.save_prompt": "Namen für das Setup eingeben (z.B. Arbeit, Kunde A):",
"settings.profiles.select_to_load": "Bitte w\u00e4hlen Sie ein Profil zum Laden aus", "settings.profiles.select_to_load": "Bitte wählen Sie ein Setup zum Laden aus",
"settings.profiles.select_to_delete": "Bitte w\u00e4hlen Sie ein Profil zum L\u00f6schen aus", "settings.profiles.select_to_delete": "Bitte wählen Sie ein Setup zum Löschen aus",
"settings.profiles.export_title": "Konfiguration exportieren", "settings.profiles.export_title": "Einstellungen exportieren",
"settings.profiles.import_title": "Konfiguration importieren", "settings.profiles.import_title": "Einstellungen importieren",
"update.checking.title": "Update-Pr\u00fcfung", "update.checking.title": "Update-Pr\u00fcfung",
"update.checking.label": "Suche nach Updates...", "update.checking.label": "Suche nach Updates...",

View file

@ -58,6 +58,10 @@
"dialog.language_changed.msg": "The language setting was updated. Restart now to apply the selected language everywhere.", "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_now": "Restart Now",
"dialog.language_changed.restart_later": "Restart Later", "dialog.language_changed.restart_later": "Restart Later",
"dialog.branding_changed.title": "Branding Changed",
"dialog.branding_changed.msg": "The active branding was changed. Restart now so the updated visual identity is applied everywhere.",
"dialog.branding_changed.restart_now": "Restart Now",
"dialog.branding_changed.restart_later": "Restart Later",
"dialog.restart_failed.title": "Restart Failed", "dialog.restart_failed.title": "Restart Failed",
"dialog.restart_failed.msg": "Could not automatically restart the application:\n\n{error}\n\nPlease restart manually.", "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.title": "Update Check Timeout",
@ -84,8 +88,28 @@
"settings.tab.urls": "URLs", "settings.tab.urls": "URLs",
"settings.tab.logging": "Logging", "settings.tab.logging": "Logging",
"settings.tab.window": "Window", "settings.tab.window": "Window",
"settings.tab.profiles": "Profiles", "settings.tab.profiles": "Setups",
"settings.tab.general": "General", "settings.tab.general": "General",
"settings.tab.branding": "Branding",
"settings.branding.select_label": "Branding:",
"settings.branding.select_tooltip": "Choose the branding that should be loaded automatically on startup.",
"settings.branding.help_text": "Branding controls the app name and logo/icon. It stays clearly separated from your saved setups.",
"settings.branding.display_name_label": "Name:",
"settings.branding.app_name_label": "Application name:",
"settings.branding.window_title_label": "Window title (optional):",
"settings.branding.logo_path_label": "Logo/Icon file (optional):",
"settings.branding.save_as_btn": "Save Branding",
"settings.branding.export_btn": "Export Branding",
"settings.branding.import_btn": "Import Branding",
"settings.branding.delete_btn": "Delete Branding",
"settings.branding.export_title": "Export Branding",
"settings.branding.import_title": "Import Branding",
"settings.branding.preview_label": "Preview:",
"settings.branding.no_icon_selected": "No icon selected",
"settings.branding.preview_default_name": "Default",
"settings.branding.save_as_title": "Save Branding",
"settings.branding.save_as_prompt": "Enter a name for the branding:",
"settings.branding.restart_note": "Branding changes are persisted immediately and are fully applied after restarting the app.",
"settings.web_url.label": "Web Application URL:", "settings.web_url.label": "Web Application URL:",
"settings.web_url.placeholder": "e.g., http://localhost:8080 or file:///./webapp/index.html", "settings.web_url.placeholder": "e.g., http://localhost:8080 or file:///./webapp/index.html",
"settings.web_url.open_btn": "Open", "settings.web_url.open_btn": "Open",
@ -106,12 +130,22 @@
"settings.log_file.browse_btn": "Browse...", "settings.log_file.browse_btn": "Browse...",
"settings.window.width_label": "Window Width:", "settings.window.width_label": "Window Width:",
"settings.window.height_label": "Window Height:", "settings.window.height_label": "Window Height:",
"settings.profiles.label": "Saved Configuration Profiles:", "settings.profiles.label": "Saved setups on this device:",
"settings.profiles.save_btn": "Save as Profile", "settings.profiles.label_tooltip": "Saved setups are named snapshots of your current settings kept on this device for quick switching.",
"settings.profiles.load_btn": "Load Profile", "settings.profiles.help_text": "Save the current settings as a named setup for quick switching on this device. Use export/import when you want to back up or share a configuration file.",
"settings.profiles.delete_btn": "Delete Profile", "settings.profiles.list_tooltip": "Shows the saved setups available on this device.",
"settings.profiles.export_btn": "Export Configuration", "settings.profiles.save_btn": "Save Setup",
"settings.profiles.import_btn": "Import Configuration", "settings.profiles.save_tooltip": "Save the current settings as a named setup on this device.",
"settings.profiles.load_btn": "Load Setup",
"settings.profiles.load_tooltip": "Load the selected saved setup into the dialog.",
"settings.profiles.delete_btn": "Delete Setup",
"settings.profiles.delete_tooltip": "Delete the selected saved setup from this device.",
"settings.profiles.transfer_label": "Backup or share the current settings:",
"settings.profiles.transfer_tooltip": "Export creates a JSON file for backup or sharing. Import reads such a file and applies it here.",
"settings.profiles.export_btn": "Export to File",
"settings.profiles.export_tooltip": "Export the current settings to a JSON file for backup or sharing.",
"settings.profiles.import_btn": "Import from File",
"settings.profiles.import_tooltip": "Import settings from a JSON file and apply them here.",
"settings.general.language_label": "Language:", "settings.general.language_label": "Language:",
"settings.general.language_auto": "System Default (Auto)", "settings.general.language_auto": "System Default (Auto)",
"settings.general.language_restart_note": "Language change takes effect after restart.", "settings.general.language_restart_note": "Language change takes effect after restart.",
@ -123,15 +157,15 @@
"settings.edit_mapping.path_prompt": "Enter local file system path:", "settings.edit_mapping.path_prompt": "Enter local file system path:",
"settings.add_url.title": "Add URL", "settings.add_url.title": "Add URL",
"settings.add_url.prompt": "Enter URL pattern (e.g., http://example.com or http://*.example.com):", "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.title": "Save Setup",
"settings.profile.save.prompt": "Enter profile name (e.g., work, personal):", "settings.profile.save.prompt": "Enter a setup name (e.g., Work, Customer A):",
"settings.select_directory.title": "Select Directory to Allow", "settings.select_directory.title": "Select Directory to Allow",
"settings.select_log_file.title": "Select Log File", "settings.select_log_file.title": "Select Log File",
"settings.export_config.title": "Export Configuration", "settings.export_config.title": "Export Settings",
"settings.import_config.title": "Import Configuration", "settings.import_config.title": "Import Settings",
"settings.error.select_mapping": "Please select a mapping to edit", "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_load": "Please select a setup to load",
"settings.error.select_profile_delete": "Please select a profile to delete", "settings.error.select_profile_delete": "Please select a setup to delete",
"settings.web_source.url_label": "Web Application URL:", "settings.web_source.url_label": "Web Application URL:",
"settings.web_source.open_btn": "Open", "settings.web_source.open_btn": "Open",
"settings.web_source.url_mappings_label": "URL Mappings (Azure Blob Storage \u2192 Local Paths):", "settings.web_source.url_mappings_label": "URL Mappings (Azure Blob Storage \u2192 Local Paths):",
@ -154,12 +188,12 @@
"settings.paths.select_dir_title": "Select Directory to Allow", "settings.paths.select_dir_title": "Select Directory to Allow",
"settings.urls.add_title": "Add URL", "settings.urls.add_title": "Add URL",
"settings.urls.add_prompt": "Enter URL pattern (e.g., http://example.com or http://*.example.com):", "settings.urls.add_prompt": "Enter URL pattern (e.g., http://example.com or http://*.example.com):",
"settings.profiles.save_title": "Save Profile", "settings.profiles.save_title": "Save Setup",
"settings.profiles.save_prompt": "Enter profile name (e.g., work, personal):", "settings.profiles.save_prompt": "Enter a setup name (e.g., Work, Customer A):",
"settings.profiles.select_to_load": "Please select a profile to load", "settings.profiles.select_to_load": "Please select a setup to load",
"settings.profiles.select_to_delete": "Please select a profile to delete", "settings.profiles.select_to_delete": "Please select a setup to delete",
"settings.profiles.export_title": "Export Configuration", "settings.profiles.export_title": "Export Settings",
"settings.profiles.import_title": "Import Configuration", "settings.profiles.import_title": "Import Settings",
"update.checking.title": "Checking for Updates", "update.checking.title": "Checking for Updates",
"update.checking.label": "Checking for updates...", "update.checking.label": "Checking for updates...",

View file

@ -58,6 +58,10 @@
"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.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_now": "Red\u00e9marrer maintenant",
"dialog.language_changed.restart_later": "Red\u00e9marrer plus tard", "dialog.language_changed.restart_later": "Red\u00e9marrer plus tard",
"dialog.branding_changed.title": "Branding modifié",
"dialog.branding_changed.msg": "Le branding actif a été modifié. Redémarrez maintenant pour appliquer partout lidentité visuelle mise à jour.",
"dialog.branding_changed.restart_now": "Redémarrer maintenant",
"dialog.branding_changed.restart_later": "Redémarrer plus tard",
"dialog.restart_failed.title": "\u00c9chec du red\u00e9marrage", "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.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.title": "D\u00e9lai de v\u00e9rification des mises \u00e0 jour d\u00e9pass\u00e9",
@ -84,8 +88,28 @@
"settings.tab.urls": "URLs", "settings.tab.urls": "URLs",
"settings.tab.logging": "Journalisation", "settings.tab.logging": "Journalisation",
"settings.tab.window": "Fen\u00eatre", "settings.tab.window": "Fen\u00eatre",
"settings.tab.profiles": "Profils", "settings.tab.profiles": "Configs",
"settings.tab.general": "G\u00e9n\u00e9ral", "settings.tab.general": "G\u00e9n\u00e9ral",
"settings.tab.branding": "Branding",
"settings.branding.select_label": "Branding :",
"settings.branding.select_tooltip": "Choisissez le modèle de branding qui doit être chargé automatiquement au démarrage.",
"settings.branding.help_text": "Le branding contrôle lidentité visuelle de lapplication, comme le nom et les icônes. Il reste séparé de vos configurations enregistrées.",
"settings.branding.display_name_label": "Nom daffichage :",
"settings.branding.app_name_label": "Nom de lapplication :",
"settings.branding.window_title_label": "Titre de la fenêtre (facultatif) :",
"settings.branding.logo_path_label": "Chemin du logo (facultatif) :",
"settings.branding.save_as_btn": "Enregistrer le branding",
"settings.branding.export_btn": "Exporter le branding",
"settings.branding.import_btn": "Importer le branding",
"settings.branding.delete_btn": "Supprimer le branding",
"settings.branding.export_title": "Exporter le branding",
"settings.branding.import_title": "Importer le branding",
"settings.branding.preview_label": "Aperçu :",
"settings.branding.no_icon_selected": "Aucune icône sélectionnée",
"settings.branding.preview_default_name": "Default",
"settings.branding.save_as_title": "Enregistrer le branding",
"settings.branding.save_as_prompt": "Entrez un nom pour le branding :",
"settings.branding.restart_note": "Les changements de branding sont enregistrés de façon persistante et seront entièrement appliqués après le redémarrage de lapplication.",
"settings.web_url.label": "URL de l'application web\u00a0:", "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.placeholder": "p.ex. http://localhost:8080 ou file:///./webapp/index.html",
"settings.web_url.open_btn": "Ouvrir", "settings.web_url.open_btn": "Ouvrir",
@ -106,12 +130,22 @@
"settings.log_file.browse_btn": "Parcourir...", "settings.log_file.browse_btn": "Parcourir...",
"settings.window.width_label": "Largeur de la fen\u00eatre\u00a0:", "settings.window.width_label": "Largeur de la fen\u00eatre\u00a0:",
"settings.window.height_label": "Hauteur 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.label": "Configurations enregistr\u00e9es sur cet appareil\u00a0:",
"settings.profiles.save_btn": "Enregistrer comme profil", "settings.profiles.label_tooltip": "Les configurations enregistr\u00e9es sont des instantan\u00e9s nomm\u00e9s de vos r\u00e9glages actuels pour basculer rapidement sur cet appareil.",
"settings.profiles.load_btn": "Charger le profil", "settings.profiles.help_text": "Enregistrez l\u00e9tat actuel comme configuration nomm\u00e9e pour basculer rapidement sur cet appareil. Utilisez lexport/import pour sauvegarder ou partager un fichier de configuration.",
"settings.profiles.delete_btn": "Supprimer le profil", "settings.profiles.list_tooltip": "Affiche les configurations enregistr\u00e9es disponibles sur cet appareil.",
"settings.profiles.export_btn": "Exporter la configuration", "settings.profiles.save_btn": "Enregistrer la configuration",
"settings.profiles.import_btn": "Importer la configuration", "settings.profiles.save_tooltip": "Enregistre les r\u00e9glages actuels comme configuration nomm\u00e9e sur cet appareil.",
"settings.profiles.load_btn": "Charger la configuration",
"settings.profiles.load_tooltip": "Charge la configuration enregistr\u00e9e s\u00e9lectionn\u00e9e dans cette bo\u00eete de dialogue.",
"settings.profiles.delete_btn": "Supprimer la configuration",
"settings.profiles.delete_tooltip": "Supprime la configuration enregistr\u00e9e s\u00e9lectionn\u00e9e de cet appareil.",
"settings.profiles.transfer_label": "Sauvegarder ou partager les r\u00e9glages actuels\u00a0:",
"settings.profiles.transfer_tooltip": "Exporter cr\u00e9e un fichier JSON pour la sauvegarde ou le partage. Importer lit un tel fichier et lapplique ici.",
"settings.profiles.export_btn": "Exporter vers un fichier",
"settings.profiles.export_tooltip": "Exporte les r\u00e9glages actuels vers un fichier JSON pour sauvegarde ou partage.",
"settings.profiles.import_btn": "Importer depuis un fichier",
"settings.profiles.import_tooltip": "Importe des r\u00e9glages depuis un fichier JSON et les applique ici.",
"settings.general.language_label": "Langue\u00a0:", "settings.general.language_label": "Langue\u00a0:",
"settings.general.language_auto": "Par d\u00e9faut du syst\u00e8me (Auto)", "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.general.language_restart_note": "Le changement de langue prend effet apr\u00e8s red\u00e9marrage.",
@ -123,15 +157,15 @@
"settings.edit_mapping.path_prompt": "Entrez le chemin du syst\u00e8me de fichiers local\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.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.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.title": "Enregistrer la configuration",
"settings.profile.save.prompt": "Entrez le nom du profil (p.ex. travail, personnel)\u00a0:", "settings.profile.save.prompt": "Entrez un nom de configuration (p.ex. travail, client A)\u00a0:",
"settings.select_directory.title": "S\u00e9lectionner un r\u00e9pertoire autoris\u00e9", "settings.select_directory.title": "S\u00e9lectionner un r\u00e9pertoire autoris\u00e9",
"settings.select_log_file.title": "S\u00e9lectionner le fichier journal", "settings.select_log_file.title": "S\u00e9lectionner le fichier journal",
"settings.export_config.title": "Exporter la configuration", "settings.export_config.title": "Exporter les réglages",
"settings.import_config.title": "Importer la configuration", "settings.import_config.title": "Importer les réglages",
"settings.error.select_mapping": "Veuillez s\u00e9lectionner un mappage \u00e0 modifier", "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_load": "Veuillez sélectionner une configuration à charger",
"settings.error.select_profile_delete": "Veuillez s\u00e9lectionner un profil \u00e0 supprimer", "settings.error.select_profile_delete": "Veuillez sélectionner une configuration à supprimer",
"settings.web_source.url_label": "URL de l'application web\u00a0:", "settings.web_source.url_label": "URL de l'application web\u00a0:",
"settings.web_source.open_btn": "Ouvrir", "settings.web_source.open_btn": "Ouvrir",
"settings.web_source.url_mappings_label": "Mappages d'URL (Azure Blob Storage \u2192 Chemins locaux)\u00a0:", "settings.web_source.url_mappings_label": "Mappages d'URL (Azure Blob Storage \u2192 Chemins locaux)\u00a0:",
@ -154,12 +188,12 @@
"settings.paths.select_dir_title": "S\u00e9lectionner un r\u00e9pertoire autoris\u00e9", "settings.paths.select_dir_title": "S\u00e9lectionner un r\u00e9pertoire autoris\u00e9",
"settings.urls.add_title": "Ajouter une URL", "settings.urls.add_title": "Ajouter une URL",
"settings.urls.add_prompt": "Entrez le mod\u00e8le d'URL (p.ex. http://example.com ou http://*.example.com)\u00a0:", "settings.urls.add_prompt": "Entrez le mod\u00e8le d'URL (p.ex. http://example.com ou http://*.example.com)\u00a0:",
"settings.profiles.save_title": "Enregistrer le profil", "settings.profiles.save_title": "Enregistrer la configuration",
"settings.profiles.save_prompt": "Entrez le nom du profil (p.ex. travail, personnel)\u00a0:", "settings.profiles.save_prompt": "Entrez un nom de configuration (p.ex. travail, client A) :",
"settings.profiles.select_to_load": "Veuillez s\u00e9lectionner un profil \u00e0 charger", "settings.profiles.select_to_load": "Veuillez sélectionner une configuration à charger",
"settings.profiles.select_to_delete": "Veuillez s\u00e9lectionner un profil \u00e0 supprimer", "settings.profiles.select_to_delete": "Veuillez sélectionner une configuration à supprimer",
"settings.profiles.export_title": "Exporter la configuration", "settings.profiles.export_title": "Exporter les réglages",
"settings.profiles.import_title": "Importer la configuration", "settings.profiles.import_title": "Importer les réglages",
"update.checking.title": "V\u00e9rification des mises \u00e0 jour", "update.checking.title": "V\u00e9rification des mises \u00e0 jour",
"update.checking.label": "Recherche de mises \u00e0 jour...", "update.checking.label": "Recherche de mises \u00e0 jour...",

View file

@ -58,6 +58,10 @@
"dialog.language_changed.msg": "La lingua è stata aggiornata. Riavvia ora per applicarla ovunque.", "dialog.language_changed.msg": "La lingua è stata aggiornata. Riavvia ora per applicarla ovunque.",
"dialog.language_changed.restart_now": "Riavvia ora", "dialog.language_changed.restart_now": "Riavvia ora",
"dialog.language_changed.restart_later": "Riavvia più tardi", "dialog.language_changed.restart_later": "Riavvia più tardi",
"dialog.branding_changed.title": "Branding cambiato",
"dialog.branding_changed.msg": "Il branding attivo è stato modificato. Riavvia ora per applicare ovunque lidentità visiva aggiornata.",
"dialog.branding_changed.restart_now": "Riavvia ora",
"dialog.branding_changed.restart_later": "Riavvia più tardi",
"dialog.restart_failed.title": "Riavvio non riuscito", "dialog.restart_failed.title": "Riavvio non riuscito",
"dialog.restart_failed.msg": "Impossibile riavviare automaticamente l'applicazione:\n\n{error}\n\nRiavvia manualmente.", "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.title": "Timeout controllo aggiornamenti",
@ -84,8 +88,28 @@
"settings.tab.urls": "URL", "settings.tab.urls": "URL",
"settings.tab.logging": "Log", "settings.tab.logging": "Log",
"settings.tab.window": "Finestra", "settings.tab.window": "Finestra",
"settings.tab.profiles": "Profili", "settings.tab.profiles": "Config",
"settings.tab.general": "Generale", "settings.tab.general": "Generale",
"settings.tab.branding": "Branding",
"settings.branding.select_label": "Branding:",
"settings.branding.select_tooltip": "Scegli il modello di branding da caricare automaticamente allavvio.",
"settings.branding.help_text": "Il branding controlla lidentità visiva dellapp, come nome e icone. Rimane separato dalle configurazioni salvate.",
"settings.branding.display_name_label": "Nome visualizzato:",
"settings.branding.app_name_label": "Nome applicazione:",
"settings.branding.window_title_label": "Titolo finestra (opzionale):",
"settings.branding.logo_path_label": "Percorso logo (opzionale):",
"settings.branding.save_as_btn": "Salva branding",
"settings.branding.export_btn": "Esporta branding",
"settings.branding.import_btn": "Importa branding",
"settings.branding.delete_btn": "Elimina branding",
"settings.branding.export_title": "Esporta branding",
"settings.branding.import_title": "Importa branding",
"settings.branding.preview_label": "Anteprima:",
"settings.branding.no_icon_selected": "Nessuna icona selezionata",
"settings.branding.preview_default_name": "Default",
"settings.branding.save_as_title": "Salva branding",
"settings.branding.save_as_prompt": "Inserisci un nome per il branding:",
"settings.branding.restart_note": "Le modifiche al branding vengono salvate in modo persistente e saranno applicate completamente dopo il riavvio dellapplicazione.",
"settings.web_url.label": "URL applicazione web:", "settings.web_url.label": "URL applicazione web:",
"settings.web_url.placeholder": "es. http://localhost:8080 o file:///./webapp/index.html", "settings.web_url.placeholder": "es. http://localhost:8080 o file:///./webapp/index.html",
"settings.web_url.open_btn": "Apri", "settings.web_url.open_btn": "Apri",
@ -106,12 +130,22 @@
"settings.log_file.browse_btn": "Sfoglia...", "settings.log_file.browse_btn": "Sfoglia...",
"settings.window.width_label": "Larghezza finestra:", "settings.window.width_label": "Larghezza finestra:",
"settings.window.height_label": "Altezza finestra:", "settings.window.height_label": "Altezza finestra:",
"settings.profiles.label": "Profili configurazione salvati:", "settings.profiles.label": "Configurazioni salvate su questo dispositivo:",
"settings.profiles.save_btn": "Salva come profilo", "settings.profiles.label_tooltip": "Le configurazioni salvate sono istantanee con nome delle impostazioni correnti per passare rapidamente da un assetto allaltro su questo dispositivo.",
"settings.profiles.load_btn": "Carica profilo", "settings.profiles.help_text": "Salva lo stato corrente come configurazione con nome per cambiare rapidamente su questo dispositivo. Usa esporta/importa per eseguire un backup o condividere un file di configurazione.",
"settings.profiles.delete_btn": "Elimina profilo", "settings.profiles.list_tooltip": "Mostra le configurazioni salvate disponibili su questo dispositivo.",
"settings.profiles.export_btn": "Esporta configurazione", "settings.profiles.save_btn": "Salva configurazione",
"settings.profiles.import_btn": "Importa configurazione", "settings.profiles.save_tooltip": "Salva le impostazioni correnti come configurazione con nome su questo dispositivo.",
"settings.profiles.load_btn": "Carica configurazione",
"settings.profiles.load_tooltip": "Carica in questa finestra di dialogo la configurazione salvata selezionata.",
"settings.profiles.delete_btn": "Elimina configurazione",
"settings.profiles.delete_tooltip": "Elimina da questo dispositivo la configurazione salvata selezionata.",
"settings.profiles.transfer_label": "Backup o condivisione delle impostazioni correnti:",
"settings.profiles.transfer_tooltip": "Esporta crea un file JSON per backup o condivisione. Importa legge tale file e lo applica qui.",
"settings.profiles.export_btn": "Esporta in file",
"settings.profiles.export_tooltip": "Esporta le impostazioni correnti in un file JSON per backup o condivisione.",
"settings.profiles.import_btn": "Importa da file",
"settings.profiles.import_tooltip": "Importa impostazioni da un file JSON e le applica qui.",
"settings.general.language_label": "Lingua:", "settings.general.language_label": "Lingua:",
"settings.general.language_auto": "Predefinita sistema (Auto)", "settings.general.language_auto": "Predefinita sistema (Auto)",
"settings.general.language_restart_note": "La modifica lingua si applica dopo il riavvio.", "settings.general.language_restart_note": "La modifica lingua si applica dopo il riavvio.",
@ -123,15 +157,15 @@
"settings.edit_mapping.path_prompt": "Inserisci percorso file system locale:", "settings.edit_mapping.path_prompt": "Inserisci percorso file system locale:",
"settings.add_url.title": "Aggiungi URL", "settings.add_url.title": "Aggiungi URL",
"settings.add_url.prompt": "Inserisci pattern URL (es. http://example.com o http://*.example.com):", "settings.add_url.prompt": "Inserisci pattern URL (es. http://example.com o http://*.example.com):",
"settings.profile.save.title": "Salva profilo", "settings.profile.save.title": "Salva configurazione",
"settings.profile.save.prompt": "Inserisci nome profilo (es. lavoro, personale):", "settings.profile.save.prompt": "Inserisci un nome per la configurazione (es. Lavoro, Cliente A):",
"settings.select_directory.title": "Seleziona directory da consentire", "settings.select_directory.title": "Seleziona directory da consentire",
"settings.select_log_file.title": "Seleziona file di log", "settings.select_log_file.title": "Seleziona file di log",
"settings.export_config.title": "Esporta configurazione", "settings.export_config.title": "Esporta impostazioni",
"settings.import_config.title": "Importa configurazione", "settings.import_config.title": "Importa impostazioni",
"settings.error.select_mapping": "Seleziona una mappatura da modificare", "settings.error.select_mapping": "Seleziona una mappatura da modificare",
"settings.error.select_profile_load": "Seleziona un profilo da caricare", "settings.error.select_profile_load": "Seleziona una configurazione da caricare",
"settings.error.select_profile_delete": "Seleziona un profilo da eliminare", "settings.error.select_profile_delete": "Seleziona una configurazione da eliminare",
"settings.web_source.url_label": "URL applicazione web:", "settings.web_source.url_label": "URL applicazione web:",
"settings.web_source.open_btn": "Apri", "settings.web_source.open_btn": "Apri",
"settings.web_source.url_mappings_label": "Mappature URL (Azure Blob Storage \u2192 Percorsi locali):", "settings.web_source.url_mappings_label": "Mappature URL (Azure Blob Storage \u2192 Percorsi locali):",
@ -154,12 +188,12 @@
"settings.paths.select_dir_title": "Seleziona directory da consentire", "settings.paths.select_dir_title": "Seleziona directory da consentire",
"settings.urls.add_title": "Aggiungi URL", "settings.urls.add_title": "Aggiungi URL",
"settings.urls.add_prompt": "Inserisci pattern URL (es. http://example.com o http://*.example.com):", "settings.urls.add_prompt": "Inserisci pattern URL (es. http://example.com o http://*.example.com):",
"settings.profiles.save_title": "Salva profilo", "settings.profiles.save_title": "Salva configurazione",
"settings.profiles.save_prompt": "Inserisci nome profilo (es. lavoro, personale):", "settings.profiles.save_prompt": "Inserisci un nome per la configurazione (es. Lavoro, Cliente A):",
"settings.profiles.select_to_load": "Seleziona un profilo da caricare", "settings.profiles.select_to_load": "Seleziona una configurazione da caricare",
"settings.profiles.select_to_delete": "Seleziona un profilo da eliminare", "settings.profiles.select_to_delete": "Seleziona una configurazione da eliminare",
"settings.profiles.export_title": "Esporta configurazione", "settings.profiles.export_title": "Esporta impostazioni",
"settings.profiles.import_title": "Importa configurazione", "settings.profiles.import_title": "Importa impostazioni",
"update.checking.title": "Controllo aggiornamenti", "update.checking.title": "Controllo aggiornamenti",
"update.checking.label": "Controllo aggiornamenti...", "update.checking.label": "Controllo aggiornamenti...",

View file

@ -58,6 +58,10 @@
"dialog.language_changed.msg": "Настройка языка обновлена. Перезапустите сейчас, чтобы применить язык везде.", "dialog.language_changed.msg": "Настройка языка обновлена. Перезапустите сейчас, чтобы применить язык везде.",
"dialog.language_changed.restart_now": "Перезапустить сейчас", "dialog.language_changed.restart_now": "Перезапустить сейчас",
"dialog.language_changed.restart_later": "Перезапустить позже", "dialog.language_changed.restart_later": "Перезапустить позже",
"dialog.branding_changed.title": "Брендинг изменен",
"dialog.branding_changed.msg": "Активный брендинг был изменен. Перезапустите приложение сейчас, чтобы обновленная визуальная идентичность применялась везде.",
"dialog.branding_changed.restart_now": "Перезапустить сейчас",
"dialog.branding_changed.restart_later": "Перезапустить позже",
"dialog.restart_failed.title": "Сбой перезапуска", "dialog.restart_failed.title": "Сбой перезапуска",
"dialog.restart_failed.msg": "Не удалось автоматически перезапустить приложение:\n\n{error}\n\nПерезапустите вручную.", "dialog.restart_failed.msg": "Не удалось автоматически перезапустить приложение:\n\n{error}\n\nПерезапустите вручную.",
"dialog.update_timeout.title": "Таймаут проверки обновлений", "dialog.update_timeout.title": "Таймаут проверки обновлений",
@ -84,8 +88,28 @@
"settings.tab.urls": "URL", "settings.tab.urls": "URL",
"settings.tab.logging": "Логирование", "settings.tab.logging": "Логирование",
"settings.tab.window": "Окно", "settings.tab.window": "Окно",
"settings.tab.profiles": "Профили", "settings.tab.profiles": "Наборы",
"settings.tab.general": "Общие настройки", "settings.tab.general": "Общие настройки",
"settings.tab.branding": "Брендинг",
"settings.branding.select_label": "Брендинг:",
"settings.branding.select_tooltip": "Выберите шаблон брендинга, который должен автоматически загружаться при запуске.",
"settings.branding.help_text": "Брендинг управляет визуальной идентичностью приложения, например названием и иконками. Он отделен от сохраненных наборов настроек.",
"settings.branding.display_name_label": "Отображаемое имя:",
"settings.branding.app_name_label": "Имя приложения:",
"settings.branding.window_title_label": "Заголовок окна (необязательно):",
"settings.branding.logo_path_label": "Путь к логотипу (необязательно):",
"settings.branding.save_as_btn": "Сохранить брендинг",
"settings.branding.export_btn": "Экспортировать брендинг",
"settings.branding.import_btn": "Импортировать брендинг",
"settings.branding.delete_btn": "Удалить брендинг",
"settings.branding.export_title": "Экспортировать брендинг",
"settings.branding.import_title": "Импортировать брендинг",
"settings.branding.preview_label": "Предпросмотр:",
"settings.branding.no_icon_selected": "Значок не выбран",
"settings.branding.preview_default_name": "Default",
"settings.branding.save_as_title": "Сохранить брендинг",
"settings.branding.save_as_prompt": "Введите имя для брендинга:",
"settings.branding.restart_note": "Изменения брендинга сохраняются постоянно и будут полностью применены после перезапуска приложения.",
"settings.web_url.label": "URL веб-приложения:", "settings.web_url.label": "URL веб-приложения:",
"settings.web_url.placeholder": "например, http://localhost:8080 или file:///./webapp/index.html", "settings.web_url.placeholder": "например, http://localhost:8080 или file:///./webapp/index.html",
"settings.web_url.open_btn": "Открыть", "settings.web_url.open_btn": "Открыть",
@ -106,12 +130,22 @@
"settings.log_file.browse_btn": "Обзор...", "settings.log_file.browse_btn": "Обзор...",
"settings.window.width_label": "Ширина окна:", "settings.window.width_label": "Ширина окна:",
"settings.window.height_label": "Высота окна:", "settings.window.height_label": "Высота окна:",
"settings.profiles.label": "Сохраненные профили конфигурации:", "settings.profiles.label": "Сохраненные наборы настроек на этом устройстве:",
"settings.profiles.save_btn": "Сохранить как профиль", "settings.profiles.label_tooltip": "Сохраненные наборы — это именованные снимки текущих настроек для быстрого переключения на этом устройстве.",
"settings.profiles.load_btn": "Загрузить профиль", "settings.profiles.help_text": "Сохраните текущее состояние как именованный набор для быстрого переключения на этом устройстве. Используйте экспорт/импорт для резервного копирования или обмена файлом конфигурации.",
"settings.profiles.delete_btn": "Удалить профиль", "settings.profiles.list_tooltip": "Показывает сохраненные наборы, доступные на этом устройстве.",
"settings.profiles.export_btn": "Экспорт конфигурации", "settings.profiles.save_btn": "Сохранить набор",
"settings.profiles.import_btn": "Импорт конфигурации", "settings.profiles.save_tooltip": "Сохраняет текущие настройки как именованный набор на этом устройстве.",
"settings.profiles.load_btn": "Загрузить набор",
"settings.profiles.load_tooltip": "Загружает выбранный сохраненный набор в это окно.",
"settings.profiles.delete_btn": "Удалить набор",
"settings.profiles.delete_tooltip": "Удаляет выбранный сохраненный набор с этого устройства.",
"settings.profiles.transfer_label": "Сохранить резервную копию или поделиться текущими настройками:",
"settings.profiles.transfer_tooltip": "Экспорт создает JSON-файл для резервного копирования или обмена. Импорт читает такой файл и применяет его здесь.",
"settings.profiles.export_btn": "Экспорт в файл",
"settings.profiles.export_tooltip": "Экспортирует текущие настройки в JSON-файл для резервного копирования или обмена.",
"settings.profiles.import_btn": "Импорт из файла",
"settings.profiles.import_tooltip": "Импортирует настройки из JSON-файла и применяет их здесь.",
"settings.general.language_label": "Язык:", "settings.general.language_label": "Язык:",
"settings.general.language_auto": "Системный язык (авто)", "settings.general.language_auto": "Системный язык (авто)",
"settings.general.language_restart_note": "Изменение языка вступает в силу после перезапуска.", "settings.general.language_restart_note": "Изменение языка вступает в силу после перезапуска.",
@ -123,15 +157,15 @@
"settings.edit_mapping.path_prompt": "Введите локальный путь файловой системы:", "settings.edit_mapping.path_prompt": "Введите локальный путь файловой системы:",
"settings.add_url.title": "Добавить URL", "settings.add_url.title": "Добавить URL",
"settings.add_url.prompt": "Введите шаблон URL (например, http://example.com или http://*.example.com):", "settings.add_url.prompt": "Введите шаблон URL (например, http://example.com или http://*.example.com):",
"settings.profile.save.title": "Сохранить профиль", "settings.profile.save.title": "Сохранить набор",
"settings.profile.save.prompt": "Введите имя профиля (например, работа, личный):", "settings.profile.save.prompt": "Введите имя набора (например, Работа, Клиент A):",
"settings.select_directory.title": "Выберите разрешенную папку", "settings.select_directory.title": "Выберите разрешенную папку",
"settings.select_log_file.title": "Выберите файл журнала", "settings.select_log_file.title": "Выберите файл журнала",
"settings.export_config.title": "Экспорт конфигурации", "settings.export_config.title": "Экспорт настроек",
"settings.import_config.title": "Импорт конфигурации", "settings.import_config.title": "Импорт настроек",
"settings.error.select_mapping": "Выберите сопоставление для редактирования", "settings.error.select_mapping": "Выберите сопоставление для редактирования",
"settings.error.select_profile_load": "Выберите профиль для загрузки", "settings.error.select_profile_load": "Выберите набор для загрузки",
"settings.error.select_profile_delete": "Выберите профиль для удаления", "settings.error.select_profile_delete": "Выберите набор для удаления",
"settings.web_source.url_label": "URL веб-приложения:", "settings.web_source.url_label": "URL веб-приложения:",
"settings.web_source.open_btn": "Открыть", "settings.web_source.open_btn": "Открыть",
"settings.web_source.url_mappings_label": "Сопоставления URL (Azure Blob Storage → локальные пути):", "settings.web_source.url_mappings_label": "Сопоставления URL (Azure Blob Storage → локальные пути):",
@ -154,12 +188,12 @@
"settings.paths.select_dir_title": "Выберите разрешенную папку", "settings.paths.select_dir_title": "Выберите разрешенную папку",
"settings.urls.add_title": "Добавить URL", "settings.urls.add_title": "Добавить URL",
"settings.urls.add_prompt": "Введите шаблон URL (например, http://example.com или http://*.example.com):", "settings.urls.add_prompt": "Введите шаблон URL (например, http://example.com или http://*.example.com):",
"settings.profiles.save_title": "Сохранить профиль", "settings.profiles.save_title": "Сохранить набор",
"settings.profiles.save_prompt": "Введите имя профиля (например, работа, личный):", "settings.profiles.save_prompt": "Введите имя набора (например, Работа, Клиент A):",
"settings.profiles.select_to_load": "Выберите профиль для загрузки", "settings.profiles.select_to_load": "Выберите набор для загрузки",
"settings.profiles.select_to_delete": "Выберите профиль для удаления", "settings.profiles.select_to_delete": "Выберите набор для удаления",
"settings.profiles.export_title": "Экспорт конфигурации", "settings.profiles.export_title": "Экспорт настроек",
"settings.profiles.import_title": "Импорт конфигурации", "settings.profiles.import_title": "Импорт настроек",
"update.checking.title": "Проверка обновлений", "update.checking.title": "Проверка обновлений",
"update.checking.label": "Проверка обновлений...", "update.checking.label": "Проверка обновлений...",

View file

@ -58,6 +58,10 @@
"dialog.language_changed.msg": "语言设置已更新。立即重启可在所有界面生效。", "dialog.language_changed.msg": "语言设置已更新。立即重启可在所有界面生效。",
"dialog.language_changed.restart_now": "立即重启", "dialog.language_changed.restart_now": "立即重启",
"dialog.language_changed.restart_later": "稍后重启", "dialog.language_changed.restart_later": "稍后重启",
"dialog.branding_changed.title": "品牌已更改",
"dialog.branding_changed.msg": "当前品牌配置已更改。请立即重启,以便在所有界面应用更新后的视觉标识。",
"dialog.branding_changed.restart_now": "立即重启",
"dialog.branding_changed.restart_later": "稍后重启",
"dialog.restart_failed.title": "重启失败", "dialog.restart_failed.title": "重启失败",
"dialog.restart_failed.msg": "无法自动重启应用:\n\n{error}\n\n请手动重启。", "dialog.restart_failed.msg": "无法自动重启应用:\n\n{error}\n\n请手动重启。",
"dialog.update_timeout.title": "更新检查超时", "dialog.update_timeout.title": "更新检查超时",
@ -84,8 +88,28 @@
"settings.tab.urls": "URL", "settings.tab.urls": "URL",
"settings.tab.logging": "日志", "settings.tab.logging": "日志",
"settings.tab.window": "窗口", "settings.tab.window": "窗口",
"settings.tab.profiles": "配置档案", "settings.tab.profiles": "设置",
"settings.tab.general": "通用", "settings.tab.general": "通用",
"settings.tab.branding": "品牌",
"settings.branding.select_label": "品牌:",
"settings.branding.select_tooltip": "选择应用启动时应自动加载的品牌模板。",
"settings.branding.help_text": "品牌控制应用的视觉标识,例如名称和图标,并与已保存的设置保持分离。",
"settings.branding.display_name_label": "显示名称:",
"settings.branding.app_name_label": "应用名称:",
"settings.branding.window_title_label": "窗口标题(可选):",
"settings.branding.logo_path_label": "Logo 路径(可选):",
"settings.branding.save_as_btn": "保存品牌配置",
"settings.branding.export_btn": "导出品牌配置",
"settings.branding.import_btn": "导入品牌配置",
"settings.branding.delete_btn": "删除品牌配置",
"settings.branding.export_title": "导出品牌配置",
"settings.branding.import_title": "导入品牌配置",
"settings.branding.preview_label": "预览:",
"settings.branding.no_icon_selected": "未选择图标",
"settings.branding.preview_default_name": "Default",
"settings.branding.save_as_title": "保存品牌配置",
"settings.branding.save_as_prompt": "输入品牌名称:",
"settings.branding.restart_note": "品牌更改会被持久保存,并将在应用重启后完整生效。",
"settings.web_url.label": "Web 应用 URL:", "settings.web_url.label": "Web 应用 URL:",
"settings.web_url.placeholder": "例如: http://localhost:8080 或 file:///./webapp/index.html", "settings.web_url.placeholder": "例如: http://localhost:8080 或 file:///./webapp/index.html",
"settings.web_url.open_btn": "打开", "settings.web_url.open_btn": "打开",
@ -106,12 +130,22 @@
"settings.log_file.browse_btn": "浏览...", "settings.log_file.browse_btn": "浏览...",
"settings.window.width_label": "窗口宽度:", "settings.window.width_label": "窗口宽度:",
"settings.window.height_label": "窗口高度:", "settings.window.height_label": "窗口高度:",
"settings.profiles.label": "已保存配置档案:", "settings.profiles.label": "此设备上已保存的设置:",
"settings.profiles.save_btn": "保存为档案", "settings.profiles.label_tooltip": "已保存设置是当前配置的命名快照,可用于在此设备上快速切换。",
"settings.profiles.load_btn": "加载档案", "settings.profiles.help_text": "将当前状态保存为命名设置,便于在此设备上快速切换。需要备份或共享配置文件时,请使用导出/导入。",
"settings.profiles.delete_btn": "删除档案", "settings.profiles.list_tooltip": "显示此设备上可用的已保存设置。",
"settings.profiles.export_btn": "导出配置", "settings.profiles.save_btn": "保存设置",
"settings.profiles.import_btn": "导入配置", "settings.profiles.save_tooltip": "将当前设置保存为此设备上的命名设置。",
"settings.profiles.load_btn": "加载设置",
"settings.profiles.load_tooltip": "将选中的已保存设置加载到此对话框中。",
"settings.profiles.delete_btn": "删除设置",
"settings.profiles.delete_tooltip": "从此设备删除选中的已保存设置。",
"settings.profiles.transfer_label": "备份或共享当前设置:",
"settings.profiles.transfer_tooltip": "导出会创建一个 JSON 文件用于备份或共享。导入会读取此类文件并在此处应用。",
"settings.profiles.export_btn": "导出到文件",
"settings.profiles.export_tooltip": "将当前设置导出为 JSON 文件,用于备份或共享。",
"settings.profiles.import_btn": "从文件导入",
"settings.profiles.import_tooltip": "从 JSON 文件导入设置并在此处应用。",
"settings.general.language_label": "语言:", "settings.general.language_label": "语言:",
"settings.general.language_auto": "跟随系统(自动)", "settings.general.language_auto": "跟随系统(自动)",
"settings.general.language_restart_note": "语言更改将在重启后生效。", "settings.general.language_restart_note": "语言更改将在重启后生效。",
@ -123,15 +157,15 @@
"settings.edit_mapping.path_prompt": "输入本地文件系统路径:", "settings.edit_mapping.path_prompt": "输入本地文件系统路径:",
"settings.add_url.title": "添加 URL", "settings.add_url.title": "添加 URL",
"settings.add_url.prompt": "输入 URL 模式(例如: http://example.com 或 http://*.example.com:", "settings.add_url.prompt": "输入 URL 模式(例如: http://example.com 或 http://*.example.com:",
"settings.profile.save.title": "保存档案", "settings.profile.save.title": "保存设置",
"settings.profile.save.prompt": "输入配置档案名称(例如: 工作, 个人:", "settings.profile.save.prompt": "输入设置名称例如工作、客户A:",
"settings.select_directory.title": "选择允许目录", "settings.select_directory.title": "选择允许目录",
"settings.select_log_file.title": "选择日志文件", "settings.select_log_file.title": "选择日志文件",
"settings.export_config.title": "导出置", "settings.export_config.title": "导出置",
"settings.import_config.title": "导入置", "settings.import_config.title": "导入置",
"settings.error.select_mapping": "请选择要编辑的映射", "settings.error.select_mapping": "请选择要编辑的映射",
"settings.error.select_profile_load": "请选择要加载的档案", "settings.error.select_profile_load": "请选择要加载的设置",
"settings.error.select_profile_delete": "请选择要删除的档案", "settings.error.select_profile_delete": "请选择要删除的设置",
"settings.web_source.url_label": "Web 应用 URL:", "settings.web_source.url_label": "Web 应用 URL:",
"settings.web_source.open_btn": "打开", "settings.web_source.open_btn": "打开",
"settings.web_source.url_mappings_label": "URL 映射Azure Blob Storage → 本地路径):", "settings.web_source.url_mappings_label": "URL 映射Azure Blob Storage → 本地路径):",
@ -154,12 +188,12 @@
"settings.paths.select_dir_title": "选择允许目录", "settings.paths.select_dir_title": "选择允许目录",
"settings.urls.add_title": "添加 URL", "settings.urls.add_title": "添加 URL",
"settings.urls.add_prompt": "输入 URL 模式(例如: http://example.com 或 http://*.example.com:", "settings.urls.add_prompt": "输入 URL 模式(例如: http://example.com 或 http://*.example.com:",
"settings.profiles.save_title": "保存档案", "settings.profiles.save_title": "保存设置",
"settings.profiles.save_prompt": "输入配置档案名称(例如: 工作, 个人:", "settings.profiles.save_prompt": "输入设置名称例如工作、客户A:",
"settings.profiles.select_to_load": "请选择要加载的档案", "settings.profiles.select_to_load": "请选择要加载的设置",
"settings.profiles.select_to_delete": "请选择要删除的档案", "settings.profiles.select_to_delete": "请选择要删除的设置",
"settings.profiles.export_title": "导出置", "settings.profiles.export_title": "导出置",
"settings.profiles.import_title": "导入置", "settings.profiles.import_title": "导入置",
"update.checking.title": "检查更新", "update.checking.title": "检查更新",
"update.checking.label": "正在检查更新...", "update.checking.label": "正在检查更新...",

View file

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

View file

@ -18,6 +18,12 @@ DEFAULT_UPDATE_BASE_URL = "https://git.him-tools.de"
DEFAULT_UPDATE_REPO = "HIM-public/webdrop-bridge" DEFAULT_UPDATE_REPO = "HIM-public/webdrop-bridge"
DEFAULT_UPDATE_CHANNEL = "stable" DEFAULT_UPDATE_CHANNEL = "stable"
DEFAULT_UPDATE_MANIFEST_NAME = "release-manifest.json" DEFAULT_UPDATE_MANIFEST_NAME = "release-manifest.json"
DEFAULT_ACTIVE_BRANDING_ID = "default"
DEFAULT_APP_ICON_PATH = "resources/icons/app.ico"
DEFAULT_TOOLBAR_ICON_HOME = "resources/icons/home.ico"
DEFAULT_TOOLBAR_ICON_RELOAD = "resources/icons/reload.ico"
DEFAULT_TOOLBAR_ICON_OPEN = "resources/icons/open.ico"
DEFAULT_TOOLBAR_ICON_OPENWITH = "resources/icons/openwith.ico"
class ConfigurationError(Exception): class ConfigurationError(Exception):
@ -96,6 +102,14 @@ class Config:
enable_logging: bool = True enable_logging: bool = True
enable_checkout: bool = False enable_checkout: bool = False
language: str = "auto" language: str = "auto"
active_branding_id: str = DEFAULT_ACTIVE_BRANDING_ID
branding_display_name: str = "Default"
logo_path: str = ""
app_icon_path: str = DEFAULT_APP_ICON_PATH
toolbar_icon_home: str = DEFAULT_TOOLBAR_ICON_HOME
toolbar_icon_reload: str = DEFAULT_TOOLBAR_ICON_RELOAD
toolbar_icon_open: str = DEFAULT_TOOLBAR_ICON_OPEN
toolbar_icon_openwith: str = DEFAULT_TOOLBAR_ICON_OPENWITH
brand_id: str = DEFAULT_BRAND_ID brand_id: str = DEFAULT_BRAND_ID
config_dir_name: str = DEFAULT_CONFIG_DIR_NAME config_dir_name: str = DEFAULT_CONFIG_DIR_NAME
update_base_url: str = DEFAULT_UPDATE_BASE_URL update_base_url: str = DEFAULT_UPDATE_BASE_URL
@ -179,7 +193,7 @@ class Config:
# No window title specified, use default # No window title specified, use default
window_title = f"{app_name} v{__version__}" window_title = f"{app_name} v{__version__}"
return cls( config = cls(
app_name=app_name, app_name=app_name,
app_version=__version__, app_version=__version__,
log_level=data.get("log_level", "INFO").upper(), log_level=data.get("log_level", "INFO").upper(),
@ -197,6 +211,13 @@ class Config:
enable_logging=data.get("enable_logging", True), enable_logging=data.get("enable_logging", True),
enable_checkout=data.get("enable_checkout", False), enable_checkout=data.get("enable_checkout", False),
language=data.get("language", "auto"), language=data.get("language", "auto"),
active_branding_id=data.get("active_branding_id", DEFAULT_ACTIVE_BRANDING_ID),
logo_path=data.get("logo_path", ""),
app_icon_path=data.get("app_icon_path", DEFAULT_APP_ICON_PATH),
toolbar_icon_home=data.get("toolbar_icon_home", DEFAULT_TOOLBAR_ICON_HOME),
toolbar_icon_reload=data.get("toolbar_icon_reload", DEFAULT_TOOLBAR_ICON_RELOAD),
toolbar_icon_open=data.get("toolbar_icon_open", DEFAULT_TOOLBAR_ICON_OPEN),
toolbar_icon_openwith=data.get("toolbar_icon_openwith", DEFAULT_TOOLBAR_ICON_OPENWITH),
brand_id=brand_id, brand_id=brand_id,
config_dir_name=config_dir_name, config_dir_name=config_dir_name,
update_base_url=data.get("update_base_url", DEFAULT_UPDATE_BASE_URL), update_base_url=data.get("update_base_url", DEFAULT_UPDATE_BASE_URL),
@ -204,6 +225,7 @@ class Config:
update_channel=data.get("update_channel", DEFAULT_UPDATE_CHANNEL), update_channel=data.get("update_channel", DEFAULT_UPDATE_CHANNEL),
update_manifest_name=data.get("update_manifest_name", DEFAULT_UPDATE_MANIFEST_NAME), update_manifest_name=data.get("update_manifest_name", DEFAULT_UPDATE_MANIFEST_NAME),
) )
return cls._apply_runtime_branding(config)
@classmethod @classmethod
def from_env(cls, env_file: str | None = None) -> "Config": def from_env(cls, env_file: str | None = None) -> "Config":
@ -246,6 +268,12 @@ class Config:
enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true" enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true"
enable_checkout = os.getenv("ENABLE_CHECKOUT", "false").lower() == "true" enable_checkout = os.getenv("ENABLE_CHECKOUT", "false").lower() == "true"
language = os.getenv("LANGUAGE", "auto") language = os.getenv("LANGUAGE", "auto")
active_branding_id = os.getenv("BRAND_TEMPLATE", DEFAULT_ACTIVE_BRANDING_ID)
app_icon_path = os.getenv("APP_ICON_PATH", DEFAULT_APP_ICON_PATH)
toolbar_icon_home = os.getenv("TOOLBAR_ICON_HOME", DEFAULT_TOOLBAR_ICON_HOME)
toolbar_icon_reload = os.getenv("TOOLBAR_ICON_RELOAD", DEFAULT_TOOLBAR_ICON_RELOAD)
toolbar_icon_open = os.getenv("TOOLBAR_ICON_OPEN", DEFAULT_TOOLBAR_ICON_OPEN)
toolbar_icon_openwith = os.getenv("TOOLBAR_ICON_OPENWITH", DEFAULT_TOOLBAR_ICON_OPENWITH)
update_base_url = os.getenv("UPDATE_BASE_URL", DEFAULT_UPDATE_BASE_URL) update_base_url = os.getenv("UPDATE_BASE_URL", DEFAULT_UPDATE_BASE_URL)
update_repo = os.getenv("UPDATE_REPO", DEFAULT_UPDATE_REPO) update_repo = os.getenv("UPDATE_REPO", DEFAULT_UPDATE_REPO)
update_channel = os.getenv("UPDATE_CHANNEL", DEFAULT_UPDATE_CHANNEL) update_channel = os.getenv("UPDATE_CHANNEL", DEFAULT_UPDATE_CHANNEL)
@ -328,7 +356,7 @@ class Config:
f"Invalid URL_MAPPINGS: {url_mappings_str}. Error: {e}" f"Invalid URL_MAPPINGS: {url_mappings_str}. Error: {e}"
) from e ) from e
return cls( config = cls(
app_name=app_name, app_name=app_name,
app_version=app_version, app_version=app_version,
log_level=log_level, log_level=log_level,
@ -343,6 +371,12 @@ class Config:
enable_logging=enable_logging, enable_logging=enable_logging,
enable_checkout=enable_checkout, enable_checkout=enable_checkout,
language=language, language=language,
active_branding_id=active_branding_id,
app_icon_path=app_icon_path,
toolbar_icon_home=toolbar_icon_home,
toolbar_icon_reload=toolbar_icon_reload,
toolbar_icon_open=toolbar_icon_open,
toolbar_icon_openwith=toolbar_icon_openwith,
brand_id=brand_id, brand_id=brand_id,
config_dir_name=config_dir_name, config_dir_name=config_dir_name,
update_base_url=update_base_url, update_base_url=update_base_url,
@ -350,6 +384,7 @@ class Config:
update_channel=update_channel, update_channel=update_channel,
update_manifest_name=update_manifest_name, update_manifest_name=update_manifest_name,
) )
return cls._apply_runtime_branding(config)
def to_file(self, config_path: Path) -> None: def to_file(self, config_path: Path) -> None:
"""Save configuration to JSON file. """Save configuration to JSON file.
@ -378,6 +413,7 @@ class Config:
"enable_logging": self.enable_logging, "enable_logging": self.enable_logging,
"enable_checkout": self.enable_checkout, "enable_checkout": self.enable_checkout,
"language": self.language, "language": self.language,
"active_branding_id": self.active_branding_id,
"brand_id": self.brand_id, "brand_id": self.brand_id,
"config_dir_name": self.config_dir_name, "config_dir_name": self.config_dir_name,
"update_base_url": self.update_base_url, "update_base_url": self.update_base_url,
@ -390,6 +426,17 @@ class Config:
with open(config_path, "w", encoding="utf-8") as f: with open(config_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2) json.dump(data, f, indent=2)
@staticmethod
def _apply_runtime_branding(config: "Config") -> "Config":
"""Apply the persisted runtime branding template to cosmetic fields."""
try:
from webdrop_bridge.core.branding_manager import BrandingManager
BrandingManager().apply_to_config(config)
except Exception as e:
logger.warning(f"Failed to apply runtime branding: {e}")
return config
@staticmethod @staticmethod
def load_bootstrap_env(env_file: str | None = None) -> Path | None: def load_bootstrap_env(env_file: str | None = None) -> Path | None:
"""Load a bootstrap .env before configuration path lookup. """Load a bootstrap .env before configuration path lookup.

View file

@ -0,0 +1,406 @@
"""Runtime branding template management for the shared application."""
from __future__ import annotations
import json
import logging
import os
import platform
import shutil
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
from webdrop_bridge.config import DEFAULT_CONFIG_DIR_NAME, Config, ConfigurationError
logger = logging.getLogger(__name__)
DEFAULT_BRANDING_TEMPLATE_ID = "default"
DEFAULT_LOGO_PATH = "resources/icons/app.png"
SUPPORTED_LOGO_SUFFIXES = {".png", ".jpg", ".jpeg", ".bmp", ".svg", ".ico", ".icns"}
@dataclass(frozen=True)
class BrandingTemplate:
"""Serializable runtime branding template."""
template_id: str
display_name: str
app_name: str
window_title: str = ""
logo_path: str = ""
app_icon_path_windows: str = "resources/icons/app.ico"
app_icon_path_macos: str = "resources/icons/app.icns"
toolbar_icon_home: str = "resources/icons/home.ico"
toolbar_icon_reload: str = "resources/icons/reload.ico"
toolbar_icon_open: str = "resources/icons/open.ico"
toolbar_icon_openwith: str = "resources/icons/openwith.ico"
accent_color: str = "#667eea"
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "BrandingTemplate":
"""Create a template from a JSON-compatible dictionary."""
template_id = str(data.get("template_id") or data.get("id") or "").strip()
display_name = str(data.get("display_name") or template_id or "").strip()
app_name = str(data.get("app_name") or display_name or "").strip()
if not template_id:
raise ConfigurationError("Branding template requires a template_id")
if not display_name:
raise ConfigurationError("Branding template requires a display_name")
if not app_name:
raise ConfigurationError("Branding template requires an app_name")
return cls(
template_id=template_id,
display_name=display_name,
app_name=app_name,
window_title=str(data.get("window_title", "")),
logo_path=str(data.get("logo_path", "")),
app_icon_path_windows=str(data.get("app_icon_path_windows", "resources/icons/app.ico")),
app_icon_path_macos=str(data.get("app_icon_path_macos", "resources/icons/app.icns")),
toolbar_icon_home=str(data.get("toolbar_icon_home", "resources/icons/home.ico")),
toolbar_icon_reload=str(data.get("toolbar_icon_reload", "resources/icons/reload.ico")),
toolbar_icon_open=str(data.get("toolbar_icon_open", "resources/icons/open.ico")),
toolbar_icon_openwith=str(
data.get("toolbar_icon_openwith", "resources/icons/openwith.ico")
),
accent_color=str(data.get("accent_color", "#667eea")),
)
def to_dict(self) -> dict[str, Any]:
"""Convert the template to a JSON-compatible dictionary."""
return asdict(self)
def get_app_icon_path(self) -> str:
"""Return the best app icon path for the current platform."""
if platform.system() == "Darwin":
return self.app_icon_path_macos
return self.app_icon_path_windows
BUILTIN_BRANDING_TEMPLATES: dict[str, BrandingTemplate] = {
"default": BrandingTemplate(
template_id="default",
display_name="Default",
app_name="WebDrop Bridge",
window_title="",
logo_path=DEFAULT_LOGO_PATH,
accent_color="#667eea",
),
"agravity": BrandingTemplate(
template_id="agravity",
display_name="Agravity",
app_name="Agravity Bridge",
window_title="",
logo_path=DEFAULT_LOGO_PATH,
accent_color="#2d7d6e",
),
}
class BrandingManager:
"""Manage runtime branding templates independently from saved setups."""
def __init__(self, base_dir: Path | None = None) -> None:
env_dir = os.getenv("WEBDROP_BRANDING_DIR")
resolved_base = Path(env_dir).resolve() if env_dir and base_dir is None else base_dir
self.base_dir = resolved_base or self._default_base_dir()
self.templates_dir = self.base_dir / "templates"
self.assets_dir = self.base_dir / "assets"
self.active_branding_path = self.base_dir / "active_branding.json"
self.ensure_builtin_templates()
@staticmethod
def _default_base_dir() -> Path:
"""Return the shared branding storage directory."""
return Config.get_default_config_path(DEFAULT_CONFIG_DIR_NAME).parent / "branding"
def ensure_builtin_templates(self) -> None:
"""Ensure built-in templates exist on disk for discovery and later editing."""
self.templates_dir.mkdir(parents=True, exist_ok=True)
self.assets_dir.mkdir(parents=True, exist_ok=True)
for template in BUILTIN_BRANDING_TEMPLATES.values():
template_path = self.templates_dir / f"{template.template_id}.json"
if not template_path.exists():
template_path.write_text(json.dumps(template.to_dict(), indent=2), encoding="utf-8")
def list_templates(self) -> list[BrandingTemplate]:
"""List all available templates with built-ins guaranteed."""
self.ensure_builtin_templates()
templates: dict[str, BrandingTemplate] = {}
for template_path in self.templates_dir.glob("*.json"):
try:
data = json.loads(template_path.read_text(encoding="utf-8"))
template = BrandingTemplate.from_dict(data)
templates[template.template_id] = template
except (OSError, json.JSONDecodeError, ConfigurationError) as exc:
logger.warning("Skipping invalid branding template %s: %s", template_path, exc)
for template_id, template in BUILTIN_BRANDING_TEMPLATES.items():
templates.setdefault(template_id, template)
ordered_templates = sorted(
templates.values(),
key=lambda template: (
template.template_id != DEFAULT_BRANDING_TEMPLATE_ID,
template.display_name.lower(),
),
)
return ordered_templates
def has_template(self, template_id: str) -> bool:
"""Return whether a template with the given id exists."""
return any(template.template_id == template_id for template in self.list_templates())
def load_template(self, template_id: str) -> BrandingTemplate:
"""Load a template by id, falling back to the default template if missing."""
for template in self.list_templates():
if template.template_id == template_id:
return template
logger.warning("Branding template '%s' not found. Falling back to default.", template_id)
return BUILTIN_BRANDING_TEMPLATES[DEFAULT_BRANDING_TEMPLATE_ID]
def save_template(self, template: BrandingTemplate) -> Path:
"""Save or update a branding template on disk."""
if not template.template_id:
raise ConfigurationError("Branding template requires a template_id")
if template.template_id in BUILTIN_BRANDING_TEMPLATES:
raise ConfigurationError(f"Cannot overwrite built-in branding: {template.template_id}")
stored_logo_path = ""
if template.logo_path:
stored_logo_path = self._copy_logo_asset(template.logo_path, template.template_id)
stored_template = BrandingTemplate(
template_id=template.template_id,
display_name=template.display_name,
app_name=template.app_name,
window_title=template.window_title,
logo_path=stored_logo_path or template.logo_path,
app_icon_path_windows=stored_logo_path or template.app_icon_path_windows,
app_icon_path_macos=stored_logo_path or template.app_icon_path_macos,
toolbar_icon_home=template.toolbar_icon_home,
toolbar_icon_reload=template.toolbar_icon_reload,
toolbar_icon_open=template.toolbar_icon_open,
toolbar_icon_openwith=template.toolbar_icon_openwith,
accent_color=template.accent_color,
)
self.templates_dir.mkdir(parents=True, exist_ok=True)
template_path = self.templates_dir / f"{stored_template.template_id}.json"
template_path.write_text(json.dumps(stored_template.to_dict(), indent=2), encoding="utf-8")
logger.info("Branding template saved: %s", stored_template.template_id)
return template_path
def build_template(
self,
*,
template_id: str,
display_name: str,
app_name: str = "",
window_title: str = "",
logo_path: str = "",
) -> BrandingTemplate:
"""Build a validated branding template from editable UI fields."""
safe_id = self._slugify(template_id.strip() or display_name)
safe_name = display_name.strip()
logo = logo_path.strip()
resolved_app_name = (app_name or display_name).strip()
if not safe_id:
raise ConfigurationError("Branding requires a name")
if not safe_name:
raise ConfigurationError("Branding requires a display name")
if logo:
logo_file = self._resolve_asset_path(logo)
if logo_file is None or not logo_file.exists() or not logo_file.is_file():
raise ConfigurationError(f"Logo file not found: {logo}")
if logo_file.suffix.lower() not in SUPPORTED_LOGO_SUFFIXES:
raise ConfigurationError(
"Unsupported logo format. Use PNG, JPG, BMP, SVG, ICO, or ICNS."
)
return BrandingTemplate(
template_id=safe_id,
display_name=safe_name,
app_name=resolved_app_name,
window_title=window_title.strip(),
logo_path=logo,
app_icon_path_windows=logo or "resources/icons/app.ico",
app_icon_path_macos=logo or "resources/icons/app.icns",
)
@staticmethod
def _slugify(value: str) -> str:
"""Convert a human-readable branding name into a stable id."""
return "".join(c.lower() if c.isalnum() else "_" for c in value).strip("_")
@staticmethod
def _resolve_asset_path(configured_path: str) -> Path | None:
"""Resolve a branding asset path in dev and packaged layouts."""
if not configured_path:
return None
path = Path(configured_path)
candidates = [path] if path.is_absolute() else [Path.cwd() / path]
if not path.is_absolute():
project_root = Path(__file__).resolve().parents[3]
candidates.append(project_root / path)
for candidate in candidates:
if candidate.exists():
return candidate
return None
def _copy_logo_asset(self, configured_path: str, template_id: str) -> str:
"""Copy a user-selected logo into managed branding storage."""
resolved_path = self._resolve_asset_path(configured_path)
if resolved_path is None or not resolved_path.exists() or not resolved_path.is_file():
raise ConfigurationError(f"Logo file not found: {configured_path}")
self.assets_dir.mkdir(parents=True, exist_ok=True)
target_path = self.assets_dir / f"{template_id}{resolved_path.suffix.lower()}"
if resolved_path.resolve() != target_path.resolve():
shutil.copy2(resolved_path, target_path)
return str(target_path)
def export_template(self, template_id: str, export_path: Path) -> Path:
"""Export a branding into a shareable JSON file plus optional logo asset."""
template = self.load_template(template_id)
export_path.parent.mkdir(parents=True, exist_ok=True)
export_data = template.to_dict()
if template.logo_path:
resolved_logo = self._resolve_asset_path(template.logo_path)
if resolved_logo and resolved_logo.exists() and resolved_logo.is_file():
export_logo_path = export_path.parent / resolved_logo.name
if resolved_logo.resolve() != export_logo_path.resolve():
shutil.copy2(resolved_logo, export_logo_path)
export_data["logo_path"] = export_logo_path.name
export_data["app_icon_path_windows"] = export_logo_path.name
export_data["app_icon_path_macos"] = export_logo_path.name
export_path.write_text(json.dumps(export_data, indent=2), encoding="utf-8")
logger.info("Branding template exported: %s -> %s", template_id, export_path)
return export_path
def import_template(self, import_path: Path) -> BrandingTemplate:
"""Import a branding from a previously exported JSON file."""
if not import_path.exists() or not import_path.is_file():
raise ConfigurationError(f"Branding import file not found: {import_path}")
try:
data = json.loads(import_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ConfigurationError(f"Invalid branding import file: {import_path}") from exc
template = BrandingTemplate.from_dict(data)
imported_template_id = template.template_id
if imported_template_id in BUILTIN_BRANDING_TEMPLATES:
imported_template_id = self._slugify(f"{template.display_name}_imported")
imported_logo_path = ""
if template.logo_path:
candidate_logo = Path(template.logo_path)
if not candidate_logo.is_absolute():
candidate_logo = import_path.parent / candidate_logo
imported_logo_path = self._copy_logo_asset(str(candidate_logo), imported_template_id)
imported_template = BrandingTemplate(
template_id=imported_template_id,
display_name=template.display_name,
app_name=template.app_name,
window_title=template.window_title,
logo_path=imported_logo_path,
app_icon_path_windows=imported_logo_path or template.app_icon_path_windows,
app_icon_path_macos=imported_logo_path or template.app_icon_path_macos,
toolbar_icon_home=template.toolbar_icon_home,
toolbar_icon_reload=template.toolbar_icon_reload,
toolbar_icon_open=template.toolbar_icon_open,
toolbar_icon_openwith=template.toolbar_icon_openwith,
accent_color=template.accent_color,
)
self.save_template(imported_template)
logger.info("Branding template imported: %s", imported_template.template_id)
return imported_template
def delete_template(self, template_id: str) -> None:
"""Delete a user template while protecting built-ins."""
if template_id in BUILTIN_BRANDING_TEMPLATES:
raise ConfigurationError(f"Cannot delete built-in branding template: {template_id}")
template_path = self.templates_dir / f"{template_id}.json"
if not template_path.exists():
raise ConfigurationError(f"Branding template not found: {template_id}")
template_path.unlink()
logger.info("Branding template deleted: %s", template_id)
def get_active_branding_id(self) -> str:
"""Return the persisted active branding selection."""
if not self.active_branding_path.exists():
return DEFAULT_BRANDING_TEMPLATE_ID
try:
data = json.loads(self.active_branding_path.read_text(encoding="utf-8"))
template_id = str(data.get("active_branding_id", DEFAULT_BRANDING_TEMPLATE_ID))
return template_id if self.has_template(template_id) else DEFAULT_BRANDING_TEMPLATE_ID
except (OSError, json.JSONDecodeError):
return DEFAULT_BRANDING_TEMPLATE_ID
def set_active_branding_id(self, template_id: str) -> None:
"""Persist the active branding selection."""
if not self.has_template(template_id):
raise ConfigurationError(f"Branding template not found: {template_id}")
self.base_dir.mkdir(parents=True, exist_ok=True)
self.active_branding_path.write_text(
json.dumps({"active_branding_id": template_id}, indent=2),
encoding="utf-8",
)
logger.info("Active branding set to %s", template_id)
def apply_to_config(self, config: Config) -> BrandingTemplate:
"""Apply the active branding template to cosmetic config fields only."""
requested_id = (getattr(config, "active_branding_id", "") or "").strip()
if not requested_id or requested_id == DEFAULT_BRANDING_TEMPLATE_ID:
requested_id = self.get_active_branding_id()
template = self.load_template(requested_id)
default_app_name = BUILTIN_BRANDING_TEMPLATES[DEFAULT_BRANDING_TEMPLATE_ID].app_name
known_app_names = {known_template.app_name for known_template in self.list_templates()}
known_title_prefixes = {f"{app_name} v" for app_name in known_app_names}
config.active_branding_id = template.template_id
config.branding_display_name = template.display_name
if (
template.template_id != DEFAULT_BRANDING_TEMPLATE_ID
or not config.app_name
or config.app_name in known_app_names
):
config.app_name = template.app_name or default_app_name
if (
template.template_id != DEFAULT_BRANDING_TEMPLATE_ID
or not config.window_title
or any(config.window_title.startswith(prefix) for prefix in known_title_prefixes)
):
config.window_title = (
template.window_title or f"{config.app_name} v{config.app_version}"
)
config.logo_path = template.logo_path
config.app_icon_path = template.get_app_icon_path()
config.toolbar_icon_home = template.toolbar_icon_home
config.toolbar_icon_reload = template.toolbar_icon_reload
config.toolbar_icon_open = template.toolbar_icon_open
config.toolbar_icon_openwith = template.toolbar_icon_openwith
return template

View file

@ -427,7 +427,9 @@ class MainWindow(QMainWindow):
self._background_threads = [] # Keep references to background threads self._background_threads = [] # Keep references to background threads
self._background_workers = {} # Keep references to background workers self._background_workers = {} # Keep references to background workers
self._bridge_script_source = "" # Cache combined bridge source for recovery injection self._bridge_script_source = "" # Cache combined bridge source for recovery injection
self._bridge_script_re_registered = False # Flag to prevent duplicate re-registration on same load self._bridge_script_re_registered = (
False # Flag to prevent duplicate re-registration on same load
)
self._is_page_loading = False # Track if a page load is currently in progress self._is_page_loading = False # Track if a page load is currently in progress
self._pending_reload = False # Coalesce multiple rapid reload requests into one self._pending_reload = False # Coalesce multiple rapid reload requests into one
self._load_sequence = 0 # Monotonic counter to ignore stale async recovery callbacks self._load_sequence = 0 # Monotonic counter to ignore stale async recovery callbacks
@ -444,22 +446,13 @@ class MainWindow(QMainWindow):
config.window_height, config.window_height,
) )
# Set window icon # Set window icon from the active runtime branding
# Support both development mode and PyInstaller bundle icon_path = self._resolve_toolbar_icon_path(config.app_icon_path)
if hasattr(sys, "_MEIPASS"): if icon_path is not None:
# Running as PyInstaller bundle
icon_path = Path(sys._MEIPASS) / "resources" / "icons" / "app.ico" # type: ignore
else:
# Running in development mode
icon_path = (
Path(__file__).parent.parent.parent.parent / "resources" / "icons" / "app.ico"
)
if icon_path.exists():
self.setWindowIcon(QIcon(str(icon_path))) self.setWindowIcon(QIcon(str(icon_path)))
logger.debug(f"Window icon set from {icon_path}") logger.debug(f"Window icon set from {icon_path}")
else: else:
logger.warning(f"Window icon not found at {icon_path}") logger.warning(f"Window icon not found for configured path: {config.app_icon_path}")
# Create web engine view with URL for profile isolation # Create web engine view with URL for profile isolation
self.web_view = RestrictedWebEngineView( self.web_view = RestrictedWebEngineView(
@ -1189,7 +1182,9 @@ class MainWindow(QMainWindow):
# This more reliably opens files with chosen applications. # This more reliably opens files with chosen applications.
# Use a simple, more direct approach # Use a simple, more direct approach
# Get the chosen app via AppleScript, then use open command # Get the chosen app via AppleScript, then use open command
get_app_script = '''choose application with title "Select an application to open the file"''' get_app_script = (
'''choose application with title "Select an application to open the file"'''
)
try: try:
# Get the chosen application # Get the chosen application
app_result = subprocess.run( app_result = subprocess.run(
@ -1199,19 +1194,21 @@ class MainWindow(QMainWindow):
text=True, text=True,
timeout=30, timeout=30,
) )
if app_result.returncode != 0: if app_result.returncode != 0:
logger.warning(f"User cancelled app chooser or error occurred: {app_result.stderr}") logger.warning(
f"User cancelled app chooser or error occurred: {app_result.stderr}"
)
return False return False
# Get the application name (strip whitespace) # Get the application name (strip whitespace)
chosen_app = app_result.stdout.strip() chosen_app = app_result.stdout.strip()
if not chosen_app: if not chosen_app:
logger.warning("No application was selected") logger.warning("No application was selected")
return False return False
logger.info(f"User selected app: {chosen_app}") logger.info(f"User selected app: {chosen_app}")
# Now open the file with the chosen app using the 'open' command # Now open the file with the chosen app using the 'open' command
open_result = subprocess.run( open_result = subprocess.run(
["open", "-a", chosen_app, normalized_path], ["open", "-a", chosen_app, normalized_path],
@ -1220,14 +1217,16 @@ class MainWindow(QMainWindow):
text=True, text=True,
timeout=10, timeout=10,
) )
if open_result.returncode == 0: if open_result.returncode == 0:
logger.info(f"Opened '{normalized_path}' with '{chosen_app}'") logger.info(f"Opened '{normalized_path}' with '{chosen_app}'")
return True return True
else: else:
logger.warning(f"Failed to open file with '{chosen_app}': {open_result.stderr}") logger.warning(
f"Failed to open file with '{chosen_app}': {open_result.stderr}"
)
return False return False
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
logger.warning("App chooser timed out") logger.warning("App chooser timed out")
return False return False
@ -1393,7 +1392,7 @@ class MainWindow(QMainWindow):
Re-registers the bridge script to ensure it will be injected on reload, Re-registers the bridge script to ensure it will be injected on reload,
page navigation, or any load event. page navigation, or any load event.
Uses a flag to prevent duplicate re-registrations if loadStarted fires multiple times. Uses a flag to prevent duplicate re-registrations if loadStarted fires multiple times.
""" """
self._is_page_loading = True self._is_page_loading = True
@ -1412,7 +1411,7 @@ class MainWindow(QMainWindow):
Checks if the bridge script was successfully injected, with automatic recovery Checks if the bridge script was successfully injected, with automatic recovery
for page reloads and redirects. for page reloads and redirects.
Resets the re-registration flag for the next load cycle. Resets the re-registration flag for the next load cycle.
Args: Args:
@ -1433,9 +1432,11 @@ class MainWindow(QMainWindow):
logger.warning("Page failed to load") logger.warning("Page failed to load")
return return
def _verify_bridge_loaded(stage: str, attempt: int = 1, sequence: int = finished_sequence) -> None: def _verify_bridge_loaded(
stage: str, attempt: int = 1, sequence: int = finished_sequence
) -> None:
"""Check if bridge marker exists and optionally recover script injection. """Check if bridge marker exists and optionally recover script injection.
Implements multi-attempt recovery strategy: Implements multi-attempt recovery strategy:
- initial: First check after page load (50ms delay) - initial: First check after page load (50ms delay)
- recovery_N: Recovery attempts with progressive delays - recovery_N: Recovery attempts with progressive delays
@ -1485,9 +1486,7 @@ class MainWindow(QMainWindow):
delay = int(100 * (1.5 ** (attempt - 1))) delay = int(100 * (1.5 ** (attempt - 1)))
QTimer.singleShot( QTimer.singleShot(
delay, delay,
lambda: _verify_bridge_loaded( lambda: _verify_bridge_loaded("recovery", attempt + 1, sequence),
"recovery", attempt + 1, sequence
),
) )
self.web_view.page().runJavaScript(self._bridge_script_source, after_retry) self.web_view.page().runJavaScript(self._bridge_script_source, after_retry)
@ -1507,11 +1506,15 @@ class MainWindow(QMainWindow):
) )
self._re_register_bridge_script() self._re_register_bridge_script()
self.web_view.page().runJavaScript(self._bridge_script_source, after_re_register) self.web_view.page().runJavaScript(
self._bridge_script_source, after_re_register
)
return return
# All recovery attempts exhausted # All recovery attempts exhausted
logger.error("❌ WebDrop Bridge script failed to inject after all recovery attempts!") logger.error(
"❌ WebDrop Bridge script failed to inject after all recovery attempts!"
)
logger.error(" Drag-and-drop functionality is DISABLED") logger.error(" Drag-and-drop functionality is DISABLED")
logger.debug(f" Stage: {stage}, Attempt: {attempt}") logger.debug(f" Stage: {stage}, Attempt: {attempt}")
@ -1543,21 +1546,21 @@ class MainWindow(QMainWindow):
def _ensure_bridge_script_exists(self, verbose: bool = False) -> None: def _ensure_bridge_script_exists(self, verbose: bool = False) -> None:
"""Ensure bridge script exists in QWebEngineScript collection (idempotent). """Ensure bridge script exists in QWebEngineScript collection (idempotent).
Checks if the script already exists. If not, adds it. Checks if the script already exists. If not, adds it.
Never removes/re-adds to avoid race conditions with Qt's injection mechanism. Never removes/re-adds to avoid race conditions with Qt's injection mechanism.
This is safer than removing+re-adding because: This is safer than removing+re-adding because:
- Avoids concurrent access conflicts with Qt's internal injection - Avoids concurrent access conflicts with Qt's internal injection
- Prevents missing injections during rapid reloads - Prevents missing injections during rapid reloads
- Guarantees script is available without timing gaps - Guarantees script is available without timing gaps
Args: Args:
verbose: If True, use debug logging; otherwise use minimal logging verbose: If True, use debug logging; otherwise use minimal logging
""" """
try: try:
scripts = self.web_view.page().scripts() scripts = self.web_view.page().scripts()
# Check if script already exists # Check if script already exists
already_exists = False already_exists = False
for script in scripts.toList(): # type: ignore for script in scripts.toList(): # type: ignore
@ -1566,7 +1569,7 @@ class MainWindow(QMainWindow):
if verbose: if verbose:
logger.debug("Bridge script already exists in page().scripts()") logger.debug("Bridge script already exists in page().scripts()")
break break
# If script doesn't exist, add it # If script doesn't exist, add it
if not already_exists and self._bridge_script_source: if not already_exists and self._bridge_script_source:
new_script = QWebEngineScript() new_script = QWebEngineScript()
@ -1582,16 +1585,18 @@ class MainWindow(QMainWindow):
new_script.setSourceCode(self._bridge_script_source) new_script.setSourceCode(self._bridge_script_source)
scripts.insert(new_script) scripts.insert(new_script)
logger.debug(f"✓ Added bridge script to collection ({len(self._bridge_script_source)} chars)") logger.debug(
f"✓ Added bridge script to collection ({len(self._bridge_script_source)} chars)"
)
except Exception as e: except Exception as e:
logger.error(f"Failed to ensure bridge script exists: {e}") logger.error(f"Failed to ensure bridge script exists: {e}")
def _re_register_bridge_script(self, verbose: bool = False) -> None: def _re_register_bridge_script(self, verbose: bool = False) -> None:
"""Force re-registration of bridge script in QWebEngineScript collection. """Force re-registration of bridge script in QWebEngineScript collection.
Removes old script and re-adds it to ensure it's injected on next page load. Removes old script and re-adds it to ensure it's injected on next page load.
This is a fallback for recovery mechanics when normal injection fails. This is a fallback for recovery mechanics when normal injection fails.
Args: Args:
verbose: If True, use debug logging; otherwise use minimal logging verbose: If True, use debug logging; otherwise use minimal logging
""" """
@ -1622,7 +1627,9 @@ class MainWindow(QMainWindow):
scripts.insert(new_script) scripts.insert(new_script)
if verbose or removed: if verbose or removed:
logger.debug(f"✓ Re-registered webdrop-bridge script ({len(self._bridge_script_source)} chars)") logger.debug(
f"✓ Re-registered webdrop-bridge script ({len(self._bridge_script_source)} chars)"
)
except Exception as e: except Exception as e:
logger.error(f"Failed to re-register bridge script: {e}") logger.error(f"Failed to re-register bridge script: {e}")
@ -1649,9 +1656,7 @@ class MainWindow(QMainWindow):
toolbar.addSeparator() toolbar.addSeparator()
# Home button # Home button
home_icon_path = self._resolve_toolbar_icon_path( home_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_home)
os.getenv("TOOLBAR_ICON_HOME", "resources/icons/home.ico")
)
home_icon = ( home_icon = (
QIcon(str(home_icon_path)) QIcon(str(home_icon_path))
if home_icon_path is not None if home_icon_path is not None
@ -1663,9 +1668,7 @@ class MainWindow(QMainWindow):
# Refresh button # Refresh button
refresh_action = toolbar.addAction("") refresh_action = toolbar.addAction("")
reload_icon_path = self._resolve_toolbar_icon_path( reload_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_reload)
os.getenv("TOOLBAR_ICON_RELOAD", "resources/icons/reload.ico")
)
if reload_icon_path is not None: if reload_icon_path is not None:
refresh_action.setIcon(QIcon(str(reload_icon_path))) refresh_action.setIcon(QIcon(str(reload_icon_path)))
else: else:
@ -1677,9 +1680,7 @@ class MainWindow(QMainWindow):
# Open-with-default-app drop zone (right of Reload) # Open-with-default-app drop zone (right of Reload)
self._open_drop_zone = OpenDropZone() self._open_drop_zone = OpenDropZone()
open_icon_path = self._resolve_toolbar_icon_path( open_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_open)
os.getenv("TOOLBAR_ICON_OPEN", "resources/icons/open.ico")
)
if open_icon_path is not None: if open_icon_path is not None:
self._open_drop_zone.set_icon(QIcon(str(open_icon_path))) self._open_drop_zone.set_icon(QIcon(str(open_icon_path)))
self._open_drop_zone.file_opened.connect(self._on_file_opened_via_drop) self._open_drop_zone.file_opened.connect(self._on_file_opened_via_drop)
@ -1690,9 +1691,7 @@ class MainWindow(QMainWindow):
# Open-with chooser drop zone (right of Open-with-default-app) # Open-with chooser drop zone (right of Open-with-default-app)
self._open_with_drop_zone = OpenWithDropZone() self._open_with_drop_zone = OpenWithDropZone()
open_with_icon_path = self._resolve_toolbar_icon_path( open_with_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_openwith)
os.getenv("TOOLBAR_ICON_OPENWITH", "resources/icons/openwith.ico")
)
if open_with_icon_path is not None: if open_with_icon_path is not None:
self._open_with_drop_zone.set_icon(QIcon(str(open_with_icon_path))) self._open_with_drop_zone.set_icon(QIcon(str(open_with_icon_path)))
self._open_with_drop_zone.file_open_with_requested.connect( self._open_with_drop_zone.file_open_with_requested.connect(
@ -1959,6 +1958,7 @@ class MainWindow(QMainWindow):
# Store current URL before opening dialog # Store current URL before opening dialog
old_webapp_url = self.config.webapp_url old_webapp_url = self.config.webapp_url
old_language = self.config.language old_language = self.config.language
old_branding_id = self.config.active_branding_id
# Show dialog # Show dialog
dialog = SettingsDialog(self.config, self) dialog = SettingsDialog(self.config, self)
@ -1967,6 +1967,9 @@ class MainWindow(QMainWindow):
# Check if webapp URL changed # Check if webapp URL changed
new_webapp_url = self.config.webapp_url new_webapp_url = self.config.webapp_url
language_changed = old_language != self.config.language language_changed = old_language != self.config.language
branding_changed = old_branding_id != self.config.active_branding_id
restart_prompt_shown = False
if old_webapp_url != new_webapp_url: if old_webapp_url != new_webapp_url:
logger.info(f"Web application URL changed: {old_webapp_url}{new_webapp_url}") logger.info(f"Web application URL changed: {old_webapp_url}{new_webapp_url}")
@ -1976,6 +1979,7 @@ class MainWindow(QMainWindow):
if domain_changed: if domain_changed:
logger.warning("Domain has changed - recommending restart") logger.warning("Domain has changed - recommending restart")
self._handle_domain_change_restart() self._handle_domain_change_restart()
restart_prompt_shown = True
else: else:
logger.info("Path changed but domain is same - reloading...") logger.info("Path changed but domain is same - reloading...")
# Clear cache and navigate to home asynchronously # Clear cache and navigate to home asynchronously
@ -1983,7 +1987,16 @@ class MainWindow(QMainWindow):
self.web_view.clear_cache_and_cookies() self.web_view.clear_cache_and_cookies()
QTimer.singleShot(100, self._navigate_home) QTimer.singleShot(100, self._navigate_home)
if language_changed: if not restart_prompt_shown and branding_changed:
logger.info(
"Branding changed: %s%s",
old_branding_id,
self.config.active_branding_id,
)
self._handle_branding_change_restart()
restart_prompt_shown = True
if not restart_prompt_shown and language_changed:
logger.info(f"Language changed: {old_language}{self.config.language}") logger.info(f"Language changed: {old_language}{self.config.language}")
self._handle_language_change_restart() self._handle_language_change_restart()
@ -2047,21 +2060,42 @@ class MainWindow(QMainWindow):
self.web_view.clear_cache_and_cookies() self.web_view.clear_cache_and_cookies()
self._navigate_home() self._navigate_home()
def _handle_branding_change_restart(self) -> None:
"""Handle branding change by prompting for an optional restart."""
self._show_restart_prompt(
title_key="dialog.branding_changed.title",
message_key="dialog.branding_changed.msg",
restart_now_key="dialog.branding_changed.restart_now",
restart_later_key="dialog.branding_changed.restart_later",
)
def _handle_language_change_restart(self) -> None: def _handle_language_change_restart(self) -> None:
"""Handle language change by prompting for an optional restart.""" """Handle language change by prompting for an optional restart."""
self._show_restart_prompt(
title_key="dialog.language_changed.title",
message_key="dialog.language_changed.msg",
restart_now_key="dialog.language_changed.restart_now",
restart_later_key="dialog.language_changed.restart_later",
)
def _show_restart_prompt(
self,
*,
title_key: str,
message_key: str,
restart_now_key: str,
restart_later_key: str,
) -> None:
"""Show a restart prompt for settings that require a full restart."""
from PySide6.QtWidgets import QMessageBox from PySide6.QtWidgets import QMessageBox
msg = QMessageBox(self) msg = QMessageBox(self)
msg.setWindowTitle(tr("dialog.language_changed.title")) msg.setWindowTitle(tr(title_key))
msg.setIcon(QMessageBox.Icon.Information) msg.setIcon(QMessageBox.Icon.Information)
msg.setText(tr("dialog.language_changed.msg")) msg.setText(tr(message_key))
restart_now_btn = msg.addButton( restart_now_btn = msg.addButton(tr(restart_now_key), QMessageBox.ButtonRole.AcceptRole)
tr("dialog.language_changed.restart_now"), QMessageBox.ButtonRole.AcceptRole msg.addButton(tr(restart_later_key), QMessageBox.ButtonRole.RejectRole)
)
msg.addButton(
tr("dialog.language_changed.restart_later"), QMessageBox.ButtonRole.RejectRole
)
msg.exec() msg.exec()

View file

@ -4,12 +4,14 @@ import logging
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from PySide6.QtGui import QIcon, QPixmap
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QComboBox,
QDialog, QDialog,
QDialogButtonBox, QDialogButtonBox,
QFileDialog, QFileDialog,
QHBoxLayout, QHBoxLayout,
QInputDialog,
QLabel, QLabel,
QLineEdit, QLineEdit,
QListWidget, QListWidget,
@ -23,6 +25,7 @@ from PySide6.QtWidgets import (
) )
from webdrop_bridge.config import Config, ConfigurationError from webdrop_bridge.config import Config, ConfigurationError
from webdrop_bridge.core.branding_manager import BrandingManager
from webdrop_bridge.core.config_manager import ConfigExporter, ConfigProfile, ConfigValidator from webdrop_bridge.core.config_manager import ConfigExporter, ConfigProfile, ConfigValidator
from webdrop_bridge.utils.i18n import get_available_languages, tr from webdrop_bridge.utils.i18n import get_available_languages, tr
from webdrop_bridge.utils.logging import reconfigure_logging from webdrop_bridge.utils.logging import reconfigure_logging
@ -42,6 +45,7 @@ class SettingsDialog(QDialog):
""" """
super().__init__(parent) super().__init__(parent)
self.config = config self.config = config
self.branding_manager = BrandingManager()
self.profile_manager = ConfigProfile(config.config_dir_name) self.profile_manager = ConfigProfile(config.config_dir_name)
self.setWindowTitle(tr("settings.title")) self.setWindowTitle(tr("settings.title"))
self.setGeometry(100, 100, 600, 500) self.setGeometry(100, 100, 600, 500)
@ -54,6 +58,7 @@ class SettingsDialog(QDialog):
self.tabs = QTabWidget() self.tabs = QTabWidget()
self.tabs.addTab(self._create_general_tab(), tr("settings.tab.general")) self.tabs.addTab(self._create_general_tab(), tr("settings.tab.general"))
self.tabs.addTab(self._create_branding_tab(), tr("settings.tab.branding"))
self.tabs.addTab(self._create_web_source_tab(), tr("settings.tab.web_source")) 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_paths_tab(), tr("settings.tab.paths"))
self.tabs.addTab(self._create_urls_tab(), tr("settings.tab.urls")) self.tabs.addTab(self._create_urls_tab(), tr("settings.tab.urls"))
@ -83,6 +88,14 @@ class SettingsDialog(QDialog):
for m in config_data["url_mappings"] for m in config_data["url_mappings"]
] ]
selected_branding_id = config_data.get(
"active_branding_id", self.config.active_branding_id
)
old_branding_id = self.config.active_branding_id
self.branding_manager.set_active_branding_id(selected_branding_id)
self.config.active_branding_id = selected_branding_id
self.branding_manager.apply_to_config(self.config)
old_log_level = self.config.log_level old_log_level = self.config.log_level
self.config.language = config_data["language"] self.config.language = config_data["language"]
self.config.log_level = config_data["log_level"] self.config.log_level = config_data["log_level"]
@ -102,6 +115,12 @@ class SettingsDialog(QDialog):
logger.info(f"Configuration saved to {config_path}") logger.info(f"Configuration saved to {config_path}")
logger.info(f" Log level: {self.config.log_level} (was: {old_log_level})") logger.info(f" Log level: {self.config.log_level} (was: {old_log_level})")
logger.info(f" Window size: {self.config.window_width}x{self.config.window_height}") logger.info(f" Window size: {self.config.window_width}x{self.config.window_height}")
if old_branding_id != self.config.active_branding_id:
logger.info(
" Active branding changed: %s -> %s",
old_branding_id,
self.config.active_branding_id,
)
if old_log_level != self.config.log_level: if old_log_level != self.config.log_level:
reconfigure_logging( reconfigure_logging(
@ -151,6 +170,273 @@ class SettingsDialog(QDialog):
widget.setLayout(layout) widget.setLayout(layout)
return widget return widget
def _create_branding_tab(self) -> QWidget:
"""Create runtime branding tab."""
widget = QWidget()
layout = QVBoxLayout()
label = QLabel(tr("settings.branding.select_label"))
label.setToolTip(tr("settings.branding.select_tooltip"))
layout.addWidget(label)
help_label = QLabel(tr("settings.branding.help_text"))
help_label.setWordWrap(True)
help_label.setStyleSheet("color: gray; font-size: 11px;")
layout.addWidget(help_label)
self.branding_combo = QComboBox()
self.branding_combo.setToolTip(tr("settings.branding.select_tooltip"))
self._refresh_branding_combo()
self.branding_combo.currentIndexChanged.connect(self._on_branding_selection_changed)
layout.addWidget(self.branding_combo)
self.branding_display_name_input = QLineEdit()
self.branding_display_name_input.setPlaceholderText(
tr("settings.branding.display_name_label")
)
self.branding_display_name_input.textChanged.connect(self._update_branding_preview)
layout.addWidget(QLabel(tr("settings.branding.display_name_label")))
layout.addWidget(self.branding_display_name_input)
self.branding_app_name_input = QLineEdit()
self.branding_app_name_input.setPlaceholderText(tr("settings.branding.app_name_label"))
self.branding_app_name_input.textChanged.connect(self._update_branding_preview)
layout.addWidget(QLabel(tr("settings.branding.app_name_label")))
layout.addWidget(self.branding_app_name_input)
self.branding_window_title_input = QLineEdit()
self.branding_window_title_input.setPlaceholderText(
tr("settings.branding.window_title_label")
)
self.branding_window_title_input.textChanged.connect(self._update_branding_preview)
layout.addWidget(QLabel(tr("settings.branding.window_title_label")))
layout.addWidget(self.branding_window_title_input)
layout.addWidget(QLabel(tr("settings.branding.logo_path_label")))
logo_layout = QHBoxLayout()
self.branding_logo_path_input = QLineEdit()
self.branding_logo_path_input.setPlaceholderText(tr("settings.branding.logo_path_label"))
self.branding_logo_path_input.textChanged.connect(self._update_branding_preview)
logo_layout.addWidget(self.branding_logo_path_input)
self.browse_branding_logo_btn = QPushButton(tr("settings.log_file.browse_btn"))
self.browse_branding_logo_btn.clicked.connect(self._browse_branding_logo)
logo_layout.addWidget(self.browse_branding_logo_btn)
layout.addLayout(logo_layout)
layout.addWidget(QLabel(tr("settings.branding.preview_label")))
self.branding_preview_name_label = QLabel()
self.branding_preview_name_label.setStyleSheet("font-weight: bold;")
layout.addWidget(self.branding_preview_name_label)
self.branding_preview_title_label = QLabel()
self.branding_preview_title_label.setStyleSheet("color: gray;")
layout.addWidget(self.branding_preview_title_label)
self.branding_preview_icon_label = QLabel(tr("settings.branding.no_icon_selected"))
self.branding_preview_icon_label.setFixedSize(72, 72)
self.branding_preview_icon_label.setStyleSheet(
"border: 1px solid #ccc; padding: 4px; background: #fafafa;"
)
layout.addWidget(self.branding_preview_icon_label)
branding_button_layout = QHBoxLayout()
self.save_branding_as_btn = QPushButton(tr("settings.branding.save_as_btn"))
self.save_branding_as_btn.clicked.connect(self._save_branding_as)
branding_button_layout.addWidget(self.save_branding_as_btn)
self.export_branding_btn = QPushButton(tr("settings.branding.export_btn"))
self.export_branding_btn.clicked.connect(self._export_branding)
branding_button_layout.addWidget(self.export_branding_btn)
self.import_branding_btn = QPushButton(tr("settings.branding.import_btn"))
self.import_branding_btn.clicked.connect(self._import_branding)
branding_button_layout.addWidget(self.import_branding_btn)
self.delete_branding_btn = QPushButton(tr("settings.branding.delete_btn"))
self.delete_branding_btn.clicked.connect(self._delete_branding)
branding_button_layout.addWidget(self.delete_branding_btn)
layout.addLayout(branding_button_layout)
self._load_branding_into_editor(self.branding_combo.currentData() or "default")
note = QLabel(tr("settings.branding.restart_note"))
note.setWordWrap(True)
note.setStyleSheet("color: gray; font-size: 11px;")
layout.addWidget(note)
layout.addStretch()
widget.setLayout(layout)
return widget
def _refresh_branding_combo(self, selected_template_id: Optional[str] = None) -> None:
"""Refresh the branding template selector."""
current = selected_template_id or self.config.active_branding_id or "default"
self.branding_combo.blockSignals(True)
self.branding_combo.clear()
for template in self.branding_manager.list_templates():
self.branding_combo.addItem(template.display_name, template.template_id)
idx = self.branding_combo.findData(current)
if idx < 0:
idx = self.branding_combo.findData("default")
if idx >= 0:
self.branding_combo.setCurrentIndex(idx)
self.branding_combo.blockSignals(False)
def _load_branding_into_editor(self, template_id: str) -> None:
"""Load the selected branding into the editable fields."""
template = self.branding_manager.load_template(template_id)
self.branding_display_name_input.setText(template.display_name)
self.branding_app_name_input.setText(template.app_name)
self.branding_window_title_input.setText(
template.window_title or f"{template.app_name} v{self.config.app_version}"
)
self.branding_logo_path_input.setText(template.logo_path or template.get_app_icon_path())
self._update_branding_preview()
def _resolve_branding_preview_path(self, configured_path: str) -> Optional[Path]:
"""Resolve a branding preview path in both dev and packaged layouts."""
if not configured_path:
return None
path = Path(configured_path)
candidates = [path] if path.is_absolute() else [Path.cwd() / path]
if not path.is_absolute():
project_root = Path(__file__).resolve().parents[3]
candidates.append(project_root / path)
for candidate in candidates:
if candidate.exists() and candidate.is_file():
return candidate
return None
def _update_branding_preview(self) -> None:
"""Refresh the small branding preview for name and icon."""
display_name = self.branding_display_name_input.text().strip() or tr(
"settings.branding.preview_default_name"
)
self.branding_preview_name_label.setText(display_name)
effective_title = self.branding_window_title_input.text().strip() or (
self.branding_app_name_input.text().strip() or display_name
)
self.branding_preview_title_label.setText(effective_title)
logo_path = self.branding_logo_path_input.text().strip()
resolved_logo_path = self._resolve_branding_preview_path(logo_path)
if resolved_logo_path:
pixmap = QPixmap(str(resolved_logo_path))
if pixmap.isNull():
icon = QIcon(str(resolved_logo_path))
pixmap = icon.pixmap(64, 64)
if not pixmap.isNull():
self.branding_preview_icon_label.setPixmap(pixmap.scaled(64, 64))
self.branding_preview_icon_label.setText("")
return
self.branding_preview_icon_label.setPixmap(QPixmap())
self.branding_preview_icon_label.setText(tr("settings.branding.no_icon_selected"))
def _on_branding_selection_changed(self) -> None:
"""Update editable branding fields when a different template is selected."""
template_id = self.branding_combo.currentData()
if template_id:
self._load_branding_into_editor(template_id)
def _browse_branding_logo(self) -> None:
"""Select an external logo or icon file for the current branding."""
file_path, _ = QFileDialog.getOpenFileName(
self,
tr("settings.branding.logo_path_label"),
str(Path.home()),
"Image Files (*.png *.jpg *.jpeg *.svg *.ico *.icns *.bmp);;All Files (*)",
)
if file_path:
self.branding_logo_path_input.setText(file_path)
def _save_branding_as(self) -> None:
"""Save the edited branding as a new reusable branding entry."""
branding_name, ok = QInputDialog.getText(
self,
tr("settings.branding.save_as_title"),
tr("settings.branding.save_as_prompt"),
text=self.branding_display_name_input.text().strip(),
)
if not ok or not branding_name:
return
try:
display_name = self.branding_display_name_input.text().strip() or branding_name
app_name = self.branding_app_name_input.text().strip() or display_name
window_title = self.branding_window_title_input.text().strip()
template = self.branding_manager.build_template(
template_id=branding_name,
display_name=display_name,
app_name=app_name,
window_title=window_title,
logo_path=self.branding_logo_path_input.text(),
)
self.branding_manager.save_template(template)
self._refresh_branding_combo(template.template_id)
self._load_branding_into_editor(template.template_id)
except ConfigurationError as e:
self._show_error(f"Failed to save branding: {e}")
def _export_branding(self) -> None:
"""Export the selected branding so it can be shared with other users."""
template_id = self.branding_combo.currentData()
if not template_id:
return
file_path, _ = QFileDialog.getSaveFileName(
self,
tr("settings.branding.export_title"),
str(Path.home() / f"{template_id}.json"),
"JSON Files (*.json);;All Files (*)",
)
if not file_path:
return
try:
self.branding_manager.export_template(template_id, Path(file_path))
except ConfigurationError as e:
self._show_error(f"Failed to export branding: {e}")
def _import_branding(self) -> None:
"""Import a branding package from another user."""
file_path, _ = QFileDialog.getOpenFileName(
self,
tr("settings.branding.import_title"),
str(Path.home()),
"JSON Files (*.json);;All Files (*)",
)
if not file_path:
return
try:
template = self.branding_manager.import_template(Path(file_path))
self._refresh_branding_combo(template.template_id)
self._load_branding_into_editor(template.template_id)
except ConfigurationError as e:
self._show_error(f"Failed to import branding: {e}")
def _delete_branding(self) -> None:
"""Delete the currently selected custom branding."""
template_id = self.branding_combo.currentData()
if not template_id:
return
try:
self.branding_manager.delete_template(template_id)
self._refresh_branding_combo("default")
self._load_branding_into_editor("default")
except ConfigurationError as e:
self._show_error(f"Failed to delete branding: {e}")
def _create_web_source_tab(self) -> QWidget: def _create_web_source_tab(self) -> QWidget:
"""Create web source configuration tab.""" """Create web source configuration tab."""
widget = QWidget() widget = QWidget()
@ -396,41 +682,58 @@ class SettingsDialog(QDialog):
return widget return widget
def _create_profiles_tab(self) -> QWidget: def _create_profiles_tab(self) -> QWidget:
"""Create profiles management tab.""" """Create setups/import-export tab with clearer guidance."""
widget = QWidget() widget = QWidget()
layout = QVBoxLayout() layout = QVBoxLayout()
layout.addWidget(QLabel(tr("settings.profiles.label"))) saved_setups_label = QLabel(tr("settings.profiles.label"))
saved_setups_label.setToolTip(tr("settings.profiles.label_tooltip"))
layout.addWidget(saved_setups_label)
self.profiles_help_label = QLabel(tr("settings.profiles.help_text"))
self.profiles_help_label.setWordWrap(True)
self.profiles_help_label.setStyleSheet("color: gray; font-size: 11px;")
layout.addWidget(self.profiles_help_label)
self.profiles_list = QListWidget() self.profiles_list = QListWidget()
self.profiles_list.setToolTip(tr("settings.profiles.list_tooltip"))
self._refresh_profiles_list() self._refresh_profiles_list()
layout.addWidget(self.profiles_list) layout.addWidget(self.profiles_list)
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
save_profile_btn = QPushButton(tr("settings.profiles.save_btn")) self.save_profile_btn = QPushButton(tr("settings.profiles.save_btn"))
save_profile_btn.clicked.connect(self._save_profile) self.save_profile_btn.setToolTip(tr("settings.profiles.save_tooltip"))
button_layout.addWidget(save_profile_btn) self.save_profile_btn.clicked.connect(self._save_profile)
button_layout.addWidget(self.save_profile_btn)
load_profile_btn = QPushButton(tr("settings.profiles.load_btn")) self.load_profile_btn = QPushButton(tr("settings.profiles.load_btn"))
load_profile_btn.clicked.connect(self._load_profile) self.load_profile_btn.setToolTip(tr("settings.profiles.load_tooltip"))
button_layout.addWidget(load_profile_btn) self.load_profile_btn.clicked.connect(self._load_profile)
button_layout.addWidget(self.load_profile_btn)
delete_profile_btn = QPushButton(tr("settings.profiles.delete_btn")) self.delete_profile_btn = QPushButton(tr("settings.profiles.delete_btn"))
delete_profile_btn.clicked.connect(self._delete_profile) self.delete_profile_btn.setToolTip(tr("settings.profiles.delete_tooltip"))
button_layout.addWidget(delete_profile_btn) self.delete_profile_btn.clicked.connect(self._delete_profile)
button_layout.addWidget(self.delete_profile_btn)
layout.addLayout(button_layout) layout.addLayout(button_layout)
export_label = QLabel(tr("settings.profiles.transfer_label"))
export_label.setToolTip(tr("settings.profiles.transfer_tooltip"))
layout.addWidget(export_label)
export_layout = QHBoxLayout() export_layout = QHBoxLayout()
export_btn = QPushButton(tr("settings.profiles.export_btn")) self.export_btn = QPushButton(tr("settings.profiles.export_btn"))
export_btn.clicked.connect(self._export_config) self.export_btn.setToolTip(tr("settings.profiles.export_tooltip"))
export_layout.addWidget(export_btn) self.export_btn.clicked.connect(self._export_config)
export_layout.addWidget(self.export_btn)
import_btn = QPushButton(tr("settings.profiles.import_btn")) self.import_btn = QPushButton(tr("settings.profiles.import_btn"))
import_btn.clicked.connect(self._import_config) self.import_btn.setToolTip(tr("settings.profiles.import_tooltip"))
export_layout.addWidget(import_btn) self.import_btn.clicked.connect(self._import_config)
export_layout.addWidget(self.import_btn)
layout.addLayout(export_layout) layout.addLayout(export_layout)
layout.addStretch() layout.addStretch()
@ -606,6 +909,7 @@ class SettingsDialog(QDialog):
"app_name": self.config.app_name, "app_name": self.config.app_name,
"app_version": self.config.app_version, "app_version": self.config.app_version,
"language": self.language_combo.currentData(), "language": self.language_combo.currentData(),
"active_branding_id": self.branding_combo.currentData(),
"log_level": self.log_level_combo.currentText(), "log_level": self.log_level_combo.currentText(),
"log_file": self.log_file_input.text() or None, "log_file": self.log_file_input.text() or None,
"allowed_roots": [ "allowed_roots": [

View file

@ -0,0 +1,172 @@
"""Tests for runtime branding template management."""
from pathlib import Path
import pytest
from webdrop_bridge.config import Config, ConfigurationError
from webdrop_bridge.core.branding_manager import BrandingManager
def test_builtin_brandings_are_available(tmp_path):
"""Built-in default and Agravity templates should always be available."""
manager = BrandingManager(base_dir=tmp_path)
brandings = manager.list_templates()
template_ids = [template.template_id for template in brandings]
assert "default" in template_ids
assert "agravity" in template_ids
def test_active_branding_persists_across_manager_instances(tmp_path):
"""Selected active branding should persist on disk."""
manager = BrandingManager(base_dir=tmp_path)
manager.set_active_branding_id("agravity")
reloaded_manager = BrandingManager(base_dir=tmp_path)
assert reloaded_manager.get_active_branding_id() == "agravity"
def test_apply_branding_updates_cosmetic_fields_only(tmp_path):
"""Applying a branding template should not overwrite setup-specific values."""
allowed_root = tmp_path / "allowed"
allowed_root.mkdir()
config = Config(
app_name="WebDrop Bridge",
app_version="1.0.0",
log_level="INFO",
log_file=None,
allowed_roots=[allowed_root],
allowed_urls=["example.com"],
webapp_url="http://localhost:8080",
window_width=1024,
window_height=768,
enable_logging=True,
active_branding_id="agravity",
)
manager = BrandingManager(base_dir=tmp_path)
manager.apply_to_config(config)
assert config.active_branding_id == "agravity"
assert config.app_name == "Agravity Bridge"
assert config.webapp_url == "http://localhost:8080"
assert config.allowed_roots == [allowed_root]
assert config.toolbar_icon_home.endswith("home.ico")
def test_config_from_env_uses_persisted_active_branding(tmp_path, monkeypatch):
"""Config loading should apply the persisted active branding automatically."""
branding_dir = tmp_path / "branding-state"
manager = BrandingManager(base_dir=branding_dir)
manager.set_active_branding_id("agravity")
monkeypatch.setenv("WEBDROP_BRANDING_DIR", str(branding_dir))
root = tmp_path / "root"
root.mkdir()
env_file = tmp_path / ".env"
env_file.write_text(f"ALLOWED_ROOTS={root}\n", encoding="utf-8")
config = Config.from_env(str(env_file))
assert config.active_branding_id == "agravity"
assert config.app_name == "Agravity Bridge"
assert config.get_config_path().name == "config.json"
def test_switching_back_to_default_restores_default_branding(tmp_path):
"""Switching from a custom branding back to default should restore the default name."""
manager = BrandingManager(base_dir=tmp_path)
config = Config(
app_name="WebDrop Bridge",
app_version="1.0.0",
log_level="INFO",
log_file=None,
allowed_roots=[],
allowed_urls=[],
webapp_url="http://localhost:8080",
enable_logging=True,
active_branding_id="agravity",
)
manager.apply_to_config(config)
assert config.app_name == "Agravity Bridge"
config.active_branding_id = "default"
manager.apply_to_config(config)
assert config.app_name == "WebDrop Bridge"
assert config.branding_display_name == "Default"
def test_delete_custom_branding_removes_it(tmp_path):
"""Custom brandings should be removable while built-ins stay protected."""
manager = BrandingManager(base_dir=tmp_path)
template = manager.build_template(template_id="Customer B", display_name="Customer B")
manager.save_template(template)
assert manager.has_template("customer_b")
manager.delete_template("customer_b")
assert not manager.has_template("customer_b")
def test_build_template_preserves_app_and_window_titles(tmp_path):
"""Custom brandings should keep their editable app and window title values."""
manager = BrandingManager(base_dir=tmp_path)
template = manager.build_template(
template_id="Customer C",
display_name="Customer C",
app_name="Customer Bridge",
window_title="Customer Bridge Desktop",
)
assert template.app_name == "Customer Bridge"
assert template.window_title == "Customer Bridge Desktop"
def test_invalid_logo_file_is_rejected(tmp_path):
"""Non-existent logo files should not be accepted for saved brandings."""
manager = BrandingManager(base_dir=tmp_path)
with pytest.raises(ConfigurationError):
manager.build_template(
template_id="customer_c",
display_name="Customer C",
logo_path=str(tmp_path / "missing-logo.png"),
)
def test_exported_branding_can_be_imported_for_another_user(tmp_path):
"""Exported brandings should be shareable and importable by another user."""
source_logo = tmp_path / "shared-logo.png"
source_logo.write_bytes(b"fake-png-data")
source_manager = BrandingManager(base_dir=tmp_path / "source")
template = source_manager.build_template(
template_id="Customer D",
display_name="Customer D",
app_name="Customer Bridge",
window_title="Customer Window",
logo_path=str(source_logo),
)
source_manager.save_template(template)
export_path = tmp_path / "export" / "customer_d.json"
source_manager.export_template("customer_d", export_path)
target_manager = BrandingManager(base_dir=tmp_path / "target")
imported = target_manager.import_template(export_path)
assert imported.template_id == "customer_d"
assert imported.display_name == "Customer D"
assert imported.app_name == "Customer Bridge"
assert imported.window_title == "Customer Window"
assert Path(imported.logo_path).exists()
assert target_manager.has_template("customer_d")

View file

@ -9,11 +9,14 @@ from webdrop_bridge.config import Config, ConfigurationError
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def clear_env(): def clear_env(tmp_path):
"""Clear environment variables before each test to avoid persistence.""" """Clear environment variables before each test to avoid persistence."""
# Save current env # Save current env
saved_env = os.environ.copy() saved_env = os.environ.copy()
# Isolate runtime branding state from the developer machine
os.environ["WEBDROP_BRANDING_DIR"] = str(tmp_path / "branding")
# Clear relevant variables # Clear relevant variables
for key in list(os.environ.keys()): for key in list(os.environ.keys()):
if key.startswith( if key.startswith(

View file

@ -82,6 +82,25 @@ class TestMainWindowInitialization:
assert window.drag_interceptor is not None assert window.drag_interceptor is not None
class TestSettingsRestartBehavior:
"""Test restart prompts for settings changes that require a restart."""
def test_branding_change_prompts_restart(self, qtbot, sample_config):
"""Changing the active branding should trigger the restart flow."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
with patch.object(window, "_handle_branding_change_restart") as mock_restart:
with patch("webdrop_bridge.ui.settings_dialog.SettingsDialog") as mock_dialog_cls:
mock_dialog = mock_dialog_cls.return_value
mock_dialog.exec.side_effect = lambda: setattr(
window.config, "active_branding_id", "agravity"
)
window._show_settings_dialog()
mock_restart.assert_called_once()
class TestMainWindowDragIntegration: class TestMainWindowDragIntegration:
"""Test drag-and-drop integration.""" """Test drag-and-drop integration."""
@ -207,15 +226,15 @@ class TestMainWindowOpenWith:
test_file.write_text("test") test_file.write_text("test")
call_count = [0] # Use list to make it mutable in nested function call_count = [0] # Use list to make it mutable in nested function
class _AppChooseResult: class _AppChooseResult:
returncode = 0 returncode = 0
stdout = "TextEdit" # Simulated chosen app name stdout = "TextEdit" # Simulated chosen app name
class _OpenResult: class _OpenResult:
returncode = 0 returncode = 0
stdout = "" stdout = ""
def mock_run(*args, **kwargs): def mock_run(*args, **kwargs):
"""Mock subprocess.run with two different behaviors per call.""" """Mock subprocess.run with two different behaviors per call."""
call_count[0] += 1 call_count[0] += 1
@ -227,8 +246,7 @@ class TestMainWindowOpenWith:
return _OpenResult() return _OpenResult()
else: else:
raise AssertionError(f"Unexpected call #{call_count[0]} to subprocess.run") raise AssertionError(f"Unexpected call #{call_count[0]} to subprocess.run")
with patch("webdrop_bridge.ui.main_window.sys.platform", "darwin"): with patch("webdrop_bridge.ui.main_window.sys.platform", "darwin"):
with patch("webdrop_bridge.ui.main_window.subprocess.run", side_effect=mock_run): with patch("webdrop_bridge.ui.main_window.subprocess.run", side_effect=mock_run):
assert window._open_with_app_chooser(str(test_file)) is True assert window._open_with_app_chooser(str(test_file)) is True

View file

@ -1,11 +1,11 @@
"""Tests for settings dialog.""" """Tests for settings dialog."""
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import patch
import pytest import pytest
from webdrop_bridge.config import Config, ConfigurationError from webdrop_bridge.config import Config
from webdrop_bridge.ui.settings_dialog import SettingsDialog from webdrop_bridge.ui.settings_dialog import SettingsDialog
@ -44,7 +44,7 @@ class TestSettingsDialogInitialization:
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.tabs is not None assert dialog.tabs is not None
assert dialog.tabs.count() == 7 # General + previous 6 tabs assert dialog.tabs.count() == 8 # General + Branding + previous 6 tabs
def test_dialog_has_general_tab(self, qtbot, sample_config): def test_dialog_has_general_tab(self, qtbot, sample_config):
"""Test General tab exists.""" """Test General tab exists."""
@ -53,47 +53,103 @@ class TestSettingsDialogInitialization:
assert dialog.tabs.tabText(0) == "General" assert dialog.tabs.tabText(0) == "General"
def test_dialog_has_branding_tab(self, qtbot, sample_config):
"""Test Branding tab exists."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog.tabs.tabText(1) == "Branding"
def test_dialog_has_web_source_tab(self, qtbot, sample_config): def test_dialog_has_web_source_tab(self, qtbot, sample_config):
"""Test Web Source tab exists.""" """Test Web Source tab exists."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.tabs.tabText(1) == "Web Source" assert dialog.tabs.tabText(2) == "Web Source"
def test_dialog_has_paths_tab(self, qtbot, sample_config): def test_dialog_has_paths_tab(self, qtbot, sample_config):
"""Test Paths tab exists.""" """Test Paths tab exists."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.tabs.tabText(2) == "Paths" assert dialog.tabs.tabText(3) == "Paths"
def test_dialog_has_urls_tab(self, qtbot, sample_config): def test_dialog_has_urls_tab(self, qtbot, sample_config):
"""Test URLs tab exists.""" """Test URLs tab exists."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.tabs.tabText(3) == "URLs" assert dialog.tabs.tabText(4) == "URLs"
def test_dialog_has_logging_tab(self, qtbot, sample_config): def test_dialog_has_logging_tab(self, qtbot, sample_config):
"""Test Logging tab exists.""" """Test Logging tab exists."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.tabs.tabText(4) == "Logging" assert dialog.tabs.tabText(5) == "Logging"
def test_dialog_has_window_tab(self, qtbot, sample_config): def test_dialog_has_window_tab(self, qtbot, sample_config):
"""Test Window tab exists.""" """Test Window tab exists."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.tabs.tabText(5) == "Window" assert dialog.tabs.tabText(6) == "Window"
def test_dialog_has_profiles_tab(self, qtbot, sample_config): def test_dialog_has_profiles_tab(self, qtbot, sample_config):
"""Test Profiles tab exists.""" """Test Setups tab exists with clearer wording."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.tabs.tabText(6) == "Profiles" assert dialog.tabs.tabText(7) == "Setups"
def test_profiles_actions_have_explanatory_tooltips(self, qtbot, sample_config):
"""Test profile/config actions expose helpful explanations."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert "this device" in dialog.save_profile_btn.toolTip().lower()
assert "backup" in dialog.export_btn.toolTip().lower()
assert "json" in dialog.import_btn.toolTip().lower()
def test_branding_editor_fields_are_initialized(
self, qtbot, sample_config, monkeypatch, tmp_path
):
"""Test branding tab exposes editable fields for the selected template."""
monkeypatch.setenv("WEBDROP_BRANDING_DIR", str(tmp_path / "branding"))
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog.branding_display_name_input.text() == "Default"
assert dialog.branding_app_name_input.text() == "WebDrop Bridge"
assert "WebDrop Bridge" in dialog.branding_window_title_input.text()
assert dialog.branding_logo_path_input is not None
assert dialog.browse_branding_logo_btn is not None
assert dialog.branding_preview_name_label.text() == "Default"
assert dialog.branding_preview_icon_label.pixmap() is not None
assert not dialog.branding_preview_icon_label.pixmap().isNull()
assert dialog.export_branding_btn is not None
assert dialog.import_branding_btn is not None
assert dialog.delete_branding_btn is not None
def test_save_branding_as_creates_custom_template(
self, qtbot, sample_config, monkeypatch, tmp_path
):
"""Test edited branding can be saved as a new reusable template."""
monkeypatch.setenv("WEBDROP_BRANDING_DIR", str(tmp_path / "branding"))
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
logo_path = tmp_path / "customer-logo.png"
logo_path.write_bytes(b"fake-png-data")
dialog.branding_display_name_input.setText("Customer A")
dialog.branding_logo_path_input.setText(str(logo_path))
with patch("PySide6.QtWidgets.QInputDialog.getText", return_value=("Customer A", True)):
dialog._save_branding_as()
assert dialog.branding_manager.has_template("customer_a")
assert dialog.branding_combo.findData("customer_a") >= 0
class TestPathsTab: class TestPathsTab:
@ -190,7 +246,7 @@ class TestWindowTab:
class TestProfilesTab: class TestProfilesTab:
"""Test Profiles management tab.""" """Test profiles management tab."""
def test_profiles_list_initialized(self, qtbot, sample_config): def test_profiles_list_initialized(self, qtbot, sample_config):
"""Test profiles list is initialized.""" """Test profiles list is initialized."""
@ -198,6 +254,7 @@ class TestProfilesTab:
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.profiles_list is not None assert dialog.profiles_list is not None
assert dialog.profiles_help_label.wordWrap() is True
class TestConfigDataRetrieval: class TestConfigDataRetrieval:

View file

@ -0,0 +1,46 @@
"""Tests for build script version utilities."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "build" / "scripts"))
from version_utils import get_release_notes
class TestReleaseNotes:
"""Test release note extraction for published releases."""
def test_get_release_notes_from_changelog(self, tmp_path):
"""Extract only the selected version section from the changelog."""
changelog = tmp_path / "CHANGELOG.md"
changelog.write_text(
"""## [0.9.1] - 2026-04-15
### Added
- Better update text
- New installer checks
### Fixed
- Upload retries
## [0.9.0] - 2026-04-01
### Added
- Older changes
""",
encoding="utf-8",
)
notes = get_release_notes("0.9.1", project_root=tmp_path)
assert "Better update text" in notes
assert "New installer checks" in notes
assert "Older changes" not in notes
def test_get_release_notes_uses_fallback_when_missing(self, tmp_path):
"""Return a readable fallback when no changelog entry exists."""
notes = get_release_notes("0.9.1", project_root=tmp_path)
assert "WebDrop Bridge v0.9.1" in notes
assert "release package" in notes.lower()