Compare commits
No commits in common. "main" and "v0.9.0" have entirely different histories.
31 changed files with 386 additions and 1801 deletions
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
# Application
|
# Application
|
||||||
APP_NAME=WebDrop Bridge
|
APP_NAME=WebDrop Bridge
|
||||||
APP_VERSION=0.9.1
|
APP_VERSION=0.9.0
|
||||||
|
|
||||||
# Web App
|
# Web App
|
||||||
WEBAPP_URL=file:///./webapp/index.html
|
WEBAPP_URL=file:///./webapp/index.html
|
||||||
|
|
|
||||||
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -1,15 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -36,10 +36,7 @@ Create a `config.json` file with the following structure:
|
||||||
"log_file": "logs/webdrop_bridge.log",
|
"log_file": "logs/webdrop_bridge.log",
|
||||||
"window_width": 1024,
|
"window_width": 1024,
|
||||||
"window_height": 768,
|
"window_height": 768,
|
||||||
"enable_logging": true,
|
"enable_logging": true
|
||||||
"language": "auto",
|
|
||||||
"active_branding_id": "default",
|
|
||||||
"brand_id": "agravity"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -102,19 +99,6 @@ Z:\aN5PysnXIuRECzcRbvHkjL7g0\Hintergrund_Agravity.png
|
||||||
- **`window_width`**, **`window_height`** (number): Initial window size in pixels
|
- **`window_width`**, **`window_height`** (number): Initial window size in pixels
|
||||||
- Default: `1024` x `768`
|
- Default: `1024` x `768`
|
||||||
|
|
||||||
### Language and Branding Settings
|
|
||||||
|
|
||||||
- **`language`** (string): UI language code
|
|
||||||
- Use `"auto"` to follow the system locale automatically
|
|
||||||
- Bundled translations currently include `en`, `de`, `fr`, `it`, `ru`, and `zh`
|
|
||||||
|
|
||||||
- **`active_branding_id`** (string): Runtime branding template selected in the Settings dialog
|
|
||||||
- Default: `"default"`
|
|
||||||
- Useful when switching between saved branding templates without rebuilding the app
|
|
||||||
|
|
||||||
- **`brand_id`** (string): Stable packaging/update identifier for branded variants
|
|
||||||
- Usually injected during packaging and normally left unchanged by end users
|
|
||||||
|
|
||||||
- **`log_level`** (string): Logging verbosity
|
- **`log_level`** (string): Logging verbosity
|
||||||
- Options: `"DEBUG"`, `"INFO"`, `"WARNING"`, `"ERROR"`, `"CRITICAL"`
|
- Options: `"DEBUG"`, `"INFO"`, `"WARNING"`, `"ERROR"`, `"CRITICAL"`
|
||||||
- Default: `"INFO"`
|
- Default: `"INFO"`
|
||||||
|
|
@ -165,14 +149,6 @@ You can configure multiple Azure storage accounts:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration Priority
|
|
||||||
|
|
||||||
At startup, WebDrop Bridge first loads any bootstrap `.env` defaults and then prefers the persisted JSON config if it exists. This means:
|
|
||||||
|
|
||||||
1. packaged or development defaults can still come from `.env`,
|
|
||||||
2. the Settings dialog saves the active runtime configuration to JSON, and
|
|
||||||
3. the JSON file becomes the main configuration source after first save.
|
|
||||||
|
|
||||||
## Environment Variable Fallback
|
## Environment Variable Fallback
|
||||||
|
|
||||||
If no JSON config exists, WebDrop Bridge will load from `.env`:
|
If no JSON config exists, WebDrop Bridge will load from `.env`:
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ Please be respectful and constructive in all interactions. We're building a welc
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Python 3.9+
|
- Python 3.10+
|
||||||
- Git
|
- Git
|
||||||
- Familiarity with Qt/PySide6 or willingness to learn
|
- Familiarity with Qt/PySide6 or willingness to learn
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1051,7 +1051,7 @@ Help Menu
|
||||||
|
|
||||||
**Core:**
|
**Core:**
|
||||||
- PySide6 6.6.0+
|
- PySide6 6.6.0+
|
||||||
- Python 3.9+
|
- Python 3.10+
|
||||||
|
|
||||||
**Optional:**
|
**Optional:**
|
||||||
- PyInstaller (building)
|
- PyInstaller (building)
|
||||||
|
|
|
||||||
|
|
@ -143,19 +143,19 @@ brew upgrade webdrop-bridge
|
||||||
brew uninstall webdrop-bridge
|
brew uninstall webdrop-bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
For more package manager details and internal hosting options, see [docs/PACKAGE_MANAGER_SUPPORT.md](docs/PACKAGE_MANAGER_SUPPORT.md)
|
For more package manager details and internal hosting options, see [docs/PACKAGE_MANAGER_SUPPORT.md](../docs/PACKAGE_MANAGER_SUPPORT.md)
|
||||||
|
|
||||||
#### Simplest: Direct wget (if you know the version)
|
#### Simplest: Direct wget (if you know the version)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Replace X.Y.Z with a release version (e.g., 0.9.1)
|
# Replace VERSION with release tag (e.g., v0.8.0)
|
||||||
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/vX.Y.Z/WebDropBridge-X.Y.Z-win-x64.msi
|
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/VERSION/WebDropBridge_Setup.msi
|
||||||
|
|
||||||
# Real example - download v0.9.1 MSI
|
# Real example - download v0.8.0 MSI
|
||||||
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.9.1/WebDropBridge-0.9.1-win-x64.msi
|
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0/WebDropBridge_Setup.msi
|
||||||
|
|
||||||
# macOS - download v0.9.1 DMG
|
# macOS - download v0.8.0 DMG
|
||||||
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.9.1/WebDropBridge-0.9.1-macos-universal.dmg
|
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0/WebDropBridge_Setup.dmg
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Windows (PowerShell) - Full Control Script
|
#### Windows (PowerShell) - Full Control Script
|
||||||
|
|
@ -168,7 +168,7 @@ wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.9.1
|
||||||
.\build\scripts\download_release.ps1 -OutputDir "C:\Installers"
|
.\build\scripts\download_release.ps1 -OutputDir "C:\Installers"
|
||||||
|
|
||||||
# Download specific version
|
# Download specific version
|
||||||
.\build\scripts\download_release.ps1 -Version "0.9.1"
|
.\build\scripts\download_release.ps1 -Version "0.8.0"
|
||||||
|
|
||||||
# Skip checksum verification
|
# Skip checksum verification
|
||||||
.\build\scripts\download_release.ps1 -Verify $false
|
.\build\scripts\download_release.ps1 -Verify $false
|
||||||
|
|
@ -186,7 +186,7 @@ wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.9.1
|
||||||
./build/scripts/download_release.sh latest ~/Downloads
|
./build/scripts/download_release.sh latest ~/Downloads
|
||||||
|
|
||||||
# Download specific version
|
# Download specific version
|
||||||
./build/scripts/download_release.sh 0.9.1
|
./build/scripts/download_release.sh 0.8.0
|
||||||
|
|
||||||
# Skip checksum verification
|
# Skip checksum verification
|
||||||
./build/scripts/download_release.sh latest --no-verify
|
./build/scripts/download_release.sh latest --no-verify
|
||||||
|
|
|
||||||
95
README.md
95
README.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> Professional Qt-based desktop application for intelligent drag-and-drop file handling between web applications and desktop clients (InDesign, Word, Notepad++, etc.)
|
> Professional Qt-based desktop application for intelligent drag-and-drop file handling between web applications and desktop clients (InDesign, Word, Notepad++, etc.)
|
||||||
|
|
||||||
  
