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
APP_NAME=WebDrop Bridge
APP_VERSION=0.9.0
APP_VERSION=0.9.1
# Web App
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",
"window_width": 1024,
"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
- 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
- Options: `"DEBUG"`, `"INFO"`, `"WARNING"`, `"ERROR"`, `"CRITICAL"`
- 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
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
### Prerequisites
- Python 3.10+
- Python 3.9+
- Git
- Familiarity with Qt/PySide6 or willingness to learn

View file

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

View file

@ -143,19 +143,19 @@ brew upgrade 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)
```bash
# Replace VERSION with release tag (e.g., v0.8.0)
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/VERSION/WebDropBridge_Setup.msi
# 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/vX.Y.Z/WebDropBridge-X.Y.Z-win-x64.msi
# Real example - download v0.8.0 MSI
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0/WebDropBridge_Setup.msi
# Real example - download v0.9.1 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
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0/WebDropBridge_Setup.dmg
# macOS - download v0.9.1 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
@ -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"
# Download specific version
.\build\scripts\download_release.ps1 -Version "0.8.0"
.\build\scripts\download_release.ps1 -Version "0.9.1"
# Skip checksum verification
.\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
# Download specific version
./build/scripts/download_release.sh 0.8.0
./build/scripts/download_release.sh 0.9.1
# Skip checksum verification
./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.)
![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
@ -19,23 +19,25 @@ WebDrop Bridge embeds a web application in a Qt container with full filesystem a
## 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
- ✅ **Drag Interception** - Converts text paths to native file operations
- ✅ **Path Whitelist** - Security-conscious file system access control
- ✅ **Configuration Management** - Profile-based settings with validation
- ✅ **Settings Dialog** - Professional UI for path, URL, logging, and window configuration
- ✅ **Configuration Management** - JSON config, profile import/export, and validation
- ✅ **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
- ✅ **Professional Build Pipeline** - MSI for Windows, DMG for macOS
- ✅ **Comprehensive Testing** - Unit, integration, and end-to-end tests (80%+ coverage)
- ✅ **Continuous Testing** - GitHub Actions test automation
- ✅ **Comprehensive Testing** - Unit and integration coverage across core modules
- ✅ **Continuous Testing** - Automated CI validation
- ✅ **Structured Logging** - File-based logging with configurable levels
## Quick Start
### Requirements
- Python 3.10+
- Windows 10/11
- Python 3.9+
- Windows 10/11 or macOS 12+
- 200 MB disk space (includes Chromium from PyInstaller)
### 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)**
```bash
# Replace VERSION with release tag (e.g., v0.8.0)
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/VERSION/WebDropBridge_Setup.msi
# 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/vX.Y.Z/WebDropBridge-X.Y.Z-win-x64.msi
# Example for v0.8.0:
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0/WebDropBridge_Setup.msi
# Example for v0.9.1:
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)**
@ -93,6 +95,7 @@ python -m venv venv
# Install dependencies
pip install -r requirements.txt
pip install -e .
# Run application
python -m webdrop_bridge.main
@ -103,6 +106,7 @@ python -m webdrop_bridge.main
```bash
# Install development dependencies
pip install -r requirements-dev.txt
pip install -e .
# Run tests
pytest tests -v
@ -178,42 +182,36 @@ webdrop-bridge/
## 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:
- **Paths Tab** - Add/remove allowed root directories
- **URLs Tab** - Configure allowed web URLs (whitelist mode)
- **Logging Tab** - Set log level and file location
- **Window Tab** - Configure window dimensions
- **Profiles Tab** - Save/load/export-import configuration profiles
- **General Tab** - Select the UI language or follow the system locale automatically
- **Branding Tab** - Switch, import, export, and preview runtime branding templates
- **Web Source Tab** - Configure the embedded web application URL
- **Paths / URLs / Logging / Window Tabs** - Control filesystem access, allowed sites, log output, and initial window size
- **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
Create a `.env` file in the project root. Available settings:
### 2. Bootstrap Environment Variables (`.env`)
A `.env` file is still supported for local development and branded packaged defaults. It is used when no JSON config exists yet.
```bash
# Application
APP_NAME=WebDrop Bridge
APP_VERSION=1.0.0
# Paths (comma-separated)
BRAND_ID=webdrop_bridge
WEBAPP_URL=https://dev.agravity.io/
ALLOWED_ROOTS=Z:/,C:/Users/Public
# Web URLs (empty = no restriction, items = kiosk mode)
ALLOWED_URLS=
# Interface
WEBAPP_URL=file:///./webapp/index.html
LANGUAGE=auto
LOG_LEVEL=INFO
WINDOW_WIDTH=1024
WINDOW_HEIGHT=768
# Logging
LOG_LEVEL=INFO
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
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:
- Portable executable: `build/dist/windows/WebDropBridge/WebDropBridge.exe` (~195 MB)
- Professional MSI installer: `build/dist/windows/WebDropBridge-{version}-Setup.msi`
- SHA256 checksum: `build/dist/windows/WebDropBridge/WebDropBridge.exe.sha256`
- Portable executable: `build/dist/windows/webdrop_bridge/WebDropBridge/WebDropBridge.exe`
- Professional MSI installer: `build/dist/windows/webdrop_bridge/WebDropBridge-<version>-win-x64.msi`
- 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
@ -314,8 +321,8 @@ powershell -ExecutionPolicy Bypass -File build/scripts/create_release.ps1
## Troubleshooting
### Application won't start
- Ensure Python 3.10+ is installed
- Check `logs/webdrop_bridge.log` for errors
- Ensure Python 3.9+ is installed
- Check the application log in your platform-specific app data directory
- Verify all dependencies: `pip list`
### Drag-and-drop not working
@ -332,10 +339,10 @@ powershell -ExecutionPolicy Bypass -File build/scripts/create_release.ps1
| Platform | Version | Status | Notes |
|----------|---------|--------|-------|
| Windows | 10, 11 | ✅ Full | Tested on x64, MSI installer support |
| macOS | 12+ | ⚠️ **Untested** | Possible via Qt/PySide6, but never built or tested. Theoretical support only. |
| Windows | 10, 11 | ✅ Full | Primary target with MSI packaging and update support |
| 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
@ -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**:
```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**:
@ -42,7 +42,7 @@ build/
5. **Publish** (requires Chocolatey API key):
```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)
@ -54,7 +54,7 @@ build/
2. **Get SHA256 checksum**:
```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**:

View file

@ -75,8 +75,8 @@ The `download_release.ps1` (Windows) and `download_release.sh` (macOS/Linux) scr
```bash
# Download directly by version tag
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0/WebDropBridge_Setup.msi
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0/WebDropBridge_Setup.dmg
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.9.1/WebDropBridge-0.9.1-macos-universal.dmg
```
**If you need to auto-detect latest (with grep/cut, no jq needed)**
@ -111,7 +111,7 @@ sha256sum -c installer.sha256
.\download_release.ps1
# 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
.\download_release.ps1 -Verify $false
@ -124,7 +124,7 @@ sha256sum -c installer.sha256
./build/scripts/download_release.sh
# 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
./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.
```bash
python build/scripts/sync_version.py --version 0.8.0
python build/scripts/sync_version.py --version 0.9.1
```
## 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()
}
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 {
$arguments = @($brandHelper, "local-release-data", "--platform", "windows", "--version", $Version)
if ($Brands) {
@ -127,10 +139,11 @@ $headers = @{
$releaseLookupUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/tags/v$Version"
$releaseUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases"
$releaseBody = Get-ReleaseNotes -Version $Version
$releaseData = @{
tag_name = "v$Version"
name = "WebDropBridge v$Version"
body = "Shared branded release for WebDrop Bridge v$Version"
body = $releaseBody
draft = $false
prerelease = $false
} | 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())")"
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")
if [ ${#BRANDS[@]} -gt 0 ]; then
LOCAL_ARGS+=("--brands" "${BRANDS[@]}")
@ -186,15 +190,19 @@ else
fi
if [ -z "$RELEASE_ID" ]; then
RELEASE_DATA=$(cat <<EOF
{
"tag_name": "v$VERSION",
"name": "WebDropBridge v$VERSION",
"body": "Shared branded release for WebDrop Bridge v$VERSION",
"draft": false,
"prerelease": false
}
EOF
RELEASE_BODY="$(get_release_notes)"
RELEASE_DATA=$(RELEASE_BODY="$RELEASE_BODY" VERSION="$VERSION" python3 - <<'PY'
import json
import os
print(json.dumps({
"tag_name": f"v{os.environ['VERSION']}",
"name": f"WebDropBridge v{os.environ['VERSION']}",
"body": os.environ["RELEASE_BODY"],
"draft": False,
"prerelease": False,
}))
PY
)
HTTP_CODE=$(curl -s -o "$RELEASE_RESPONSE_FILE" -w "%{http_code}" -X POST \
-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"
if not init_file.exists():
raise FileNotFoundError(
f"Cannot find __init__.py at {init_file}"
)
raise FileNotFoundError(f"Cannot find __init__.py at {init_file}")
content = init_file.read_text(encoding="utf-8")
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
if not match:
raise ValueError(
f"Could not find __version__ in {init_file}. "
"Expected: __version__ = \"X.Y.Z\""
f"Could not find __version__ in {init_file}. " 'Expected: __version__ = "X.Y.Z"'
)
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
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
APP_NAME=WebDrop Bridge
APP_VERSION=0.7.1
BRAND_ID=webdrop_bridge
APP_CONFIG_DIR_NAME=webdrop_bridge
WEBAPP_URL=https://example.com
ALLOWED_ROOTS=Z:/,C:/Users/Public
ALLOWED_URLS=
LANGUAGE=auto
LOG_LEVEL=INFO
LOG_FILE=logs/webdrop_bridge.log
ENABLE_LOGGING=true
WINDOW_WIDTH=1024
WINDOW_HEIGHT=768
```
See `.env.example` for a template with all available options.
## Building with Default Configuration
If you want to use the project's `.env` file (in the project root), simply run:
If you want to use the project's default `.env` file from the repository root:
### Windows
```bash
@ -39,11 +66,11 @@ python build/scripts/build_windows.py --msi
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
```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
```
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
If you have different customer configurations:
```
```text
webdrop_bridge/
├── .env # Default project configuration
├── .env.example # Template
├── .env
├── build/
│ └── scripts/
│ ├── build_windows.py
│ └── build_macos.sh
├── customer_configs/ # Create this for customer-specific settings
├── customer_configs/
│ ├── acme_corp.env
│ ├── globex_corporation.env
│ └── initech.env
└── ...
└── config.example.json
```
Then build for each customer:
Then build per customer or brand:
```bash
# ACME Corp
python build/scripts/build_windows.py --msi --env-file customer_configs/acme_corp.env
# Globex Corporation
python build/scripts/build_windows.py --msi --env-file customer_configs/globex_corporation.env
# Initech
python build/scripts/build_windows.py --msi --env-file customer_configs/initech.env
bash build/scripts/build_macos.sh --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:
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
After installation, the application normally saves user-controlled settings to the JSON config file in the app data directory.
Users **do not** need to create their own `.env` files.
## Recommended Runtime Workflow
## After Installation
When users run the installed application:
1. The embedded `.env` is automatically available
2. Settings are loaded and applied
3. Users can optionally create a custom `.env` in the installation directory to override settings
This allows:
- **Pre-configured deployments** for your customers
- **Easy customization** by users (just edit the `.env` file)
- **No manual setup** required after installation
1. Package the app with the correct `.env` bootstrap defaults.
2. Launch the app once.
3. Configure URLs, mappings, language, and branding in the Settings dialog.
4. Let the app save `config.json` in the brand-specific config directory.
5. Reuse exported profiles or branding templates for future setups.
## Build Command Reference
### Windows
```bash
# Default (.env from project root)
# Default build using the repository root .env
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
# Without MSI (just EXE)
# Without MSI (just the packaged executable)
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
```
### macOS
```bash
# Default (.env from project root)
# Default build using the repository root .env
bash build/scripts/build_macos.sh
# Custom .env file
# Customer-specific defaults
bash build/scripts/build_macos.sh --env-file customer_configs/acme.env
# Sign app (requires Apple developer certificate)
@ -144,19 +158,11 @@ bash build/scripts/build_macos.sh --sign
bash build/scripts/build_macos.sh --notarize
```
## Configuration Validation
## Validation Notes
The build process validates that:
1. ✅ The specified `.env` file exists
2. ✅ All required environment variables are present
3. ✅ Values are valid (LOG_LEVEL is valid, paths exist for ALLOWED_ROOTS, etc.)
1. the specified `.env` file exists,
2. packaging metadata can be resolved, and
3. the resulting installer assets are created successfully.
If validation fails, the build stops with a clear error message.
## Version Management
The `APP_VERSION` is read from two places (in order):
1. `.env` file (if specified)
2. `src/webdrop_bridge/__init__.py` (as fallback)
This allows you to override the version per customer if needed.
If you need the full runtime JSON schema, see `CONFIG_README.md`.

View file

@ -20,7 +20,7 @@ webdrop_bridge/
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`
```dotenv
APP_NAME=WebDrop Bridge - ACME Corp Edition
APP_VERSION=1.0.0
BRAND_ID=acme_corp
WEBAPP_URL=https://acme-drop.example.com/drop
ALLOWED_ROOTS=Z:/acme_files/,C:/Users/Public/ACME
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
```
**Result:** Four separate MSI files:
- `WebDropBridge-1.0.0-Setup.msi` (ACME - says "ACME Corp Edition")
- `WebDropBridge-1.0.0-Setup.msi` (Globex - say "Globex Edition")
**Result:** Four separate MSI files, for example:
- `WebDropBridge-<version>-win-x64.msi` (default brand)
- `AcmeBridge-<version>-win-x64.msi` (if the customer build uses its own asset prefix)
- etc.
---

View file

@ -20,16 +20,16 @@ WebDropBridge supports installation via package managers, making it easier for u
# 1. Build the Chocolatey package
cd build/chocolatey
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
choco pack webdrop-bridge.nuspec
# 2. Share webdrop-bridge.0.8.0.nupkg
# 2. Share webdrop-bridge.<version>.nupkg
# File share: \\server\packages\
# USB drive, email, Forgejo releases, etc.
# 3. Users install it
# choco install webdrop-bridge.0.8.0.nupkg -s "\\server\packages"
# choco install webdrop-bridge.<version>.nupkg -s "\\server\packages"
```
**Advantages:**
@ -56,21 +56,21 @@ choco pack webdrop-bridge.nuspec
python build/scripts/build_windows.py --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
# Replace: $Checksum = ''
# With: $Checksum = 'YOUR_SHA256_HASH'
# 4. Update version in chocolatey/webdrop-bridge.nuspec
# <version>0.8.0</version>
# <version>X.Y.Z</version>
# 5. Create the package
cd build/chocolatey
choco pack webdrop-bridge.nuspec
```
This creates `webdrop-bridge.0.8.0.nupkg`
This creates `webdrop-bridge.<version>.nupkg`
### 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/"
# 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)
choco install webdrop-bridge
@ -95,7 +95,7 @@ If you want public distribution (requires community maintainer account):
```bash
# Push to community repo
choco push webdrop-bridge.0.8.0.nupkg --api-key YOUR_CHOCOLATEY_API_KEY
choco push webdrop-bridge.<version>.nupkg --api-key YOUR_CHOCOLATEY_API_KEY
```
**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:
```powershell
# User downloads webdrop-bridge.0.8.0.nupkg and runs:
choco install webdrop-bridge.0.8.0.nupkg -s C:\path\to\package\folder
# User downloads webdrop-bridge.<version>.nupkg and runs:
choco install webdrop-bridge.<version>.nupkg -s C:\path\to\package\folder
```
### User Installation
@ -119,7 +119,7 @@ choco install webdrop-bridge
choco install webdrop-bridge
# 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)
@ -183,11 +183,11 @@ Submit to `homebrew/casks` (requires more maintenance but no separate tap):
bash build/scripts/build_macos.sh
# 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
# 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
```
@ -210,7 +210,7 @@ webdrop-bridge --version # If CLI exists, or check Applications folder
### Step 1: Build Release
```bash
# Release v0.8.0
# Release vX.Y.Z
# Windows MSI
python build/scripts/build_windows.py --msi
@ -224,8 +224,8 @@ bash build/scripts/build_macos.sh
Tag and upload installers to Forgejo:
```bash
git tag -a v0.8.0 -m "Release 0.8.0"
git push upstream v0.8.0
git tag -a vX.Y.Z -m "Release X.Y.Z"
git push upstream vX.Y.Z
# Upload MSI and DMG to Forgejo release page
```
@ -234,10 +234,10 @@ git push upstream v0.8.0
```bash
# Windows
certutil -hashfile WebDropBridge_Setup.msi SHA256
certutil -hashfile WebDropBridge-<version>-win-x64.msi SHA256
# macOS
shasum -a 256 WebDropBridge_Setup.dmg
shasum -a 256 WebDropBridge-<version>-macos-universal.dmg
```
### Step 4: Update Package Manager Files
@ -258,7 +258,7 @@ sha256 "MACOS_SHA256_HASH"
```powershell
cd build/chocolatey
choco pack
choco install webdrop-bridge.0.8.0.nupkg -s .
choco install webdrop-bridge.<version>.nupkg -s .
```
**Homebrew (with tap):**
@ -270,7 +270,7 @@ brew install ./build/homebrew/webdrop-bridge.rb
**Chocolatey:**
```powershell
choco push webdrop-bridge.0.8.0.nupkg --api-key YOUR_KEY
choco push webdrop-bridge.<version>.nupkg --api-key YOUR_KEY
```
**Homebrew:**
@ -343,7 +343,7 @@ This works for:
### Chocolatey Issues
**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`
- Verify SHA256 matches in `chocolateyInstall.ps1`
@ -356,7 +356,7 @@ This works for:
**Formula won't install:**
- Validate syntax: `brew audit --formula webdrop-bridge.rb`
- Check URL is accessible: `curl -I URL`
- Verify SHA256: `shasum -a 256 WebDropBridge_Setup.dmg`
- Verify SHA256: `shasum -a 256 WebDropBridge-<version>-macos-universal.dmg`
**Upgrade fails:**
- Remove old version: `brew uninstall webdrop-bridge`
@ -375,7 +375,7 @@ This works for:
1. **Easiest: Direct Distribution**
- 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 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.restart_now": "Jetzt 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.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",
@ -84,8 +88,28 @@
"settings.tab.urls": "URLs",
"settings.tab.logging": "Protokollierung",
"settings.tab.window": "Fenster",
"settings.tab.profiles": "Profile",
"settings.tab.profiles": "Setups",
"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.placeholder": "z.B. http://localhost:8080 oder file:///./webapp/index.html",
"settings.web_url.open_btn": "\u00d6ffnen",
@ -106,12 +130,22 @@
"settings.log_file.browse_btn": "Durchsuchen...",
"settings.window.width_label": "Fensterbreite:",
"settings.window.height_label": "Fensterh\u00f6he:",
"settings.profiles.label": "Gespeicherte Konfigurationsprofile:",
"settings.profiles.save_btn": "Als Profil speichern",
"settings.profiles.load_btn": "Profil laden",
"settings.profiles.delete_btn": "Profil l\u00f6schen",
"settings.profiles.export_btn": "Konfiguration exportieren",
"settings.profiles.import_btn": "Konfiguration importieren",
"settings.profiles.label": "Gespeicherte Setups auf diesem Ger\u00e4t:",
"settings.profiles.label_tooltip": "Gespeicherte Setups sind benannte Schnappsch\u00fcsse Ihrer aktuellen Einstellungen f\u00fcr den schnellen Wechsel auf diesem Ger\u00e4t.",
"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.list_tooltip": "Zeigt die auf diesem Ger\u00e4t verf\u00fcgbaren gespeicherten Setups.",
"settings.profiles.save_btn": "Setup speichern",
"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_auto": "Systemstandard (Auto)",
"settings.general.language_restart_note": "Sprach\u00e4nderung wirksam nach Neustart.",
@ -123,15 +157,15 @@
"settings.edit_mapping.path_prompt": "Lokalen Dateisystempfad eingeben:",
"settings.add_url.title": "URL hinzuf\u00fcgen",
"settings.add_url.prompt": "URL-Muster eingeben (z.B. http://example.com oder http://*.example.com):",
"settings.profile.save.title": "Profil speichern",
"settings.profile.save.prompt": "Profilnamen eingeben (z.B. Arbeit, Privat):",
"settings.profile.save.title": "Setup speichern",
"settings.profile.save.prompt": "Namen für das Setup eingeben (z.B. Arbeit, Kunde A):",
"settings.select_directory.title": "Verzeichnis ausw\u00e4hlen",
"settings.select_log_file.title": "Protokolldatei ausw\u00e4hlen",
"settings.export_config.title": "Konfiguration exportieren",
"settings.import_config.title": "Konfiguration importieren",
"settings.export_config.title": "Einstellungen exportieren",
"settings.import_config.title": "Einstellungen importieren",
"settings.error.select_mapping": "Bitte w\u00e4hlen Sie eine Zuordnung zur Bearbeitung aus",
"settings.error.select_profile_load": "Bitte w\u00e4hlen Sie ein Profil zum Laden aus",
"settings.error.select_profile_delete": "Bitte w\u00e4hlen Sie ein Profil zum L\u00f6schen aus",
"settings.error.select_profile_load": "Bitte w\u00e4hlen Sie ein Setup zum Laden 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.open_btn": "\u00d6ffnen",
"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.urls.add_title": "URL hinzuf\u00fcgen",
"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_prompt": "Profilnamen eingeben (z.B. Arbeit, Privat):",
"settings.profiles.select_to_load": "Bitte w\u00e4hlen Sie ein Profil zum Laden aus",
"settings.profiles.select_to_delete": "Bitte w\u00e4hlen Sie ein Profil zum L\u00f6schen aus",
"settings.profiles.export_title": "Konfiguration exportieren",
"settings.profiles.import_title": "Konfiguration importieren",
"settings.profiles.save_title": "Setup speichern",
"settings.profiles.save_prompt": "Namen für das Setup eingeben (z.B. Arbeit, Kunde A):",
"settings.profiles.select_to_load": "Bitte wählen Sie ein Setup zum Laden aus",
"settings.profiles.select_to_delete": "Bitte wählen Sie ein Setup zum Löschen aus",
"settings.profiles.export_title": "Einstellungen exportieren",
"settings.profiles.import_title": "Einstellungen importieren",
"update.checking.title": "Update-Pr\u00fcfung",
"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.restart_now": "Restart Now",
"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.msg": "Could not automatically restart the application:\n\n{error}\n\nPlease restart manually.",
"dialog.update_timeout.title": "Update Check Timeout",
@ -84,8 +88,28 @@
"settings.tab.urls": "URLs",
"settings.tab.logging": "Logging",
"settings.tab.window": "Window",
"settings.tab.profiles": "Profiles",
"settings.tab.profiles": "Setups",
"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.placeholder": "e.g., http://localhost:8080 or file:///./webapp/index.html",
"settings.web_url.open_btn": "Open",
@ -106,12 +130,22 @@
"settings.log_file.browse_btn": "Browse...",
"settings.window.width_label": "Window Width:",
"settings.window.height_label": "Window Height:",
"settings.profiles.label": "Saved Configuration Profiles:",
"settings.profiles.save_btn": "Save as Profile",
"settings.profiles.load_btn": "Load Profile",
"settings.profiles.delete_btn": "Delete Profile",
"settings.profiles.export_btn": "Export Configuration",
"settings.profiles.import_btn": "Import Configuration",
"settings.profiles.label": "Saved setups on this device:",
"settings.profiles.label_tooltip": "Saved setups are named snapshots of your current settings kept on this device for quick switching.",
"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.list_tooltip": "Shows the saved setups available on this device.",
"settings.profiles.save_btn": "Save Setup",
"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_auto": "System Default (Auto)",
"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.add_url.title": "Add URL",
"settings.add_url.prompt": "Enter URL pattern (e.g., http://example.com or http://*.example.com):",
"settings.profile.save.title": "Save Profile",
"settings.profile.save.prompt": "Enter profile name (e.g., work, personal):",
"settings.profile.save.title": "Save Setup",
"settings.profile.save.prompt": "Enter a setup name (e.g., Work, Customer A):",
"settings.select_directory.title": "Select Directory to Allow",
"settings.select_log_file.title": "Select Log File",
"settings.export_config.title": "Export Configuration",
"settings.import_config.title": "Import Configuration",
"settings.export_config.title": "Export Settings",
"settings.import_config.title": "Import Settings",
"settings.error.select_mapping": "Please select a mapping to edit",
"settings.error.select_profile_load": "Please select a profile to load",
"settings.error.select_profile_delete": "Please select a profile to delete",
"settings.error.select_profile_load": "Please select a setup to load",
"settings.error.select_profile_delete": "Please select a setup to delete",
"settings.web_source.url_label": "Web Application URL:",
"settings.web_source.open_btn": "Open",
"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.urls.add_title": "Add URL",
"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_prompt": "Enter profile name (e.g., work, personal):",
"settings.profiles.select_to_load": "Please select a profile to load",
"settings.profiles.select_to_delete": "Please select a profile to delete",
"settings.profiles.export_title": "Export Configuration",
"settings.profiles.import_title": "Import Configuration",
"settings.profiles.save_title": "Save Setup",
"settings.profiles.save_prompt": "Enter a setup name (e.g., Work, Customer A):",
"settings.profiles.select_to_load": "Please select a setup to load",
"settings.profiles.select_to_delete": "Please select a setup to delete",
"settings.profiles.export_title": "Export Settings",
"settings.profiles.import_title": "Import Settings",
"update.checking.title": "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.restart_now": "Red\u00e9marrer maintenant",
"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.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",
@ -84,8 +88,28 @@
"settings.tab.urls": "URLs",
"settings.tab.logging": "Journalisation",
"settings.tab.window": "Fen\u00eatre",
"settings.tab.profiles": "Profils",
"settings.tab.profiles": "Configs",
"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.placeholder": "p.ex. http://localhost:8080 ou file:///./webapp/index.html",
"settings.web_url.open_btn": "Ouvrir",
@ -106,12 +130,22 @@
"settings.log_file.browse_btn": "Parcourir...",
"settings.window.width_label": "Largeur de la fen\u00eatre\u00a0:",
"settings.window.height_label": "Hauteur de la fen\u00eatre\u00a0:",
"settings.profiles.label": "Profils de configuration enregistr\u00e9s\u00a0:",
"settings.profiles.save_btn": "Enregistrer comme profil",
"settings.profiles.load_btn": "Charger le profil",
"settings.profiles.delete_btn": "Supprimer le profil",
"settings.profiles.export_btn": "Exporter la configuration",
"settings.profiles.import_btn": "Importer la configuration",
"settings.profiles.label": "Configurations enregistr\u00e9es sur cet appareil\u00a0:",
"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.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.list_tooltip": "Affiche les configurations enregistr\u00e9es disponibles sur cet appareil.",
"settings.profiles.save_btn": "Enregistrer 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_auto": "Par d\u00e9faut du syst\u00e8me (Auto)",
"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.add_url.title": "Ajouter une URL",
"settings.add_url.prompt": "Entrez le mod\u00e8le d'URL (p.ex. http://example.com ou http://*.example.com)\u00a0:",
"settings.profile.save.title": "Enregistrer le profil",
"settings.profile.save.prompt": "Entrez le nom du profil (p.ex. travail, personnel)\u00a0:",
"settings.profile.save.title": "Enregistrer la configuration",
"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_log_file.title": "S\u00e9lectionner le fichier journal",
"settings.export_config.title": "Exporter la configuration",
"settings.import_config.title": "Importer la configuration",
"settings.export_config.title": "Exporter les réglages",
"settings.import_config.title": "Importer les réglages",
"settings.error.select_mapping": "Veuillez s\u00e9lectionner un mappage \u00e0 modifier",
"settings.error.select_profile_load": "Veuillez s\u00e9lectionner un profil \u00e0 charger",
"settings.error.select_profile_delete": "Veuillez s\u00e9lectionner un profil \u00e0 supprimer",
"settings.error.select_profile_load": "Veuillez sélectionner une configuration à charger",
"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.open_btn": "Ouvrir",
"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.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.profiles.save_title": "Enregistrer le profil",
"settings.profiles.save_prompt": "Entrez le nom du profil (p.ex. travail, personnel)\u00a0:",
"settings.profiles.select_to_load": "Veuillez s\u00e9lectionner un profil \u00e0 charger",
"settings.profiles.select_to_delete": "Veuillez s\u00e9lectionner un profil \u00e0 supprimer",
"settings.profiles.export_title": "Exporter la configuration",
"settings.profiles.import_title": "Importer la configuration",
"settings.profiles.save_title": "Enregistrer la configuration",
"settings.profiles.save_prompt": "Entrez un nom de configuration (p.ex. travail, client A) :",
"settings.profiles.select_to_load": "Veuillez sélectionner une configuration à charger",
"settings.profiles.select_to_delete": "Veuillez sélectionner une configuration à supprimer",
"settings.profiles.export_title": "Exporter les réglages",
"settings.profiles.import_title": "Importer les réglages",
"update.checking.title": "V\u00e9rification des 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.restart_now": "Riavvia ora",
"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.msg": "Impossibile riavviare automaticamente l'applicazione:\n\n{error}\n\nRiavvia manualmente.",
"dialog.update_timeout.title": "Timeout controllo aggiornamenti",
@ -84,8 +88,28 @@
"settings.tab.urls": "URL",
"settings.tab.logging": "Log",
"settings.tab.window": "Finestra",
"settings.tab.profiles": "Profili",
"settings.tab.profiles": "Config",
"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.placeholder": "es. http://localhost:8080 o file:///./webapp/index.html",
"settings.web_url.open_btn": "Apri",
@ -106,12 +130,22 @@
"settings.log_file.browse_btn": "Sfoglia...",
"settings.window.width_label": "Larghezza finestra:",
"settings.window.height_label": "Altezza finestra:",
"settings.profiles.label": "Profili configurazione salvati:",
"settings.profiles.save_btn": "Salva come profilo",
"settings.profiles.load_btn": "Carica profilo",
"settings.profiles.delete_btn": "Elimina profilo",
"settings.profiles.export_btn": "Esporta configurazione",
"settings.profiles.import_btn": "Importa configurazione",
"settings.profiles.label": "Configurazioni salvate su questo dispositivo:",
"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.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.list_tooltip": "Mostra le configurazioni salvate disponibili su questo dispositivo.",
"settings.profiles.save_btn": "Salva 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_auto": "Predefinita sistema (Auto)",
"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.add_url.title": "Aggiungi URL",
"settings.add_url.prompt": "Inserisci pattern URL (es. http://example.com o http://*.example.com):",
"settings.profile.save.title": "Salva profilo",
"settings.profile.save.prompt": "Inserisci nome profilo (es. lavoro, personale):",
"settings.profile.save.title": "Salva configurazione",
"settings.profile.save.prompt": "Inserisci un nome per la configurazione (es. Lavoro, Cliente A):",
"settings.select_directory.title": "Seleziona directory da consentire",
"settings.select_log_file.title": "Seleziona file di log",
"settings.export_config.title": "Esporta configurazione",
"settings.import_config.title": "Importa configurazione",
"settings.export_config.title": "Esporta impostazioni",
"settings.import_config.title": "Importa impostazioni",
"settings.error.select_mapping": "Seleziona una mappatura da modificare",
"settings.error.select_profile_load": "Seleziona un profilo da caricare",
"settings.error.select_profile_delete": "Seleziona un profilo da eliminare",
"settings.error.select_profile_load": "Seleziona una configurazione da caricare",
"settings.error.select_profile_delete": "Seleziona una configurazione da eliminare",
"settings.web_source.url_label": "URL applicazione web:",
"settings.web_source.open_btn": "Apri",
"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.urls.add_title": "Aggiungi URL",
"settings.urls.add_prompt": "Inserisci pattern URL (es. http://example.com o http://*.example.com):",
"settings.profiles.save_title": "Salva profilo",
"settings.profiles.save_prompt": "Inserisci nome profilo (es. lavoro, personale):",
"settings.profiles.select_to_load": "Seleziona un profilo da caricare",
"settings.profiles.select_to_delete": "Seleziona un profilo da eliminare",
"settings.profiles.export_title": "Esporta configurazione",
"settings.profiles.import_title": "Importa configurazione",
"settings.profiles.save_title": "Salva configurazione",
"settings.profiles.save_prompt": "Inserisci un nome per la configurazione (es. Lavoro, Cliente A):",
"settings.profiles.select_to_load": "Seleziona una configurazione da caricare",
"settings.profiles.select_to_delete": "Seleziona una configurazione da eliminare",
"settings.profiles.export_title": "Esporta impostazioni",
"settings.profiles.import_title": "Importa impostazioni",
"update.checking.title": "Controllo aggiornamenti",
"update.checking.label": "Controllo aggiornamenti...",

View file

@ -58,6 +58,10 @@
"dialog.language_changed.msg": "Настройка языка обновлена. Перезапустите сейчас, чтобы применить язык везде.",
"dialog.language_changed.restart_now": "Перезапустить сейчас",
"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.msg": "Не удалось автоматически перезапустить приложение:\n\n{error}\n\nПерезапустите вручную.",
"dialog.update_timeout.title": "Таймаут проверки обновлений",
@ -84,8 +88,28 @@
"settings.tab.urls": "URL",
"settings.tab.logging": "Логирование",
"settings.tab.window": "Окно",
"settings.tab.profiles": "Профили",
"settings.tab.profiles": "Наборы",
"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.placeholder": "например, http://localhost:8080 или file:///./webapp/index.html",
"settings.web_url.open_btn": "Открыть",
@ -106,12 +130,22 @@
"settings.log_file.browse_btn": "Обзор...",
"settings.window.width_label": "Ширина окна:",
"settings.window.height_label": "Высота окна:",
"settings.profiles.label": "Сохраненные профили конфигурации:",
"settings.profiles.save_btn": "Сохранить как профиль",
"settings.profiles.load_btn": "Загрузить профиль",
"settings.profiles.delete_btn": "Удалить профиль",
"settings.profiles.export_btn": "Экспорт конфигурации",
"settings.profiles.import_btn": "Импорт конфигурации",
"settings.profiles.label": "Сохраненные наборы настроек на этом устройстве:",
"settings.profiles.label_tooltip": "Сохраненные наборы — это именованные снимки текущих настроек для быстрого переключения на этом устройстве.",
"settings.profiles.help_text": "Сохраните текущее состояние как именованный набор для быстрого переключения на этом устройстве. Используйте экспорт/импорт для резервного копирования или обмена файлом конфигурации.",
"settings.profiles.list_tooltip": "Показывает сохраненные наборы, доступные на этом устройстве.",
"settings.profiles.save_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_auto": "Системный язык (авто)",
"settings.general.language_restart_note": "Изменение языка вступает в силу после перезапуска.",
@ -123,15 +157,15 @@
"settings.edit_mapping.path_prompt": "Введите локальный путь файловой системы:",
"settings.add_url.title": "Добавить URL",
"settings.add_url.prompt": "Введите шаблон URL (например, http://example.com или http://*.example.com):",
"settings.profile.save.title": "Сохранить профиль",
"settings.profile.save.prompt": "Введите имя профиля (например, работа, личный):",
"settings.profile.save.title": "Сохранить набор",
"settings.profile.save.prompt": "Введите имя набора (например, Работа, Клиент A):",
"settings.select_directory.title": "Выберите разрешенную папку",
"settings.select_log_file.title": "Выберите файл журнала",
"settings.export_config.title": "Экспорт конфигурации",
"settings.import_config.title": "Импорт конфигурации",
"settings.export_config.title": "Экспорт настроек",
"settings.import_config.title": "Импорт настроек",
"settings.error.select_mapping": "Выберите сопоставление для редактирования",
"settings.error.select_profile_load": "Выберите профиль для загрузки",
"settings.error.select_profile_delete": "Выберите профиль для удаления",
"settings.error.select_profile_load": "Выберите набор для загрузки",
"settings.error.select_profile_delete": "Выберите набор для удаления",
"settings.web_source.url_label": "URL веб-приложения:",
"settings.web_source.open_btn": "Открыть",
"settings.web_source.url_mappings_label": "Сопоставления URL (Azure Blob Storage → локальные пути):",
@ -154,12 +188,12 @@
"settings.paths.select_dir_title": "Выберите разрешенную папку",
"settings.urls.add_title": "Добавить URL",
"settings.urls.add_prompt": "Введите шаблон URL (например, http://example.com или http://*.example.com):",
"settings.profiles.save_title": "Сохранить профиль",
"settings.profiles.save_prompt": "Введите имя профиля (например, работа, личный):",
"settings.profiles.select_to_load": "Выберите профиль для загрузки",
"settings.profiles.select_to_delete": "Выберите профиль для удаления",
"settings.profiles.export_title": "Экспорт конфигурации",
"settings.profiles.import_title": "Импорт конфигурации",
"settings.profiles.save_title": "Сохранить набор",
"settings.profiles.save_prompt": "Введите имя набора (например, Работа, Клиент A):",
"settings.profiles.select_to_load": "Выберите набор для загрузки",
"settings.profiles.select_to_delete": "Выберите набор для удаления",
"settings.profiles.export_title": "Экспорт настроек",
"settings.profiles.import_title": "Импорт настроек",
"update.checking.title": "Проверка обновлений",
"update.checking.label": "Проверка обновлений...",

View file

@ -58,6 +58,10 @@
"dialog.language_changed.msg": "语言设置已更新。立即重启可在所有界面生效。",
"dialog.language_changed.restart_now": "立即重启",
"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.msg": "无法自动重启应用:\n\n{error}\n\n请手动重启。",
"dialog.update_timeout.title": "更新检查超时",
@ -84,8 +88,28 @@
"settings.tab.urls": "URL",
"settings.tab.logging": "日志",
"settings.tab.window": "窗口",
"settings.tab.profiles": "配置档案",
"settings.tab.profiles": "设置",
"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.placeholder": "例如: http://localhost:8080 或 file:///./webapp/index.html",
"settings.web_url.open_btn": "打开",
@ -106,12 +130,22 @@
"settings.log_file.browse_btn": "浏览...",
"settings.window.width_label": "窗口宽度:",
"settings.window.height_label": "窗口高度:",
"settings.profiles.label": "已保存配置档案:",
"settings.profiles.save_btn": "保存为档案",
"settings.profiles.load_btn": "加载档案",
"settings.profiles.delete_btn": "删除档案",
"settings.profiles.export_btn": "导出配置",
"settings.profiles.import_btn": "导入配置",
"settings.profiles.label": "此设备上已保存的设置:",
"settings.profiles.label_tooltip": "已保存设置是当前配置的命名快照,可用于在此设备上快速切换。",
"settings.profiles.help_text": "将当前状态保存为命名设置,便于在此设备上快速切换。需要备份或共享配置文件时,请使用导出/导入。",
"settings.profiles.list_tooltip": "显示此设备上可用的已保存设置。",
"settings.profiles.save_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_auto": "跟随系统(自动)",
"settings.general.language_restart_note": "语言更改将在重启后生效。",
@ -123,15 +157,15 @@
"settings.edit_mapping.path_prompt": "输入本地文件系统路径:",
"settings.add_url.title": "添加 URL",
"settings.add_url.prompt": "输入 URL 模式(例如: http://example.com 或 http://*.example.com:",
"settings.profile.save.title": "保存档案",
"settings.profile.save.prompt": "输入配置档案名称(例如: 工作, 个人:",
"settings.profile.save.title": "保存设置",
"settings.profile.save.prompt": "输入设置名称例如工作、客户A:",
"settings.select_directory.title": "选择允许目录",
"settings.select_log_file.title": "选择日志文件",
"settings.export_config.title": "导出置",
"settings.import_config.title": "导入置",
"settings.export_config.title": "导出置",
"settings.import_config.title": "导入置",
"settings.error.select_mapping": "请选择要编辑的映射",
"settings.error.select_profile_load": "请选择要加载的档案",
"settings.error.select_profile_delete": "请选择要删除的档案",
"settings.error.select_profile_load": "请选择要加载的设置",
"settings.error.select_profile_delete": "请选择要删除的设置",
"settings.web_source.url_label": "Web 应用 URL:",
"settings.web_source.open_btn": "打开",
"settings.web_source.url_mappings_label": "URL 映射Azure Blob Storage → 本地路径):",
@ -154,12 +188,12 @@
"settings.paths.select_dir_title": "选择允许目录",
"settings.urls.add_title": "添加 URL",
"settings.urls.add_prompt": "输入 URL 模式(例如: http://example.com 或 http://*.example.com:",
"settings.profiles.save_title": "保存档案",
"settings.profiles.save_prompt": "输入配置档案名称(例如: 工作, 个人:",
"settings.profiles.select_to_load": "请选择要加载的档案",
"settings.profiles.select_to_delete": "请选择要删除的档案",
"settings.profiles.export_title": "导出置",
"settings.profiles.import_title": "导入置",
"settings.profiles.save_title": "保存设置",
"settings.profiles.save_prompt": "输入设置名称例如工作、客户A:",
"settings.profiles.select_to_load": "请选择要加载的设置",
"settings.profiles.select_to_delete": "请选择要删除的设置",
"settings.profiles.export_title": "导出置",
"settings.profiles.import_title": "导入置",
"update.checking.title": "检查更新",
"update.checking.label": "正在检查更新...",

View file

@ -1,6 +1,6 @@
"""WebDrop Bridge - Qt-based desktop application for intelligent drag-and-drop file handling."""
__version__ = "0.9.0"
__version__ = "0.9.1"
__author__ = "WebDrop Team"
__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_CHANNEL = "stable"
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):
@ -96,6 +102,14 @@ class Config:
enable_logging: bool = True
enable_checkout: bool = False
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
config_dir_name: str = DEFAULT_CONFIG_DIR_NAME
update_base_url: str = DEFAULT_UPDATE_BASE_URL
@ -179,7 +193,7 @@ class Config:
# No window title specified, use default
window_title = f"{app_name} v{__version__}"
return cls(
config = cls(
app_name=app_name,
app_version=__version__,
log_level=data.get("log_level", "INFO").upper(),
@ -197,6 +211,13 @@ class Config:
enable_logging=data.get("enable_logging", True),
enable_checkout=data.get("enable_checkout", False),
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,
config_dir_name=config_dir_name,
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_manifest_name=data.get("update_manifest_name", DEFAULT_UPDATE_MANIFEST_NAME),
)
return cls._apply_runtime_branding(config)
@classmethod
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_checkout = os.getenv("ENABLE_CHECKOUT", "false").lower() == "true"
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_repo = os.getenv("UPDATE_REPO", DEFAULT_UPDATE_REPO)
update_channel = os.getenv("UPDATE_CHANNEL", DEFAULT_UPDATE_CHANNEL)
@ -328,7 +356,7 @@ class Config:
f"Invalid URL_MAPPINGS: {url_mappings_str}. Error: {e}"
) from e
return cls(
config = cls(
app_name=app_name,
app_version=app_version,
log_level=log_level,
@ -343,6 +371,12 @@ class Config:
enable_logging=enable_logging,
enable_checkout=enable_checkout,
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,
config_dir_name=config_dir_name,
update_base_url=update_base_url,
@ -350,6 +384,7 @@ class Config:
update_channel=update_channel,
update_manifest_name=update_manifest_name,
)
return cls._apply_runtime_branding(config)
def to_file(self, config_path: Path) -> None:
"""Save configuration to JSON file.
@ -378,6 +413,7 @@ class Config:
"enable_logging": self.enable_logging,
"enable_checkout": self.enable_checkout,
"language": self.language,
"active_branding_id": self.active_branding_id,
"brand_id": self.brand_id,
"config_dir_name": self.config_dir_name,
"update_base_url": self.update_base_url,
@ -390,6 +426,17 @@ class Config:
with open(config_path, "w", encoding="utf-8") as f:
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
def load_bootstrap_env(env_file: str | None = None) -> Path | None:
"""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_workers = {} # Keep references to background workers
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._pending_reload = False # Coalesce multiple rapid reload requests into one
self._load_sequence = 0 # Monotonic counter to ignore stale async recovery callbacks
@ -444,22 +446,13 @@ class MainWindow(QMainWindow):
config.window_height,
)
# Set window icon
# Support both development mode and PyInstaller bundle
if hasattr(sys, "_MEIPASS"):
# 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():
# Set window icon from the active runtime branding
icon_path = self._resolve_toolbar_icon_path(config.app_icon_path)
if icon_path is not None:
self.setWindowIcon(QIcon(str(icon_path)))
logger.debug(f"Window icon set from {icon_path}")
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
self.web_view = RestrictedWebEngineView(
@ -1189,7 +1182,9 @@ class MainWindow(QMainWindow):
# This more reliably opens files with chosen applications.
# Use a simple, more direct approach
# 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:
# Get the chosen application
app_result = subprocess.run(
@ -1199,19 +1194,21 @@ class MainWindow(QMainWindow):
text=True,
timeout=30,
)
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
# Get the application name (strip whitespace)
chosen_app = app_result.stdout.strip()
if not chosen_app:
logger.warning("No application was selected")
return False
logger.info(f"User selected app: {chosen_app}")
# Now open the file with the chosen app using the 'open' command
open_result = subprocess.run(
["open", "-a", chosen_app, normalized_path],
@ -1220,14 +1217,16 @@ class MainWindow(QMainWindow):
text=True,
timeout=10,
)
if open_result.returncode == 0:
logger.info(f"Opened '{normalized_path}' with '{chosen_app}'")
return True
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
except subprocess.TimeoutExpired:
logger.warning("App chooser timed out")
return False
@ -1393,7 +1392,7 @@ class MainWindow(QMainWindow):
Re-registers the bridge script to ensure it will be injected on reload,
page navigation, or any load event.
Uses a flag to prevent duplicate re-registrations if loadStarted fires multiple times.
"""
self._is_page_loading = True
@ -1412,7 +1411,7 @@ class MainWindow(QMainWindow):
Checks if the bridge script was successfully injected, with automatic recovery
for page reloads and redirects.
Resets the re-registration flag for the next load cycle.
Args:
@ -1433,9 +1432,11 @@ class MainWindow(QMainWindow):
logger.warning("Page failed to load")
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.
Implements multi-attempt recovery strategy:
- initial: First check after page load (50ms delay)
- recovery_N: Recovery attempts with progressive delays
@ -1485,9 +1486,7 @@ class MainWindow(QMainWindow):
delay = int(100 * (1.5 ** (attempt - 1)))
QTimer.singleShot(
delay,
lambda: _verify_bridge_loaded(
"recovery", attempt + 1, sequence
),
lambda: _verify_bridge_loaded("recovery", attempt + 1, sequence),
)
self.web_view.page().runJavaScript(self._bridge_script_source, after_retry)
@ -1507,11 +1506,15 @@ class MainWindow(QMainWindow):
)
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
# 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.debug(f" Stage: {stage}, Attempt: {attempt}")
@ -1543,21 +1546,21 @@ class MainWindow(QMainWindow):
def _ensure_bridge_script_exists(self, verbose: bool = False) -> None:
"""Ensure bridge script exists in QWebEngineScript collection (idempotent).
Checks if the script already exists. If not, adds it.
Never removes/re-adds to avoid race conditions with Qt's injection mechanism.
This is safer than removing+re-adding because:
- Avoids concurrent access conflicts with Qt's internal injection
- Prevents missing injections during rapid reloads
- Guarantees script is available without timing gaps
Args:
verbose: If True, use debug logging; otherwise use minimal logging
"""
try:
scripts = self.web_view.page().scripts()
# Check if script already exists
already_exists = False
for script in scripts.toList(): # type: ignore
@ -1566,7 +1569,7 @@ class MainWindow(QMainWindow):
if verbose:
logger.debug("Bridge script already exists in page().scripts()")
break
# If script doesn't exist, add it
if not already_exists and self._bridge_script_source:
new_script = QWebEngineScript()
@ -1582,16 +1585,18 @@ class MainWindow(QMainWindow):
new_script.setSourceCode(self._bridge_script_source)
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:
logger.error(f"Failed to ensure bridge script exists: {e}")
def _re_register_bridge_script(self, verbose: bool = False) -> None:
"""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.
This is a fallback for recovery mechanics when normal injection fails.
Args:
verbose: If True, use debug logging; otherwise use minimal logging
"""
@ -1622,7 +1627,9 @@ class MainWindow(QMainWindow):
scripts.insert(new_script)
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:
logger.error(f"Failed to re-register bridge script: {e}")
@ -1649,9 +1656,7 @@ class MainWindow(QMainWindow):
toolbar.addSeparator()
# Home button
home_icon_path = self._resolve_toolbar_icon_path(
os.getenv("TOOLBAR_ICON_HOME", "resources/icons/home.ico")
)
home_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_home)
home_icon = (
QIcon(str(home_icon_path))
if home_icon_path is not None
@ -1663,9 +1668,7 @@ class MainWindow(QMainWindow):
# Refresh button
refresh_action = toolbar.addAction("")
reload_icon_path = self._resolve_toolbar_icon_path(
os.getenv("TOOLBAR_ICON_RELOAD", "resources/icons/reload.ico")
)
reload_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_reload)
if reload_icon_path is not None:
refresh_action.setIcon(QIcon(str(reload_icon_path)))
else:
@ -1677,9 +1680,7 @@ class MainWindow(QMainWindow):
# Open-with-default-app drop zone (right of Reload)
self._open_drop_zone = OpenDropZone()
open_icon_path = self._resolve_toolbar_icon_path(
os.getenv("TOOLBAR_ICON_OPEN", "resources/icons/open.ico")
)
open_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_open)
if open_icon_path is not None:
self._open_drop_zone.set_icon(QIcon(str(open_icon_path)))
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)
self._open_with_drop_zone = OpenWithDropZone()
open_with_icon_path = self._resolve_toolbar_icon_path(
os.getenv("TOOLBAR_ICON_OPENWITH", "resources/icons/openwith.ico")
)
open_with_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_openwith)
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.file_open_with_requested.connect(
@ -1959,6 +1958,7 @@ class MainWindow(QMainWindow):
# Store current URL before opening dialog
old_webapp_url = self.config.webapp_url
old_language = self.config.language
old_branding_id = self.config.active_branding_id
# Show dialog
dialog = SettingsDialog(self.config, self)
@ -1967,6 +1967,9 @@ class MainWindow(QMainWindow):
# Check if webapp URL changed
new_webapp_url = self.config.webapp_url
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:
logger.info(f"Web application URL changed: {old_webapp_url}{new_webapp_url}")
@ -1976,6 +1979,7 @@ class MainWindow(QMainWindow):
if domain_changed:
logger.warning("Domain has changed - recommending restart")
self._handle_domain_change_restart()
restart_prompt_shown = True
else:
logger.info("Path changed but domain is same - reloading...")
# Clear cache and navigate to home asynchronously
@ -1983,7 +1987,16 @@ class MainWindow(QMainWindow):
self.web_view.clear_cache_and_cookies()
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}")
self._handle_language_change_restart()
@ -2047,21 +2060,42 @@ class MainWindow(QMainWindow):
self.web_view.clear_cache_and_cookies()
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:
"""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
msg = QMessageBox(self)
msg.setWindowTitle(tr("dialog.language_changed.title"))
msg.setWindowTitle(tr(title_key))
msg.setIcon(QMessageBox.Icon.Information)
msg.setText(tr("dialog.language_changed.msg"))
msg.setText(tr(message_key))
restart_now_btn = msg.addButton(
tr("dialog.language_changed.restart_now"), QMessageBox.ButtonRole.AcceptRole
)
msg.addButton(
tr("dialog.language_changed.restart_later"), QMessageBox.ButtonRole.RejectRole
)
restart_now_btn = msg.addButton(tr(restart_now_key), QMessageBox.ButtonRole.AcceptRole)
msg.addButton(tr(restart_later_key), QMessageBox.ButtonRole.RejectRole)
msg.exec()

View file

@ -4,12 +4,14 @@ import logging
from pathlib import Path
from typing import Any, Dict, Optional
from PySide6.QtGui import QIcon, QPixmap
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QHBoxLayout,
QInputDialog,
QLabel,
QLineEdit,
QListWidget,
@ -23,6 +25,7 @@ from PySide6.QtWidgets import (
)
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.utils.i18n import get_available_languages, tr
from webdrop_bridge.utils.logging import reconfigure_logging
@ -42,6 +45,7 @@ class SettingsDialog(QDialog):
"""
super().__init__(parent)
self.config = config
self.branding_manager = BrandingManager()
self.profile_manager = ConfigProfile(config.config_dir_name)
self.setWindowTitle(tr("settings.title"))
self.setGeometry(100, 100, 600, 500)
@ -54,6 +58,7 @@ class SettingsDialog(QDialog):
self.tabs = QTabWidget()
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_paths_tab(), tr("settings.tab.paths"))
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"]
]
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
self.config.language = config_data["language"]
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" Log level: {self.config.log_level} (was: {old_log_level})")
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:
reconfigure_logging(
@ -151,6 +170,273 @@ class SettingsDialog(QDialog):
widget.setLayout(layout)
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:
"""Create web source configuration tab."""
widget = QWidget()
@ -396,41 +682,58 @@ class SettingsDialog(QDialog):
return widget
def _create_profiles_tab(self) -> QWidget:
"""Create profiles management tab."""
"""Create setups/import-export tab with clearer guidance."""
widget = QWidget()
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.setToolTip(tr("settings.profiles.list_tooltip"))
self._refresh_profiles_list()
layout.addWidget(self.profiles_list)
button_layout = QHBoxLayout()
save_profile_btn = QPushButton(tr("settings.profiles.save_btn"))
save_profile_btn.clicked.connect(self._save_profile)
button_layout.addWidget(save_profile_btn)
self.save_profile_btn = QPushButton(tr("settings.profiles.save_btn"))
self.save_profile_btn.setToolTip(tr("settings.profiles.save_tooltip"))
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"))
load_profile_btn.clicked.connect(self._load_profile)
button_layout.addWidget(load_profile_btn)
self.load_profile_btn = QPushButton(tr("settings.profiles.load_btn"))
self.load_profile_btn.setToolTip(tr("settings.profiles.load_tooltip"))
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"))
delete_profile_btn.clicked.connect(self._delete_profile)
button_layout.addWidget(delete_profile_btn)
self.delete_profile_btn = QPushButton(tr("settings.profiles.delete_btn"))
self.delete_profile_btn.setToolTip(tr("settings.profiles.delete_tooltip"))
self.delete_profile_btn.clicked.connect(self._delete_profile)
button_layout.addWidget(self.delete_profile_btn)
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_btn = QPushButton(tr("settings.profiles.export_btn"))
export_btn.clicked.connect(self._export_config)
export_layout.addWidget(export_btn)
self.export_btn = QPushButton(tr("settings.profiles.export_btn"))
self.export_btn.setToolTip(tr("settings.profiles.export_tooltip"))
self.export_btn.clicked.connect(self._export_config)
export_layout.addWidget(self.export_btn)
import_btn = QPushButton(tr("settings.profiles.import_btn"))
import_btn.clicked.connect(self._import_config)
export_layout.addWidget(import_btn)
self.import_btn = QPushButton(tr("settings.profiles.import_btn"))
self.import_btn.setToolTip(tr("settings.profiles.import_tooltip"))
self.import_btn.clicked.connect(self._import_config)
export_layout.addWidget(self.import_btn)
layout.addLayout(export_layout)
layout.addStretch()
@ -606,6 +909,7 @@ class SettingsDialog(QDialog):
"app_name": self.config.app_name,
"app_version": self.config.app_version,
"language": self.language_combo.currentData(),
"active_branding_id": self.branding_combo.currentData(),
"log_level": self.log_level_combo.currentText(),
"log_file": self.log_file_input.text() or None,
"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)
def clear_env():
def clear_env(tmp_path):
"""Clear environment variables before each test to avoid persistence."""
# Save current env
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
for key in list(os.environ.keys()):
if key.startswith(

View file

@ -82,6 +82,25 @@ class TestMainWindowInitialization:
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:
"""Test drag-and-drop integration."""
@ -207,15 +226,15 @@ class TestMainWindowOpenWith:
test_file.write_text("test")
call_count = [0] # Use list to make it mutable in nested function
class _AppChooseResult:
returncode = 0
stdout = "TextEdit" # Simulated chosen app name
class _OpenResult:
returncode = 0
stdout = ""
def mock_run(*args, **kwargs):
"""Mock subprocess.run with two different behaviors per call."""
call_count[0] += 1
@ -227,8 +246,7 @@ class TestMainWindowOpenWith:
return _OpenResult()
else:
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.subprocess.run", side_effect=mock_run):
assert window._open_with_app_chooser(str(test_file)) is True

View file

@ -1,11 +1,11 @@
"""Tests for settings dialog."""
from pathlib import Path
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from webdrop_bridge.config import Config, ConfigurationError
from webdrop_bridge.config import Config
from webdrop_bridge.ui.settings_dialog import SettingsDialog
@ -44,7 +44,7 @@ class TestSettingsDialogInitialization:
qtbot.addWidget(dialog)
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):
"""Test General tab exists."""
@ -53,47 +53,103 @@ class TestSettingsDialogInitialization:
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):
"""Test Web Source tab exists."""
dialog = SettingsDialog(sample_config)
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):
"""Test Paths tab exists."""
dialog = SettingsDialog(sample_config)
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):
"""Test URLs tab exists."""
dialog = SettingsDialog(sample_config)
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):
"""Test Logging tab exists."""
dialog = SettingsDialog(sample_config)
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):
"""Test Window tab exists."""
dialog = SettingsDialog(sample_config)
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):
"""Test Profiles tab exists."""
"""Test Setups tab exists with clearer wording."""
dialog = SettingsDialog(sample_config)
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:
@ -190,7 +246,7 @@ class TestWindowTab:
class TestProfilesTab:
"""Test Profiles management tab."""
"""Test profiles management tab."""
def test_profiles_list_initialized(self, qtbot, sample_config):
"""Test profiles list is initialized."""
@ -198,6 +254,7 @@ class TestProfilesTab:
qtbot.addWidget(dialog)
assert dialog.profiles_list is not None
assert dialog.profiles_help_label.wordWrap() is True
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()