|
  
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|
@ -19,25 +19,23 @@ WebDrop Bridge embeds a web application in a Qt container with full filesystem a
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- ✅ **Qt-based Architecture** - Professional cross-platform desktop app via PySide6 for Windows and macOS
|
- ✅ **Qt-based Architecture** - Professional Windows support via PySide6 (macOS support planned)
|
||||||
- ✅ **Embedded Web App** - QtWebEngine provides Chromium without browser limitations
|
- ✅ **Embedded Web App** - QtWebEngine provides Chromium without browser limitations
|
||||||
- ✅ **Drag Interception** - Converts text paths to native file operations
|
- ✅ **Drag Interception** - Converts text paths to native file operations
|
||||||
- ✅ **Path Whitelist** - Security-conscious file system access control
|
- ✅ **Path Whitelist** - Security-conscious file system access control
|
||||||
- ✅ **Configuration Management** - JSON config, profile import/export, and validation
|
- ✅ **Configuration Management** - Profile-based settings with validation
|
||||||
- ✅ **Runtime Branding** - Switch branding templates and packaged variants without code changes
|
- ✅ **Settings Dialog** - Professional UI for path, URL, logging, and window configuration
|
||||||
- ✅ **Multilingual UI** - Built-in translations for English, German, French, Italian, Russian, and Chinese
|
|
||||||
- ✅ **Settings Dialog** - Language, branding, web source, path, URL, logging, and window configuration
|
|
||||||
- ✅ **Auto-Update System** - Automatic release detection via Forgejo API
|
- ✅ **Auto-Update System** - Automatic release detection via Forgejo API
|
||||||
- ✅ **Professional Build Pipeline** - MSI for Windows, DMG for macOS
|
- ✅ **Professional Build Pipeline** - MSI for Windows, DMG for macOS
|
||||||
- ✅ **Comprehensive Testing** - Unit and integration coverage across core modules
|
- ✅ **Comprehensive Testing** - Unit, integration, and end-to-end tests (80%+ coverage)
|
||||||
- ✅ **Continuous Testing** - Automated CI validation
|
- ✅ **Continuous Testing** - GitHub Actions test automation
|
||||||
- ✅ **Structured Logging** - File-based logging with configurable levels
|
- ✅ **Structured Logging** - File-based logging with configurable levels
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
- Python 3.9+
|
- Python 3.10+
|
||||||
- Windows 10/11 or macOS 12+
|
- Windows 10/11
|
||||||
- 200 MB disk space (includes Chromium from PyInstaller)
|
- 200 MB disk space (includes Chromium from PyInstaller)
|
||||||
|
|
||||||
### Installation from Pre-Built Release (Recommended)
|
### Installation from Pre-Built Release (Recommended)
|
||||||
|
|
@ -60,11 +58,11 @@ brew upgrade webdrop-bridge # Update to latest version
|
||||||
**Option 2: Direct wget (if you know the version)**
|
**Option 2: Direct wget (if you know the version)**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Replace X.Y.Z with a release version (e.g., 0.9.1)
|
# Replace VERSION with release tag (e.g., v0.8.0)
|
||||||
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/vX.Y.Z/WebDropBridge-X.Y.Z-win-x64.msi
|
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/VERSION/WebDropBridge_Setup.msi
|
||||||
|
|
||||||
# Example for v0.9.1:
|
# Example for v0.8.0:
|
||||||
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.9.1/WebDropBridge-0.9.1-win-x64.msi
|
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0/WebDropBridge_Setup.msi
|
||||||
```
|
```
|
||||||
|
|
||||||
**Option 3: Automated script (auto-detects platform)**
|
**Option 3: Automated script (auto-detects platform)**
|
||||||
|
|
@ -95,7 +93,6 @@ python -m venv venv
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
pip install -e .
|
|
||||||
|
|
||||||
# Run application
|
# Run application
|
||||||
python -m webdrop_bridge.main
|
python -m webdrop_bridge.main
|
||||||
|
|
@ -106,7 +103,6 @@ python -m webdrop_bridge.main
|
||||||
```bash
|
```bash
|
||||||
# Install development dependencies
|
# Install development dependencies
|
||||||
pip install -r requirements-dev.txt
|
pip install -r requirements-dev.txt
|
||||||
pip install -e .
|
|
||||||
|
|
||||||
# Run tests
|
# Run tests
|
||||||
pytest tests -v
|
pytest tests -v
|
||||||
|
|
@ -182,36 +178,42 @@ webdrop-bridge/
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
WebDrop Bridge supports persisted JSON configuration plus optional bootstrap environment defaults.
|
WebDrop Bridge supports two configuration methods:
|
||||||
|
|
||||||
### 1. Settings Dialog / JSON Config (Recommended)
|
### 1. Settings Dialog (Recommended)
|
||||||
Launch the application and access the Settings menu to configure:
|
Launch the application and access the Settings menu to configure:
|
||||||
- **General Tab** - Select the UI language or follow the system locale automatically
|
- **Paths Tab** - Add/remove allowed root directories
|
||||||
- **Branding Tab** - Switch, import, export, and preview runtime branding templates
|
- **URLs Tab** - Configure allowed web URLs (whitelist mode)
|
||||||
- **Web Source Tab** - Configure the embedded web application URL
|
- **Logging Tab** - Set log level and file location
|
||||||
- **Paths / URLs / Logging / Window Tabs** - Control filesystem access, allowed sites, log output, and initial window size
|
- **Window Tab** - Configure window dimensions
|
||||||
- **Profiles Tab** - Save, load, import, and export complete configuration profiles
|
- **Profiles Tab** - Save/load/export-import configuration profiles
|
||||||
|
|
||||||
Saved settings are written to the brand-specific application config directory as `config.json`.
|
Profiles are saved in `~/.webdrop_bridge/profiles/`
|
||||||
|
|
||||||
### 2. Bootstrap Environment Variables (`.env`)
|
### 2. Environment Variables
|
||||||
A `.env` file is still supported for local development and branded packaged defaults. It is used when no JSON config exists yet.
|
Create a `.env` file in the project root. Available settings:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Application
|
||||||
APP_NAME=WebDrop Bridge
|
APP_NAME=WebDrop Bridge
|
||||||
BRAND_ID=webdrop_bridge
|
APP_VERSION=1.0.0
|
||||||
WEBAPP_URL=https://dev.agravity.io/
|
|
||||||
|
# Paths (comma-separated)
|
||||||
ALLOWED_ROOTS=Z:/,C:/Users/Public
|
ALLOWED_ROOTS=Z:/,C:/Users/Public
|
||||||
|
|
||||||
|
# Web URLs (empty = no restriction, items = kiosk mode)
|
||||||
ALLOWED_URLS=
|
ALLOWED_URLS=
|
||||||
LANGUAGE=auto
|
|
||||||
LOG_LEVEL=INFO
|
# Interface
|
||||||
|
WEBAPP_URL=file:///./webapp/index.html
|
||||||
WINDOW_WIDTH=1024
|
WINDOW_WIDTH=1024
|
||||||
WINDOW_HEIGHT=768
|
WINDOW_HEIGHT=768
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=INFO
|
||||||
ENABLE_LOGGING=true
|
ENABLE_LOGGING=true
|
||||||
```
|
```
|
||||||
|
|
||||||
For the full JSON structure and branding workflow, see [CONFIG_README.md](CONFIG_README.md) and [BRANDING_AND_RELEASES.md](docs/BRANDING_AND_RELEASES.md).
|
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
WebDrop Bridge includes comprehensive test coverage with unit, integration, and end-to-end tests.
|
WebDrop Bridge includes comprehensive test coverage with unit, integration, and end-to-end tests.
|
||||||
|
|
@ -270,20 +272,11 @@ python build/scripts/build_windows.py --msi --code-sign
|
||||||
```
|
```
|
||||||
|
|
||||||
Output:
|
Output:
|
||||||
- Portable executable: `build/dist/windows/webdrop_bridge/WebDropBridge/WebDropBridge.exe`
|
- Portable executable: `build/dist/windows/WebDropBridge/WebDropBridge.exe` (~195 MB)
|
||||||
- Professional MSI installer: `build/dist/windows/webdrop_bridge/WebDropBridge-<version>-win-x64.msi`
|
- Professional MSI installer: `build/dist/windows/WebDropBridge-{version}-Setup.msi`
|
||||||
- SHA256 checksum: `build/dist/windows/webdrop_bridge/WebDropBridge-<version>-win-x64.msi.sha256`
|
- SHA256 checksum: `build/dist/windows/WebDropBridge/WebDropBridge.exe.sha256`
|
||||||
|
|
||||||
### macOS DMG Installer
|
**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.
|
||||||
|
|
||||||
```bash
|
|
||||||
bash build/scripts/build_macos.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Output:
|
|
||||||
- Application bundle: `build/dist/macos/webdrop_bridge/WebDropBridge.app`
|
|
||||||
- DMG installer: `build/dist/macos/webdrop_bridge/WebDropBridge-<version>-macos-universal.dmg`
|
|
||||||
- SHA256 checksum: `build/dist/macos/webdrop_bridge/WebDropBridge-<version>-macos-universal.dmg.sha256`
|
|
||||||
|
|
||||||
### Creating Releases
|
### Creating Releases
|
||||||
|
|
||||||
|
|
@ -321,8 +314,8 @@ powershell -ExecutionPolicy Bypass -File build/scripts/create_release.ps1
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Application won't start
|
### Application won't start
|
||||||
- Ensure Python 3.9+ is installed
|
- Ensure Python 3.10+ is installed
|
||||||
- Check the application log in your platform-specific app data directory
|
- Check `logs/webdrop_bridge.log` for errors
|
||||||
- Verify all dependencies: `pip list`
|
- Verify all dependencies: `pip list`
|
||||||
|
|
||||||
### Drag-and-drop not working
|
### Drag-and-drop not working
|
||||||
|
|
@ -339,10 +332,10 @@ powershell -ExecutionPolicy Bypass -File build/scripts/create_release.ps1
|
||||||
|
|
||||||
| Platform | Version | Status | Notes |
|
| Platform | Version | Status | Notes |
|
||||||
|----------|---------|--------|-------|
|
|----------|---------|--------|-------|
|
||||||
| Windows | 10, 11 | ✅ Full | Primary target with MSI packaging and update support |
|
| Windows | 10, 11 | ✅ Full | Tested on x64, MSI installer support |
|
||||||
| macOS | 12, 13, 14 | ✅ Supported | Universal DMG builds for Intel and Apple Silicon |
|
| macOS | 12+ | ⚠️ **Untested** | Possible via Qt/PySide6, but never built or tested. Theoretical support only. |
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|
@ -374,4 +367,4 @@ MIT License - see [LICENSE](LICENSE) file for details
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Development Phase**: Phase 5 Release Candidates | **Last Updated**: April 16, 2026 | **Current Version**: 0.9.1 | **Python**: 3.9+ | **Qt**: PySide6 (Qt 6)
|
**Development Phase**: Phase 4 Complete | **Last Updated**: February 18, 2026 | **Current Version**: 1.0.0 | **Python**: 3.10+ | **Qt**: PySide6 (Qt 6)
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ build/
|
||||||
|
|
||||||
2. **Get SHA256 checksum**:
|
2. **Get SHA256 checksum**:
|
||||||
```powershell
|
```powershell
|
||||||
certutil -hashfile build/dist/windows/webdrop_bridge/WebDropBridge-<version>-win-x64.msi SHA256
|
certutil -hashfile build/dist/windows/WebDropBridge_Setup.msi SHA256
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Update package files**:
|
3. **Update package files**:
|
||||||
|
|
@ -42,7 +42,7 @@ build/
|
||||||
|
|
||||||
5. **Publish** (requires Chocolatey API key):
|
5. **Publish** (requires Chocolatey API key):
|
||||||
```powershell
|
```powershell
|
||||||
choco push webdrop-bridge.<version>.nupkg --api-key YOUR_KEY
|
choco push webdrop-bridge.0.8.0.nupkg --api-key YOUR_KEY
|
||||||
```
|
```
|
||||||
|
|
||||||
### Homebrew Formula (macOS)
|
### Homebrew Formula (macOS)
|
||||||
|
|
@ -54,7 +54,7 @@ build/
|
||||||
|
|
||||||
2. **Get SHA256 checksum**:
|
2. **Get SHA256 checksum**:
|
||||||
```bash
|
```bash
|
||||||
shasum -a 256 build/dist/macos/webdrop_bridge/WebDropBridge-<version>-macos-universal.dmg
|
shasum -a 256 build/dist/macos/WebDropBridge_Setup.dmg
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Update formula**:
|
3. **Update formula**:
|
||||||
|
|
|
||||||
|
|
@ -75,8 +75,8 @@ The `download_release.ps1` (Windows) and `download_release.sh` (macOS/Linux) scr
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Download directly by version tag
|
# Download directly by version tag
|
||||||
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.9.1/WebDropBridge-0.9.1-win-x64.msi
|
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0/WebDropBridge_Setup.msi
|
||||||
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.9.1/WebDropBridge-0.9.1-macos-universal.dmg
|
wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0/WebDropBridge_Setup.dmg
|
||||||
```
|
```
|
||||||
|
|
||||||
**If you need to auto-detect latest (with grep/cut, no jq needed)**
|
**If you need to auto-detect latest (with grep/cut, no jq needed)**
|
||||||
|
|
@ -111,7 +111,7 @@ sha256sum -c installer.sha256
|
||||||
.\download_release.ps1
|
.\download_release.ps1
|
||||||
|
|
||||||
# Specific version to Downloads folder
|
# Specific version to Downloads folder
|
||||||
.\download_release.ps1 -Version "0.9.1" -OutputDir "$env:USERPROFILE\Downloads"
|
.\download_release.ps1 -Version "0.8.0" -OutputDir "$env:USERPROFILE\Downloads"
|
||||||
|
|
||||||
# Skip checksum verification
|
# Skip checksum verification
|
||||||
.\download_release.ps1 -Verify $false
|
.\download_release.ps1 -Verify $false
|
||||||
|
|
@ -124,7 +124,7 @@ sha256sum -c installer.sha256
|
||||||
./build/scripts/download_release.sh
|
./build/scripts/download_release.sh
|
||||||
|
|
||||||
# Specific version to Downloads
|
# Specific version to Downloads
|
||||||
./build/scripts/download_release.sh 0.9.1 ~/Downloads
|
./build/scripts/download_release.sh 0.8.0 ~/Downloads
|
||||||
|
|
||||||
# Skip checksum verification
|
# Skip checksum verification
|
||||||
./build/scripts/download_release.sh latest --no-verify
|
./build/scripts/download_release.sh latest --no-verify
|
||||||
|
|
@ -165,7 +165,7 @@ Automated release creation with versioning and asset uploads.
|
||||||
Manages consistent versioning across the project.
|
Manages consistent versioning across the project.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python build/scripts/sync_version.py --version 0.9.1
|
python build/scripts/sync_version.py --version 0.8.0
|
||||||
```
|
```
|
||||||
|
|
||||||
## Integration Flow
|
## Integration Flow
|
||||||
|
|
|
||||||
|
|
@ -34,18 +34,6 @@ function Get-CurrentVersion {
|
||||||
return (& $pythonExe -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$projectRoot/build/scripts').resolve())); from version_utils import get_current_version; print(get_current_version())").Trim()
|
return (& $pythonExe -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$projectRoot/build/scripts').resolve())); from version_utils import get_current_version; print(get_current_version())").Trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-ReleaseNotes {
|
|
||||||
param([string]$Version)
|
|
||||||
|
|
||||||
$notes = & $pythonExe -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$projectRoot/build/scripts').resolve())); from version_utils import get_release_notes; print(get_release_notes('$Version'))"
|
|
||||||
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
return "## WebDrop Bridge v$Version`n`nThis release package contains the latest improvements, fixes, and installer updates for this version."
|
|
||||||
}
|
|
||||||
|
|
||||||
return ($notes | Out-String).Trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-LocalReleaseData {
|
function Get-LocalReleaseData {
|
||||||
$arguments = @($brandHelper, "local-release-data", "--platform", "windows", "--version", $Version)
|
$arguments = @($brandHelper, "local-release-data", "--platform", "windows", "--version", $Version)
|
||||||
if ($Brands) {
|
if ($Brands) {
|
||||||
|
|
@ -139,11 +127,10 @@ $headers = @{
|
||||||
|
|
||||||
$releaseLookupUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/tags/v$Version"
|
$releaseLookupUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/tags/v$Version"
|
||||||
$releaseUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases"
|
$releaseUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases"
|
||||||
$releaseBody = Get-ReleaseNotes -Version $Version
|
|
||||||
$releaseData = @{
|
$releaseData = @{
|
||||||
tag_name = "v$Version"
|
tag_name = "v$Version"
|
||||||
name = "WebDropBridge v$Version"
|
name = "WebDropBridge v$Version"
|
||||||
body = $releaseBody
|
body = "Shared branded release for WebDrop Bridge v$Version"
|
||||||
draft = $false
|
draft = $false
|
||||||
prerelease = $false
|
prerelease = $false
|
||||||
} | ConvertTo-Json
|
} | ConvertTo-Json
|
||||||
|
|
|
||||||
|
|
@ -40,10 +40,6 @@ if [ -z "$VERSION" ]; then
|
||||||
VERSION="$(python3 -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$PROJECT_ROOT/build/scripts').resolve())); from version_utils import get_current_version; print(get_current_version())")"
|
VERSION="$(python3 -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$PROJECT_ROOT/build/scripts').resolve())); from version_utils import get_current_version; print(get_current_version())")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
get_release_notes() {
|
|
||||||
python3 -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$PROJECT_ROOT/build/scripts').resolve())); from version_utils import get_release_notes; print(get_release_notes('$VERSION'))"
|
|
||||||
}
|
|
||||||
|
|
||||||
LOCAL_ARGS=("$BRAND_HELPER" "local-release-data" "--platform" "macos" "--version" "$VERSION")
|
LOCAL_ARGS=("$BRAND_HELPER" "local-release-data" "--platform" "macos" "--version" "$VERSION")
|
||||||
if [ ${#BRANDS[@]} -gt 0 ]; then
|
if [ ${#BRANDS[@]} -gt 0 ]; then
|
||||||
LOCAL_ARGS+=("--brands" "${BRANDS[@]}")
|
LOCAL_ARGS+=("--brands" "${BRANDS[@]}")
|
||||||
|
|
@ -190,19 +186,15 @@ else
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
RELEASE_BODY="$(get_release_notes)"
|
RELEASE_DATA=$(cat <<EOF
|
||||||
RELEASE_DATA=$(RELEASE_BODY="$RELEASE_BODY" VERSION="$VERSION" python3 - <<'PY'
|
{
|
||||||
import json
|
"tag_name": "v$VERSION",
|
||||||
import os
|
"name": "WebDropBridge v$VERSION",
|
||||||
|
"body": "Shared branded release for WebDrop Bridge v$VERSION",
|
||||||
print(json.dumps({
|
"draft": false,
|
||||||
"tag_name": f"v{os.environ['VERSION']}",
|
"prerelease": false
|
||||||
"name": f"WebDropBridge v{os.environ['VERSION']}",
|
}
|
||||||
"body": os.environ["RELEASE_BODY"],
|
EOF
|
||||||
"draft": False,
|
|
||||||
"prerelease": False,
|
|
||||||
}))
|
|
||||||
PY
|
|
||||||
)
|
)
|
||||||
HTTP_CODE=$(curl -s -o "$RELEASE_RESPONSE_FILE" -w "%{http_code}" -X POST \
|
HTTP_CODE=$(curl -s -o "$RELEASE_RESPONSE_FILE" -w "%{http_code}" -X POST \
|
||||||
-H "Authorization: Basic $BASIC_AUTH" \
|
-H "Authorization: Basic $BASIC_AUTH" \
|
||||||
|
|
|
||||||
|
|
@ -33,68 +33,17 @@ def get_current_version() -> str:
|
||||||
init_file = project_root / "src" / "webdrop_bridge" / "__init__.py"
|
init_file = project_root / "src" / "webdrop_bridge" / "__init__.py"
|
||||||
|
|
||||||
if not init_file.exists():
|
if not init_file.exists():
|
||||||
raise FileNotFoundError(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")
|
content = init_file.read_text(encoding="utf-8")
|
||||||
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
|
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
|
||||||
|
|
||||||
if not match:
|
if not match:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Could not find __version__ in {init_file}. " 'Expected: __version__ = "X.Y.Z"'
|
f"Could not find __version__ in {init_file}. "
|
||||||
|
"Expected: __version__ = \"X.Y.Z\""
|
||||||
)
|
)
|
||||||
|
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
|
|
||||||
|
|
||||||
def extract_release_notes(changelog_content: str, version: str) -> str | None:
|
|
||||||
"""Extract the notes for a specific version from changelog content.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
changelog_content: Full text of CHANGELOG.md
|
|
||||||
version: Version to extract, e.g. "0.9.1"
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The section content for that version, or None if not found.
|
|
||||||
"""
|
|
||||||
version_header = re.compile(
|
|
||||||
rf"^##\s*\[?{re.escape(version)}\]?(?:\s*-\s*.+)?\s*$",
|
|
||||||
re.MULTILINE,
|
|
||||||
)
|
|
||||||
match = version_header.search(changelog_content)
|
|
||||||
if not match:
|
|
||||||
return None
|
|
||||||
|
|
||||||
section_start = match.end()
|
|
||||||
next_header = re.search(r"^##\s+", changelog_content[section_start:], re.MULTILINE)
|
|
||||||
section_end = section_start + next_header.start() if next_header else len(changelog_content)
|
|
||||||
section = changelog_content[section_start:section_end].strip()
|
|
||||||
return section or None
|
|
||||||
|
|
||||||
|
|
||||||
def get_release_notes(version: str, project_root: Path | None = None) -> str:
|
|
||||||
"""Build a readable release body for publishing.
|
|
||||||
|
|
||||||
Prefers the matching version section from CHANGELOG.md. If no changelog
|
|
||||||
entry exists yet, falls back to a generic but user-facing description.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
version: Release version string.
|
|
||||||
project_root: Optional project root override for testing.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Release notes text suitable for Forgejo/GitHub release bodies.
|
|
||||||
"""
|
|
||||||
root = project_root or get_project_root()
|
|
||||||
changelog_file = root / "CHANGELOG.md"
|
|
||||||
|
|
||||||
if changelog_file.exists():
|
|
||||||
content = changelog_file.read_text(encoding="utf-8")
|
|
||||||
notes = extract_release_notes(content, version)
|
|
||||||
if notes:
|
|
||||||
return f"## WebDrop Bridge v{version}\n\n{notes}"
|
|
||||||
|
|
||||||
return (
|
|
||||||
f"## WebDrop Bridge v{version}\n\n"
|
|
||||||
"This release package contains the latest improvements, fixes, "
|
|
||||||
"and installer updates for this version."
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,33 @@
|
||||||
# Configuration Management for Builds
|
# Configuration Management for Builds
|
||||||
|
|
||||||
This document explains how configuration and branding work for development builds, packaged installers, and installed applications.
|
This document explains how configuration is handled when building executables and installers for WebDrop Bridge.
|
||||||
|
|
||||||
## Current Configuration Model
|
## Overview
|
||||||
|
|
||||||
WebDrop Bridge now uses a **JSON-first runtime configuration** with optional `.env` bootstrap defaults:
|
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.
|
||||||
|
|
||||||
1. **Bootstrap `.env`** (optional)
|
## Configuration File
|
||||||
- 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
|
|
||||||
|
|
||||||
2. **Persisted JSON config** (preferred)
|
The configuration file must be named `.env` and contains settings like:
|
||||||
- Windows: `%APPDATA%\<config_dir_name>\config.json`
|
|
||||||
- macOS/Linux: `~/.config/<config_dir_name>/config.json`
|
|
||||||
- Created and maintained by the Settings dialog
|
|
||||||
- Takes precedence for day-to-day user settings after first launch
|
|
||||||
|
|
||||||
In practice, installers can ship with a curated `.env`, while user changes are saved into `config.json`.
|
|
||||||
|
|
||||||
## What Belongs Where?
|
|
||||||
|
|
||||||
### Use JSON config for:
|
|
||||||
- `url_mappings`
|
|
||||||
- `allowed_roots`
|
|
||||||
- `allowed_urls`
|
|
||||||
- window size and logging settings
|
|
||||||
- `language`
|
|
||||||
- `active_branding_id`
|
|
||||||
|
|
||||||
### Use `.env` bootstrap defaults for:
|
|
||||||
- `APP_NAME`
|
|
||||||
- `BRAND_ID`
|
|
||||||
- `APP_CONFIG_DIR_NAME`
|
|
||||||
- update channel and repository defaults
|
|
||||||
- packaged first-launch defaults for customer-specific builds
|
|
||||||
|
|
||||||
## Example Bootstrap `.env`
|
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
APP_NAME=WebDrop Bridge
|
APP_NAME=WebDrop Bridge
|
||||||
BRAND_ID=webdrop_bridge
|
APP_VERSION=0.7.1
|
||||||
APP_CONFIG_DIR_NAME=webdrop_bridge
|
|
||||||
WEBAPP_URL=https://example.com
|
WEBAPP_URL=https://example.com
|
||||||
ALLOWED_ROOTS=Z:/,C:/Users/Public
|
ALLOWED_ROOTS=Z:/,C:/Users/Public
|
||||||
ALLOWED_URLS=
|
ALLOWED_URLS=
|
||||||
LANGUAGE=auto
|
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
LOG_FILE=logs/webdrop_bridge.log
|
||||||
ENABLE_LOGGING=true
|
ENABLE_LOGGING=true
|
||||||
WINDOW_WIDTH=1024
|
WINDOW_WIDTH=1024
|
||||||
WINDOW_HEIGHT=768
|
WINDOW_HEIGHT=768
|
||||||
```
|
```
|
||||||
|
|
||||||
|
See `.env.example` for a template with all available options.
|
||||||
|
|
||||||
## Building with Default Configuration
|
## Building with Default Configuration
|
||||||
|
|
||||||
If you want to use the project's default `.env` file from the repository root:
|
If you want to use the project's `.env` file (in the project root), simply run:
|
||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -66,11 +39,11 @@ python build/scripts/build_windows.py --msi
|
||||||
bash build/scripts/build_macos.sh
|
bash build/scripts/build_macos.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
> The build scripts currently expect a `.env` file to exist. This is intentional so packaged builds always have explicit bootstrap defaults.
|
**Important:** The build will **fail** if `.env` doesn't exist. This prevents accidentally shipping without configuration.
|
||||||
|
|
||||||
## Building with Custom Customer Defaults
|
## Building with Custom Configuration
|
||||||
|
|
||||||
For customer-specific or branded releases, provide a different `.env` file during packaging:
|
For different customers or deployments, you can specify a custom `.env` file:
|
||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -82,73 +55,86 @@ python build/scripts/build_windows.py --msi --env-file path/to/customer1.env
|
||||||
bash build/scripts/build_macos.sh --env-file path/to/customer1.env
|
bash build/scripts/build_macos.sh --env-file path/to/customer1.env
|
||||||
```
|
```
|
||||||
|
|
||||||
This bundles those bootstrap defaults into the packaged app while still allowing the installed application to persist later changes in JSON.
|
The custom `.env` file will be bundled into the executable and users will receive those pre-configured settings.
|
||||||
|
|
||||||
## Example: Multi-Customer Setup
|
## Example: Multi-Customer Setup
|
||||||
|
|
||||||
```text
|
If you have different customer configurations:
|
||||||
|
|
||||||
|
```
|
||||||
webdrop_bridge/
|
webdrop_bridge/
|
||||||
├── .env
|
├── .env # Default project configuration
|
||||||
|
├── .env.example # Template
|
||||||
├── build/
|
├── build/
|
||||||
│ └── scripts/
|
│ └── scripts/
|
||||||
│ ├── build_windows.py
|
│ ├── build_windows.py
|
||||||
│ └── build_macos.sh
|
│ └── build_macos.sh
|
||||||
├── customer_configs/
|
├── customer_configs/ # Create this for customer-specific settings
|
||||||
│ ├── acme_corp.env
|
│ ├── acme_corp.env
|
||||||
│ ├── globex_corporation.env
|
│ ├── globex_corporation.env
|
||||||
│ └── initech.env
|
│ └── initech.env
|
||||||
└── config.example.json
|
└── ...
|
||||||
```
|
```
|
||||||
|
|
||||||
Then build per customer or brand:
|
Then build for each customer:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# ACME Corp
|
||||||
python build/scripts/build_windows.py --msi --env-file customer_configs/acme_corp.env
|
python build/scripts/build_windows.py --msi --env-file customer_configs/acme_corp.env
|
||||||
|
|
||||||
|
# Globex Corporation
|
||||||
python build/scripts/build_windows.py --msi --env-file customer_configs/globex_corporation.env
|
python build/scripts/build_windows.py --msi --env-file customer_configs/globex_corporation.env
|
||||||
bash build/scripts/build_macos.sh --env-file customer_configs/initech.env
|
|
||||||
|
# Initech
|
||||||
|
python build/scripts/build_windows.py --msi --env-file customer_configs/initech.env
|
||||||
```
|
```
|
||||||
|
|
||||||
## What Gets Bundled into Installers?
|
Each MSI will include that customer's specific configuration (URLs, allowed paths, etc.).
|
||||||
|
|
||||||
During packaging, the supplied `.env` file is bundled so the application can resolve:
|
## What Gets Bundled
|
||||||
- app display name
|
|
||||||
- brand/config directory name
|
|
||||||
- update channel defaults
|
|
||||||
- initial web source and logging defaults
|
|
||||||
|
|
||||||
After installation, the application normally saves user-controlled settings to the JSON config file in the app data directory.
|
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
|
||||||
|
|
||||||
## Recommended Runtime Workflow
|
Users **do not** need to create their own `.env` files.
|
||||||
|
|
||||||
1. Package the app with the correct `.env` bootstrap defaults.
|
## After Installation
|
||||||
2. Launch the app once.
|
|
||||||
3. Configure URLs, mappings, language, and branding in the Settings dialog.
|
When users run the installed application:
|
||||||
4. Let the app save `config.json` in the brand-specific config directory.
|
1. The embedded `.env` is automatically available
|
||||||
5. Reuse exported profiles or branding templates for future setups.
|
2. Settings are loaded and applied
|
||||||
|
3. Users can optionally create a custom `.env` in the installation directory to override settings
|
||||||
|
|
||||||
|
This allows:
|
||||||
|
- **Pre-configured deployments** for your customers
|
||||||
|
- **Easy customization** by users (just edit the `.env` file)
|
||||||
|
- **No manual setup** required after installation
|
||||||
|
|
||||||
## Build Command Reference
|
## Build Command Reference
|
||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
```bash
|
```bash
|
||||||
# Default build using the repository root .env
|
# Default (.env from project root)
|
||||||
python build/scripts/build_windows.py --msi
|
python build/scripts/build_windows.py --msi
|
||||||
|
|
||||||
# Customer-specific defaults
|
# Custom .env file
|
||||||
python build/scripts/build_windows.py --msi --env-file customer_configs/acme.env
|
python build/scripts/build_windows.py --msi --env-file customer_configs/acme.env
|
||||||
|
|
||||||
# Without MSI (just the packaged executable)
|
# Without MSI (just EXE)
|
||||||
python build/scripts/build_windows.py
|
python build/scripts/build_windows.py
|
||||||
|
|
||||||
# Sign executable (requires signing setup)
|
# Sign executable (requires CODE_SIGN_CERT env var)
|
||||||
python build/scripts/build_windows.py --msi --code-sign
|
python build/scripts/build_windows.py --msi --code-sign
|
||||||
```
|
```
|
||||||
|
|
||||||
### macOS
|
### macOS
|
||||||
```bash
|
```bash
|
||||||
# Default build using the repository root .env
|
# Default (.env from project root)
|
||||||
bash build/scripts/build_macos.sh
|
bash build/scripts/build_macos.sh
|
||||||
|
|
||||||
# Customer-specific defaults
|
# Custom .env file
|
||||||
bash build/scripts/build_macos.sh --env-file customer_configs/acme.env
|
bash build/scripts/build_macos.sh --env-file customer_configs/acme.env
|
||||||
|
|
||||||
# Sign app (requires Apple developer certificate)
|
# Sign app (requires Apple developer certificate)
|
||||||
|
|
@ -158,11 +144,19 @@ bash build/scripts/build_macos.sh --sign
|
||||||
bash build/scripts/build_macos.sh --notarize
|
bash build/scripts/build_macos.sh --notarize
|
||||||
```
|
```
|
||||||
|
|
||||||
## Validation Notes
|
## Configuration Validation
|
||||||
|
|
||||||
The build process validates that:
|
The build process validates that:
|
||||||
1. the specified `.env` file exists,
|
1. ✅ The specified `.env` file exists
|
||||||
2. packaging metadata can be resolved, and
|
2. ✅ All required environment variables are present
|
||||||
3. the resulting installer assets are created successfully.
|
3. ✅ Values are valid (LOG_LEVEL is valid, paths exist for ALLOWED_ROOTS, etc.)
|
||||||
|
|
||||||
If you need the full runtime JSON schema, see `CONFIG_README.md`.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ webdrop_bridge/
|
||||||
python build/scripts/build_windows.py --msi
|
python build/scripts/build_windows.py --msi
|
||||||
```
|
```
|
||||||
|
|
||||||
**Result:** `WebDropBridge-<version>-win-x64.msi` with your packaged bootstrap defaults bundled.
|
**Result:** `WebDropBridge-x.x.x-Setup.msi` with your `.env` configuration bundled.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -47,7 +47,7 @@ webdrop_bridge/
|
||||||
**Customer Config Example:** `deploy/customer_configs/acme_corp.env`
|
**Customer Config Example:** `deploy/customer_configs/acme_corp.env`
|
||||||
```dotenv
|
```dotenv
|
||||||
APP_NAME=WebDrop Bridge - ACME Corp Edition
|
APP_NAME=WebDrop Bridge - ACME Corp Edition
|
||||||
BRAND_ID=acme_corp
|
APP_VERSION=1.0.0
|
||||||
WEBAPP_URL=https://acme-drop.example.com/drop
|
WEBAPP_URL=https://acme-drop.example.com/drop
|
||||||
ALLOWED_ROOTS=Z:/acme_files/,C:/Users/Public/ACME
|
ALLOWED_ROOTS=Z:/acme_files/,C:/Users/Public/ACME
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
@ -72,9 +72,9 @@ python build/scripts/build_windows.py --msi --env-file deploy/customer_configs/i
|
||||||
python build/scripts/build_windows.py --msi --env-file deploy/customer_configs/wayne_enterprises.env
|
python build/scripts/build_windows.py --msi --env-file deploy/customer_configs/wayne_enterprises.env
|
||||||
```
|
```
|
||||||
|
|
||||||
**Result:** Four separate MSI files, for example:
|
**Result:** Four separate MSI files:
|
||||||
- `WebDropBridge-<version>-win-x64.msi` (default brand)
|
- `WebDropBridge-1.0.0-Setup.msi` (ACME - says "ACME Corp Edition")
|
||||||
- `AcmeBridge-<version>-win-x64.msi` (if the customer build uses its own asset prefix)
|
- `WebDropBridge-1.0.0-Setup.msi` (Globex - say "Globex Edition")
|
||||||
- etc.
|
- etc.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -20,16 +20,16 @@ WebDropBridge supports installation via package managers, making it easier for u
|
||||||
# 1. Build the Chocolatey package
|
# 1. Build the Chocolatey package
|
||||||
cd build/chocolatey
|
cd build/chocolatey
|
||||||
python ../../build/scripts/build_windows.py --msi
|
python ../../build/scripts/build_windows.py --msi
|
||||||
certutil -hashfile "../../build/dist/windows/webdrop_bridge/WebDropBridge-<version>-win-x64.msi" SHA256
|
certutil -hashfile "../../build/dist/windows/WebDropBridge_Setup.msi" SHA256
|
||||||
# Update checksum in tools/chocolateyInstall.ps1
|
# Update checksum in tools/chocolateyInstall.ps1
|
||||||
choco pack webdrop-bridge.nuspec
|
choco pack webdrop-bridge.nuspec
|
||||||
|
|
||||||
# 2. Share webdrop-bridge.<version>.nupkg
|
# 2. Share webdrop-bridge.0.8.0.nupkg
|
||||||
# File share: \\server\packages\
|
# File share: \\server\packages\
|
||||||
# USB drive, email, Forgejo releases, etc.
|
# USB drive, email, Forgejo releases, etc.
|
||||||
|
|
||||||
# 3. Users install it
|
# 3. Users install it
|
||||||
# choco install webdrop-bridge.<version>.nupkg -s "\\server\packages"
|
# choco install webdrop-bridge.0.8.0.nupkg -s "\\server\packages"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Advantages:**
|
**Advantages:**
|
||||||
|
|
@ -56,21 +56,21 @@ choco pack webdrop-bridge.nuspec
|
||||||
python build/scripts/build_windows.py --msi
|
python build/scripts/build_windows.py --msi
|
||||||
|
|
||||||
# 2. Calculate SHA256 checksum of the MSI
|
# 2. Calculate SHA256 checksum of the MSI
|
||||||
certutil -hashfile "build/dist/windows/webdrop_bridge/WebDropBridge-<version>-win-x64.msi" SHA256
|
certutil -hashfile "build/dist/windows/WebDropBridge_Setup.msi" SHA256
|
||||||
|
|
||||||
# 3. Update the checksum in build/chocolatey/tools/chocolateyInstall.ps1
|
# 3. Update the checksum in build/chocolatey/tools/chocolateyInstall.ps1
|
||||||
# Replace: $Checksum = ''
|
# Replace: $Checksum = ''
|
||||||
# With: $Checksum = 'YOUR_SHA256_HASH'
|
# With: $Checksum = 'YOUR_SHA256_HASH'
|
||||||
|
|
||||||
# 4. Update version in chocolatey/webdrop-bridge.nuspec
|
# 4. Update version in chocolatey/webdrop-bridge.nuspec
|
||||||
# <version>X.Y.Z</version>
|
# <version>0.8.0</version>
|
||||||
|
|
||||||
# 5. Create the package
|
# 5. Create the package
|
||||||
cd build/chocolatey
|
cd build/chocolatey
|
||||||
choco pack webdrop-bridge.nuspec
|
choco pack webdrop-bridge.nuspec
|
||||||
```
|
```
|
||||||
|
|
||||||
This creates `webdrop-bridge.<version>.nupkg`
|
This creates `webdrop-bridge.0.8.0.nupkg`
|
||||||
|
|
||||||
### Publishing to Chocolatey
|
### Publishing to Chocolatey
|
||||||
|
|
||||||
|
|
@ -83,7 +83,7 @@ Host on your own NuGet server (Azure Artifacts, Artifactory, ProGet, etc.):
|
||||||
choco source add -n=internal-repo -s "https://your-artifactory.internal/nuget/chocolatey/"
|
choco source add -n=internal-repo -s "https://your-artifactory.internal/nuget/chocolatey/"
|
||||||
|
|
||||||
# Push package to internal repo
|
# Push package to internal repo
|
||||||
nuget push webdrop-bridge.<version>.nupkg -Source https://your-artifactory.internal/nuget/chocolatey/ -ApiKey YOUR_API_KEY
|
nuget push webdrop-bridge.0.8.0.nupkg -Source https://your-artifactory.internal/nuget/chocolatey/ -ApiKey YOUR_API_KEY
|
||||||
|
|
||||||
# Users install from internal repo (already configured)
|
# Users install from internal repo (already configured)
|
||||||
choco install webdrop-bridge
|
choco install webdrop-bridge
|
||||||
|
|
@ -95,7 +95,7 @@ If you want public distribution (requires community maintainer account):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Push to community repo
|
# Push to community repo
|
||||||
choco push webdrop-bridge.<version>.nupkg --api-key YOUR_CHOCOLATEY_API_KEY
|
choco push webdrop-bridge.0.8.0.nupkg --api-key YOUR_CHOCOLATEY_API_KEY
|
||||||
```
|
```
|
||||||
|
|
||||||
**Option 3: No Repository (Direct Distribution)**
|
**Option 3: No Repository (Direct Distribution)**
|
||||||
|
|
@ -103,8 +103,8 @@ choco push webdrop-bridge.<version>.nupkg --api-key YOUR_CHOCOLATEY_API_KEY
|
||||||
Share the `.nupkg` file directly, users install locally:
|
Share the `.nupkg` file directly, users install locally:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# User downloads webdrop-bridge.<version>.nupkg and runs:
|
# User downloads webdrop-bridge.0.8.0.nupkg and runs:
|
||||||
choco install webdrop-bridge.<version>.nupkg -s C:\path\to\package\folder
|
choco install webdrop-bridge.0.8.0.nupkg -s C:\path\to\package\folder
|
||||||
```
|
```
|
||||||
|
|
||||||
### User Installation
|
### User Installation
|
||||||
|
|
@ -119,7 +119,7 @@ choco install webdrop-bridge
|
||||||
choco install webdrop-bridge
|
choco install webdrop-bridge
|
||||||
|
|
||||||
# If distributing directly
|
# If distributing directly
|
||||||
choco install webdrop-bridge.<version>.nupkg -s "\\network\share\packages"
|
choco install webdrop-bridge.0.8.0.nupkg -s "\\network\share\packages"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Homebrew (macOS)
|
## Homebrew (macOS)
|
||||||
|
|
@ -183,11 +183,11 @@ Submit to `homebrew/casks` (requires more maintenance but no separate tap):
|
||||||
bash build/scripts/build_macos.sh
|
bash build/scripts/build_macos.sh
|
||||||
|
|
||||||
# 2. Calculate SHA256 checksum
|
# 2. Calculate SHA256 checksum
|
||||||
shasum -a 256 "build/dist/macos/webdrop_bridge/WebDropBridge-<version>-macos-universal.dmg"
|
shasum -a 256 "build/dist/macos/WebDropBridge_Setup.dmg"
|
||||||
|
|
||||||
# 3. Update formula with checksum and URL
|
# 3. Update formula with checksum and URL
|
||||||
# build/homebrew/webdrop-bridge.rb
|
# build/homebrew/webdrop-bridge.rb
|
||||||
# - url: https://git.him-tools.de/...releases/download/vX.Y.Z/WebDropBridge-X.Y.Z-macos-universal.dmg
|
# - url: https://git.him-tools.de/...releases/download/vX.X.X/WebDropBridge_Setup.dmg
|
||||||
# - sha256: YOUR_SHA256_HASH
|
# - sha256: YOUR_SHA256_HASH
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -210,7 +210,7 @@ webdrop-bridge --version # If CLI exists, or check Applications folder
|
||||||
### Step 1: Build Release
|
### Step 1: Build Release
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Release vX.Y.Z
|
# Release v0.8.0
|
||||||
|
|
||||||
# Windows MSI
|
# Windows MSI
|
||||||
python build/scripts/build_windows.py --msi
|
python build/scripts/build_windows.py --msi
|
||||||
|
|
@ -224,8 +224,8 @@ bash build/scripts/build_macos.sh
|
||||||
Tag and upload installers to Forgejo:
|
Tag and upload installers to Forgejo:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git tag -a vX.Y.Z -m "Release X.Y.Z"
|
git tag -a v0.8.0 -m "Release 0.8.0"
|
||||||
git push upstream vX.Y.Z
|
git push upstream v0.8.0
|
||||||
|
|
||||||
# Upload MSI and DMG to Forgejo release page
|
# Upload MSI and DMG to Forgejo release page
|
||||||
```
|
```
|
||||||
|
|
@ -234,10 +234,10 @@ git push upstream vX.Y.Z
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Windows
|
# Windows
|
||||||
certutil -hashfile WebDropBridge-<version>-win-x64.msi SHA256
|
certutil -hashfile WebDropBridge_Setup.msi SHA256
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
shasum -a 256 WebDropBridge-<version>-macos-universal.dmg
|
shasum -a 256 WebDropBridge_Setup.dmg
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 4: Update Package Manager Files
|
### Step 4: Update Package Manager Files
|
||||||
|
|
@ -258,7 +258,7 @@ sha256 "MACOS_SHA256_HASH"
|
||||||
```powershell
|
```powershell
|
||||||
cd build/chocolatey
|
cd build/chocolatey
|
||||||
choco pack
|
choco pack
|
||||||
choco install webdrop-bridge.<version>.nupkg -s .
|
choco install webdrop-bridge.0.8.0.nupkg -s .
|
||||||
```
|
```
|
||||||
|
|
||||||
**Homebrew (with tap):**
|
**Homebrew (with tap):**
|
||||||
|
|
@ -270,7 +270,7 @@ brew install ./build/homebrew/webdrop-bridge.rb
|
||||||
|
|
||||||
**Chocolatey:**
|
**Chocolatey:**
|
||||||
```powershell
|
```powershell
|
||||||
choco push webdrop-bridge.<version>.nupkg --api-key YOUR_KEY
|
choco push webdrop-bridge.0.8.0.nupkg --api-key YOUR_KEY
|
||||||
```
|
```
|
||||||
|
|
||||||
**Homebrew:**
|
**Homebrew:**
|
||||||
|
|
@ -343,7 +343,7 @@ This works for:
|
||||||
### Chocolatey Issues
|
### Chocolatey Issues
|
||||||
|
|
||||||
**Package won't install:**
|
**Package won't install:**
|
||||||
- Verify checksum: `certutil -hashfile WebDropBridge-<version>-win-x64.msi SHA256`
|
- Verify checksum: `certutil -hashfile WebDropBridge_Setup.msi SHA256`
|
||||||
- Check MSI exists at URL: `wget URL`
|
- Check MSI exists at URL: `wget URL`
|
||||||
- Verify SHA256 matches in `chocolateyInstall.ps1`
|
- Verify SHA256 matches in `chocolateyInstall.ps1`
|
||||||
|
|
||||||
|
|
@ -356,7 +356,7 @@ This works for:
|
||||||
**Formula won't install:**
|
**Formula won't install:**
|
||||||
- Validate syntax: `brew audit --formula webdrop-bridge.rb`
|
- Validate syntax: `brew audit --formula webdrop-bridge.rb`
|
||||||
- Check URL is accessible: `curl -I URL`
|
- Check URL is accessible: `curl -I URL`
|
||||||
- Verify SHA256: `shasum -a 256 WebDropBridge-<version>-macos-universal.dmg`
|
- Verify SHA256: `shasum -a 256 WebDropBridge_Setup.dmg`
|
||||||
|
|
||||||
**Upgrade fails:**
|
**Upgrade fails:**
|
||||||
- Remove old version: `brew uninstall webdrop-bridge`
|
- Remove old version: `brew uninstall webdrop-bridge`
|
||||||
|
|
@ -375,7 +375,7 @@ This works for:
|
||||||
|
|
||||||
1. **Easiest: Direct Distribution** ✅
|
1. **Easiest: Direct Distribution** ✅
|
||||||
- Share `.nupkg` file via file share or email
|
- Share `.nupkg` file via file share or email
|
||||||
- Users: `choco install webdrop-bridge.<version>.nupkg -s "\\share\packages"`
|
- Users: `choco install webdrop-bridge.0.8.0.nupkg -s "\\share\packages"`
|
||||||
- No infrastructure needed
|
- No infrastructure needed
|
||||||
- No maintainer account required
|
- No maintainer account required
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,10 +58,6 @@
|
||||||
"dialog.language_changed.msg": "Die Spracheinstellung wurde aktualisiert. Starten Sie jetzt neu, um die ausgew\u00e4hlte Sprache \u00fcberall anzuwenden.",
|
"dialog.language_changed.msg": "Die Spracheinstellung wurde aktualisiert. Starten Sie jetzt neu, um die ausgew\u00e4hlte Sprache \u00fcberall anzuwenden.",
|
||||||
"dialog.language_changed.restart_now": "Jetzt neu starten",
|
"dialog.language_changed.restart_now": "Jetzt neu starten",
|
||||||
"dialog.language_changed.restart_later": "Sp\u00e4ter neu starten",
|
"dialog.language_changed.restart_later": "Sp\u00e4ter neu starten",
|
||||||
"dialog.branding_changed.title": "Branding ge\u00e4ndert",
|
|
||||||
"dialog.branding_changed.msg": "Das aktive Branding wurde geändert. Starten Sie jetzt neu, damit die aktualisierte visuelle Identität überall angewendet wird.",
|
|
||||||
"dialog.branding_changed.restart_now": "Jetzt neu starten",
|
|
||||||
"dialog.branding_changed.restart_later": "Sp\u00e4ter neu starten",
|
|
||||||
"dialog.restart_failed.title": "Neustart fehlgeschlagen",
|
"dialog.restart_failed.title": "Neustart fehlgeschlagen",
|
||||||
"dialog.restart_failed.msg": "Die Anwendung konnte nicht automatisch neu gestartet werden:\n\n{error}\n\nBitte starten Sie manuell neu.",
|
"dialog.restart_failed.msg": "Die Anwendung konnte nicht automatisch neu gestartet werden:\n\n{error}\n\nBitte starten Sie manuell neu.",
|
||||||
"dialog.update_timeout.title": "Zeitüberschreitung bei der Update-Pr\u00fcfung",
|
"dialog.update_timeout.title": "Zeitüberschreitung bei der Update-Pr\u00fcfung",
|
||||||
|
|
@ -88,28 +84,8 @@
|
||||||
"settings.tab.urls": "URLs",
|
"settings.tab.urls": "URLs",
|
||||||
"settings.tab.logging": "Protokollierung",
|
"settings.tab.logging": "Protokollierung",
|
||||||
"settings.tab.window": "Fenster",
|
"settings.tab.window": "Fenster",
|
||||||
"settings.tab.profiles": "Setups",
|
"settings.tab.profiles": "Profile",
|
||||||
"settings.tab.general": "Allgemein",
|
"settings.tab.general": "Allgemein",
|
||||||
"settings.tab.branding": "Branding",
|
|
||||||
"settings.branding.select_label": "Branding:",
|
|
||||||
"settings.branding.select_tooltip": "Wählen Sie das Branding, das beim Start automatisch geladen werden soll.",
|
|
||||||
"settings.branding.help_text": "Branding steuert Name sowie Logo/Icon der App. Änderungen sind klar von den gespeicherten Setups getrennt.",
|
|
||||||
"settings.branding.display_name_label": "Name:",
|
|
||||||
"settings.branding.app_name_label": "Anwendungsname:",
|
|
||||||
"settings.branding.window_title_label": "Fenstertitel (optional):",
|
|
||||||
"settings.branding.logo_path_label": "Logo/Icon-Datei (optional):",
|
|
||||||
"settings.branding.save_as_btn": "Branding speichern",
|
|
||||||
"settings.branding.export_btn": "Branding exportieren",
|
|
||||||
"settings.branding.import_btn": "Branding importieren",
|
|
||||||
"settings.branding.delete_btn": "Branding löschen",
|
|
||||||
"settings.branding.export_title": "Branding exportieren",
|
|
||||||
"settings.branding.import_title": "Branding importieren",
|
|
||||||
"settings.branding.preview_label": "Vorschau:",
|
|
||||||
"settings.branding.no_icon_selected": "Kein Icon ausgewählt",
|
|
||||||
"settings.branding.preview_default_name": "Default",
|
|
||||||
"settings.branding.save_as_title": "Branding speichern",
|
|
||||||
"settings.branding.save_as_prompt": "Name für das Branding eingeben:",
|
|
||||||
"settings.branding.restart_note": "Branding-Änderungen werden persistent gespeichert und nach einem Neustart vollständig angewendet.",
|
|
||||||
"settings.web_url.label": "Web-Anwendungs-URL:",
|
"settings.web_url.label": "Web-Anwendungs-URL:",
|
||||||
"settings.web_url.placeholder": "z.B. http://localhost:8080 oder file:///./webapp/index.html",
|
"settings.web_url.placeholder": "z.B. http://localhost:8080 oder file:///./webapp/index.html",
|
||||||
"settings.web_url.open_btn": "\u00d6ffnen",
|
"settings.web_url.open_btn": "\u00d6ffnen",
|
||||||
|
|
@ -130,22 +106,12 @@
|
||||||
"settings.log_file.browse_btn": "Durchsuchen...",
|
"settings.log_file.browse_btn": "Durchsuchen...",
|
||||||
"settings.window.width_label": "Fensterbreite:",
|
"settings.window.width_label": "Fensterbreite:",
|
||||||
"settings.window.height_label": "Fensterh\u00f6he:",
|
"settings.window.height_label": "Fensterh\u00f6he:",
|
||||||
"settings.profiles.label": "Gespeicherte Setups auf diesem Ger\u00e4t:",
|
"settings.profiles.label": "Gespeicherte Konfigurationsprofile:",
|
||||||
"settings.profiles.label_tooltip": "Gespeicherte Setups sind benannte Schnappsch\u00fcsse Ihrer aktuellen Einstellungen f\u00fcr den schnellen Wechsel auf diesem Ger\u00e4t.",
|
"settings.profiles.save_btn": "Als Profil speichern",
|
||||||
"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.load_btn": "Profil laden",
|
||||||
"settings.profiles.list_tooltip": "Zeigt die auf diesem Ger\u00e4t verf\u00fcgbaren gespeicherten Setups.",
|
"settings.profiles.delete_btn": "Profil l\u00f6schen",
|
||||||
"settings.profiles.save_btn": "Setup speichern",
|
"settings.profiles.export_btn": "Konfiguration exportieren",
|
||||||
"settings.profiles.save_tooltip": "Speichert die aktuellen Einstellungen als benanntes Setup auf diesem Ger\u00e4t.",
|
"settings.profiles.import_btn": "Konfiguration importieren",
|
||||||
"settings.profiles.load_btn": "Setup laden",
|
|
||||||
"settings.profiles.load_tooltip": "L\u00e4dt das ausgew\u00e4hlte gespeicherte Setup in diesen Dialog.",
|
|
||||||
"settings.profiles.delete_btn": "Setup l\u00f6schen",
|
|
||||||
"settings.profiles.delete_tooltip": "L\u00f6scht das ausgew\u00e4hlte gespeicherte Setup von diesem Ger\u00e4t.",
|
|
||||||
"settings.profiles.transfer_label": "Aktuelle Einstellungen sichern oder teilen:",
|
|
||||||
"settings.profiles.transfer_tooltip": "Export erstellt eine JSON-Datei zum Sichern oder Teilen. Import liest eine solche Datei ein und wendet sie hier an.",
|
|
||||||
"settings.profiles.export_btn": "In Datei exportieren",
|
|
||||||
"settings.profiles.export_tooltip": "Exportiert die aktuellen Einstellungen als JSON-Datei zum Sichern oder Teilen.",
|
|
||||||
"settings.profiles.import_btn": "Aus Datei importieren",
|
|
||||||
"settings.profiles.import_tooltip": "Importiert Einstellungen aus einer JSON-Datei und wendet sie hier an.",
|
|
||||||
"settings.general.language_label": "Sprache:",
|
"settings.general.language_label": "Sprache:",
|
||||||
"settings.general.language_auto": "Systemstandard (Auto)",
|
"settings.general.language_auto": "Systemstandard (Auto)",
|
||||||
"settings.general.language_restart_note": "Sprach\u00e4nderung wirksam nach Neustart.",
|
"settings.general.language_restart_note": "Sprach\u00e4nderung wirksam nach Neustart.",
|
||||||
|
|
@ -157,15 +123,15 @@
|
||||||
"settings.edit_mapping.path_prompt": "Lokalen Dateisystempfad eingeben:",
|
"settings.edit_mapping.path_prompt": "Lokalen Dateisystempfad eingeben:",
|
||||||
"settings.add_url.title": "URL hinzuf\u00fcgen",
|
"settings.add_url.title": "URL hinzuf\u00fcgen",
|
||||||
"settings.add_url.prompt": "URL-Muster eingeben (z.B. http://example.com oder http://*.example.com):",
|
"settings.add_url.prompt": "URL-Muster eingeben (z.B. http://example.com oder http://*.example.com):",
|
||||||
"settings.profile.save.title": "Setup speichern",
|
"settings.profile.save.title": "Profil speichern",
|
||||||
"settings.profile.save.prompt": "Namen für das Setup eingeben (z.B. Arbeit, Kunde A):",
|
"settings.profile.save.prompt": "Profilnamen eingeben (z.B. Arbeit, Privat):",
|
||||||
"settings.select_directory.title": "Verzeichnis ausw\u00e4hlen",
|
"settings.select_directory.title": "Verzeichnis ausw\u00e4hlen",
|
||||||
"settings.select_log_file.title": "Protokolldatei ausw\u00e4hlen",
|
"settings.select_log_file.title": "Protokolldatei ausw\u00e4hlen",
|
||||||
"settings.export_config.title": "Einstellungen exportieren",
|
"settings.export_config.title": "Konfiguration exportieren",
|
||||||
"settings.import_config.title": "Einstellungen importieren",
|
"settings.import_config.title": "Konfiguration importieren",
|
||||||
"settings.error.select_mapping": "Bitte w\u00e4hlen Sie eine Zuordnung zur Bearbeitung aus",
|
"settings.error.select_mapping": "Bitte w\u00e4hlen Sie eine Zuordnung zur Bearbeitung aus",
|
||||||
"settings.error.select_profile_load": "Bitte w\u00e4hlen Sie ein Setup zum Laden 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 Setup zum L\u00f6schen aus",
|
"settings.error.select_profile_delete": "Bitte w\u00e4hlen Sie ein Profil zum L\u00f6schen aus",
|
||||||
"settings.web_source.url_label": "Webanwendungs-URL:",
|
"settings.web_source.url_label": "Webanwendungs-URL:",
|
||||||
"settings.web_source.open_btn": "\u00d6ffnen",
|
"settings.web_source.open_btn": "\u00d6ffnen",
|
||||||
"settings.web_source.url_mappings_label": "URL-Zuordnungen (Azure Blob Storage \u2192 Lokale Pfade):",
|
"settings.web_source.url_mappings_label": "URL-Zuordnungen (Azure Blob Storage \u2192 Lokale Pfade):",
|
||||||
|
|
@ -188,12 +154,12 @@
|
||||||
"settings.paths.select_dir_title": "Verzeichnis ausw\u00e4hlen",
|
"settings.paths.select_dir_title": "Verzeichnis ausw\u00e4hlen",
|
||||||
"settings.urls.add_title": "URL hinzuf\u00fcgen",
|
"settings.urls.add_title": "URL hinzuf\u00fcgen",
|
||||||
"settings.urls.add_prompt": "URL-Muster eingeben (z.B. http://example.com oder http://*.example.com):",
|
"settings.urls.add_prompt": "URL-Muster eingeben (z.B. http://example.com oder http://*.example.com):",
|
||||||
"settings.profiles.save_title": "Setup speichern",
|
"settings.profiles.save_title": "Profil speichern",
|
||||||
"settings.profiles.save_prompt": "Namen für das Setup eingeben (z.B. Arbeit, Kunde A):",
|
"settings.profiles.save_prompt": "Profilnamen eingeben (z.B. Arbeit, Privat):",
|
||||||
"settings.profiles.select_to_load": "Bitte wählen Sie ein Setup zum Laden aus",
|
"settings.profiles.select_to_load": "Bitte w\u00e4hlen Sie ein Profil zum Laden aus",
|
||||||
"settings.profiles.select_to_delete": "Bitte wählen Sie ein Setup zum Löschen aus",
|
"settings.profiles.select_to_delete": "Bitte w\u00e4hlen Sie ein Profil zum L\u00f6schen aus",
|
||||||
"settings.profiles.export_title": "Einstellungen exportieren",
|
"settings.profiles.export_title": "Konfiguration exportieren",
|
||||||
"settings.profiles.import_title": "Einstellungen importieren",
|
"settings.profiles.import_title": "Konfiguration importieren",
|
||||||
|
|
||||||
"update.checking.title": "Update-Pr\u00fcfung",
|
"update.checking.title": "Update-Pr\u00fcfung",
|
||||||
"update.checking.label": "Suche nach Updates...",
|
"update.checking.label": "Suche nach Updates...",
|
||||||
|
|
|
||||||
|
|
@ -58,10 +58,6 @@
|
||||||
"dialog.language_changed.msg": "The language setting was updated. Restart now to apply the selected language everywhere.",
|
"dialog.language_changed.msg": "The language setting was updated. Restart now to apply the selected language everywhere.",
|
||||||
"dialog.language_changed.restart_now": "Restart Now",
|
"dialog.language_changed.restart_now": "Restart Now",
|
||||||
"dialog.language_changed.restart_later": "Restart Later",
|
"dialog.language_changed.restart_later": "Restart Later",
|
||||||
"dialog.branding_changed.title": "Branding Changed",
|
|
||||||
"dialog.branding_changed.msg": "The active branding was changed. Restart now so the updated visual identity is applied everywhere.",
|
|
||||||
"dialog.branding_changed.restart_now": "Restart Now",
|
|
||||||
"dialog.branding_changed.restart_later": "Restart Later",
|
|
||||||
"dialog.restart_failed.title": "Restart Failed",
|
"dialog.restart_failed.title": "Restart Failed",
|
||||||
"dialog.restart_failed.msg": "Could not automatically restart the application:\n\n{error}\n\nPlease restart manually.",
|
"dialog.restart_failed.msg": "Could not automatically restart the application:\n\n{error}\n\nPlease restart manually.",
|
||||||
"dialog.update_timeout.title": "Update Check Timeout",
|
"dialog.update_timeout.title": "Update Check Timeout",
|
||||||
|
|
@ -88,28 +84,8 @@
|
||||||
"settings.tab.urls": "URLs",
|
"settings.tab.urls": "URLs",
|
||||||
"settings.tab.logging": "Logging",
|
"settings.tab.logging": "Logging",
|
||||||
"settings.tab.window": "Window",
|
"settings.tab.window": "Window",
|
||||||
"settings.tab.profiles": "Setups",
|
"settings.tab.profiles": "Profiles",
|
||||||
"settings.tab.general": "General",
|
"settings.tab.general": "General",
|
||||||
"settings.tab.branding": "Branding",
|
|
||||||
"settings.branding.select_label": "Branding:",
|
|
||||||
"settings.branding.select_tooltip": "Choose the branding that should be loaded automatically on startup.",
|
|
||||||
"settings.branding.help_text": "Branding controls the app name and logo/icon. It stays clearly separated from your saved setups.",
|
|
||||||
"settings.branding.display_name_label": "Name:",
|
|
||||||
"settings.branding.app_name_label": "Application name:",
|
|
||||||
"settings.branding.window_title_label": "Window title (optional):",
|
|
||||||
"settings.branding.logo_path_label": "Logo/Icon file (optional):",
|
|
||||||
"settings.branding.save_as_btn": "Save Branding",
|
|
||||||
"settings.branding.export_btn": "Export Branding",
|
|
||||||
"settings.branding.import_btn": "Import Branding",
|
|
||||||
"settings.branding.delete_btn": "Delete Branding",
|
|
||||||
"settings.branding.export_title": "Export Branding",
|
|
||||||
"settings.branding.import_title": "Import Branding",
|
|
||||||
"settings.branding.preview_label": "Preview:",
|
|
||||||
"settings.branding.no_icon_selected": "No icon selected",
|
|
||||||
"settings.branding.preview_default_name": "Default",
|
|
||||||
"settings.branding.save_as_title": "Save Branding",
|
|
||||||
"settings.branding.save_as_prompt": "Enter a name for the branding:",
|
|
||||||
"settings.branding.restart_note": "Branding changes are persisted immediately and are fully applied after restarting the app.",
|
|
||||||
"settings.web_url.label": "Web Application URL:",
|
"settings.web_url.label": "Web Application URL:",
|
||||||
"settings.web_url.placeholder": "e.g., http://localhost:8080 or file:///./webapp/index.html",
|
"settings.web_url.placeholder": "e.g., http://localhost:8080 or file:///./webapp/index.html",
|
||||||
"settings.web_url.open_btn": "Open",
|
"settings.web_url.open_btn": "Open",
|
||||||
|
|
@ -130,22 +106,12 @@
|
||||||
"settings.log_file.browse_btn": "Browse...",
|
"settings.log_file.browse_btn": "Browse...",
|
||||||
"settings.window.width_label": "Window Width:",
|
"settings.window.width_label": "Window Width:",
|
||||||
"settings.window.height_label": "Window Height:",
|
"settings.window.height_label": "Window Height:",
|
||||||
"settings.profiles.label": "Saved setups on this device:",
|
"settings.profiles.label": "Saved Configuration Profiles:",
|
||||||
"settings.profiles.label_tooltip": "Saved setups are named snapshots of your current settings kept on this device for quick switching.",
|
"settings.profiles.save_btn": "Save as Profile",
|
||||||
"settings.profiles.help_text": "Save the current settings as a named setup for quick switching on this device. Use export/import when you want to back up or share a configuration file.",
|
"settings.profiles.load_btn": "Load Profile",
|
||||||
"settings.profiles.list_tooltip": "Shows the saved setups available on this device.",
|
"settings.profiles.delete_btn": "Delete Profile",
|
||||||
"settings.profiles.save_btn": "Save Setup",
|
"settings.profiles.export_btn": "Export Configuration",
|
||||||
"settings.profiles.save_tooltip": "Save the current settings as a named setup on this device.",
|
"settings.profiles.import_btn": "Import Configuration",
|
||||||
"settings.profiles.load_btn": "Load Setup",
|
|
||||||
"settings.profiles.load_tooltip": "Load the selected saved setup into the dialog.",
|
|
||||||
"settings.profiles.delete_btn": "Delete Setup",
|
|
||||||
"settings.profiles.delete_tooltip": "Delete the selected saved setup from this device.",
|
|
||||||
"settings.profiles.transfer_label": "Backup or share the current settings:",
|
|
||||||
"settings.profiles.transfer_tooltip": "Export creates a JSON file for backup or sharing. Import reads such a file and applies it here.",
|
|
||||||
"settings.profiles.export_btn": "Export to File",
|
|
||||||
"settings.profiles.export_tooltip": "Export the current settings to a JSON file for backup or sharing.",
|
|
||||||
"settings.profiles.import_btn": "Import from File",
|
|
||||||
"settings.profiles.import_tooltip": "Import settings from a JSON file and apply them here.",
|
|
||||||
"settings.general.language_label": "Language:",
|
"settings.general.language_label": "Language:",
|
||||||
"settings.general.language_auto": "System Default (Auto)",
|
"settings.general.language_auto": "System Default (Auto)",
|
||||||
"settings.general.language_restart_note": "Language change takes effect after restart.",
|
"settings.general.language_restart_note": "Language change takes effect after restart.",
|
||||||
|
|
@ -157,15 +123,15 @@
|
||||||
"settings.edit_mapping.path_prompt": "Enter local file system path:",
|
"settings.edit_mapping.path_prompt": "Enter local file system path:",
|
||||||
"settings.add_url.title": "Add URL",
|
"settings.add_url.title": "Add URL",
|
||||||
"settings.add_url.prompt": "Enter URL pattern (e.g., http://example.com or http://*.example.com):",
|
"settings.add_url.prompt": "Enter URL pattern (e.g., http://example.com or http://*.example.com):",
|
||||||
"settings.profile.save.title": "Save Setup",
|
"settings.profile.save.title": "Save Profile",
|
||||||
"settings.profile.save.prompt": "Enter a setup name (e.g., Work, Customer A):",
|
"settings.profile.save.prompt": "Enter profile name (e.g., work, personal):",
|
||||||
"settings.select_directory.title": "Select Directory to Allow",
|
"settings.select_directory.title": "Select Directory to Allow",
|
||||||
"settings.select_log_file.title": "Select Log File",
|
"settings.select_log_file.title": "Select Log File",
|
||||||
"settings.export_config.title": "Export Settings",
|
"settings.export_config.title": "Export Configuration",
|
||||||
"settings.import_config.title": "Import Settings",
|
"settings.import_config.title": "Import Configuration",
|
||||||
"settings.error.select_mapping": "Please select a mapping to edit",
|
"settings.error.select_mapping": "Please select a mapping to edit",
|
||||||
"settings.error.select_profile_load": "Please select a setup to load",
|
"settings.error.select_profile_load": "Please select a profile to load",
|
||||||
"settings.error.select_profile_delete": "Please select a setup to delete",
|
"settings.error.select_profile_delete": "Please select a profile to delete",
|
||||||
"settings.web_source.url_label": "Web Application URL:",
|
"settings.web_source.url_label": "Web Application URL:",
|
||||||
"settings.web_source.open_btn": "Open",
|
"settings.web_source.open_btn": "Open",
|
||||||
"settings.web_source.url_mappings_label": "URL Mappings (Azure Blob Storage \u2192 Local Paths):",
|
"settings.web_source.url_mappings_label": "URL Mappings (Azure Blob Storage \u2192 Local Paths):",
|
||||||
|
|
@ -188,12 +154,12 @@
|
||||||
"settings.paths.select_dir_title": "Select Directory to Allow",
|
"settings.paths.select_dir_title": "Select Directory to Allow",
|
||||||
"settings.urls.add_title": "Add URL",
|
"settings.urls.add_title": "Add URL",
|
||||||
"settings.urls.add_prompt": "Enter URL pattern (e.g., http://example.com or http://*.example.com):",
|
"settings.urls.add_prompt": "Enter URL pattern (e.g., http://example.com or http://*.example.com):",
|
||||||
"settings.profiles.save_title": "Save Setup",
|
"settings.profiles.save_title": "Save Profile",
|
||||||
"settings.profiles.save_prompt": "Enter a setup name (e.g., Work, Customer A):",
|
"settings.profiles.save_prompt": "Enter profile name (e.g., work, personal):",
|
||||||
"settings.profiles.select_to_load": "Please select a setup to load",
|
"settings.profiles.select_to_load": "Please select a profile to load",
|
||||||
"settings.profiles.select_to_delete": "Please select a setup to delete",
|
"settings.profiles.select_to_delete": "Please select a profile to delete",
|
||||||
"settings.profiles.export_title": "Export Settings",
|
"settings.profiles.export_title": "Export Configuration",
|
||||||
"settings.profiles.import_title": "Import Settings",
|
"settings.profiles.import_title": "Import Configuration",
|
||||||
|
|
||||||
"update.checking.title": "Checking for Updates",
|
"update.checking.title": "Checking for Updates",
|
||||||
"update.checking.label": "Checking for updates...",
|
"update.checking.label": "Checking for updates...",
|
||||||
|
|
|
||||||
|
|
@ -58,10 +58,6 @@
|
||||||
"dialog.language_changed.msg": "Le param\u00e8tre de langue a \u00e9t\u00e9 mis \u00e0 jour. Red\u00e9marrez maintenant pour appliquer la langue s\u00e9lectionn\u00e9e partout.",
|
"dialog.language_changed.msg": "Le param\u00e8tre de langue a \u00e9t\u00e9 mis \u00e0 jour. Red\u00e9marrez maintenant pour appliquer la langue s\u00e9lectionn\u00e9e partout.",
|
||||||
"dialog.language_changed.restart_now": "Red\u00e9marrer maintenant",
|
"dialog.language_changed.restart_now": "Red\u00e9marrer maintenant",
|
||||||
"dialog.language_changed.restart_later": "Red\u00e9marrer plus tard",
|
"dialog.language_changed.restart_later": "Red\u00e9marrer plus tard",
|
||||||
"dialog.branding_changed.title": "Branding modifié",
|
|
||||||
"dialog.branding_changed.msg": "Le branding actif a été modifié. Redémarrez maintenant pour appliquer partout l’identité visuelle mise à jour.",
|
|
||||||
"dialog.branding_changed.restart_now": "Redémarrer maintenant",
|
|
||||||
"dialog.branding_changed.restart_later": "Redémarrer plus tard",
|
|
||||||
"dialog.restart_failed.title": "\u00c9chec du red\u00e9marrage",
|
"dialog.restart_failed.title": "\u00c9chec du red\u00e9marrage",
|
||||||
"dialog.restart_failed.msg": "Impossible de red\u00e9marrer automatiquement l'application\u00a0:\n\n{error}\n\nVeuillez red\u00e9marrer manuellement.",
|
"dialog.restart_failed.msg": "Impossible de red\u00e9marrer automatiquement l'application\u00a0:\n\n{error}\n\nVeuillez red\u00e9marrer manuellement.",
|
||||||
"dialog.update_timeout.title": "D\u00e9lai de v\u00e9rification des mises \u00e0 jour d\u00e9pass\u00e9",
|
"dialog.update_timeout.title": "D\u00e9lai de v\u00e9rification des mises \u00e0 jour d\u00e9pass\u00e9",
|
||||||
|
|
@ -88,28 +84,8 @@
|
||||||
"settings.tab.urls": "URLs",
|
"settings.tab.urls": "URLs",
|
||||||
"settings.tab.logging": "Journalisation",
|
"settings.tab.logging": "Journalisation",
|
||||||
"settings.tab.window": "Fen\u00eatre",
|
"settings.tab.window": "Fen\u00eatre",
|
||||||
"settings.tab.profiles": "Configs",
|
"settings.tab.profiles": "Profils",
|
||||||
"settings.tab.general": "G\u00e9n\u00e9ral",
|
"settings.tab.general": "G\u00e9n\u00e9ral",
|
||||||
"settings.tab.branding": "Branding",
|
|
||||||
"settings.branding.select_label": "Branding :",
|
|
||||||
"settings.branding.select_tooltip": "Choisissez le modèle de branding qui doit être chargé automatiquement au démarrage.",
|
|
||||||
"settings.branding.help_text": "Le branding contrôle l’identité visuelle de l’application, comme le nom et les icônes. Il reste séparé de vos configurations enregistrées.",
|
|
||||||
"settings.branding.display_name_label": "Nom d’affichage :",
|
|
||||||
"settings.branding.app_name_label": "Nom de l’application :",
|
|
||||||
"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 l’application.",
|
|
||||||
"settings.web_url.label": "URL de l'application web\u00a0:",
|
"settings.web_url.label": "URL de l'application web\u00a0:",
|
||||||
"settings.web_url.placeholder": "p.ex. http://localhost:8080 ou file:///./webapp/index.html",
|
"settings.web_url.placeholder": "p.ex. http://localhost:8080 ou file:///./webapp/index.html",
|
||||||
"settings.web_url.open_btn": "Ouvrir",
|
"settings.web_url.open_btn": "Ouvrir",
|
||||||
|
|
@ -130,22 +106,12 @@
|
||||||
"settings.log_file.browse_btn": "Parcourir...",
|
"settings.log_file.browse_btn": "Parcourir...",
|
||||||
"settings.window.width_label": "Largeur de la fen\u00eatre\u00a0:",
|
"settings.window.width_label": "Largeur de la fen\u00eatre\u00a0:",
|
||||||
"settings.window.height_label": "Hauteur de la fen\u00eatre\u00a0:",
|
"settings.window.height_label": "Hauteur de la fen\u00eatre\u00a0:",
|
||||||
"settings.profiles.label": "Configurations enregistr\u00e9es sur cet appareil\u00a0:",
|
"settings.profiles.label": "Profils de configuration enregistr\u00e9s\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.save_btn": "Enregistrer comme profil",
|
||||||
"settings.profiles.help_text": "Enregistrez l’\u00e9tat actuel comme configuration nomm\u00e9e pour basculer rapidement sur cet appareil. Utilisez l’export/import pour sauvegarder ou partager un fichier de configuration.",
|
"settings.profiles.load_btn": "Charger le profil",
|
||||||
"settings.profiles.list_tooltip": "Affiche les configurations enregistr\u00e9es disponibles sur cet appareil.",
|
"settings.profiles.delete_btn": "Supprimer le profil",
|
||||||
"settings.profiles.save_btn": "Enregistrer la configuration",
|
"settings.profiles.export_btn": "Exporter la configuration",
|
||||||
"settings.profiles.save_tooltip": "Enregistre les r\u00e9glages actuels comme configuration nomm\u00e9e sur cet appareil.",
|
"settings.profiles.import_btn": "Importer la configuration",
|
||||||
"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 l’applique ici.",
|
|
||||||
"settings.profiles.export_btn": "Exporter vers un fichier",
|
|
||||||
"settings.profiles.export_tooltip": "Exporte les r\u00e9glages actuels vers un fichier JSON pour sauvegarde ou partage.",
|
|
||||||
"settings.profiles.import_btn": "Importer depuis un fichier",
|
|
||||||
"settings.profiles.import_tooltip": "Importe des r\u00e9glages depuis un fichier JSON et les applique ici.",
|
|
||||||
"settings.general.language_label": "Langue\u00a0:",
|
"settings.general.language_label": "Langue\u00a0:",
|
||||||
"settings.general.language_auto": "Par d\u00e9faut du syst\u00e8me (Auto)",
|
"settings.general.language_auto": "Par d\u00e9faut du syst\u00e8me (Auto)",
|
||||||
"settings.general.language_restart_note": "Le changement de langue prend effet apr\u00e8s red\u00e9marrage.",
|
"settings.general.language_restart_note": "Le changement de langue prend effet apr\u00e8s red\u00e9marrage.",
|
||||||
|
|
@ -157,15 +123,15 @@
|
||||||
"settings.edit_mapping.path_prompt": "Entrez le chemin du syst\u00e8me de fichiers local\u00a0:",
|
"settings.edit_mapping.path_prompt": "Entrez le chemin du syst\u00e8me de fichiers local\u00a0:",
|
||||||
"settings.add_url.title": "Ajouter une URL",
|
"settings.add_url.title": "Ajouter une URL",
|
||||||
"settings.add_url.prompt": "Entrez le mod\u00e8le d'URL (p.ex. http://example.com ou http://*.example.com)\u00a0:",
|
"settings.add_url.prompt": "Entrez le mod\u00e8le d'URL (p.ex. http://example.com ou http://*.example.com)\u00a0:",
|
||||||
"settings.profile.save.title": "Enregistrer la configuration",
|
"settings.profile.save.title": "Enregistrer le profil",
|
||||||
"settings.profile.save.prompt": "Entrez un nom de configuration (p.ex. travail, client A)\u00a0:",
|
"settings.profile.save.prompt": "Entrez le nom du profil (p.ex. travail, personnel)\u00a0:",
|
||||||
"settings.select_directory.title": "S\u00e9lectionner un r\u00e9pertoire autoris\u00e9",
|
"settings.select_directory.title": "S\u00e9lectionner un r\u00e9pertoire autoris\u00e9",
|
||||||
"settings.select_log_file.title": "S\u00e9lectionner le fichier journal",
|
"settings.select_log_file.title": "S\u00e9lectionner le fichier journal",
|
||||||
"settings.export_config.title": "Exporter les réglages",
|
"settings.export_config.title": "Exporter la configuration",
|
||||||
"settings.import_config.title": "Importer les réglages",
|
"settings.import_config.title": "Importer la configuration",
|
||||||
"settings.error.select_mapping": "Veuillez s\u00e9lectionner un mappage \u00e0 modifier",
|
"settings.error.select_mapping": "Veuillez s\u00e9lectionner un mappage \u00e0 modifier",
|
||||||
"settings.error.select_profile_load": "Veuillez sélectionner une configuration à charger",
|
"settings.error.select_profile_load": "Veuillez s\u00e9lectionner un profil \u00e0 charger",
|
||||||
"settings.error.select_profile_delete": "Veuillez sélectionner une configuration à supprimer",
|
"settings.error.select_profile_delete": "Veuillez s\u00e9lectionner un profil \u00e0 supprimer",
|
||||||
"settings.web_source.url_label": "URL de l'application web\u00a0:",
|
"settings.web_source.url_label": "URL de l'application web\u00a0:",
|
||||||
"settings.web_source.open_btn": "Ouvrir",
|
"settings.web_source.open_btn": "Ouvrir",
|
||||||
"settings.web_source.url_mappings_label": "Mappages d'URL (Azure Blob Storage \u2192 Chemins locaux)\u00a0:",
|
"settings.web_source.url_mappings_label": "Mappages d'URL (Azure Blob Storage \u2192 Chemins locaux)\u00a0:",
|
||||||
|
|
@ -188,12 +154,12 @@
|
||||||
"settings.paths.select_dir_title": "S\u00e9lectionner un r\u00e9pertoire autoris\u00e9",
|
"settings.paths.select_dir_title": "S\u00e9lectionner un r\u00e9pertoire autoris\u00e9",
|
||||||
"settings.urls.add_title": "Ajouter une URL",
|
"settings.urls.add_title": "Ajouter une URL",
|
||||||
"settings.urls.add_prompt": "Entrez le mod\u00e8le d'URL (p.ex. http://example.com ou http://*.example.com)\u00a0:",
|
"settings.urls.add_prompt": "Entrez le mod\u00e8le d'URL (p.ex. http://example.com ou http://*.example.com)\u00a0:",
|
||||||
"settings.profiles.save_title": "Enregistrer la configuration",
|
"settings.profiles.save_title": "Enregistrer le profil",
|
||||||
"settings.profiles.save_prompt": "Entrez un nom de configuration (p.ex. travail, client A) :",
|
"settings.profiles.save_prompt": "Entrez le nom du profil (p.ex. travail, personnel)\u00a0:",
|
||||||
"settings.profiles.select_to_load": "Veuillez sélectionner une configuration à charger",
|
"settings.profiles.select_to_load": "Veuillez s\u00e9lectionner un profil \u00e0 charger",
|
||||||
"settings.profiles.select_to_delete": "Veuillez sélectionner une configuration à supprimer",
|
"settings.profiles.select_to_delete": "Veuillez s\u00e9lectionner un profil \u00e0 supprimer",
|
||||||
"settings.profiles.export_title": "Exporter les réglages",
|
"settings.profiles.export_title": "Exporter la configuration",
|
||||||
"settings.profiles.import_title": "Importer les réglages",
|
"settings.profiles.import_title": "Importer la configuration",
|
||||||
|
|
||||||
"update.checking.title": "V\u00e9rification des mises \u00e0 jour",
|
"update.checking.title": "V\u00e9rification des mises \u00e0 jour",
|
||||||
"update.checking.label": "Recherche de mises \u00e0 jour...",
|
"update.checking.label": "Recherche de mises \u00e0 jour...",
|
||||||
|
|
|
||||||
|
|
@ -58,10 +58,6 @@
|
||||||
"dialog.language_changed.msg": "La lingua è stata aggiornata. Riavvia ora per applicarla ovunque.",
|
"dialog.language_changed.msg": "La lingua è stata aggiornata. Riavvia ora per applicarla ovunque.",
|
||||||
"dialog.language_changed.restart_now": "Riavvia ora",
|
"dialog.language_changed.restart_now": "Riavvia ora",
|
||||||
"dialog.language_changed.restart_later": "Riavvia più tardi",
|
"dialog.language_changed.restart_later": "Riavvia più tardi",
|
||||||
"dialog.branding_changed.title": "Branding cambiato",
|
|
||||||
"dialog.branding_changed.msg": "Il branding attivo è stato modificato. Riavvia ora per applicare ovunque l’identità visiva aggiornata.",
|
|
||||||
"dialog.branding_changed.restart_now": "Riavvia ora",
|
|
||||||
"dialog.branding_changed.restart_later": "Riavvia più tardi",
|
|
||||||
"dialog.restart_failed.title": "Riavvio non riuscito",
|
"dialog.restart_failed.title": "Riavvio non riuscito",
|
||||||
"dialog.restart_failed.msg": "Impossibile riavviare automaticamente l'applicazione:\n\n{error}\n\nRiavvia manualmente.",
|
"dialog.restart_failed.msg": "Impossibile riavviare automaticamente l'applicazione:\n\n{error}\n\nRiavvia manualmente.",
|
||||||
"dialog.update_timeout.title": "Timeout controllo aggiornamenti",
|
"dialog.update_timeout.title": "Timeout controllo aggiornamenti",
|
||||||
|
|
@ -88,28 +84,8 @@
|
||||||
"settings.tab.urls": "URL",
|
"settings.tab.urls": "URL",
|
||||||
"settings.tab.logging": "Log",
|
"settings.tab.logging": "Log",
|
||||||
"settings.tab.window": "Finestra",
|
"settings.tab.window": "Finestra",
|
||||||
"settings.tab.profiles": "Config",
|
"settings.tab.profiles": "Profili",
|
||||||
"settings.tab.general": "Generale",
|
"settings.tab.general": "Generale",
|
||||||
"settings.tab.branding": "Branding",
|
|
||||||
"settings.branding.select_label": "Branding:",
|
|
||||||
"settings.branding.select_tooltip": "Scegli il modello di branding da caricare automaticamente all’avvio.",
|
|
||||||
"settings.branding.help_text": "Il branding controlla l’identità visiva dell’app, 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 dell’applicazione.",
|
|
||||||
"settings.web_url.label": "URL applicazione web:",
|
"settings.web_url.label": "URL applicazione web:",
|
||||||
"settings.web_url.placeholder": "es. http://localhost:8080 o file:///./webapp/index.html",
|
"settings.web_url.placeholder": "es. http://localhost:8080 o file:///./webapp/index.html",
|
||||||
"settings.web_url.open_btn": "Apri",
|
"settings.web_url.open_btn": "Apri",
|
||||||
|
|
@ -130,22 +106,12 @@
|
||||||
"settings.log_file.browse_btn": "Sfoglia...",
|
"settings.log_file.browse_btn": "Sfoglia...",
|
||||||
"settings.window.width_label": "Larghezza finestra:",
|
"settings.window.width_label": "Larghezza finestra:",
|
||||||
"settings.window.height_label": "Altezza finestra:",
|
"settings.window.height_label": "Altezza finestra:",
|
||||||
"settings.profiles.label": "Configurazioni salvate su questo dispositivo:",
|
"settings.profiles.label": "Profili configurazione salvati:",
|
||||||
"settings.profiles.label_tooltip": "Le configurazioni salvate sono istantanee con nome delle impostazioni correnti per passare rapidamente da un assetto all’altro su questo dispositivo.",
|
"settings.profiles.save_btn": "Salva come profilo",
|
||||||
"settings.profiles.help_text": "Salva lo stato corrente come configurazione con nome per cambiare rapidamente su questo dispositivo. Usa esporta/importa per eseguire un backup o condividere un file di configurazione.",
|
"settings.profiles.load_btn": "Carica profilo",
|
||||||
"settings.profiles.list_tooltip": "Mostra le configurazioni salvate disponibili su questo dispositivo.",
|
"settings.profiles.delete_btn": "Elimina profilo",
|
||||||
"settings.profiles.save_btn": "Salva configurazione",
|
"settings.profiles.export_btn": "Esporta configurazione",
|
||||||
"settings.profiles.save_tooltip": "Salva le impostazioni correnti come configurazione con nome su questo dispositivo.",
|
"settings.profiles.import_btn": "Importa configurazione",
|
||||||
"settings.profiles.load_btn": "Carica configurazione",
|
|
||||||
"settings.profiles.load_tooltip": "Carica in questa finestra di dialogo la configurazione salvata selezionata.",
|
|
||||||
"settings.profiles.delete_btn": "Elimina configurazione",
|
|
||||||
"settings.profiles.delete_tooltip": "Elimina da questo dispositivo la configurazione salvata selezionata.",
|
|
||||||
"settings.profiles.transfer_label": "Backup o condivisione delle impostazioni correnti:",
|
|
||||||
"settings.profiles.transfer_tooltip": "Esporta crea un file JSON per backup o condivisione. Importa legge tale file e lo applica qui.",
|
|
||||||
"settings.profiles.export_btn": "Esporta in file",
|
|
||||||
"settings.profiles.export_tooltip": "Esporta le impostazioni correnti in un file JSON per backup o condivisione.",
|
|
||||||
"settings.profiles.import_btn": "Importa da file",
|
|
||||||
"settings.profiles.import_tooltip": "Importa impostazioni da un file JSON e le applica qui.",
|
|
||||||
"settings.general.language_label": "Lingua:",
|
"settings.general.language_label": "Lingua:",
|
||||||
"settings.general.language_auto": "Predefinita sistema (Auto)",
|
"settings.general.language_auto": "Predefinita sistema (Auto)",
|
||||||
"settings.general.language_restart_note": "La modifica lingua si applica dopo il riavvio.",
|
"settings.general.language_restart_note": "La modifica lingua si applica dopo il riavvio.",
|
||||||
|
|
@ -157,15 +123,15 @@
|
||||||
"settings.edit_mapping.path_prompt": "Inserisci percorso file system locale:",
|
"settings.edit_mapping.path_prompt": "Inserisci percorso file system locale:",
|
||||||
"settings.add_url.title": "Aggiungi URL",
|
"settings.add_url.title": "Aggiungi URL",
|
||||||
"settings.add_url.prompt": "Inserisci pattern URL (es. http://example.com o http://*.example.com):",
|
"settings.add_url.prompt": "Inserisci pattern URL (es. http://example.com o http://*.example.com):",
|
||||||
"settings.profile.save.title": "Salva configurazione",
|
"settings.profile.save.title": "Salva profilo",
|
||||||
"settings.profile.save.prompt": "Inserisci un nome per la configurazione (es. Lavoro, Cliente A):",
|
"settings.profile.save.prompt": "Inserisci nome profilo (es. lavoro, personale):",
|
||||||
"settings.select_directory.title": "Seleziona directory da consentire",
|
"settings.select_directory.title": "Seleziona directory da consentire",
|
||||||
"settings.select_log_file.title": "Seleziona file di log",
|
"settings.select_log_file.title": "Seleziona file di log",
|
||||||
"settings.export_config.title": "Esporta impostazioni",
|
"settings.export_config.title": "Esporta configurazione",
|
||||||
"settings.import_config.title": "Importa impostazioni",
|
"settings.import_config.title": "Importa configurazione",
|
||||||
"settings.error.select_mapping": "Seleziona una mappatura da modificare",
|
"settings.error.select_mapping": "Seleziona una mappatura da modificare",
|
||||||
"settings.error.select_profile_load": "Seleziona una configurazione da caricare",
|
"settings.error.select_profile_load": "Seleziona un profilo da caricare",
|
||||||
"settings.error.select_profile_delete": "Seleziona una configurazione da eliminare",
|
"settings.error.select_profile_delete": "Seleziona un profilo da eliminare",
|
||||||
"settings.web_source.url_label": "URL applicazione web:",
|
"settings.web_source.url_label": "URL applicazione web:",
|
||||||
"settings.web_source.open_btn": "Apri",
|
"settings.web_source.open_btn": "Apri",
|
||||||
"settings.web_source.url_mappings_label": "Mappature URL (Azure Blob Storage \u2192 Percorsi locali):",
|
"settings.web_source.url_mappings_label": "Mappature URL (Azure Blob Storage \u2192 Percorsi locali):",
|
||||||
|
|
@ -188,12 +154,12 @@
|
||||||
"settings.paths.select_dir_title": "Seleziona directory da consentire",
|
"settings.paths.select_dir_title": "Seleziona directory da consentire",
|
||||||
"settings.urls.add_title": "Aggiungi URL",
|
"settings.urls.add_title": "Aggiungi URL",
|
||||||
"settings.urls.add_prompt": "Inserisci pattern URL (es. http://example.com o http://*.example.com):",
|
"settings.urls.add_prompt": "Inserisci pattern URL (es. http://example.com o http://*.example.com):",
|
||||||
"settings.profiles.save_title": "Salva configurazione",
|
"settings.profiles.save_title": "Salva profilo",
|
||||||
"settings.profiles.save_prompt": "Inserisci un nome per la configurazione (es. Lavoro, Cliente A):",
|
"settings.profiles.save_prompt": "Inserisci nome profilo (es. lavoro, personale):",
|
||||||
"settings.profiles.select_to_load": "Seleziona una configurazione da caricare",
|
"settings.profiles.select_to_load": "Seleziona un profilo da caricare",
|
||||||
"settings.profiles.select_to_delete": "Seleziona una configurazione da eliminare",
|
"settings.profiles.select_to_delete": "Seleziona un profilo da eliminare",
|
||||||
"settings.profiles.export_title": "Esporta impostazioni",
|
"settings.profiles.export_title": "Esporta configurazione",
|
||||||
"settings.profiles.import_title": "Importa impostazioni",
|
"settings.profiles.import_title": "Importa configurazione",
|
||||||
|
|
||||||
"update.checking.title": "Controllo aggiornamenti",
|
"update.checking.title": "Controllo aggiornamenti",
|
||||||
"update.checking.label": "Controllo aggiornamenti...",
|
"update.checking.label": "Controllo aggiornamenti...",
|
||||||
|
|
|
||||||
|
|
@ -58,10 +58,6 @@
|
||||||
"dialog.language_changed.msg": "Настройка языка обновлена. Перезапустите сейчас, чтобы применить язык везде.",
|
"dialog.language_changed.msg": "Настройка языка обновлена. Перезапустите сейчас, чтобы применить язык везде.",
|
||||||
"dialog.language_changed.restart_now": "Перезапустить сейчас",
|
"dialog.language_changed.restart_now": "Перезапустить сейчас",
|
||||||
"dialog.language_changed.restart_later": "Перезапустить позже",
|
"dialog.language_changed.restart_later": "Перезапустить позже",
|
||||||
"dialog.branding_changed.title": "Брендинг изменен",
|
|
||||||
"dialog.branding_changed.msg": "Активный брендинг был изменен. Перезапустите приложение сейчас, чтобы обновленная визуальная идентичность применялась везде.",
|
|
||||||
"dialog.branding_changed.restart_now": "Перезапустить сейчас",
|
|
||||||
"dialog.branding_changed.restart_later": "Перезапустить позже",
|
|
||||||
"dialog.restart_failed.title": "Сбой перезапуска",
|
"dialog.restart_failed.title": "Сбой перезапуска",
|
||||||
"dialog.restart_failed.msg": "Не удалось автоматически перезапустить приложение:\n\n{error}\n\nПерезапустите вручную.",
|
"dialog.restart_failed.msg": "Не удалось автоматически перезапустить приложение:\n\n{error}\n\nПерезапустите вручную.",
|
||||||
"dialog.update_timeout.title": "Таймаут проверки обновлений",
|
"dialog.update_timeout.title": "Таймаут проверки обновлений",
|
||||||
|
|
@ -88,28 +84,8 @@
|
||||||
"settings.tab.urls": "URL",
|
"settings.tab.urls": "URL",
|
||||||
"settings.tab.logging": "Логирование",
|
"settings.tab.logging": "Логирование",
|
||||||
"settings.tab.window": "Окно",
|
"settings.tab.window": "Окно",
|
||||||
"settings.tab.profiles": "Наборы",
|
"settings.tab.profiles": "Профили",
|
||||||
"settings.tab.general": "Общие настройки",
|
"settings.tab.general": "Общие настройки",
|
||||||
"settings.tab.branding": "Брендинг",
|
|
||||||
"settings.branding.select_label": "Брендинг:",
|
|
||||||
"settings.branding.select_tooltip": "Выберите шаблон брендинга, который должен автоматически загружаться при запуске.",
|
|
||||||
"settings.branding.help_text": "Брендинг управляет визуальной идентичностью приложения, например названием и иконками. Он отделен от сохраненных наборов настроек.",
|
|
||||||
"settings.branding.display_name_label": "Отображаемое имя:",
|
|
||||||
"settings.branding.app_name_label": "Имя приложения:",
|
|
||||||
"settings.branding.window_title_label": "Заголовок окна (необязательно):",
|
|
||||||
"settings.branding.logo_path_label": "Путь к логотипу (необязательно):",
|
|
||||||
"settings.branding.save_as_btn": "Сохранить брендинг",
|
|
||||||
"settings.branding.export_btn": "Экспортировать брендинг",
|
|
||||||
"settings.branding.import_btn": "Импортировать брендинг",
|
|
||||||
"settings.branding.delete_btn": "Удалить брендинг",
|
|
||||||
"settings.branding.export_title": "Экспортировать брендинг",
|
|
||||||
"settings.branding.import_title": "Импортировать брендинг",
|
|
||||||
"settings.branding.preview_label": "Предпросмотр:",
|
|
||||||
"settings.branding.no_icon_selected": "Значок не выбран",
|
|
||||||
"settings.branding.preview_default_name": "Default",
|
|
||||||
"settings.branding.save_as_title": "Сохранить брендинг",
|
|
||||||
"settings.branding.save_as_prompt": "Введите имя для брендинга:",
|
|
||||||
"settings.branding.restart_note": "Изменения брендинга сохраняются постоянно и будут полностью применены после перезапуска приложения.",
|
|
||||||
"settings.web_url.label": "URL веб-приложения:",
|
"settings.web_url.label": "URL веб-приложения:",
|
||||||
"settings.web_url.placeholder": "например, http://localhost:8080 или file:///./webapp/index.html",
|
"settings.web_url.placeholder": "например, http://localhost:8080 или file:///./webapp/index.html",
|
||||||
"settings.web_url.open_btn": "Открыть",
|
"settings.web_url.open_btn": "Открыть",
|
||||||
|
|
@ -130,22 +106,12 @@
|
||||||
"settings.log_file.browse_btn": "Обзор...",
|
"settings.log_file.browse_btn": "Обзор...",
|
||||||
"settings.window.width_label": "Ширина окна:",
|
"settings.window.width_label": "Ширина окна:",
|
||||||
"settings.window.height_label": "Высота окна:",
|
"settings.window.height_label": "Высота окна:",
|
||||||
"settings.profiles.label": "Сохраненные наборы настроек на этом устройстве:",
|
"settings.profiles.label": "Сохраненные профили конфигурации:",
|
||||||
"settings.profiles.label_tooltip": "Сохраненные наборы — это именованные снимки текущих настроек для быстрого переключения на этом устройстве.",
|
"settings.profiles.save_btn": "Сохранить как профиль",
|
||||||
"settings.profiles.help_text": "Сохраните текущее состояние как именованный набор для быстрого переключения на этом устройстве. Используйте экспорт/импорт для резервного копирования или обмена файлом конфигурации.",
|
"settings.profiles.load_btn": "Загрузить профиль",
|
||||||
"settings.profiles.list_tooltip": "Показывает сохраненные наборы, доступные на этом устройстве.",
|
"settings.profiles.delete_btn": "Удалить профиль",
|
||||||
"settings.profiles.save_btn": "Сохранить набор",
|
"settings.profiles.export_btn": "Экспорт конфигурации",
|
||||||
"settings.profiles.save_tooltip": "Сохраняет текущие настройки как именованный набор на этом устройстве.",
|
"settings.profiles.import_btn": "Импорт конфигурации",
|
||||||
"settings.profiles.load_btn": "Загрузить набор",
|
|
||||||
"settings.profiles.load_tooltip": "Загружает выбранный сохраненный набор в это окно.",
|
|
||||||
"settings.profiles.delete_btn": "Удалить набор",
|
|
||||||
"settings.profiles.delete_tooltip": "Удаляет выбранный сохраненный набор с этого устройства.",
|
|
||||||
"settings.profiles.transfer_label": "Сохранить резервную копию или поделиться текущими настройками:",
|
|
||||||
"settings.profiles.transfer_tooltip": "Экспорт создает JSON-файл для резервного копирования или обмена. Импорт читает такой файл и применяет его здесь.",
|
|
||||||
"settings.profiles.export_btn": "Экспорт в файл",
|
|
||||||
"settings.profiles.export_tooltip": "Экспортирует текущие настройки в JSON-файл для резервного копирования или обмена.",
|
|
||||||
"settings.profiles.import_btn": "Импорт из файла",
|
|
||||||
"settings.profiles.import_tooltip": "Импортирует настройки из JSON-файла и применяет их здесь.",
|
|
||||||
"settings.general.language_label": "Язык:",
|
"settings.general.language_label": "Язык:",
|
||||||
"settings.general.language_auto": "Системный язык (авто)",
|
"settings.general.language_auto": "Системный язык (авто)",
|
||||||
"settings.general.language_restart_note": "Изменение языка вступает в силу после перезапуска.",
|
"settings.general.language_restart_note": "Изменение языка вступает в силу после перезапуска.",
|
||||||
|
|
@ -157,15 +123,15 @@
|
||||||
"settings.edit_mapping.path_prompt": "Введите локальный путь файловой системы:",
|
"settings.edit_mapping.path_prompt": "Введите локальный путь файловой системы:",
|
||||||
"settings.add_url.title": "Добавить URL",
|
"settings.add_url.title": "Добавить URL",
|
||||||
"settings.add_url.prompt": "Введите шаблон URL (например, http://example.com или http://*.example.com):",
|
"settings.add_url.prompt": "Введите шаблон URL (например, http://example.com или http://*.example.com):",
|
||||||
"settings.profile.save.title": "Сохранить набор",
|
"settings.profile.save.title": "Сохранить профиль",
|
||||||
"settings.profile.save.prompt": "Введите имя набора (например, Работа, Клиент A):",
|
"settings.profile.save.prompt": "Введите имя профиля (например, работа, личный):",
|
||||||
"settings.select_directory.title": "Выберите разрешенную папку",
|
"settings.select_directory.title": "Выберите разрешенную папку",
|
||||||
"settings.select_log_file.title": "Выберите файл журнала",
|
"settings.select_log_file.title": "Выберите файл журнала",
|
||||||
"settings.export_config.title": "Экспорт настроек",
|
"settings.export_config.title": "Экспорт конфигурации",
|
||||||
"settings.import_config.title": "Импорт настроек",
|
"settings.import_config.title": "Импорт конфигурации",
|
||||||
"settings.error.select_mapping": "Выберите сопоставление для редактирования",
|
"settings.error.select_mapping": "Выберите сопоставление для редактирования",
|
||||||
"settings.error.select_profile_load": "Выберите набор для загрузки",
|
"settings.error.select_profile_load": "Выберите профиль для загрузки",
|
||||||
"settings.error.select_profile_delete": "Выберите набор для удаления",
|
"settings.error.select_profile_delete": "Выберите профиль для удаления",
|
||||||
"settings.web_source.url_label": "URL веб-приложения:",
|
"settings.web_source.url_label": "URL веб-приложения:",
|
||||||
"settings.web_source.open_btn": "Открыть",
|
"settings.web_source.open_btn": "Открыть",
|
||||||
"settings.web_source.url_mappings_label": "Сопоставления URL (Azure Blob Storage → локальные пути):",
|
"settings.web_source.url_mappings_label": "Сопоставления URL (Azure Blob Storage → локальные пути):",
|
||||||
|
|
@ -188,12 +154,12 @@
|
||||||
"settings.paths.select_dir_title": "Выберите разрешенную папку",
|
"settings.paths.select_dir_title": "Выберите разрешенную папку",
|
||||||
"settings.urls.add_title": "Добавить URL",
|
"settings.urls.add_title": "Добавить URL",
|
||||||
"settings.urls.add_prompt": "Введите шаблон URL (например, http://example.com или http://*.example.com):",
|
"settings.urls.add_prompt": "Введите шаблон URL (например, http://example.com или http://*.example.com):",
|
||||||
"settings.profiles.save_title": "Сохранить набор",
|
"settings.profiles.save_title": "Сохранить профиль",
|
||||||
"settings.profiles.save_prompt": "Введите имя набора (например, Работа, Клиент A):",
|
"settings.profiles.save_prompt": "Введите имя профиля (например, работа, личный):",
|
||||||
"settings.profiles.select_to_load": "Выберите набор для загрузки",
|
"settings.profiles.select_to_load": "Выберите профиль для загрузки",
|
||||||
"settings.profiles.select_to_delete": "Выберите набор для удаления",
|
"settings.profiles.select_to_delete": "Выберите профиль для удаления",
|
||||||
"settings.profiles.export_title": "Экспорт настроек",
|
"settings.profiles.export_title": "Экспорт конфигурации",
|
||||||
"settings.profiles.import_title": "Импорт настроек",
|
"settings.profiles.import_title": "Импорт конфигурации",
|
||||||
|
|
||||||
"update.checking.title": "Проверка обновлений",
|
"update.checking.title": "Проверка обновлений",
|
||||||
"update.checking.label": "Проверка обновлений...",
|
"update.checking.label": "Проверка обновлений...",
|
||||||
|
|
|
||||||
|
|
@ -58,10 +58,6 @@
|
||||||
"dialog.language_changed.msg": "语言设置已更新。立即重启可在所有界面生效。",
|
"dialog.language_changed.msg": "语言设置已更新。立即重启可在所有界面生效。",
|
||||||
"dialog.language_changed.restart_now": "立即重启",
|
"dialog.language_changed.restart_now": "立即重启",
|
||||||
"dialog.language_changed.restart_later": "稍后重启",
|
"dialog.language_changed.restart_later": "稍后重启",
|
||||||
"dialog.branding_changed.title": "品牌已更改",
|
|
||||||
"dialog.branding_changed.msg": "当前品牌配置已更改。请立即重启,以便在所有界面应用更新后的视觉标识。",
|
|
||||||
"dialog.branding_changed.restart_now": "立即重启",
|
|
||||||
"dialog.branding_changed.restart_later": "稍后重启",
|
|
||||||
"dialog.restart_failed.title": "重启失败",
|
"dialog.restart_failed.title": "重启失败",
|
||||||
"dialog.restart_failed.msg": "无法自动重启应用:\n\n{error}\n\n请手动重启。",
|
"dialog.restart_failed.msg": "无法自动重启应用:\n\n{error}\n\n请手动重启。",
|
||||||
"dialog.update_timeout.title": "更新检查超时",
|
"dialog.update_timeout.title": "更新检查超时",
|
||||||
|
|
@ -88,28 +84,8 @@
|
||||||
"settings.tab.urls": "URL",
|
"settings.tab.urls": "URL",
|
||||||
"settings.tab.logging": "日志",
|
"settings.tab.logging": "日志",
|
||||||
"settings.tab.window": "窗口",
|
"settings.tab.window": "窗口",
|
||||||
"settings.tab.profiles": "设置",
|
"settings.tab.profiles": "配置档案",
|
||||||
"settings.tab.general": "通用",
|
"settings.tab.general": "通用",
|
||||||
"settings.tab.branding": "品牌",
|
|
||||||
"settings.branding.select_label": "品牌:",
|
|
||||||
"settings.branding.select_tooltip": "选择应用启动时应自动加载的品牌模板。",
|
|
||||||
"settings.branding.help_text": "品牌控制应用的视觉标识,例如名称和图标,并与已保存的设置保持分离。",
|
|
||||||
"settings.branding.display_name_label": "显示名称:",
|
|
||||||
"settings.branding.app_name_label": "应用名称:",
|
|
||||||
"settings.branding.window_title_label": "窗口标题(可选):",
|
|
||||||
"settings.branding.logo_path_label": "Logo 路径(可选):",
|
|
||||||
"settings.branding.save_as_btn": "保存品牌配置",
|
|
||||||
"settings.branding.export_btn": "导出品牌配置",
|
|
||||||
"settings.branding.import_btn": "导入品牌配置",
|
|
||||||
"settings.branding.delete_btn": "删除品牌配置",
|
|
||||||
"settings.branding.export_title": "导出品牌配置",
|
|
||||||
"settings.branding.import_title": "导入品牌配置",
|
|
||||||
"settings.branding.preview_label": "预览:",
|
|
||||||
"settings.branding.no_icon_selected": "未选择图标",
|
|
||||||
"settings.branding.preview_default_name": "Default",
|
|
||||||
"settings.branding.save_as_title": "保存品牌配置",
|
|
||||||
"settings.branding.save_as_prompt": "输入品牌名称:",
|
|
||||||
"settings.branding.restart_note": "品牌更改会被持久保存,并将在应用重启后完整生效。",
|
|
||||||
"settings.web_url.label": "Web 应用 URL:",
|
"settings.web_url.label": "Web 应用 URL:",
|
||||||
"settings.web_url.placeholder": "例如: http://localhost:8080 或 file:///./webapp/index.html",
|
"settings.web_url.placeholder": "例如: http://localhost:8080 或 file:///./webapp/index.html",
|
||||||
"settings.web_url.open_btn": "打开",
|
"settings.web_url.open_btn": "打开",
|
||||||
|
|
@ -130,22 +106,12 @@
|
||||||
"settings.log_file.browse_btn": "浏览...",
|
"settings.log_file.browse_btn": "浏览...",
|
||||||
"settings.window.width_label": "窗口宽度:",
|
"settings.window.width_label": "窗口宽度:",
|
||||||
"settings.window.height_label": "窗口高度:",
|
"settings.window.height_label": "窗口高度:",
|
||||||
"settings.profiles.label": "此设备上已保存的设置:",
|
"settings.profiles.label": "已保存配置档案:",
|
||||||
"settings.profiles.label_tooltip": "已保存设置是当前配置的命名快照,可用于在此设备上快速切换。",
|
"settings.profiles.save_btn": "保存为档案",
|
||||||
"settings.profiles.help_text": "将当前状态保存为命名设置,便于在此设备上快速切换。需要备份或共享配置文件时,请使用导出/导入。",
|
"settings.profiles.load_btn": "加载档案",
|
||||||
"settings.profiles.list_tooltip": "显示此设备上可用的已保存设置。",
|
"settings.profiles.delete_btn": "删除档案",
|
||||||
"settings.profiles.save_btn": "保存设置",
|
"settings.profiles.export_btn": "导出配置",
|
||||||
"settings.profiles.save_tooltip": "将当前设置保存为此设备上的命名设置。",
|
"settings.profiles.import_btn": "导入配置",
|
||||||
"settings.profiles.load_btn": "加载设置",
|
|
||||||
"settings.profiles.load_tooltip": "将选中的已保存设置加载到此对话框中。",
|
|
||||||
"settings.profiles.delete_btn": "删除设置",
|
|
||||||
"settings.profiles.delete_tooltip": "从此设备删除选中的已保存设置。",
|
|
||||||
"settings.profiles.transfer_label": "备份或共享当前设置:",
|
|
||||||
"settings.profiles.transfer_tooltip": "导出会创建一个 JSON 文件用于备份或共享。导入会读取此类文件并在此处应用。",
|
|
||||||
"settings.profiles.export_btn": "导出到文件",
|
|
||||||
"settings.profiles.export_tooltip": "将当前设置导出为 JSON 文件,用于备份或共享。",
|
|
||||||
"settings.profiles.import_btn": "从文件导入",
|
|
||||||
"settings.profiles.import_tooltip": "从 JSON 文件导入设置并在此处应用。",
|
|
||||||
"settings.general.language_label": "语言:",
|
"settings.general.language_label": "语言:",
|
||||||
"settings.general.language_auto": "跟随系统(自动)",
|
"settings.general.language_auto": "跟随系统(自动)",
|
||||||
"settings.general.language_restart_note": "语言更改将在重启后生效。",
|
"settings.general.language_restart_note": "语言更改将在重启后生效。",
|
||||||
|
|
@ -157,15 +123,15 @@
|
||||||
"settings.edit_mapping.path_prompt": "输入本地文件系统路径:",
|
"settings.edit_mapping.path_prompt": "输入本地文件系统路径:",
|
||||||
"settings.add_url.title": "添加 URL",
|
"settings.add_url.title": "添加 URL",
|
||||||
"settings.add_url.prompt": "输入 URL 模式(例如: http://example.com 或 http://*.example.com):",
|
"settings.add_url.prompt": "输入 URL 模式(例如: http://example.com 或 http://*.example.com):",
|
||||||
"settings.profile.save.title": "保存设置",
|
"settings.profile.save.title": "保存档案",
|
||||||
"settings.profile.save.prompt": "输入设置名称(例如:工作、客户A):",
|
"settings.profile.save.prompt": "输入配置档案名称(例如: 工作, 个人):",
|
||||||
"settings.select_directory.title": "选择允许目录",
|
"settings.select_directory.title": "选择允许目录",
|
||||||
"settings.select_log_file.title": "选择日志文件",
|
"settings.select_log_file.title": "选择日志文件",
|
||||||
"settings.export_config.title": "导出设置",
|
"settings.export_config.title": "导出配置",
|
||||||
"settings.import_config.title": "导入设置",
|
"settings.import_config.title": "导入配置",
|
||||||
"settings.error.select_mapping": "请选择要编辑的映射",
|
"settings.error.select_mapping": "请选择要编辑的映射",
|
||||||
"settings.error.select_profile_load": "请选择要加载的设置",
|
"settings.error.select_profile_load": "请选择要加载的档案",
|
||||||
"settings.error.select_profile_delete": "请选择要删除的设置",
|
"settings.error.select_profile_delete": "请选择要删除的档案",
|
||||||
"settings.web_source.url_label": "Web 应用 URL:",
|
"settings.web_source.url_label": "Web 应用 URL:",
|
||||||
"settings.web_source.open_btn": "打开",
|
"settings.web_source.open_btn": "打开",
|
||||||
"settings.web_source.url_mappings_label": "URL 映射(Azure Blob Storage → 本地路径):",
|
"settings.web_source.url_mappings_label": "URL 映射(Azure Blob Storage → 本地路径):",
|
||||||
|
|
@ -188,12 +154,12 @@
|
||||||
"settings.paths.select_dir_title": "选择允许目录",
|
"settings.paths.select_dir_title": "选择允许目录",
|
||||||
"settings.urls.add_title": "添加 URL",
|
"settings.urls.add_title": "添加 URL",
|
||||||
"settings.urls.add_prompt": "输入 URL 模式(例如: http://example.com 或 http://*.example.com):",
|
"settings.urls.add_prompt": "输入 URL 模式(例如: http://example.com 或 http://*.example.com):",
|
||||||
"settings.profiles.save_title": "保存设置",
|
"settings.profiles.save_title": "保存档案",
|
||||||
"settings.profiles.save_prompt": "输入设置名称(例如:工作、客户A):",
|
"settings.profiles.save_prompt": "输入配置档案名称(例如: 工作, 个人):",
|
||||||
"settings.profiles.select_to_load": "请选择要加载的设置",
|
"settings.profiles.select_to_load": "请选择要加载的档案",
|
||||||
"settings.profiles.select_to_delete": "请选择要删除的设置",
|
"settings.profiles.select_to_delete": "请选择要删除的档案",
|
||||||
"settings.profiles.export_title": "导出设置",
|
"settings.profiles.export_title": "导出配置",
|
||||||
"settings.profiles.import_title": "导入设置",
|
"settings.profiles.import_title": "导入配置",
|
||||||
|
|
||||||
"update.checking.title": "检查更新",
|
"update.checking.title": "检查更新",
|
||||||
"update.checking.label": "正在检查更新...",
|
"update.checking.label": "正在检查更新...",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""WebDrop Bridge - Qt-based desktop application for intelligent drag-and-drop file handling."""
|
"""WebDrop Bridge - Qt-based desktop application for intelligent drag-and-drop file handling."""
|
||||||
|
|
||||||
__version__ = "0.9.1"
|
__version__ = "0.9.0"
|
||||||
__author__ = "WebDrop Team"
|
__author__ = "WebDrop Team"
|
||||||
__license__ = "MIT"
|
__license__ = "MIT"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,6 @@ DEFAULT_UPDATE_BASE_URL = "https://git.him-tools.de"
|
||||||
DEFAULT_UPDATE_REPO = "HIM-public/webdrop-bridge"
|
DEFAULT_UPDATE_REPO = "HIM-public/webdrop-bridge"
|
||||||
DEFAULT_UPDATE_CHANNEL = "stable"
|
DEFAULT_UPDATE_CHANNEL = "stable"
|
||||||
DEFAULT_UPDATE_MANIFEST_NAME = "release-manifest.json"
|
DEFAULT_UPDATE_MANIFEST_NAME = "release-manifest.json"
|
||||||
DEFAULT_ACTIVE_BRANDING_ID = "default"
|
|
||||||
DEFAULT_APP_ICON_PATH = "resources/icons/app.ico"
|
|
||||||
DEFAULT_TOOLBAR_ICON_HOME = "resources/icons/home.ico"
|
|
||||||
DEFAULT_TOOLBAR_ICON_RELOAD = "resources/icons/reload.ico"
|
|
||||||
DEFAULT_TOOLBAR_ICON_OPEN = "resources/icons/open.ico"
|
|
||||||
DEFAULT_TOOLBAR_ICON_OPENWITH = "resources/icons/openwith.ico"
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigurationError(Exception):
|
class ConfigurationError(Exception):
|
||||||
|
|
@ -102,14 +96,6 @@ class Config:
|
||||||
enable_logging: bool = True
|
enable_logging: bool = True
|
||||||
enable_checkout: bool = False
|
enable_checkout: bool = False
|
||||||
language: str = "auto"
|
language: str = "auto"
|
||||||
active_branding_id: str = DEFAULT_ACTIVE_BRANDING_ID
|
|
||||||
branding_display_name: str = "Default"
|
|
||||||
logo_path: str = ""
|
|
||||||
app_icon_path: str = DEFAULT_APP_ICON_PATH
|
|
||||||
toolbar_icon_home: str = DEFAULT_TOOLBAR_ICON_HOME
|
|
||||||
toolbar_icon_reload: str = DEFAULT_TOOLBAR_ICON_RELOAD
|
|
||||||
toolbar_icon_open: str = DEFAULT_TOOLBAR_ICON_OPEN
|
|
||||||
toolbar_icon_openwith: str = DEFAULT_TOOLBAR_ICON_OPENWITH
|
|
||||||
brand_id: str = DEFAULT_BRAND_ID
|
brand_id: str = DEFAULT_BRAND_ID
|
||||||
config_dir_name: str = DEFAULT_CONFIG_DIR_NAME
|
config_dir_name: str = DEFAULT_CONFIG_DIR_NAME
|
||||||
update_base_url: str = DEFAULT_UPDATE_BASE_URL
|
update_base_url: str = DEFAULT_UPDATE_BASE_URL
|
||||||
|
|
@ -193,7 +179,7 @@ class Config:
|
||||||
# No window title specified, use default
|
# No window title specified, use default
|
||||||
window_title = f"{app_name} v{__version__}"
|
window_title = f"{app_name} v{__version__}"
|
||||||
|
|
||||||
config = cls(
|
return cls(
|
||||||
app_name=app_name,
|
app_name=app_name,
|
||||||
app_version=__version__,
|
app_version=__version__,
|
||||||
log_level=data.get("log_level", "INFO").upper(),
|
log_level=data.get("log_level", "INFO").upper(),
|
||||||
|
|
@ -211,13 +197,6 @@ class Config:
|
||||||
enable_logging=data.get("enable_logging", True),
|
enable_logging=data.get("enable_logging", True),
|
||||||
enable_checkout=data.get("enable_checkout", False),
|
enable_checkout=data.get("enable_checkout", False),
|
||||||
language=data.get("language", "auto"),
|
language=data.get("language", "auto"),
|
||||||
active_branding_id=data.get("active_branding_id", DEFAULT_ACTIVE_BRANDING_ID),
|
|
||||||
logo_path=data.get("logo_path", ""),
|
|
||||||
app_icon_path=data.get("app_icon_path", DEFAULT_APP_ICON_PATH),
|
|
||||||
toolbar_icon_home=data.get("toolbar_icon_home", DEFAULT_TOOLBAR_ICON_HOME),
|
|
||||||
toolbar_icon_reload=data.get("toolbar_icon_reload", DEFAULT_TOOLBAR_ICON_RELOAD),
|
|
||||||
toolbar_icon_open=data.get("toolbar_icon_open", DEFAULT_TOOLBAR_ICON_OPEN),
|
|
||||||
toolbar_icon_openwith=data.get("toolbar_icon_openwith", DEFAULT_TOOLBAR_ICON_OPENWITH),
|
|
||||||
brand_id=brand_id,
|
brand_id=brand_id,
|
||||||
config_dir_name=config_dir_name,
|
config_dir_name=config_dir_name,
|
||||||
update_base_url=data.get("update_base_url", DEFAULT_UPDATE_BASE_URL),
|
update_base_url=data.get("update_base_url", DEFAULT_UPDATE_BASE_URL),
|
||||||
|
|
@ -225,7 +204,6 @@ class Config:
|
||||||
update_channel=data.get("update_channel", DEFAULT_UPDATE_CHANNEL),
|
update_channel=data.get("update_channel", DEFAULT_UPDATE_CHANNEL),
|
||||||
update_manifest_name=data.get("update_manifest_name", DEFAULT_UPDATE_MANIFEST_NAME),
|
update_manifest_name=data.get("update_manifest_name", DEFAULT_UPDATE_MANIFEST_NAME),
|
||||||
)
|
)
|
||||||
return cls._apply_runtime_branding(config)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls, env_file: str | None = None) -> "Config":
|
def from_env(cls, env_file: str | None = None) -> "Config":
|
||||||
|
|
@ -268,12 +246,6 @@ class Config:
|
||||||
enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true"
|
enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true"
|
||||||
enable_checkout = os.getenv("ENABLE_CHECKOUT", "false").lower() == "true"
|
enable_checkout = os.getenv("ENABLE_CHECKOUT", "false").lower() == "true"
|
||||||
language = os.getenv("LANGUAGE", "auto")
|
language = os.getenv("LANGUAGE", "auto")
|
||||||
active_branding_id = os.getenv("BRAND_TEMPLATE", DEFAULT_ACTIVE_BRANDING_ID)
|
|
||||||
app_icon_path = os.getenv("APP_ICON_PATH", DEFAULT_APP_ICON_PATH)
|
|
||||||
toolbar_icon_home = os.getenv("TOOLBAR_ICON_HOME", DEFAULT_TOOLBAR_ICON_HOME)
|
|
||||||
toolbar_icon_reload = os.getenv("TOOLBAR_ICON_RELOAD", DEFAULT_TOOLBAR_ICON_RELOAD)
|
|
||||||
toolbar_icon_open = os.getenv("TOOLBAR_ICON_OPEN", DEFAULT_TOOLBAR_ICON_OPEN)
|
|
||||||
toolbar_icon_openwith = os.getenv("TOOLBAR_ICON_OPENWITH", DEFAULT_TOOLBAR_ICON_OPENWITH)
|
|
||||||
update_base_url = os.getenv("UPDATE_BASE_URL", DEFAULT_UPDATE_BASE_URL)
|
update_base_url = os.getenv("UPDATE_BASE_URL", DEFAULT_UPDATE_BASE_URL)
|
||||||
update_repo = os.getenv("UPDATE_REPO", DEFAULT_UPDATE_REPO)
|
update_repo = os.getenv("UPDATE_REPO", DEFAULT_UPDATE_REPO)
|
||||||
update_channel = os.getenv("UPDATE_CHANNEL", DEFAULT_UPDATE_CHANNEL)
|
update_channel = os.getenv("UPDATE_CHANNEL", DEFAULT_UPDATE_CHANNEL)
|
||||||
|
|
@ -356,7 +328,7 @@ class Config:
|
||||||
f"Invalid URL_MAPPINGS: {url_mappings_str}. Error: {e}"
|
f"Invalid URL_MAPPINGS: {url_mappings_str}. Error: {e}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
config = cls(
|
return cls(
|
||||||
app_name=app_name,
|
app_name=app_name,
|
||||||
app_version=app_version,
|
app_version=app_version,
|
||||||
log_level=log_level,
|
log_level=log_level,
|
||||||
|
|
@ -371,12 +343,6 @@ class Config:
|
||||||
enable_logging=enable_logging,
|
enable_logging=enable_logging,
|
||||||
enable_checkout=enable_checkout,
|
enable_checkout=enable_checkout,
|
||||||
language=language,
|
language=language,
|
||||||
active_branding_id=active_branding_id,
|
|
||||||
app_icon_path=app_icon_path,
|
|
||||||
toolbar_icon_home=toolbar_icon_home,
|
|
||||||
toolbar_icon_reload=toolbar_icon_reload,
|
|
||||||
toolbar_icon_open=toolbar_icon_open,
|
|
||||||
toolbar_icon_openwith=toolbar_icon_openwith,
|
|
||||||
brand_id=brand_id,
|
brand_id=brand_id,
|
||||||
config_dir_name=config_dir_name,
|
config_dir_name=config_dir_name,
|
||||||
update_base_url=update_base_url,
|
update_base_url=update_base_url,
|
||||||
|
|
@ -384,7 +350,6 @@ class Config:
|
||||||
update_channel=update_channel,
|
update_channel=update_channel,
|
||||||
update_manifest_name=update_manifest_name,
|
update_manifest_name=update_manifest_name,
|
||||||
)
|
)
|
||||||
return cls._apply_runtime_branding(config)
|
|
||||||
|
|
||||||
def to_file(self, config_path: Path) -> None:
|
def to_file(self, config_path: Path) -> None:
|
||||||
"""Save configuration to JSON file.
|
"""Save configuration to JSON file.
|
||||||
|
|
@ -413,7 +378,6 @@ class Config:
|
||||||
"enable_logging": self.enable_logging,
|
"enable_logging": self.enable_logging,
|
||||||
"enable_checkout": self.enable_checkout,
|
"enable_checkout": self.enable_checkout,
|
||||||
"language": self.language,
|
"language": self.language,
|
||||||
"active_branding_id": self.active_branding_id,
|
|
||||||
"brand_id": self.brand_id,
|
"brand_id": self.brand_id,
|
||||||
"config_dir_name": self.config_dir_name,
|
"config_dir_name": self.config_dir_name,
|
||||||
"update_base_url": self.update_base_url,
|
"update_base_url": self.update_base_url,
|
||||||
|
|
@ -426,17 +390,6 @@ class Config:
|
||||||
with open(config_path, "w", encoding="utf-8") as f:
|
with open(config_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(data, f, indent=2)
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _apply_runtime_branding(config: "Config") -> "Config":
|
|
||||||
"""Apply the persisted runtime branding template to cosmetic fields."""
|
|
||||||
try:
|
|
||||||
from webdrop_bridge.core.branding_manager import BrandingManager
|
|
||||||
|
|
||||||
BrandingManager().apply_to_config(config)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to apply runtime branding: {e}")
|
|
||||||
return config
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def load_bootstrap_env(env_file: str | None = None) -> Path | None:
|
def load_bootstrap_env(env_file: str | None = None) -> Path | None:
|
||||||
"""Load a bootstrap .env before configuration path lookup.
|
"""Load a bootstrap .env before configuration path lookup.
|
||||||
|
|
|
||||||
|
|
@ -1,406 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -427,9 +427,7 @@ class MainWindow(QMainWindow):
|
||||||
self._background_threads = [] # Keep references to background threads
|
self._background_threads = [] # Keep references to background threads
|
||||||
self._background_workers = {} # Keep references to background workers
|
self._background_workers = {} # Keep references to background workers
|
||||||
self._bridge_script_source = "" # Cache combined bridge source for recovery injection
|
self._bridge_script_source = "" # Cache combined bridge source for recovery injection
|
||||||
self._bridge_script_re_registered = (
|
self._bridge_script_re_registered = False # Flag to prevent duplicate re-registration on same load
|
||||||
False # Flag to prevent duplicate re-registration on same load
|
|
||||||
)
|
|
||||||
self._is_page_loading = False # Track if a page load is currently in progress
|
self._is_page_loading = False # Track if a page load is currently in progress
|
||||||
self._pending_reload = False # Coalesce multiple rapid reload requests into one
|
self._pending_reload = False # Coalesce multiple rapid reload requests into one
|
||||||
self._load_sequence = 0 # Monotonic counter to ignore stale async recovery callbacks
|
self._load_sequence = 0 # Monotonic counter to ignore stale async recovery callbacks
|
||||||
|
|
@ -446,13 +444,22 @@ class MainWindow(QMainWindow):
|
||||||
config.window_height,
|
config.window_height,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set window icon from the active runtime branding
|
# Set window icon
|
||||||
icon_path = self._resolve_toolbar_icon_path(config.app_icon_path)
|
# Support both development mode and PyInstaller bundle
|
||||||
if icon_path is not None:
|
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():
|
||||||
self.setWindowIcon(QIcon(str(icon_path)))
|
self.setWindowIcon(QIcon(str(icon_path)))
|
||||||
logger.debug(f"Window icon set from {icon_path}")
|
logger.debug(f"Window icon set from {icon_path}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Window icon not found for configured path: {config.app_icon_path}")
|
logger.warning(f"Window icon not found at {icon_path}")
|
||||||
|
|
||||||
# Create web engine view with URL for profile isolation
|
# Create web engine view with URL for profile isolation
|
||||||
self.web_view = RestrictedWebEngineView(
|
self.web_view = RestrictedWebEngineView(
|
||||||
|
|
@ -1182,9 +1189,7 @@ class MainWindow(QMainWindow):
|
||||||
# This more reliably opens files with chosen applications.
|
# This more reliably opens files with chosen applications.
|
||||||
# Use a simple, more direct approach
|
# Use a simple, more direct approach
|
||||||
# Get the chosen app via AppleScript, then use open command
|
# Get the chosen app via AppleScript, then use open command
|
||||||
get_app_script = (
|
get_app_script = '''choose application with title "Select an application to open the file"'''
|
||||||
'''choose application with title "Select an application to open the file"'''
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
# Get the chosen application
|
# Get the chosen application
|
||||||
app_result = subprocess.run(
|
app_result = subprocess.run(
|
||||||
|
|
@ -1194,21 +1199,19 @@ class MainWindow(QMainWindow):
|
||||||
text=True,
|
text=True,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
|
|
||||||
if app_result.returncode != 0:
|
if app_result.returncode != 0:
|
||||||
logger.warning(
|
logger.warning(f"User cancelled app chooser or error occurred: {app_result.stderr}")
|
||||||
f"User cancelled app chooser or error occurred: {app_result.stderr}"
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Get the application name (strip whitespace)
|
# Get the application name (strip whitespace)
|
||||||
chosen_app = app_result.stdout.strip()
|
chosen_app = app_result.stdout.strip()
|
||||||
if not chosen_app:
|
if not chosen_app:
|
||||||
logger.warning("No application was selected")
|
logger.warning("No application was selected")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
logger.info(f"User selected app: {chosen_app}")
|
logger.info(f"User selected app: {chosen_app}")
|
||||||
|
|
||||||
# Now open the file with the chosen app using the 'open' command
|
# Now open the file with the chosen app using the 'open' command
|
||||||
open_result = subprocess.run(
|
open_result = subprocess.run(
|
||||||
["open", "-a", chosen_app, normalized_path],
|
["open", "-a", chosen_app, normalized_path],
|
||||||
|
|
@ -1217,16 +1220,14 @@ class MainWindow(QMainWindow):
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
if open_result.returncode == 0:
|
if open_result.returncode == 0:
|
||||||
logger.info(f"Opened '{normalized_path}' with '{chosen_app}'")
|
logger.info(f"Opened '{normalized_path}' with '{chosen_app}'")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(f"Failed to open file with '{chosen_app}': {open_result.stderr}")
|
||||||
f"Failed to open file with '{chosen_app}': {open_result.stderr}"
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
logger.warning("App chooser timed out")
|
logger.warning("App chooser timed out")
|
||||||
return False
|
return False
|
||||||
|
|
@ -1392,7 +1393,7 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
Re-registers the bridge script to ensure it will be injected on reload,
|
Re-registers the bridge script to ensure it will be injected on reload,
|
||||||
page navigation, or any load event.
|
page navigation, or any load event.
|
||||||
|
|
||||||
Uses a flag to prevent duplicate re-registrations if loadStarted fires multiple times.
|
Uses a flag to prevent duplicate re-registrations if loadStarted fires multiple times.
|
||||||
"""
|
"""
|
||||||
self._is_page_loading = True
|
self._is_page_loading = True
|
||||||
|
|
@ -1411,7 +1412,7 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
Checks if the bridge script was successfully injected, with automatic recovery
|
Checks if the bridge script was successfully injected, with automatic recovery
|
||||||
for page reloads and redirects.
|
for page reloads and redirects.
|
||||||
|
|
||||||
Resets the re-registration flag for the next load cycle.
|
Resets the re-registration flag for the next load cycle.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -1432,11 +1433,9 @@ class MainWindow(QMainWindow):
|
||||||
logger.warning("Page failed to load")
|
logger.warning("Page failed to load")
|
||||||
return
|
return
|
||||||
|
|
||||||
def _verify_bridge_loaded(
|
def _verify_bridge_loaded(stage: str, attempt: int = 1, sequence: int = finished_sequence) -> None:
|
||||||
stage: str, attempt: int = 1, sequence: int = finished_sequence
|
|
||||||
) -> None:
|
|
||||||
"""Check if bridge marker exists and optionally recover script injection.
|
"""Check if bridge marker exists and optionally recover script injection.
|
||||||
|
|
||||||
Implements multi-attempt recovery strategy:
|
Implements multi-attempt recovery strategy:
|
||||||
- initial: First check after page load (50ms delay)
|
- initial: First check after page load (50ms delay)
|
||||||
- recovery_N: Recovery attempts with progressive delays
|
- recovery_N: Recovery attempts with progressive delays
|
||||||
|
|
@ -1486,7 +1485,9 @@ class MainWindow(QMainWindow):
|
||||||
delay = int(100 * (1.5 ** (attempt - 1)))
|
delay = int(100 * (1.5 ** (attempt - 1)))
|
||||||
QTimer.singleShot(
|
QTimer.singleShot(
|
||||||
delay,
|
delay,
|
||||||
lambda: _verify_bridge_loaded("recovery", attempt + 1, sequence),
|
lambda: _verify_bridge_loaded(
|
||||||
|
"recovery", attempt + 1, sequence
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.web_view.page().runJavaScript(self._bridge_script_source, after_retry)
|
self.web_view.page().runJavaScript(self._bridge_script_source, after_retry)
|
||||||
|
|
@ -1506,15 +1507,11 @@ class MainWindow(QMainWindow):
|
||||||
)
|
)
|
||||||
|
|
||||||
self._re_register_bridge_script()
|
self._re_register_bridge_script()
|
||||||
self.web_view.page().runJavaScript(
|
self.web_view.page().runJavaScript(self._bridge_script_source, after_re_register)
|
||||||
self._bridge_script_source, after_re_register
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# All recovery attempts exhausted
|
# All recovery attempts exhausted
|
||||||
logger.error(
|
logger.error("❌ WebDrop Bridge script failed to inject after all recovery attempts!")
|
||||||
"❌ WebDrop Bridge script failed to inject after all recovery attempts!"
|
|
||||||
)
|
|
||||||
logger.error(" Drag-and-drop functionality is DISABLED")
|
logger.error(" Drag-and-drop functionality is DISABLED")
|
||||||
logger.debug(f" Stage: {stage}, Attempt: {attempt}")
|
logger.debug(f" Stage: {stage}, Attempt: {attempt}")
|
||||||
|
|
||||||
|
|
@ -1546,21 +1543,21 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
def _ensure_bridge_script_exists(self, verbose: bool = False) -> None:
|
def _ensure_bridge_script_exists(self, verbose: bool = False) -> None:
|
||||||
"""Ensure bridge script exists in QWebEngineScript collection (idempotent).
|
"""Ensure bridge script exists in QWebEngineScript collection (idempotent).
|
||||||
|
|
||||||
Checks if the script already exists. If not, adds it.
|
Checks if the script already exists. If not, adds it.
|
||||||
Never removes/re-adds to avoid race conditions with Qt's injection mechanism.
|
Never removes/re-adds to avoid race conditions with Qt's injection mechanism.
|
||||||
|
|
||||||
This is safer than removing+re-adding because:
|
This is safer than removing+re-adding because:
|
||||||
- Avoids concurrent access conflicts with Qt's internal injection
|
- Avoids concurrent access conflicts with Qt's internal injection
|
||||||
- Prevents missing injections during rapid reloads
|
- Prevents missing injections during rapid reloads
|
||||||
- Guarantees script is available without timing gaps
|
- Guarantees script is available without timing gaps
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
verbose: If True, use debug logging; otherwise use minimal logging
|
verbose: If True, use debug logging; otherwise use minimal logging
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
scripts = self.web_view.page().scripts()
|
scripts = self.web_view.page().scripts()
|
||||||
|
|
||||||
# Check if script already exists
|
# Check if script already exists
|
||||||
already_exists = False
|
already_exists = False
|
||||||
for script in scripts.toList(): # type: ignore
|
for script in scripts.toList(): # type: ignore
|
||||||
|
|
@ -1569,7 +1566,7 @@ class MainWindow(QMainWindow):
|
||||||
if verbose:
|
if verbose:
|
||||||
logger.debug("Bridge script already exists in page().scripts()")
|
logger.debug("Bridge script already exists in page().scripts()")
|
||||||
break
|
break
|
||||||
|
|
||||||
# If script doesn't exist, add it
|
# If script doesn't exist, add it
|
||||||
if not already_exists and self._bridge_script_source:
|
if not already_exists and self._bridge_script_source:
|
||||||
new_script = QWebEngineScript()
|
new_script = QWebEngineScript()
|
||||||
|
|
@ -1585,18 +1582,16 @@ class MainWindow(QMainWindow):
|
||||||
new_script.setSourceCode(self._bridge_script_source)
|
new_script.setSourceCode(self._bridge_script_source)
|
||||||
|
|
||||||
scripts.insert(new_script)
|
scripts.insert(new_script)
|
||||||
logger.debug(
|
logger.debug(f"✓ Added bridge script to collection ({len(self._bridge_script_source)} chars)")
|
||||||
f"✓ Added bridge script to collection ({len(self._bridge_script_source)} chars)"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to ensure bridge script exists: {e}")
|
logger.error(f"Failed to ensure bridge script exists: {e}")
|
||||||
|
|
||||||
def _re_register_bridge_script(self, verbose: bool = False) -> None:
|
def _re_register_bridge_script(self, verbose: bool = False) -> None:
|
||||||
"""Force re-registration of bridge script in QWebEngineScript collection.
|
"""Force re-registration of bridge script in QWebEngineScript collection.
|
||||||
|
|
||||||
Removes old script and re-adds it to ensure it's injected on next page load.
|
Removes old script and re-adds it to ensure it's injected on next page load.
|
||||||
This is a fallback for recovery mechanics when normal injection fails.
|
This is a fallback for recovery mechanics when normal injection fails.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
verbose: If True, use debug logging; otherwise use minimal logging
|
verbose: If True, use debug logging; otherwise use minimal logging
|
||||||
"""
|
"""
|
||||||
|
|
@ -1627,9 +1622,7 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
scripts.insert(new_script)
|
scripts.insert(new_script)
|
||||||
if verbose or removed:
|
if verbose or removed:
|
||||||
logger.debug(
|
logger.debug(f"✓ Re-registered webdrop-bridge script ({len(self._bridge_script_source)} chars)")
|
||||||
f"✓ Re-registered webdrop-bridge script ({len(self._bridge_script_source)} chars)"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to re-register bridge script: {e}")
|
logger.error(f"Failed to re-register bridge script: {e}")
|
||||||
|
|
||||||
|
|
@ -1656,7 +1649,9 @@ class MainWindow(QMainWindow):
|
||||||
toolbar.addSeparator()
|
toolbar.addSeparator()
|
||||||
|
|
||||||
# Home button
|
# Home button
|
||||||
home_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_home)
|
home_icon_path = self._resolve_toolbar_icon_path(
|
||||||
|
os.getenv("TOOLBAR_ICON_HOME", "resources/icons/home.ico")
|
||||||
|
)
|
||||||
home_icon = (
|
home_icon = (
|
||||||
QIcon(str(home_icon_path))
|
QIcon(str(home_icon_path))
|
||||||
if home_icon_path is not None
|
if home_icon_path is not None
|
||||||
|
|
@ -1668,7 +1663,9 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
# Refresh button
|
# Refresh button
|
||||||
refresh_action = toolbar.addAction("")
|
refresh_action = toolbar.addAction("")
|
||||||
reload_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_reload)
|
reload_icon_path = self._resolve_toolbar_icon_path(
|
||||||
|
os.getenv("TOOLBAR_ICON_RELOAD", "resources/icons/reload.ico")
|
||||||
|
)
|
||||||
if reload_icon_path is not None:
|
if reload_icon_path is not None:
|
||||||
refresh_action.setIcon(QIcon(str(reload_icon_path)))
|
refresh_action.setIcon(QIcon(str(reload_icon_path)))
|
||||||
else:
|
else:
|
||||||
|
|
@ -1680,7 +1677,9 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
# Open-with-default-app drop zone (right of Reload)
|
# Open-with-default-app drop zone (right of Reload)
|
||||||
self._open_drop_zone = OpenDropZone()
|
self._open_drop_zone = OpenDropZone()
|
||||||
open_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_open)
|
open_icon_path = self._resolve_toolbar_icon_path(
|
||||||
|
os.getenv("TOOLBAR_ICON_OPEN", "resources/icons/open.ico")
|
||||||
|
)
|
||||||
if open_icon_path is not None:
|
if open_icon_path is not None:
|
||||||
self._open_drop_zone.set_icon(QIcon(str(open_icon_path)))
|
self._open_drop_zone.set_icon(QIcon(str(open_icon_path)))
|
||||||
self._open_drop_zone.file_opened.connect(self._on_file_opened_via_drop)
|
self._open_drop_zone.file_opened.connect(self._on_file_opened_via_drop)
|
||||||
|
|
@ -1691,7 +1690,9 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
# Open-with chooser drop zone (right of Open-with-default-app)
|
# Open-with chooser drop zone (right of Open-with-default-app)
|
||||||
self._open_with_drop_zone = OpenWithDropZone()
|
self._open_with_drop_zone = OpenWithDropZone()
|
||||||
open_with_icon_path = self._resolve_toolbar_icon_path(self.config.toolbar_icon_openwith)
|
open_with_icon_path = self._resolve_toolbar_icon_path(
|
||||||
|
os.getenv("TOOLBAR_ICON_OPENWITH", "resources/icons/openwith.ico")
|
||||||
|
)
|
||||||
if open_with_icon_path is not None:
|
if open_with_icon_path is not None:
|
||||||
self._open_with_drop_zone.set_icon(QIcon(str(open_with_icon_path)))
|
self._open_with_drop_zone.set_icon(QIcon(str(open_with_icon_path)))
|
||||||
self._open_with_drop_zone.file_open_with_requested.connect(
|
self._open_with_drop_zone.file_open_with_requested.connect(
|
||||||
|
|
@ -1958,7 +1959,6 @@ class MainWindow(QMainWindow):
|
||||||
# Store current URL before opening dialog
|
# Store current URL before opening dialog
|
||||||
old_webapp_url = self.config.webapp_url
|
old_webapp_url = self.config.webapp_url
|
||||||
old_language = self.config.language
|
old_language = self.config.language
|
||||||
old_branding_id = self.config.active_branding_id
|
|
||||||
|
|
||||||
# Show dialog
|
# Show dialog
|
||||||
dialog = SettingsDialog(self.config, self)
|
dialog = SettingsDialog(self.config, self)
|
||||||
|
|
@ -1967,9 +1967,6 @@ class MainWindow(QMainWindow):
|
||||||
# Check if webapp URL changed
|
# Check if webapp URL changed
|
||||||
new_webapp_url = self.config.webapp_url
|
new_webapp_url = self.config.webapp_url
|
||||||
language_changed = old_language != self.config.language
|
language_changed = old_language != self.config.language
|
||||||
branding_changed = old_branding_id != self.config.active_branding_id
|
|
||||||
restart_prompt_shown = False
|
|
||||||
|
|
||||||
if old_webapp_url != new_webapp_url:
|
if old_webapp_url != new_webapp_url:
|
||||||
logger.info(f"Web application URL changed: {old_webapp_url} → {new_webapp_url}")
|
logger.info(f"Web application URL changed: {old_webapp_url} → {new_webapp_url}")
|
||||||
|
|
||||||
|
|
@ -1979,7 +1976,6 @@ class MainWindow(QMainWindow):
|
||||||
if domain_changed:
|
if domain_changed:
|
||||||
logger.warning("Domain has changed - recommending restart")
|
logger.warning("Domain has changed - recommending restart")
|
||||||
self._handle_domain_change_restart()
|
self._handle_domain_change_restart()
|
||||||
restart_prompt_shown = True
|
|
||||||
else:
|
else:
|
||||||
logger.info("Path changed but domain is same - reloading...")
|
logger.info("Path changed but domain is same - reloading...")
|
||||||
# Clear cache and navigate to home asynchronously
|
# Clear cache and navigate to home asynchronously
|
||||||
|
|
@ -1987,16 +1983,7 @@ class MainWindow(QMainWindow):
|
||||||
self.web_view.clear_cache_and_cookies()
|
self.web_view.clear_cache_and_cookies()
|
||||||
QTimer.singleShot(100, self._navigate_home)
|
QTimer.singleShot(100, self._navigate_home)
|
||||||
|
|
||||||
if not restart_prompt_shown and branding_changed:
|
if language_changed:
|
||||||
logger.info(
|
|
||||||
"Branding changed: %s → %s",
|
|
||||||
old_branding_id,
|
|
||||||
self.config.active_branding_id,
|
|
||||||
)
|
|
||||||
self._handle_branding_change_restart()
|
|
||||||
restart_prompt_shown = True
|
|
||||||
|
|
||||||
if not restart_prompt_shown and language_changed:
|
|
||||||
logger.info(f"Language changed: {old_language} → {self.config.language}")
|
logger.info(f"Language changed: {old_language} → {self.config.language}")
|
||||||
self._handle_language_change_restart()
|
self._handle_language_change_restart()
|
||||||
|
|
||||||
|
|
@ -2060,42 +2047,21 @@ class MainWindow(QMainWindow):
|
||||||
self.web_view.clear_cache_and_cookies()
|
self.web_view.clear_cache_and_cookies()
|
||||||
self._navigate_home()
|
self._navigate_home()
|
||||||
|
|
||||||
def _handle_branding_change_restart(self) -> None:
|
|
||||||
"""Handle branding change by prompting for an optional restart."""
|
|
||||||
self._show_restart_prompt(
|
|
||||||
title_key="dialog.branding_changed.title",
|
|
||||||
message_key="dialog.branding_changed.msg",
|
|
||||||
restart_now_key="dialog.branding_changed.restart_now",
|
|
||||||
restart_later_key="dialog.branding_changed.restart_later",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _handle_language_change_restart(self) -> None:
|
def _handle_language_change_restart(self) -> None:
|
||||||
"""Handle language change by prompting for an optional restart."""
|
"""Handle language change by prompting for an optional restart."""
|
||||||
self._show_restart_prompt(
|
|
||||||
title_key="dialog.language_changed.title",
|
|
||||||
message_key="dialog.language_changed.msg",
|
|
||||||
restart_now_key="dialog.language_changed.restart_now",
|
|
||||||
restart_later_key="dialog.language_changed.restart_later",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _show_restart_prompt(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
title_key: str,
|
|
||||||
message_key: str,
|
|
||||||
restart_now_key: str,
|
|
||||||
restart_later_key: str,
|
|
||||||
) -> None:
|
|
||||||
"""Show a restart prompt for settings that require a full restart."""
|
|
||||||
from PySide6.QtWidgets import QMessageBox
|
from PySide6.QtWidgets import QMessageBox
|
||||||
|
|
||||||
msg = QMessageBox(self)
|
msg = QMessageBox(self)
|
||||||
msg.setWindowTitle(tr(title_key))
|
msg.setWindowTitle(tr("dialog.language_changed.title"))
|
||||||
msg.setIcon(QMessageBox.Icon.Information)
|
msg.setIcon(QMessageBox.Icon.Information)
|
||||||
msg.setText(tr(message_key))
|
msg.setText(tr("dialog.language_changed.msg"))
|
||||||
|
|
||||||
restart_now_btn = msg.addButton(tr(restart_now_key), QMessageBox.ButtonRole.AcceptRole)
|
restart_now_btn = msg.addButton(
|
||||||
msg.addButton(tr(restart_later_key), QMessageBox.ButtonRole.RejectRole)
|
tr("dialog.language_changed.restart_now"), QMessageBox.ButtonRole.AcceptRole
|
||||||
|
)
|
||||||
|
msg.addButton(
|
||||||
|
tr("dialog.language_changed.restart_later"), QMessageBox.ButtonRole.RejectRole
|
||||||
|
)
|
||||||
|
|
||||||
msg.exec()
|
msg.exec()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,12 @@ import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from PySide6.QtGui import QIcon, QPixmap
|
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QDialog,
|
QDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QInputDialog,
|
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
QListWidget,
|
QListWidget,
|
||||||
|
|
@ -25,7 +23,6 @@ from PySide6.QtWidgets import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from webdrop_bridge.config import Config, ConfigurationError
|
from webdrop_bridge.config import Config, ConfigurationError
|
||||||
from webdrop_bridge.core.branding_manager import BrandingManager
|
|
||||||
from webdrop_bridge.core.config_manager import ConfigExporter, ConfigProfile, ConfigValidator
|
from webdrop_bridge.core.config_manager import ConfigExporter, ConfigProfile, ConfigValidator
|
||||||
from webdrop_bridge.utils.i18n import get_available_languages, tr
|
from webdrop_bridge.utils.i18n import get_available_languages, tr
|
||||||
from webdrop_bridge.utils.logging import reconfigure_logging
|
from webdrop_bridge.utils.logging import reconfigure_logging
|
||||||
|
|
@ -45,7 +42,6 @@ class SettingsDialog(QDialog):
|
||||||
"""
|
"""
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.config = config
|
self.config = config
|
||||||
self.branding_manager = BrandingManager()
|
|
||||||
self.profile_manager = ConfigProfile(config.config_dir_name)
|
self.profile_manager = ConfigProfile(config.config_dir_name)
|
||||||
self.setWindowTitle(tr("settings.title"))
|
self.setWindowTitle(tr("settings.title"))
|
||||||
self.setGeometry(100, 100, 600, 500)
|
self.setGeometry(100, 100, 600, 500)
|
||||||
|
|
@ -58,7 +54,6 @@ class SettingsDialog(QDialog):
|
||||||
|
|
||||||
self.tabs = QTabWidget()
|
self.tabs = QTabWidget()
|
||||||
self.tabs.addTab(self._create_general_tab(), tr("settings.tab.general"))
|
self.tabs.addTab(self._create_general_tab(), tr("settings.tab.general"))
|
||||||
self.tabs.addTab(self._create_branding_tab(), tr("settings.tab.branding"))
|
|
||||||
self.tabs.addTab(self._create_web_source_tab(), tr("settings.tab.web_source"))
|
self.tabs.addTab(self._create_web_source_tab(), tr("settings.tab.web_source"))
|
||||||
self.tabs.addTab(self._create_paths_tab(), tr("settings.tab.paths"))
|
self.tabs.addTab(self._create_paths_tab(), tr("settings.tab.paths"))
|
||||||
self.tabs.addTab(self._create_urls_tab(), tr("settings.tab.urls"))
|
self.tabs.addTab(self._create_urls_tab(), tr("settings.tab.urls"))
|
||||||
|
|
@ -88,14 +83,6 @@ class SettingsDialog(QDialog):
|
||||||
for m in config_data["url_mappings"]
|
for m in config_data["url_mappings"]
|
||||||
]
|
]
|
||||||
|
|
||||||
selected_branding_id = config_data.get(
|
|
||||||
"active_branding_id", self.config.active_branding_id
|
|
||||||
)
|
|
||||||
old_branding_id = self.config.active_branding_id
|
|
||||||
self.branding_manager.set_active_branding_id(selected_branding_id)
|
|
||||||
self.config.active_branding_id = selected_branding_id
|
|
||||||
self.branding_manager.apply_to_config(self.config)
|
|
||||||
|
|
||||||
old_log_level = self.config.log_level
|
old_log_level = self.config.log_level
|
||||||
self.config.language = config_data["language"]
|
self.config.language = config_data["language"]
|
||||||
self.config.log_level = config_data["log_level"]
|
self.config.log_level = config_data["log_level"]
|
||||||
|
|
@ -115,12 +102,6 @@ class SettingsDialog(QDialog):
|
||||||
logger.info(f"Configuration saved to {config_path}")
|
logger.info(f"Configuration saved to {config_path}")
|
||||||
logger.info(f" Log level: {self.config.log_level} (was: {old_log_level})")
|
logger.info(f" Log level: {self.config.log_level} (was: {old_log_level})")
|
||||||
logger.info(f" Window size: {self.config.window_width}x{self.config.window_height}")
|
logger.info(f" Window size: {self.config.window_width}x{self.config.window_height}")
|
||||||
if old_branding_id != self.config.active_branding_id:
|
|
||||||
logger.info(
|
|
||||||
" Active branding changed: %s -> %s",
|
|
||||||
old_branding_id,
|
|
||||||
self.config.active_branding_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
if old_log_level != self.config.log_level:
|
if old_log_level != self.config.log_level:
|
||||||
reconfigure_logging(
|
reconfigure_logging(
|
||||||
|
|
@ -170,273 +151,6 @@ class SettingsDialog(QDialog):
|
||||||
widget.setLayout(layout)
|
widget.setLayout(layout)
|
||||||
return widget
|
return widget
|
||||||
|
|
||||||
def _create_branding_tab(self) -> QWidget:
|
|
||||||
"""Create runtime branding tab."""
|
|
||||||
widget = QWidget()
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
|
|
||||||
label = QLabel(tr("settings.branding.select_label"))
|
|
||||||
label.setToolTip(tr("settings.branding.select_tooltip"))
|
|
||||||
layout.addWidget(label)
|
|
||||||
|
|
||||||
help_label = QLabel(tr("settings.branding.help_text"))
|
|
||||||
help_label.setWordWrap(True)
|
|
||||||
help_label.setStyleSheet("color: gray; font-size: 11px;")
|
|
||||||
layout.addWidget(help_label)
|
|
||||||
|
|
||||||
self.branding_combo = QComboBox()
|
|
||||||
self.branding_combo.setToolTip(tr("settings.branding.select_tooltip"))
|
|
||||||
self._refresh_branding_combo()
|
|
||||||
self.branding_combo.currentIndexChanged.connect(self._on_branding_selection_changed)
|
|
||||||
layout.addWidget(self.branding_combo)
|
|
||||||
|
|
||||||
self.branding_display_name_input = QLineEdit()
|
|
||||||
self.branding_display_name_input.setPlaceholderText(
|
|
||||||
tr("settings.branding.display_name_label")
|
|
||||||
)
|
|
||||||
self.branding_display_name_input.textChanged.connect(self._update_branding_preview)
|
|
||||||
layout.addWidget(QLabel(tr("settings.branding.display_name_label")))
|
|
||||||
layout.addWidget(self.branding_display_name_input)
|
|
||||||
|
|
||||||
self.branding_app_name_input = QLineEdit()
|
|
||||||
self.branding_app_name_input.setPlaceholderText(tr("settings.branding.app_name_label"))
|
|
||||||
self.branding_app_name_input.textChanged.connect(self._update_branding_preview)
|
|
||||||
layout.addWidget(QLabel(tr("settings.branding.app_name_label")))
|
|
||||||
layout.addWidget(self.branding_app_name_input)
|
|
||||||
|
|
||||||
self.branding_window_title_input = QLineEdit()
|
|
||||||
self.branding_window_title_input.setPlaceholderText(
|
|
||||||
tr("settings.branding.window_title_label")
|
|
||||||
)
|
|
||||||
self.branding_window_title_input.textChanged.connect(self._update_branding_preview)
|
|
||||||
layout.addWidget(QLabel(tr("settings.branding.window_title_label")))
|
|
||||||
layout.addWidget(self.branding_window_title_input)
|
|
||||||
|
|
||||||
layout.addWidget(QLabel(tr("settings.branding.logo_path_label")))
|
|
||||||
logo_layout = QHBoxLayout()
|
|
||||||
self.branding_logo_path_input = QLineEdit()
|
|
||||||
self.branding_logo_path_input.setPlaceholderText(tr("settings.branding.logo_path_label"))
|
|
||||||
self.branding_logo_path_input.textChanged.connect(self._update_branding_preview)
|
|
||||||
logo_layout.addWidget(self.branding_logo_path_input)
|
|
||||||
|
|
||||||
self.browse_branding_logo_btn = QPushButton(tr("settings.log_file.browse_btn"))
|
|
||||||
self.browse_branding_logo_btn.clicked.connect(self._browse_branding_logo)
|
|
||||||
logo_layout.addWidget(self.browse_branding_logo_btn)
|
|
||||||
layout.addLayout(logo_layout)
|
|
||||||
|
|
||||||
layout.addWidget(QLabel(tr("settings.branding.preview_label")))
|
|
||||||
self.branding_preview_name_label = QLabel()
|
|
||||||
self.branding_preview_name_label.setStyleSheet("font-weight: bold;")
|
|
||||||
layout.addWidget(self.branding_preview_name_label)
|
|
||||||
|
|
||||||
self.branding_preview_title_label = QLabel()
|
|
||||||
self.branding_preview_title_label.setStyleSheet("color: gray;")
|
|
||||||
layout.addWidget(self.branding_preview_title_label)
|
|
||||||
|
|
||||||
self.branding_preview_icon_label = QLabel(tr("settings.branding.no_icon_selected"))
|
|
||||||
self.branding_preview_icon_label.setFixedSize(72, 72)
|
|
||||||
self.branding_preview_icon_label.setStyleSheet(
|
|
||||||
"border: 1px solid #ccc; padding: 4px; background: #fafafa;"
|
|
||||||
)
|
|
||||||
layout.addWidget(self.branding_preview_icon_label)
|
|
||||||
|
|
||||||
branding_button_layout = QHBoxLayout()
|
|
||||||
self.save_branding_as_btn = QPushButton(tr("settings.branding.save_as_btn"))
|
|
||||||
self.save_branding_as_btn.clicked.connect(self._save_branding_as)
|
|
||||||
branding_button_layout.addWidget(self.save_branding_as_btn)
|
|
||||||
|
|
||||||
self.export_branding_btn = QPushButton(tr("settings.branding.export_btn"))
|
|
||||||
self.export_branding_btn.clicked.connect(self._export_branding)
|
|
||||||
branding_button_layout.addWidget(self.export_branding_btn)
|
|
||||||
|
|
||||||
self.import_branding_btn = QPushButton(tr("settings.branding.import_btn"))
|
|
||||||
self.import_branding_btn.clicked.connect(self._import_branding)
|
|
||||||
branding_button_layout.addWidget(self.import_branding_btn)
|
|
||||||
|
|
||||||
self.delete_branding_btn = QPushButton(tr("settings.branding.delete_btn"))
|
|
||||||
self.delete_branding_btn.clicked.connect(self._delete_branding)
|
|
||||||
branding_button_layout.addWidget(self.delete_branding_btn)
|
|
||||||
layout.addLayout(branding_button_layout)
|
|
||||||
|
|
||||||
self._load_branding_into_editor(self.branding_combo.currentData() or "default")
|
|
||||||
|
|
||||||
note = QLabel(tr("settings.branding.restart_note"))
|
|
||||||
note.setWordWrap(True)
|
|
||||||
note.setStyleSheet("color: gray; font-size: 11px;")
|
|
||||||
layout.addWidget(note)
|
|
||||||
|
|
||||||
layout.addStretch()
|
|
||||||
widget.setLayout(layout)
|
|
||||||
return widget
|
|
||||||
|
|
||||||
def _refresh_branding_combo(self, selected_template_id: Optional[str] = None) -> None:
|
|
||||||
"""Refresh the branding template selector."""
|
|
||||||
current = selected_template_id or self.config.active_branding_id or "default"
|
|
||||||
self.branding_combo.blockSignals(True)
|
|
||||||
self.branding_combo.clear()
|
|
||||||
for template in self.branding_manager.list_templates():
|
|
||||||
self.branding_combo.addItem(template.display_name, template.template_id)
|
|
||||||
|
|
||||||
idx = self.branding_combo.findData(current)
|
|
||||||
if idx < 0:
|
|
||||||
idx = self.branding_combo.findData("default")
|
|
||||||
if idx >= 0:
|
|
||||||
self.branding_combo.setCurrentIndex(idx)
|
|
||||||
self.branding_combo.blockSignals(False)
|
|
||||||
|
|
||||||
def _load_branding_into_editor(self, template_id: str) -> None:
|
|
||||||
"""Load the selected branding into the editable fields."""
|
|
||||||
template = self.branding_manager.load_template(template_id)
|
|
||||||
self.branding_display_name_input.setText(template.display_name)
|
|
||||||
self.branding_app_name_input.setText(template.app_name)
|
|
||||||
self.branding_window_title_input.setText(
|
|
||||||
template.window_title or f"{template.app_name} v{self.config.app_version}"
|
|
||||||
)
|
|
||||||
self.branding_logo_path_input.setText(template.logo_path or template.get_app_icon_path())
|
|
||||||
self._update_branding_preview()
|
|
||||||
|
|
||||||
def _resolve_branding_preview_path(self, configured_path: str) -> Optional[Path]:
|
|
||||||
"""Resolve a branding preview path in both dev and packaged layouts."""
|
|
||||||
if not configured_path:
|
|
||||||
return None
|
|
||||||
|
|
||||||
path = Path(configured_path)
|
|
||||||
candidates = [path] if path.is_absolute() else [Path.cwd() / path]
|
|
||||||
if not path.is_absolute():
|
|
||||||
project_root = Path(__file__).resolve().parents[3]
|
|
||||||
candidates.append(project_root / path)
|
|
||||||
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists() and candidate.is_file():
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _update_branding_preview(self) -> None:
|
|
||||||
"""Refresh the small branding preview for name and icon."""
|
|
||||||
display_name = self.branding_display_name_input.text().strip() or tr(
|
|
||||||
"settings.branding.preview_default_name"
|
|
||||||
)
|
|
||||||
self.branding_preview_name_label.setText(display_name)
|
|
||||||
|
|
||||||
effective_title = self.branding_window_title_input.text().strip() or (
|
|
||||||
self.branding_app_name_input.text().strip() or display_name
|
|
||||||
)
|
|
||||||
self.branding_preview_title_label.setText(effective_title)
|
|
||||||
|
|
||||||
logo_path = self.branding_logo_path_input.text().strip()
|
|
||||||
resolved_logo_path = self._resolve_branding_preview_path(logo_path)
|
|
||||||
if resolved_logo_path:
|
|
||||||
pixmap = QPixmap(str(resolved_logo_path))
|
|
||||||
if pixmap.isNull():
|
|
||||||
icon = QIcon(str(resolved_logo_path))
|
|
||||||
pixmap = icon.pixmap(64, 64)
|
|
||||||
|
|
||||||
if not pixmap.isNull():
|
|
||||||
self.branding_preview_icon_label.setPixmap(pixmap.scaled(64, 64))
|
|
||||||
self.branding_preview_icon_label.setText("")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.branding_preview_icon_label.setPixmap(QPixmap())
|
|
||||||
self.branding_preview_icon_label.setText(tr("settings.branding.no_icon_selected"))
|
|
||||||
|
|
||||||
def _on_branding_selection_changed(self) -> None:
|
|
||||||
"""Update editable branding fields when a different template is selected."""
|
|
||||||
template_id = self.branding_combo.currentData()
|
|
||||||
if template_id:
|
|
||||||
self._load_branding_into_editor(template_id)
|
|
||||||
|
|
||||||
def _browse_branding_logo(self) -> None:
|
|
||||||
"""Select an external logo or icon file for the current branding."""
|
|
||||||
file_path, _ = QFileDialog.getOpenFileName(
|
|
||||||
self,
|
|
||||||
tr("settings.branding.logo_path_label"),
|
|
||||||
str(Path.home()),
|
|
||||||
"Image Files (*.png *.jpg *.jpeg *.svg *.ico *.icns *.bmp);;All Files (*)",
|
|
||||||
)
|
|
||||||
if file_path:
|
|
||||||
self.branding_logo_path_input.setText(file_path)
|
|
||||||
|
|
||||||
def _save_branding_as(self) -> None:
|
|
||||||
"""Save the edited branding as a new reusable branding entry."""
|
|
||||||
branding_name, ok = QInputDialog.getText(
|
|
||||||
self,
|
|
||||||
tr("settings.branding.save_as_title"),
|
|
||||||
tr("settings.branding.save_as_prompt"),
|
|
||||||
text=self.branding_display_name_input.text().strip(),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not ok or not branding_name:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
display_name = self.branding_display_name_input.text().strip() or branding_name
|
|
||||||
app_name = self.branding_app_name_input.text().strip() or display_name
|
|
||||||
window_title = self.branding_window_title_input.text().strip()
|
|
||||||
template = self.branding_manager.build_template(
|
|
||||||
template_id=branding_name,
|
|
||||||
display_name=display_name,
|
|
||||||
app_name=app_name,
|
|
||||||
window_title=window_title,
|
|
||||||
logo_path=self.branding_logo_path_input.text(),
|
|
||||||
)
|
|
||||||
self.branding_manager.save_template(template)
|
|
||||||
self._refresh_branding_combo(template.template_id)
|
|
||||||
self._load_branding_into_editor(template.template_id)
|
|
||||||
except ConfigurationError as e:
|
|
||||||
self._show_error(f"Failed to save branding: {e}")
|
|
||||||
|
|
||||||
def _export_branding(self) -> None:
|
|
||||||
"""Export the selected branding so it can be shared with other users."""
|
|
||||||
template_id = self.branding_combo.currentData()
|
|
||||||
if not template_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
file_path, _ = QFileDialog.getSaveFileName(
|
|
||||||
self,
|
|
||||||
tr("settings.branding.export_title"),
|
|
||||||
str(Path.home() / f"{template_id}.json"),
|
|
||||||
"JSON Files (*.json);;All Files (*)",
|
|
||||||
)
|
|
||||||
if not file_path:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.branding_manager.export_template(template_id, Path(file_path))
|
|
||||||
except ConfigurationError as e:
|
|
||||||
self._show_error(f"Failed to export branding: {e}")
|
|
||||||
|
|
||||||
def _import_branding(self) -> None:
|
|
||||||
"""Import a branding package from another user."""
|
|
||||||
file_path, _ = QFileDialog.getOpenFileName(
|
|
||||||
self,
|
|
||||||
tr("settings.branding.import_title"),
|
|
||||||
str(Path.home()),
|
|
||||||
"JSON Files (*.json);;All Files (*)",
|
|
||||||
)
|
|
||||||
if not file_path:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
template = self.branding_manager.import_template(Path(file_path))
|
|
||||||
self._refresh_branding_combo(template.template_id)
|
|
||||||
self._load_branding_into_editor(template.template_id)
|
|
||||||
except ConfigurationError as e:
|
|
||||||
self._show_error(f"Failed to import branding: {e}")
|
|
||||||
|
|
||||||
def _delete_branding(self) -> None:
|
|
||||||
"""Delete the currently selected custom branding."""
|
|
||||||
template_id = self.branding_combo.currentData()
|
|
||||||
if not template_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.branding_manager.delete_template(template_id)
|
|
||||||
self._refresh_branding_combo("default")
|
|
||||||
self._load_branding_into_editor("default")
|
|
||||||
except ConfigurationError as e:
|
|
||||||
self._show_error(f"Failed to delete branding: {e}")
|
|
||||||
|
|
||||||
def _create_web_source_tab(self) -> QWidget:
|
def _create_web_source_tab(self) -> QWidget:
|
||||||
"""Create web source configuration tab."""
|
"""Create web source configuration tab."""
|
||||||
widget = QWidget()
|
widget = QWidget()
|
||||||
|
|
@ -682,58 +396,41 @@ class SettingsDialog(QDialog):
|
||||||
return widget
|
return widget
|
||||||
|
|
||||||
def _create_profiles_tab(self) -> QWidget:
|
def _create_profiles_tab(self) -> QWidget:
|
||||||
"""Create setups/import-export tab with clearer guidance."""
|
"""Create profiles management tab."""
|
||||||
widget = QWidget()
|
widget = QWidget()
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
saved_setups_label = QLabel(tr("settings.profiles.label"))
|
layout.addWidget(QLabel(tr("settings.profiles.label")))
|
||||||
saved_setups_label.setToolTip(tr("settings.profiles.label_tooltip"))
|
|
||||||
layout.addWidget(saved_setups_label)
|
|
||||||
|
|
||||||
self.profiles_help_label = QLabel(tr("settings.profiles.help_text"))
|
|
||||||
self.profiles_help_label.setWordWrap(True)
|
|
||||||
self.profiles_help_label.setStyleSheet("color: gray; font-size: 11px;")
|
|
||||||
layout.addWidget(self.profiles_help_label)
|
|
||||||
|
|
||||||
self.profiles_list = QListWidget()
|
self.profiles_list = QListWidget()
|
||||||
self.profiles_list.setToolTip(tr("settings.profiles.list_tooltip"))
|
|
||||||
self._refresh_profiles_list()
|
self._refresh_profiles_list()
|
||||||
layout.addWidget(self.profiles_list)
|
layout.addWidget(self.profiles_list)
|
||||||
|
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
|
|
||||||
self.save_profile_btn = QPushButton(tr("settings.profiles.save_btn"))
|
save_profile_btn = QPushButton(tr("settings.profiles.save_btn"))
|
||||||
self.save_profile_btn.setToolTip(tr("settings.profiles.save_tooltip"))
|
save_profile_btn.clicked.connect(self._save_profile)
|
||||||
self.save_profile_btn.clicked.connect(self._save_profile)
|
button_layout.addWidget(save_profile_btn)
|
||||||
button_layout.addWidget(self.save_profile_btn)
|
|
||||||
|
|
||||||
self.load_profile_btn = QPushButton(tr("settings.profiles.load_btn"))
|
load_profile_btn = QPushButton(tr("settings.profiles.load_btn"))
|
||||||
self.load_profile_btn.setToolTip(tr("settings.profiles.load_tooltip"))
|
load_profile_btn.clicked.connect(self._load_profile)
|
||||||
self.load_profile_btn.clicked.connect(self._load_profile)
|
button_layout.addWidget(load_profile_btn)
|
||||||
button_layout.addWidget(self.load_profile_btn)
|
|
||||||
|
|
||||||
self.delete_profile_btn = QPushButton(tr("settings.profiles.delete_btn"))
|
delete_profile_btn = QPushButton(tr("settings.profiles.delete_btn"))
|
||||||
self.delete_profile_btn.setToolTip(tr("settings.profiles.delete_tooltip"))
|
delete_profile_btn.clicked.connect(self._delete_profile)
|
||||||
self.delete_profile_btn.clicked.connect(self._delete_profile)
|
button_layout.addWidget(delete_profile_btn)
|
||||||
button_layout.addWidget(self.delete_profile_btn)
|
|
||||||
|
|
||||||
layout.addLayout(button_layout)
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
export_label = QLabel(tr("settings.profiles.transfer_label"))
|
|
||||||
export_label.setToolTip(tr("settings.profiles.transfer_tooltip"))
|
|
||||||
layout.addWidget(export_label)
|
|
||||||
|
|
||||||
export_layout = QHBoxLayout()
|
export_layout = QHBoxLayout()
|
||||||
|
|
||||||
self.export_btn = QPushButton(tr("settings.profiles.export_btn"))
|
export_btn = QPushButton(tr("settings.profiles.export_btn"))
|
||||||
self.export_btn.setToolTip(tr("settings.profiles.export_tooltip"))
|
export_btn.clicked.connect(self._export_config)
|
||||||
self.export_btn.clicked.connect(self._export_config)
|
export_layout.addWidget(export_btn)
|
||||||
export_layout.addWidget(self.export_btn)
|
|
||||||
|
|
||||||
self.import_btn = QPushButton(tr("settings.profiles.import_btn"))
|
import_btn = QPushButton(tr("settings.profiles.import_btn"))
|
||||||
self.import_btn.setToolTip(tr("settings.profiles.import_tooltip"))
|
import_btn.clicked.connect(self._import_config)
|
||||||
self.import_btn.clicked.connect(self._import_config)
|
export_layout.addWidget(import_btn)
|
||||||
export_layout.addWidget(self.import_btn)
|
|
||||||
|
|
||||||
layout.addLayout(export_layout)
|
layout.addLayout(export_layout)
|
||||||
layout.addStretch()
|
layout.addStretch()
|
||||||
|
|
@ -909,7 +606,6 @@ class SettingsDialog(QDialog):
|
||||||
"app_name": self.config.app_name,
|
"app_name": self.config.app_name,
|
||||||
"app_version": self.config.app_version,
|
"app_version": self.config.app_version,
|
||||||
"language": self.language_combo.currentData(),
|
"language": self.language_combo.currentData(),
|
||||||
"active_branding_id": self.branding_combo.currentData(),
|
|
||||||
"log_level": self.log_level_combo.currentText(),
|
"log_level": self.log_level_combo.currentText(),
|
||||||
"log_file": self.log_file_input.text() or None,
|
"log_file": self.log_file_input.text() or None,
|
||||||
"allowed_roots": [
|
"allowed_roots": [
|
||||||
|
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
"""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")
|
|
||||||
|
|
@ -9,14 +9,11 @@ from webdrop_bridge.config import Config, ConfigurationError
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def clear_env(tmp_path):
|
def clear_env():
|
||||||
"""Clear environment variables before each test to avoid persistence."""
|
"""Clear environment variables before each test to avoid persistence."""
|
||||||
# Save current env
|
# Save current env
|
||||||
saved_env = os.environ.copy()
|
saved_env = os.environ.copy()
|
||||||
|
|
||||||
# Isolate runtime branding state from the developer machine
|
|
||||||
os.environ["WEBDROP_BRANDING_DIR"] = str(tmp_path / "branding")
|
|
||||||
|
|
||||||
# Clear relevant variables
|
# Clear relevant variables
|
||||||
for key in list(os.environ.keys()):
|
for key in list(os.environ.keys()):
|
||||||
if key.startswith(
|
if key.startswith(
|
||||||
|
|
|
||||||
|
|
@ -82,25 +82,6 @@ class TestMainWindowInitialization:
|
||||||
assert window.drag_interceptor is not None
|
assert window.drag_interceptor is not None
|
||||||
|
|
||||||
|
|
||||||
class TestSettingsRestartBehavior:
|
|
||||||
"""Test restart prompts for settings changes that require a restart."""
|
|
||||||
|
|
||||||
def test_branding_change_prompts_restart(self, qtbot, sample_config):
|
|
||||||
"""Changing the active branding should trigger the restart flow."""
|
|
||||||
window = MainWindow(sample_config)
|
|
||||||
qtbot.addWidget(window)
|
|
||||||
|
|
||||||
with patch.object(window, "_handle_branding_change_restart") as mock_restart:
|
|
||||||
with patch("webdrop_bridge.ui.settings_dialog.SettingsDialog") as mock_dialog_cls:
|
|
||||||
mock_dialog = mock_dialog_cls.return_value
|
|
||||||
mock_dialog.exec.side_effect = lambda: setattr(
|
|
||||||
window.config, "active_branding_id", "agravity"
|
|
||||||
)
|
|
||||||
window._show_settings_dialog()
|
|
||||||
|
|
||||||
mock_restart.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
class TestMainWindowDragIntegration:
|
class TestMainWindowDragIntegration:
|
||||||
"""Test drag-and-drop integration."""
|
"""Test drag-and-drop integration."""
|
||||||
|
|
||||||
|
|
@ -226,15 +207,15 @@ class TestMainWindowOpenWith:
|
||||||
test_file.write_text("test")
|
test_file.write_text("test")
|
||||||
|
|
||||||
call_count = [0] # Use list to make it mutable in nested function
|
call_count = [0] # Use list to make it mutable in nested function
|
||||||
|
|
||||||
class _AppChooseResult:
|
class _AppChooseResult:
|
||||||
returncode = 0
|
returncode = 0
|
||||||
stdout = "TextEdit" # Simulated chosen app name
|
stdout = "TextEdit" # Simulated chosen app name
|
||||||
|
|
||||||
class _OpenResult:
|
class _OpenResult:
|
||||||
returncode = 0
|
returncode = 0
|
||||||
stdout = ""
|
stdout = ""
|
||||||
|
|
||||||
def mock_run(*args, **kwargs):
|
def mock_run(*args, **kwargs):
|
||||||
"""Mock subprocess.run with two different behaviors per call."""
|
"""Mock subprocess.run with two different behaviors per call."""
|
||||||
call_count[0] += 1
|
call_count[0] += 1
|
||||||
|
|
@ -246,7 +227,8 @@ class TestMainWindowOpenWith:
|
||||||
return _OpenResult()
|
return _OpenResult()
|
||||||
else:
|
else:
|
||||||
raise AssertionError(f"Unexpected call #{call_count[0]} to subprocess.run")
|
raise AssertionError(f"Unexpected call #{call_count[0]} to subprocess.run")
|
||||||
|
|
||||||
|
|
||||||
with patch("webdrop_bridge.ui.main_window.sys.platform", "darwin"):
|
with patch("webdrop_bridge.ui.main_window.sys.platform", "darwin"):
|
||||||
with patch("webdrop_bridge.ui.main_window.subprocess.run", side_effect=mock_run):
|
with patch("webdrop_bridge.ui.main_window.subprocess.run", side_effect=mock_run):
|
||||||
assert window._open_with_app_chooser(str(test_file)) is True
|
assert window._open_with_app_chooser(str(test_file)) is True
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
"""Tests for settings dialog."""
|
"""Tests for settings dialog."""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from webdrop_bridge.config import Config
|
from webdrop_bridge.config import Config, ConfigurationError
|
||||||
from webdrop_bridge.ui.settings_dialog import SettingsDialog
|
from webdrop_bridge.ui.settings_dialog import SettingsDialog
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -44,7 +44,7 @@ class TestSettingsDialogInitialization:
|
||||||
qtbot.addWidget(dialog)
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
assert dialog.tabs is not None
|
assert dialog.tabs is not None
|
||||||
assert dialog.tabs.count() == 8 # General + Branding + previous 6 tabs
|
assert dialog.tabs.count() == 7 # General + previous 6 tabs
|
||||||
|
|
||||||
def test_dialog_has_general_tab(self, qtbot, sample_config):
|
def test_dialog_has_general_tab(self, qtbot, sample_config):
|
||||||
"""Test General tab exists."""
|
"""Test General tab exists."""
|
||||||
|
|
@ -53,103 +53,47 @@ class TestSettingsDialogInitialization:
|
||||||
|
|
||||||
assert dialog.tabs.tabText(0) == "General"
|
assert dialog.tabs.tabText(0) == "General"
|
||||||
|
|
||||||
def test_dialog_has_branding_tab(self, qtbot, sample_config):
|
|
||||||
"""Test Branding tab exists."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
assert dialog.tabs.tabText(1) == "Branding"
|
|
||||||
|
|
||||||
def test_dialog_has_web_source_tab(self, qtbot, sample_config):
|
def test_dialog_has_web_source_tab(self, qtbot, sample_config):
|
||||||
"""Test Web Source tab exists."""
|
"""Test Web Source tab exists."""
|
||||||
dialog = SettingsDialog(sample_config)
|
dialog = SettingsDialog(sample_config)
|
||||||
qtbot.addWidget(dialog)
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
assert dialog.tabs.tabText(2) == "Web Source"
|
assert dialog.tabs.tabText(1) == "Web Source"
|
||||||
|
|
||||||
def test_dialog_has_paths_tab(self, qtbot, sample_config):
|
def test_dialog_has_paths_tab(self, qtbot, sample_config):
|
||||||
"""Test Paths tab exists."""
|
"""Test Paths tab exists."""
|
||||||
dialog = SettingsDialog(sample_config)
|
dialog = SettingsDialog(sample_config)
|
||||||
qtbot.addWidget(dialog)
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
assert dialog.tabs.tabText(3) == "Paths"
|
assert dialog.tabs.tabText(2) == "Paths"
|
||||||
|
|
||||||
def test_dialog_has_urls_tab(self, qtbot, sample_config):
|
def test_dialog_has_urls_tab(self, qtbot, sample_config):
|
||||||
"""Test URLs tab exists."""
|
"""Test URLs tab exists."""
|
||||||
dialog = SettingsDialog(sample_config)
|
dialog = SettingsDialog(sample_config)
|
||||||
qtbot.addWidget(dialog)
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
assert dialog.tabs.tabText(4) == "URLs"
|
assert dialog.tabs.tabText(3) == "URLs"
|
||||||
|
|
||||||
def test_dialog_has_logging_tab(self, qtbot, sample_config):
|
def test_dialog_has_logging_tab(self, qtbot, sample_config):
|
||||||
"""Test Logging tab exists."""
|
"""Test Logging tab exists."""
|
||||||
dialog = SettingsDialog(sample_config)
|
dialog = SettingsDialog(sample_config)
|
||||||
qtbot.addWidget(dialog)
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
assert dialog.tabs.tabText(5) == "Logging"
|
assert dialog.tabs.tabText(4) == "Logging"
|
||||||
|
|
||||||
def test_dialog_has_window_tab(self, qtbot, sample_config):
|
def test_dialog_has_window_tab(self, qtbot, sample_config):
|
||||||
"""Test Window tab exists."""
|
"""Test Window tab exists."""
|
||||||
dialog = SettingsDialog(sample_config)
|
dialog = SettingsDialog(sample_config)
|
||||||
qtbot.addWidget(dialog)
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
assert dialog.tabs.tabText(6) == "Window"
|
assert dialog.tabs.tabText(5) == "Window"
|
||||||
|
|
||||||
def test_dialog_has_profiles_tab(self, qtbot, sample_config):
|
def test_dialog_has_profiles_tab(self, qtbot, sample_config):
|
||||||
"""Test Setups tab exists with clearer wording."""
|
"""Test Profiles tab exists."""
|
||||||
dialog = SettingsDialog(sample_config)
|
dialog = SettingsDialog(sample_config)
|
||||||
qtbot.addWidget(dialog)
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
assert dialog.tabs.tabText(7) == "Setups"
|
assert dialog.tabs.tabText(6) == "Profiles"
|
||||||
|
|
||||||
def test_profiles_actions_have_explanatory_tooltips(self, qtbot, sample_config):
|
|
||||||
"""Test profile/config actions expose helpful explanations."""
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
assert "this device" in dialog.save_profile_btn.toolTip().lower()
|
|
||||||
assert "backup" in dialog.export_btn.toolTip().lower()
|
|
||||||
assert "json" in dialog.import_btn.toolTip().lower()
|
|
||||||
|
|
||||||
def test_branding_editor_fields_are_initialized(
|
|
||||||
self, qtbot, sample_config, monkeypatch, tmp_path
|
|
||||||
):
|
|
||||||
"""Test branding tab exposes editable fields for the selected template."""
|
|
||||||
monkeypatch.setenv("WEBDROP_BRANDING_DIR", str(tmp_path / "branding"))
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
assert dialog.branding_display_name_input.text() == "Default"
|
|
||||||
assert dialog.branding_app_name_input.text() == "WebDrop Bridge"
|
|
||||||
assert "WebDrop Bridge" in dialog.branding_window_title_input.text()
|
|
||||||
assert dialog.branding_logo_path_input is not None
|
|
||||||
assert dialog.browse_branding_logo_btn is not None
|
|
||||||
assert dialog.branding_preview_name_label.text() == "Default"
|
|
||||||
assert dialog.branding_preview_icon_label.pixmap() is not None
|
|
||||||
assert not dialog.branding_preview_icon_label.pixmap().isNull()
|
|
||||||
assert dialog.export_branding_btn is not None
|
|
||||||
assert dialog.import_branding_btn is not None
|
|
||||||
assert dialog.delete_branding_btn is not None
|
|
||||||
|
|
||||||
def test_save_branding_as_creates_custom_template(
|
|
||||||
self, qtbot, sample_config, monkeypatch, tmp_path
|
|
||||||
):
|
|
||||||
"""Test edited branding can be saved as a new reusable template."""
|
|
||||||
monkeypatch.setenv("WEBDROP_BRANDING_DIR", str(tmp_path / "branding"))
|
|
||||||
dialog = SettingsDialog(sample_config)
|
|
||||||
qtbot.addWidget(dialog)
|
|
||||||
|
|
||||||
logo_path = tmp_path / "customer-logo.png"
|
|
||||||
logo_path.write_bytes(b"fake-png-data")
|
|
||||||
|
|
||||||
dialog.branding_display_name_input.setText("Customer A")
|
|
||||||
dialog.branding_logo_path_input.setText(str(logo_path))
|
|
||||||
|
|
||||||
with patch("PySide6.QtWidgets.QInputDialog.getText", return_value=("Customer A", True)):
|
|
||||||
dialog._save_branding_as()
|
|
||||||
|
|
||||||
assert dialog.branding_manager.has_template("customer_a")
|
|
||||||
assert dialog.branding_combo.findData("customer_a") >= 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestPathsTab:
|
class TestPathsTab:
|
||||||
|
|
@ -246,7 +190,7 @@ class TestWindowTab:
|
||||||
|
|
||||||
|
|
||||||
class TestProfilesTab:
|
class TestProfilesTab:
|
||||||
"""Test profiles management tab."""
|
"""Test Profiles management tab."""
|
||||||
|
|
||||||
def test_profiles_list_initialized(self, qtbot, sample_config):
|
def test_profiles_list_initialized(self, qtbot, sample_config):
|
||||||
"""Test profiles list is initialized."""
|
"""Test profiles list is initialized."""
|
||||||
|
|
@ -254,7 +198,6 @@ class TestProfilesTab:
|
||||||
qtbot.addWidget(dialog)
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
assert dialog.profiles_list is not None
|
assert dialog.profiles_list is not None
|
||||||
assert dialog.profiles_help_label.wordWrap() is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestConfigDataRetrieval:
|
class TestConfigDataRetrieval:
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
"""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()
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue