Compare commits

..

24 commits
v0.8.2 ... main

Author SHA1 Message Date
a135dd0d96 bump to version 0.8.6
Some checks failed
Tests & Quality Checks / Test on Python 3.11 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.11-1 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12-1 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.10 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.11-2 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12-2 (push) Has been cancelled
Tests & Quality Checks / Build Artifacts (push) Has been cancelled
Tests & Quality Checks / Build Artifacts-1 (push) Has been cancelled
2026-03-12 14:12:27 +01:00
25ebe5d7ea feat: Add fallback mechanism for branded download selection when manifest is unavailable 2026-03-12 14:09:11 +01:00
1de604e7e2 feat: Enhance asset upload process with Python script for reliability and retry logic 2026-03-12 13:59:33 +01:00
ef96184dc3 fix: Rename WiX object file and correct source directory path for Heat-generated files
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-12 11:23:58 +01:00
5cb3bf9f76 feat: Update product name format to include version in Wix configuration
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-12 11:06:53 +01:00
8e07e7e855 bump to version 0.8.5
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-12 11:02:32 +01:00
093b196d0d feat: Enhance update manager to handle UTF-8 BOM and improve asset resolution logic 2026-03-12 11:00:43 +01:00
44124595d8 feat: Add documentation for architecture and translations
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-12 09:22:07 +01:00
c80128118e cleanup
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-12 09:18:06 +01:00
df76cb9b36 feat: Add toolbar icon configuration and update handling for Agravity
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-12 09:07:14 +01:00
eab1009d8c Add brand-specific update channel and environment configuration
- Updated `brand_config.py` to include `WEBDROP_UPDATE_CHANNEL` in the environment variables.
- Enhanced `build_macos.sh` to create a bundled `.env` file with brand-specific defaults, including the update channel.
- Implemented a method in `build_windows.py` to create a bundled `.env` file for Windows builds, incorporating brand-specific runtime defaults.
- Modified `config.py` to ensure the application can locate the `.env` file in various installation scenarios.
- Added unit tests in `test_config.py` to verify the loading of the bootstrap `.env` from the PyInstaller runtime directory.
- Generated new WiX object and script files for the Windows installer, including application shortcuts and registry entries.
2026-03-12 09:04:27 +01:00
de6e9838e5 bump to version 0.8.4 2026-03-12 08:39:51 +01:00
67bfe4a600 Enhance branding and release workflows
- Updated README.md to include a reference to branding and releases documentation.
- Modified brand_config.py to support multi-brand packaging, including functions for collecting local release data and merging release manifests.
- Adjusted build_macos.sh to set a default brand if none is specified and updated DMG naming conventions.
- Enhanced create_release.ps1 and create_release.sh scripts to support dry-run functionality and improved artifact handling.
- Added a new template for brand configuration in build/brands/template.jsonc.
- Created comprehensive branding and releases documentation in docs/BRANDING_AND_RELEASES.md.
- Added unit tests for new branding functionalities in test_brand_config.py.
2026-03-12 08:38:40 +01:00
fd69996c53 feat: Implement brand-aware release creation for Agravity
Some checks failed
Tests & Quality Checks / Test on Python 3.11 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.11-1 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12-1 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.10 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.11-2 (push) Has been cancelled
Tests & Quality Checks / Test on Python 3.12-2 (push) Has been cancelled
Tests & Quality Checks / Build Artifacts (push) Has been cancelled
Tests & Quality Checks / Build Artifacts-1 (push) Has been cancelled
- Added support for multiple brands in release scripts, allowing for branded artifacts.
- Introduced brand configuration management with JSON files for each brand.
- Created a new `brand_config.py` script to handle brand-specific logic and asset resolution.
- Updated `create_release.ps1` and `create_release.sh` scripts to utilize brand configurations and generate release manifests.
- Added unit tests for brand configuration loading and release manifest generation.
- Introduced `agravity` brand with its specific configuration in `agravity.json`.
2026-03-10 16:18:28 +01:00
b988532aaa feat: implement brand-specific configuration and update management for Agravity Bridge 2026-03-10 16:02:24 +01:00
baf56e040f fix(translations): improve Russian and Chinese translations for clarity and consistency
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-10 15:04:40 +01:00
b4c8692738 feat: add Italian, Russian, and Chinese translations for UI elements and dialogs
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-10 15:01:37 +01:00
a48cc01254 feat: add language change handling and prompts for restart in settings
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-10 14:38:17 +01:00
7daec731ca Add internationalization support with English and French translations
- Introduced a new i18n module for managing translations using JSON files.
- Added English (en.json) and French (fr.json) translation files for UI elements.
- Implemented lazy initialization of the translator to load translations on demand.
- Added unit tests for translation lookup, fallback behavior, and available languages detection.
2026-03-10 14:32:38 +01:00
fd0482ed2d installer creation files
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-10 13:27:10 +01:00
10ab3a418a bump version to 0.8.3 2026-03-10 13:19:29 +01:00
856aec65de feat: ensure toolbar icons are included in MSI bundle during build process
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-03-10 13:17:35 +01:00
a261de3460 feat: implement Open With functionality for file chooser integration 2026-03-10 13:16:27 +01:00
939c2f896f Refactor code structure for improved readability and maintainability 2026-03-10 12:52:12 +01:00
45 changed files with 4423 additions and 12585 deletions

View file

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

6
.gitignore vendored
View file

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

View file

@ -0,0 +1 @@
{"timestamp": "2026-03-12T10:57:42.150570", "release": {"tag_name": "v0.8.4", "name": "WebDropBridge v0.8.4", "version": "0.8.4", "body": "Shared branded release for WebDrop Bridge v0.8.4", "assets": [{"id": 49, "name": "AgravityBridge-0.8.4-win-x64.msi", "size": 214445231, "download_count": 2, "created_at": "2026-03-12T08:25:03Z", "uuid": "7ffcd98a-99a9-4100-8e71-3ebe63534b8f", "browser_download_url": "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/AgravityBridge-0.8.4-win-x64.msi", "type": "attachment"}, {"id": 50, "name": "AgravityBridge-0.8.4-win-x64.msi.sha256", "size": 64, "download_count": 2, "created_at": "2026-03-12T08:25:03Z", "uuid": "ddd00072-a5bc-422f-93c0-7cc3bc3408d3", "browser_download_url": "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/AgravityBridge-0.8.4-win-x64.msi.sha256", "type": "attachment"}, {"id": 47, "name": "WebDropBridge-0.8.4-win-x64.msi", "size": 214445229, "download_count": 0, "created_at": "2026-03-12T08:24:20Z", "uuid": "5a20eef9-b77d-4e04-be06-d85c3ebd3f6e", "browser_download_url": "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/WebDropBridge-0.8.4-win-x64.msi", "type": "attachment"}, {"id": 48, "name": "WebDropBridge-0.8.4-win-x64.msi.sha256", "size": 64, "download_count": 0, "created_at": "2026-03-12T08:24:21Z", "uuid": "9972b3bb-7c4b-4b26-951a-5a8dfc1a1f27", "browser_download_url": "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/WebDropBridge-0.8.4-win-x64.msi.sha256", "type": "attachment"}, {"id": 51, "name": "release-manifest.json", "size": 931, "download_count": 0, "created_at": "2026-03-12T08:25:03Z", "uuid": "e3c13ccd-cbc6-4eb1-988e-7f465a75eca6", "browser_download_url": "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/release-manifest.json", "type": "attachment"}], "published_at": "2026-03-12T08:23:40Z"}}

View file

@ -77,6 +77,8 @@ wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0
For more installation options and details, see [QUICKSTART.md](QUICKSTART.md#installing-from-release-wget) and [PACKAGE_MANAGER_SUPPORT.md](docs/PACKAGE_MANAGER_SUPPORT.md) For more installation options and details, see [QUICKSTART.md](QUICKSTART.md#installing-from-release-wget) and [PACKAGE_MANAGER_SUPPORT.md](docs/PACKAGE_MANAGER_SUPPORT.md)
For multi-brand packaging and release workflows, see [BRANDING_AND_RELEASES.md](docs/BRANDING_AND_RELEASES.md).
### Installation from Source ### Installation from Source
```bash ```bash
@ -141,6 +143,11 @@ webdrop-bridge/
└── README.md # This file └── README.md # This file
``` ```
## Documentation
- [Architecture Guide](docs/ARCHITECTURE.md)
- [Translations Guide (i18n)](docs/TRANSLATIONS_GUIDE.md)
## Architecture ## Architecture
``` ```

File diff suppressed because one or more lines are too long

View file

@ -2,23 +2,23 @@
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" <Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui" xmlns:ui="http://schemas.microsoft.com/wix/2010/ui"
xmlns:util="http://schemas.microsoft.com/wix/UtilExtension"> xmlns:util="http://schemas.microsoft.com/wix/UtilExtension">
<Product Id="*" Name="WebDrop Bridge" Language="1033" Version="0.8.2" <Product Id="*" Name="{product_name_with_version}" Language="1033" Version="{version}"
Manufacturer="HIM-Tools" Manufacturer="{manufacturer}"
UpgradeCode="12345678-1234-1234-1234-123456789012"> UpgradeCode="{upgrade_code}">
<Package InstallerVersion="200" Compressed="yes" InstallScope="perMachine" Platform="x64" /> <Package InstallerVersion="200" Compressed="yes" InstallScope="perMachine" Platform="x64" />
<Media Id="1" Cabinet="WebDropBridge.cab" EmbedCab="yes" /> <Media Id="1" Cabinet="{asset_prefix}.cab" EmbedCab="yes" />
<!-- Required property for WixUI_InstallDir dialog set --> <!-- Required property for WixUI_InstallDir dialog set -->
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" /> <Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />
<!-- Application Icon --> <!-- Application Icon -->
<Icon Id="AppIcon.ico" SourceFile="$(var.ResourcesDir)\icons\app.ico" /> <Icon Id="AppIcon.ico" SourceFile="{icon_ico}" />
<!-- Custom branding for InstallDir dialog set --> <!-- Custom branding for InstallDir dialog set -->
<WixVariable Id="WixUIDialogBmp" Value="$(var.ResourcesDir)\icons\background.bmp" /> <WixVariable Id="WixUIDialogBmp" Value="{dialog_bmp}" />
<WixVariable Id="WixUIBannerBmp" Value="$(var.ResourcesDir)\icons\banner.bmp" /> <WixVariable Id="WixUIBannerBmp" Value="{banner_bmp}" />
<WixVariable Id="WixUILicenseRtf" Value="$(var.ResourcesDir)\license.rtf" /> <WixVariable Id="WixUILicenseRtf" Value="{license_rtf}" />
<!-- Installation UI dialogs --> <!-- Installation UI dialogs -->
<UIRef Id="WixUI_InstallDir" /> <UIRef Id="WixUI_InstallDir" />
@ -26,12 +26,12 @@
<!-- Close running application before installation --> <!-- Close running application before installation -->
<util:CloseApplication <util:CloseApplication
Target="WebDropBridge.exe" Target="{exe_name}.exe"
CloseMessage="yes" CloseMessage="yes"
RebootPrompt="no" RebootPrompt="no"
ElevatedCloseMessage="no" /> ElevatedCloseMessage="no" />
<Feature Id="ProductFeature" Title="WebDrop Bridge" Level="1"> <Feature Id="ProductFeature" Title="{product_name}" Level="1">
<ComponentGroupRef Id="AppFiles" /> <ComponentGroupRef Id="AppFiles" />
<ComponentRef Id="ProgramMenuShortcut" /> <ComponentRef Id="ProgramMenuShortcut" />
<ComponentRef Id="DesktopShortcut" /> <ComponentRef Id="DesktopShortcut" />
@ -39,10 +39,10 @@
<Directory Id="TARGETDIR" Name="SourceDir"> <Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="ProgramFiles64Folder"> <Directory Id="ProgramFiles64Folder">
<Directory Id="INSTALLFOLDER" Name="WebDrop Bridge" /> <Directory Id="INSTALLFOLDER" Name="{install_dir_name}" />
</Directory> </Directory>
<Directory Id="ProgramMenuFolder"> <Directory Id="ProgramMenuFolder">
<Directory Id="ApplicationProgramsFolder" Name="WebDrop Bridge"/> <Directory Id="ApplicationProgramsFolder" Name="{product_name}"/>
</Directory> </Directory>
<Directory Id="DesktopFolder" /> <Directory Id="DesktopFolder" />
</Directory> </Directory>
@ -50,16 +50,16 @@
<DirectoryRef Id="ApplicationProgramsFolder"> <DirectoryRef Id="ApplicationProgramsFolder">
<Component Id="ProgramMenuShortcut" Guid="*"> <Component Id="ProgramMenuShortcut" Guid="*">
<Shortcut Id="ApplicationStartMenuShortcut" <Shortcut Id="ApplicationStartMenuShortcut"
Name="WebDrop Bridge" Name="{product_name}"
Description="Web Drag-and-Drop Bridge" Description="{shortcut_description}"
Target="[INSTALLFOLDER]WebDropBridge.exe" Target="[INSTALLFOLDER]{exe_name}.exe"
Icon="AppIcon.ico" Icon="AppIcon.ico"
IconIndex="0" IconIndex="0"
WorkingDirectory="INSTALLFOLDER" /> WorkingDirectory="INSTALLFOLDER" />
<RemoveFolder Id="ApplicationProgramsFolderRemove" <RemoveFolder Id="ApplicationProgramsFolderRemove"
On="uninstall" /> On="uninstall" />
<RegistryValue Root="HKCU" <RegistryValue Root="HKCU"
Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\WebDropBridge" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\{exe_name}"
Name="installed" Name="installed"
Type="integer" Type="integer"
Value="1" Value="1"
@ -70,14 +70,14 @@
<DirectoryRef Id="DesktopFolder"> <DirectoryRef Id="DesktopFolder">
<Component Id="DesktopShortcut" Guid="*"> <Component Id="DesktopShortcut" Guid="*">
<Shortcut Id="DesktopApplicationShortcut" <Shortcut Id="DesktopApplicationShortcut"
Name="WebDrop Bridge" Name="{product_name}"
Description="Web Drag-and-Drop Bridge" Description="{shortcut_description}"
Target="[INSTALLFOLDER]WebDropBridge.exe" Target="[INSTALLFOLDER]{exe_name}.exe"
Icon="AppIcon.ico" Icon="AppIcon.ico"
IconIndex="0" IconIndex="0"
WorkingDirectory="INSTALLFOLDER" /> WorkingDirectory="INSTALLFOLDER" />
<RegistryValue Root="HKCU" <RegistryValue Root="HKCU"
Key="Software\WebDropBridge" Key="Software\{exe_name}"
Name="DesktopShortcut" Name="DesktopShortcut"
Type="integer" Type="integer"
Value="1" Value="1"

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
{
"brand_id": "agravity",
"display_name": "Agravity Bridge",
"asset_prefix": "AgravityBridge",
"exe_name": "AgravityBridge",
"manufacturer": "agravity",
"install_dir_name": "Agravity Bridge",
"shortcut_description": "Agravity drag-and-drop bridge",
"bundle_identifier": "io.agravity.bridge",
"config_dir_name": "agravity_bridge",
"msi_upgrade_code": "4a7c80da-6170-4d88-8efc-3f30636f6392",
"update_channel": "stable",
"icon_ico": "resources/icons/app.ico",
"icon_icns": "resources/icons/app.icns",
"dialog_bmp": "resources/icons/background.bmp",
"banner_bmp": "resources/icons/banner.bmp",
"license_rtf": "resources/license.rtf",
"toolbar_icon_home": "resources/icons/home.ico",
"toolbar_icon_reload": "resources/icons/reload.ico",
"toolbar_icon_open": "resources/icons/open.ico",
"toolbar_icon_openwith": "resources/icons/openwith.ico"
}

View file

@ -0,0 +1,24 @@
{
// Copy this file to build/brands/<your-brand>.json (without comments)
// and replace values.
"brand_id": "your_brand_id",
"display_name": "Your Brand Bridge",
"asset_prefix": "YourBrandBridge",
"exe_name": "YourBrandBridge",
"manufacturer": "Your Company",
"install_dir_name": "Your Brand Bridge",
"shortcut_description": "Your brand drag-and-drop bridge",
"bundle_identifier": "com.yourcompany.bridge",
"config_dir_name": "your_brand_bridge",
"msi_upgrade_code": "00000000-0000-0000-0000-000000000000",
"update_channel": "stable",
"icon_ico": "resources/icons/app.ico",
"icon_icns": "resources/icons/app.icns",
"dialog_bmp": "resources/icons/background.bmp",
"banner_bmp": "resources/icons/banner.bmp",
"license_rtf": "resources/license.rtf",
"toolbar_icon_home": "resources/icons/home.ico",
"toolbar_icon_reload": "resources/icons/reload.ico",
"toolbar_icon_open": "resources/icons/open.ico",
"toolbar_icon_openwith": "resources/icons/openwith.ico"
}

View file

@ -0,0 +1,397 @@
"""Brand-aware build configuration helpers."""
from __future__ import annotations
import argparse
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
class BrandConfig:
"""Packaging metadata for a branded build."""
brand_id: str
display_name: str
asset_prefix: str
exe_name: str
manufacturer: str
install_dir_name: str
shortcut_description: str
bundle_identifier: str
config_dir_name: str
msi_upgrade_code: str
update_channel: str
icon_ico: Path
icon_icns: Path
dialog_bmp: Path
banner_bmp: Path
license_rtf: Path
toolbar_icon_home: str
toolbar_icon_reload: str
toolbar_icon_open: str
toolbar_icon_openwith: str
def windows_installer_name(self, version: str) -> str:
return f"{self.asset_prefix}-{version}-win-x64.msi"
def macos_installer_name(self, version: str) -> str:
return f"{self.asset_prefix}-{version}-macos-universal.dmg"
@property
def app_bundle_name(self) -> str:
return f"{self.asset_prefix}.app"
DEFAULT_BRAND_VALUES: dict[str, Any] = {
"brand_id": "webdrop_bridge",
"display_name": "WebDrop Bridge",
"asset_prefix": "WebDropBridge",
"exe_name": "WebDropBridge",
"manufacturer": "HIM-Tools",
"install_dir_name": "WebDrop Bridge",
"shortcut_description": "Web Drag-and-Drop Bridge",
"bundle_identifier": "de.him_tools.webdrop-bridge",
"config_dir_name": "webdrop_bridge",
"msi_upgrade_code": "12345678-1234-1234-1234-123456789012",
"update_channel": "stable",
"icon_ico": "resources/icons/app.ico",
"icon_icns": "resources/icons/app.icns",
"dialog_bmp": "resources/icons/background.bmp",
"banner_bmp": "resources/icons/banner.bmp",
"license_rtf": "resources/license.rtf",
"toolbar_icon_home": "resources/icons/home.ico",
"toolbar_icon_reload": "resources/icons/reload.ico",
"toolbar_icon_open": "resources/icons/open.ico",
"toolbar_icon_openwith": "resources/icons/openwith.ico",
}
DEFAULT_BRAND_ID = str(DEFAULT_BRAND_VALUES["brand_id"])
def project_root() -> Path:
return Path(__file__).resolve().parents[2]
def brands_dir(root: Path | None = None) -> Path:
base = root or project_root()
return base / "build" / "brands"
def available_brand_names(root: Path | None = None) -> list[str]:
"""Return all supported brand names, including the default build."""
base = root or project_root()
names = [DEFAULT_BRAND_ID]
manifest_dir = brands_dir(base)
if manifest_dir.exists():
for manifest in sorted(manifest_dir.glob("*.json")):
if manifest.stem not in names:
names.append(manifest.stem)
return names
def load_brand_config(
brand: str | None = None,
*,
root: Path | None = None,
manifest_path: Path | None = None,
) -> BrandConfig:
"""Load a brand manifest with defaults and asset fallbacks."""
base = root or project_root()
values = dict(DEFAULT_BRAND_VALUES)
if manifest_path is None and brand and brand != DEFAULT_BRAND_ID:
manifest_path = brands_dir(base) / f"{brand}.json"
if manifest_path and manifest_path.exists():
values.update(json.loads(manifest_path.read_text(encoding="utf-8")))
elif manifest_path and not manifest_path.exists():
raise FileNotFoundError(f"Brand manifest not found: {manifest_path}")
def resolve_asset(key: str) -> Path:
candidate = base / str(values.get(key, DEFAULT_BRAND_VALUES[key]))
if candidate.exists():
return candidate
return base / str(DEFAULT_BRAND_VALUES[key])
return BrandConfig(
brand_id=str(values["brand_id"]),
display_name=str(values["display_name"]),
asset_prefix=str(values["asset_prefix"]),
exe_name=str(values["exe_name"]),
manufacturer=str(values["manufacturer"]),
install_dir_name=str(values["install_dir_name"]),
shortcut_description=str(values["shortcut_description"]),
bundle_identifier=str(values["bundle_identifier"]),
config_dir_name=str(values["config_dir_name"]),
msi_upgrade_code=str(values["msi_upgrade_code"]),
update_channel=str(values.get("update_channel", "stable")),
icon_ico=resolve_asset("icon_ico"),
icon_icns=resolve_asset("icon_icns"),
dialog_bmp=resolve_asset("dialog_bmp"),
banner_bmp=resolve_asset("banner_bmp"),
license_rtf=resolve_asset("license_rtf"),
toolbar_icon_home=str(
values.get("toolbar_icon_home", DEFAULT_BRAND_VALUES["toolbar_icon_home"])
),
toolbar_icon_reload=str(
values.get("toolbar_icon_reload", DEFAULT_BRAND_VALUES["toolbar_icon_reload"])
),
toolbar_icon_open=str(
values.get("toolbar_icon_open", DEFAULT_BRAND_VALUES["toolbar_icon_open"])
),
toolbar_icon_openwith=str(
values.get("toolbar_icon_openwith", DEFAULT_BRAND_VALUES["toolbar_icon_openwith"])
),
)
def generate_release_manifest(
version: str,
brands: list[str],
*,
output_path: Path,
root: Path | None = None,
) -> Path:
"""Generate a shared release-manifest.json from local build outputs."""
base = root or project_root()
manifest: dict[str, Any] = {
"version": version,
"channel": "stable",
"brands": {},
}
for brand_name in brands:
brand = load_brand_config(brand_name, root=base)
manifest["channel"] = brand.update_channel
entries: dict[str, dict[str, str]] = {}
windows_dir = base / "build" / "dist" / "windows" / brand.brand_id
windows_installer = windows_dir / brand.windows_installer_name(version)
windows_checksum = windows_dir / f"{windows_installer.name}.sha256"
if windows_installer.exists():
entries["windows-x64"] = {
"installer": windows_installer.name,
"checksum": windows_checksum.name if windows_checksum.exists() else "",
}
macos_dir = base / "build" / "dist" / "macos" / brand.brand_id
macos_installer = macos_dir / brand.macos_installer_name(version)
macos_checksum = macos_dir / f"{macos_installer.name}.sha256"
if macos_installer.exists():
entries["macos-universal"] = {
"installer": macos_installer.name,
"checksum": macos_checksum.name if macos_checksum.exists() else "",
}
if entries:
manifest["brands"][brand.brand_id] = entries
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
return output_path
def merge_release_manifests(
base_manifest: dict[str, Any], overlay_manifest: dict[str, Any]
) -> dict[str, Any]:
"""Merge two release manifests, preserving previously uploaded platforms."""
merged: dict[str, Any] = {
"version": overlay_manifest.get("version") or base_manifest.get("version", ""),
"channel": overlay_manifest.get("channel") or base_manifest.get("channel", "stable"),
"brands": dict(base_manifest.get("brands", {})),
}
for brand_id, entries in overlay_manifest.get("brands", {}).items():
brand_entry = dict(merged["brands"].get(brand_id, {}))
for platform_key, platform_value in entries.items():
if platform_value:
brand_entry[platform_key] = platform_value
merged["brands"][brand_id] = brand_entry
return merged
def collect_local_release_data(
version: str,
*,
platform: str,
root: Path | None = None,
brands: list[str] | None = None,
) -> dict[str, Any]:
"""Collect local artifacts and manifest entries for the requested platform."""
base = root or project_root()
selected_brands = brands or available_brand_names(base)
release_manifest: dict[str, Any] = {
"version": version,
"channel": "stable",
"brands": {},
}
artifacts: list[str] = []
found_brands: list[str] = []
for brand_name in selected_brands:
brand = load_brand_config(brand_name, root=base)
release_manifest["channel"] = brand.update_channel
if platform == "windows":
artifact_dir = base / "build" / "dist" / "windows" / brand.brand_id
installer = artifact_dir / brand.windows_installer_name(version)
checksum = artifact_dir / f"{installer.name}.sha256"
platform_key = "windows-x64"
elif platform == "macos":
artifact_dir = base / "build" / "dist" / "macos" / brand.brand_id
installer = artifact_dir / brand.macos_installer_name(version)
checksum = artifact_dir / f"{installer.name}.sha256"
platform_key = "macos-universal"
if not installer.exists() and brand.brand_id == DEFAULT_BRAND_ID:
legacy_installer = (base / "build" / "dist" / "macos") / brand.macos_installer_name(
version
)
legacy_checksum = legacy_installer.parent / f"{legacy_installer.name}.sha256"
if legacy_installer.exists():
installer = legacy_installer
checksum = legacy_checksum
else:
raise ValueError(f"Unsupported platform: {platform}")
if not installer.exists():
continue
found_brands.append(brand.brand_id)
artifacts.append(str(installer))
if checksum.exists():
artifacts.append(str(checksum))
release_manifest["brands"].setdefault(brand.brand_id, {})[platform_key] = {
"installer": installer.name,
"checksum": checksum.name if checksum.exists() else "",
}
return {
"version": version,
"platform": platform,
"brands": found_brands,
"artifacts": artifacts,
"manifest": release_manifest,
}
def cli_env(args: argparse.Namespace) -> int:
brand = load_brand_config(args.brand)
assignments = {
"WEBDROP_BRAND_ID": brand.brand_id,
"WEBDROP_APP_DISPLAY_NAME": brand.display_name,
"WEBDROP_ASSET_PREFIX": brand.asset_prefix,
"WEBDROP_EXE_NAME": brand.exe_name,
"WEBDROP_BUNDLE_ID": brand.bundle_identifier,
"WEBDROP_CONFIG_DIR_NAME": brand.config_dir_name,
"WEBDROP_UPDATE_CHANNEL": brand.update_channel,
"WEBDROP_ICON_ICO": str(brand.icon_ico),
"WEBDROP_ICON_ICNS": str(brand.icon_icns),
"WEBDROP_TOOLBAR_ICON_HOME": brand.toolbar_icon_home,
"WEBDROP_TOOLBAR_ICON_RELOAD": brand.toolbar_icon_reload,
"WEBDROP_TOOLBAR_ICON_OPEN": brand.toolbar_icon_open,
"WEBDROP_TOOLBAR_ICON_OPENWITH": brand.toolbar_icon_openwith,
}
for key, value in assignments.items():
print(f'export {key}="{value}"')
return 0
def cli_manifest(args: argparse.Namespace) -> int:
output = generate_release_manifest(
args.version,
args.brands,
output_path=Path(args.output).resolve(),
)
print(output)
return 0
def cli_local_release_data(args: argparse.Namespace) -> int:
data = collect_local_release_data(
args.version,
platform=args.platform,
brands=args.brands,
)
print(json.dumps(data, indent=2))
return 0
def cli_merge_manifests(args: argparse.Namespace) -> int:
base_manifest = json.loads(Path(args.base).read_text(encoding="utf-8"))
overlay_manifest = json.loads(Path(args.overlay).read_text(encoding="utf-8"))
merged = merge_release_manifests(base_manifest, overlay_manifest)
output_path = Path(args.output)
output_path.write_text(json.dumps(merged, indent=2), encoding="utf-8")
print(output_path)
return 0
def cli_show(args: argparse.Namespace) -> int:
brand = load_brand_config(args.brand)
print(
json.dumps(
{
"brand_id": brand.brand_id,
"display_name": brand.display_name,
"asset_prefix": brand.asset_prefix,
"exe_name": brand.exe_name,
"manufacturer": brand.manufacturer,
"install_dir_name": brand.install_dir_name,
"shortcut_description": brand.shortcut_description,
"bundle_identifier": brand.bundle_identifier,
"config_dir_name": brand.config_dir_name,
"msi_upgrade_code": brand.msi_upgrade_code,
"update_channel": brand.update_channel,
"toolbar_icon_home": brand.toolbar_icon_home,
"toolbar_icon_reload": brand.toolbar_icon_reload,
"toolbar_icon_open": brand.toolbar_icon_open,
"toolbar_icon_openwith": brand.toolbar_icon_openwith,
},
indent=2,
)
)
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="Brand-aware build configuration")
subparsers = parser.add_subparsers(dest="command", required=True)
env_parser = subparsers.add_parser("env")
env_parser.add_argument("--brand", required=True)
env_parser.set_defaults(func=cli_env)
manifest_parser = subparsers.add_parser("release-manifest")
manifest_parser.add_argument("--version", required=True)
manifest_parser.add_argument("--output", required=True)
manifest_parser.add_argument("--brands", nargs="+", required=True)
manifest_parser.set_defaults(func=cli_manifest)
local_parser = subparsers.add_parser("local-release-data")
local_parser.add_argument("--version", required=True)
local_parser.add_argument("--platform", choices=["windows", "macos"], required=True)
local_parser.add_argument("--brands", nargs="+")
local_parser.set_defaults(func=cli_local_release_data)
merge_parser = subparsers.add_parser("merge-manifests")
merge_parser.add_argument("--base", required=True)
merge_parser.add_argument("--overlay", required=True)
merge_parser.add_argument("--output", required=True)
merge_parser.set_defaults(func=cli_merge_manifests)
show_parser = subparsers.add_parser("show")
show_parser.add_argument("--brand", required=True)
show_parser.set_defaults(func=cli_show)
args = parser.parse_args()
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -28,10 +28,13 @@ DIST_DIR="$BUILD_DIR/dist/macos"
TEMP_BUILD="$BUILD_DIR/temp/macos" TEMP_BUILD="$BUILD_DIR/temp/macos"
SPECS_DIR="$BUILD_DIR/specs" SPECS_DIR="$BUILD_DIR/specs"
SPEC_FILE="$BUILD_DIR/webdrop_bridge.spec" SPEC_FILE="$BUILD_DIR/webdrop_bridge.spec"
BRAND_HELPER="$BUILD_DIR/scripts/brand_config.py"
BRAND=""
APP_NAME="WebDropBridge" APP_NAME="WebDropBridge"
DMG_VOLUME_NAME="WebDrop Bridge" DMG_VOLUME_NAME="WebDrop Bridge"
VERSION="1.0.0" BUNDLE_IDENTIFIER="de.him_tools.webdrop-bridge"
VERSION=""
# Default .env file # Default .env file
ENV_FILE="$PROJECT_ROOT/.env" ENV_FILE="$PROJECT_ROOT/.env"
@ -54,6 +57,10 @@ while [[ $# -gt 0 ]]; do
ENV_FILE="$2" ENV_FILE="$2"
shift 2 shift 2
;; ;;
--brand)
BRAND="$2"
shift 2
;;
*) *)
echo "Unknown option: $1" echo "Unknown option: $1"
exit 1 exit 1
@ -70,6 +77,23 @@ fi
echo "📋 Using configuration: $ENV_FILE" echo "📋 Using configuration: $ENV_FILE"
if [ -z "$BRAND" ]; then
BRAND="webdrop_bridge"
fi
eval "$(python3 "$BRAND_HELPER" env --brand "$BRAND")"
APP_NAME="$WEBDROP_ASSET_PREFIX"
DMG_VOLUME_NAME="$WEBDROP_APP_DISPLAY_NAME"
BUNDLE_IDENTIFIER="$WEBDROP_BUNDLE_ID"
DIST_DIR="$BUILD_DIR/dist/macos/$WEBDROP_BRAND_ID"
TEMP_BUILD="$BUILD_DIR/temp/macos/$WEBDROP_BRAND_ID"
if [ -n "$WEBDROP_APP_DISPLAY_NAME" ]; then
echo "🏷️ Building brand: $WEBDROP_APP_DISPLAY_NAME ($WEBDROP_BRAND_ID)"
fi
VERSION="$(python3 -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$BUILD_DIR/scripts').resolve())); from version_utils import get_current_version; print(get_current_version())")"
# Colors for output # Colors for output
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
@ -176,8 +200,27 @@ build_executable() {
log_info "Building macOS executable with PyInstaller..." log_info "Building macOS executable with PyInstaller..."
echo "" echo ""
# Create bundled runtime .env with brand defaults so first launch
# uses brand-specific app name and config directory.
BUNDLED_ENV_FILE="$TEMP_BUILD/.env"
cp "$ENV_FILE" "$BUNDLED_ENV_FILE"
{
echo ""
echo "# Brand-specific defaults added during packaging"
echo "APP_NAME=\"$WEBDROP_APP_DISPLAY_NAME\""
echo "BRAND_ID=\"$WEBDROP_BRAND_ID\""
echo "APP_CONFIG_DIR_NAME=\"$WEBDROP_CONFIG_DIR_NAME\""
echo "UPDATE_CHANNEL=\"$WEBDROP_UPDATE_CHANNEL\""
echo "TOOLBAR_ICON_HOME=\"$WEBDROP_TOOLBAR_ICON_HOME\""
echo "TOOLBAR_ICON_RELOAD=\"$WEBDROP_TOOLBAR_ICON_RELOAD\""
echo "TOOLBAR_ICON_OPEN=\"$WEBDROP_TOOLBAR_ICON_OPEN\""
echo "TOOLBAR_ICON_OPENWITH=\"$WEBDROP_TOOLBAR_ICON_OPENWITH\""
} >> "$BUNDLED_ENV_FILE"
# Export env file for spec file to pick up # Export env file for spec file to pick up
export WEBDROP_ENV_FILE="$ENV_FILE" export WEBDROP_ENV_FILE="$BUNDLED_ENV_FILE"
export WEBDROP_VERSION="$VERSION"
export WEBDROP_BUNDLE_ID="$BUNDLE_IDENTIFIER"
python3 -m PyInstaller \ python3 -m PyInstaller \
--distpath="$DIST_DIR" \ --distpath="$DIST_DIR" \
@ -199,7 +242,7 @@ create_dmg() {
log_info "Creating DMG package..." log_info "Creating DMG package..."
echo "" echo ""
DMG_FILE="$DIST_DIR/${APP_NAME}-${VERSION}.dmg" DMG_FILE="$DIST_DIR/${APP_NAME}-${VERSION}-macos-universal.dmg"
# Remove existing DMG # Remove existing DMG
if [ -f "$DMG_FILE" ]; then if [ -f "$DMG_FILE" ]; then
@ -252,6 +295,8 @@ create_dmg() {
SIZE=$(du -h "$DMG_FILE" | cut -f1) SIZE=$(du -h "$DMG_FILE" | cut -f1)
log_success "DMG created successfully" log_success "DMG created successfully"
log_info "Output: $DMG_FILE (Size: $SIZE)" log_info "Output: $DMG_FILE (Size: $SIZE)"
shasum -a 256 "$DMG_FILE" | awk '{print $1}' > "$DMG_FILE.sha256"
log_info "Checksum: $DMG_FILE.sha256"
echo "" echo ""
} }

View file

@ -38,14 +38,17 @@ import argparse
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
from dotenv import dotenv_values
# Import shared version utilities # Import shared version utilities
from brand_config import load_brand_config
from sync_version import get_current_version, do_sync_version from sync_version import get_current_version, do_sync_version
class WindowsBuilder: class WindowsBuilder:
"""Build Windows installer using PyInstaller.""" """Build Windows installer using PyInstaller."""
def __init__(self, env_file: Path | None = None): def __init__(self, env_file: Path | None = None, brand: str | None = None):
"""Initialize builder paths. """Initialize builder paths.
Args: Args:
@ -53,10 +56,12 @@ class WindowsBuilder:
If that doesn't exist, raises error. If that doesn't exist, raises error.
""" """
self.project_root = Path(__file__).parent.parent.parent self.project_root = Path(__file__).parent.parent.parent
self.brand = load_brand_config(brand, root=self.project_root)
self.build_dir = self.project_root / "build" self.build_dir = self.project_root / "build"
self.dist_dir = self.build_dir / "dist" / "windows" self.dist_dir = self.build_dir / "dist" / "windows" / self.brand.brand_id
self.temp_dir = self.build_dir / "temp" / "windows" self.temp_dir = self.build_dir / "temp" / "windows" / self.brand.brand_id
self.spec_file = self.build_dir / "webdrop_bridge.spec" self.spec_file = self.build_dir / "webdrop_bridge.spec"
self.wix_template = self.build_dir / "WebDropBridge.wxs"
self.version = get_current_version() self.version = get_current_version()
# Validate and set env file # Validate and set env file
@ -74,6 +79,7 @@ class WindowsBuilder:
self.env_file = env_file self.env_file = env_file
print(f"📋 Using configuration: {self.env_file}") print(f"📋 Using configuration: {self.env_file}")
print(f"🏷️ Building brand: {self.brand.display_name} ({self.brand.brand_id})")
def _get_version(self) -> str: def _get_version(self) -> str:
"""Get version from __init__.py. """Get version from __init__.py.
@ -91,6 +97,48 @@ class WindowsBuilder:
shutil.rmtree(path) shutil.rmtree(path)
print(f" Removed {path}") print(f" Removed {path}")
@staticmethod
def _format_env_value(value: str) -> str:
"""Format env values safely for .env files."""
if any(ch in value for ch in [" ", "#", '"', "'", "\t"]):
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
return value
def _create_bundled_env_file(self) -> Path:
"""Create a bundled .env file with brand-specific runtime defaults."""
values = dotenv_values(self.env_file)
overrides = {
"APP_NAME": self.brand.display_name,
"BRAND_ID": self.brand.brand_id,
"APP_CONFIG_DIR_NAME": self.brand.config_dir_name,
"UPDATE_CHANNEL": self.brand.update_channel,
"TOOLBAR_ICON_HOME": self.brand.toolbar_icon_home,
"TOOLBAR_ICON_RELOAD": self.brand.toolbar_icon_reload,
"TOOLBAR_ICON_OPEN": self.brand.toolbar_icon_open,
"TOOLBAR_ICON_OPENWITH": self.brand.toolbar_icon_openwith,
}
output_env = self.temp_dir / ".env"
output_env.parent.mkdir(parents=True, exist_ok=True)
lines: list[str] = []
for key, raw_value in values.items():
if key in overrides:
continue
if raw_value is None:
lines.append(key)
else:
lines.append(f"{key}={self._format_env_value(str(raw_value))}")
lines.append("")
lines.append("# Brand-specific defaults added during packaging")
for key, value in overrides.items():
lines.append(f"{key}={self._format_env_value(value)}")
output_env.write_text("\n".join(lines) + "\n", encoding="utf-8")
return output_env
def build_executable(self) -> bool: def build_executable(self) -> bool:
"""Build executable using PyInstaller.""" """Build executable using PyInstaller."""
print("\n🔨 Building Windows executable with PyInstaller...") print("\n🔨 Building Windows executable with PyInstaller...")
@ -115,7 +163,16 @@ class WindowsBuilder:
# Set environment variable for spec file to use # Set environment variable for spec file to use
env = os.environ.copy() env = os.environ.copy()
env["WEBDROP_ENV_FILE"] = str(self.env_file) env["WEBDROP_ENV_FILE"] = str(self._create_bundled_env_file())
env["WEBDROP_BRAND_ID"] = self.brand.brand_id
env["WEBDROP_APP_DISPLAY_NAME"] = self.brand.display_name
env["WEBDROP_ASSET_PREFIX"] = self.brand.asset_prefix
env["WEBDROP_EXE_NAME"] = self.brand.exe_name
env["WEBDROP_BUNDLE_ID"] = self.brand.bundle_identifier
env["WEBDROP_CONFIG_DIR_NAME"] = self.brand.config_dir_name
env["WEBDROP_ICON_ICO"] = str(self.brand.icon_ico)
env["WEBDROP_ICON_ICNS"] = str(self.brand.icon_icns)
env["WEBDROP_VERSION"] = self.version
result = subprocess.run(cmd, cwd=str(self.project_root), text=True, env=env) result = subprocess.run(cmd, cwd=str(self.project_root), text=True, env=env)
@ -123,8 +180,8 @@ class WindowsBuilder:
print("❌ PyInstaller build failed") print("❌ PyInstaller build failed")
return False return False
# Check if executable exists (now in WebDropBridge/WebDropBridge.exe due to COLLECT) # Check if executable exists (inside the COLLECT directory)
exe_path = self.dist_dir / "WebDropBridge" / "WebDropBridge.exe" exe_path = self.dist_dir / self.brand.exe_name / f"{self.brand.exe_name}.exe"
if not exe_path.exists(): if not exe_path.exists():
print(f"❌ Executable not found at {exe_path}") print(f"❌ Executable not found at {exe_path}")
return False return False
@ -134,7 +191,9 @@ class WindowsBuilder:
# Calculate total dist size # Calculate total dist size
total_size = sum( total_size = sum(
f.stat().st_size for f in self.dist_dir.glob("WebDropBridge/**/*") if f.is_file() f.stat().st_size
for f in self.dist_dir.glob(f"{self.brand.exe_name}/**/*")
if f.is_file()
) )
if total_size > 0: if total_size > 0:
print(f" Total size: {total_size / 1024 / 1024:.1f} MB") print(f" Total size: {total_size / 1024 / 1024:.1f} MB")
@ -243,9 +302,13 @@ class WindowsBuilder:
if not self._create_wix_source(): if not self._create_wix_source():
return False return False
# Ensure toolbar icons are present in bundled resources before harvesting.
if not self._ensure_toolbar_icons_in_bundle():
return False
# Harvest application files using Heat # Harvest application files using Heat
print(f" Harvesting application files...") print(f" Harvesting application files...")
dist_folder = self.dist_dir / "WebDropBridge" dist_folder = self.dist_dir / self.brand.exe_name
if not dist_folder.exists(): if not dist_folder.exists():
print(f"❌ Distribution folder not found: {dist_folder}") print(f"❌ Distribution folder not found: {dist_folder}")
return False return False
@ -285,9 +348,9 @@ class WindowsBuilder:
print(f" ✓ Marked components as 64-bit") print(f" ✓ Marked components as 64-bit")
# Compile both WiX files # Compile both WiX files
wix_obj = self.build_dir / "WebDropBridge.wixobj" wix_obj = self.build_dir / "WebDropBridge.generated.wixobj"
wix_files_obj = self.build_dir / "WebDropBridge_Files.wixobj" wix_files_obj = self.build_dir / "WebDropBridge_Files.wixobj"
msi_output = self.dist_dir / f"WebDropBridge-{self.version}-Setup.msi" msi_output = self.dist_dir / self.brand.windows_installer_name(self.version)
# Run candle compiler - make sure to use correct source directory # Run candle compiler - make sure to use correct source directory
candle_cmd = [ candle_cmd = [
@ -297,11 +360,11 @@ class WindowsBuilder:
"-ext", "-ext",
"WixUtilExtension", "WixUtilExtension",
f"-dDistDir={self.dist_dir}", f"-dDistDir={self.dist_dir}",
f"-dSourceDir={self.dist_dir}\\WebDropBridge", # Set SourceDir for Heat-generated files f"-dSourceDir={self.dist_dir / self.brand.exe_name}", # Set SourceDir for Heat-generated files
f"-dResourcesDir={self.project_root}\\resources", # Set ResourcesDir for branding assets f"-dResourcesDir={self.project_root}\\resources", # Set ResourcesDir for branding assets
"-o", "-o",
str(self.build_dir) + "\\", str(self.build_dir) + "\\",
str(self.build_dir / "WebDropBridge.wxs"), str(self.build_dir / "WebDropBridge.generated.wxs"),
] ]
if harvest_file.exists(): if harvest_file.exists():
@ -321,7 +384,7 @@ class WindowsBuilder:
"-ext", "-ext",
"WixUtilExtension", "WixUtilExtension",
"-b", "-b",
str(self.dist_dir / "WebDropBridge"), # Base path for source files str(self.dist_dir / self.brand.exe_name), # Base path for source files
"-o", "-o",
str(msi_output), str(msi_output),
str(wix_obj), str(wix_obj),
@ -349,106 +412,64 @@ class WindowsBuilder:
print("✅ MSI installer created successfully") print("✅ MSI installer created successfully")
print(f"📦 Output: {msi_output}") print(f"📦 Output: {msi_output}")
print(f" Size: {msi_output.stat().st_size / 1024 / 1024:.1f} MB") print(f" Size: {msi_output.stat().st_size / 1024 / 1024:.1f} MB")
self.generate_checksum(msi_output)
return True return True
def _ensure_toolbar_icons_in_bundle(self) -> bool:
"""Ensure toolbar icon files exist in the bundled app folder.
This guarantees WiX Heat harvest includes these icons in the MSI,
even if a previous PyInstaller run omitted them.
"""
src_icons_dir = self.project_root / "resources" / "icons"
bundle_icons_dir = self.dist_dir / self.brand.exe_name / "_internal" / "resources" / "icons"
required_icons = ["home.ico", "reload.ico", "open.ico", "openwith.ico"]
try:
bundle_icons_dir.mkdir(parents=True, exist_ok=True)
for icon_name in required_icons:
src = src_icons_dir / icon_name
dst = bundle_icons_dir / icon_name
if not src.exists():
print(f"❌ Required icon not found: {src}")
return False
if not dst.exists() or src.stat().st_mtime > dst.stat().st_mtime:
shutil.copy2(src, dst)
print(f" ✓ Ensured toolbar icon in bundle: {icon_name}")
return True
except Exception as e:
print(f"❌ Failed to ensure toolbar icons in bundle: {e}")
return False
def _create_wix_source(self) -> bool: def _create_wix_source(self) -> bool:
"""Create WiX source file for MSI generation. """Create WiX source file for MSI generation.
Creates per-machine installation (Program Files). Creates per-machine installation (Program Files).
Installation requires admin rights, but the app does not. Installation requires admin rights, but the app does not.
""" """
wix_content = f"""<?xml version="1.0" encoding="UTF-8"?> wix_template = self.wix_template.read_text(encoding="utf-8")
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" wix_content = wix_template.format(
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui" product_name=self.brand.display_name,
xmlns:util="http://schemas.microsoft.com/wix/UtilExtension"> product_name_with_version=f"{self.brand.display_name} v{self.version}",
<Product Id="*" Name="WebDrop Bridge" Language="1033" Version="{self.version}" version=self.version,
Manufacturer="HIM-Tools" manufacturer=self.brand.manufacturer,
UpgradeCode="12345678-1234-1234-1234-123456789012"> upgrade_code=self.brand.msi_upgrade_code,
asset_prefix=self.brand.asset_prefix,
icon_ico=str(self.brand.icon_ico),
dialog_bmp=str(self.brand.dialog_bmp),
banner_bmp=str(self.brand.banner_bmp),
license_rtf=str(self.brand.license_rtf),
exe_name=self.brand.exe_name,
install_dir_name=self.brand.install_dir_name,
shortcut_description=self.brand.shortcut_description,
)
<Package InstallerVersion="200" Compressed="yes" InstallScope="perMachine" Platform="x64" /> wix_file = self.build_dir / "WebDropBridge.generated.wxs"
<Media Id="1" Cabinet="WebDropBridge.cab" EmbedCab="yes" />
<!-- Required property for WixUI_InstallDir dialog set -->
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />
<!-- Application Icon -->
<Icon Id="AppIcon.ico" SourceFile="$(var.ResourcesDir)\\icons\\app.ico" />
<!-- Custom branding for InstallDir dialog set -->
<WixVariable Id="WixUIDialogBmp" Value="$(var.ResourcesDir)\\icons\\background.bmp" />
<WixVariable Id="WixUIBannerBmp" Value="$(var.ResourcesDir)\\icons\\banner.bmp" />
<WixVariable Id="WixUILicenseRtf" Value="$(var.ResourcesDir)\\license.rtf" />
<!-- Installation UI dialogs -->
<UIRef Id="WixUI_InstallDir" />
<UIRef Id="WixUI_ErrorProgressText" />
<!-- Close running application before installation -->
<util:CloseApplication
Target="WebDropBridge.exe"
CloseMessage="yes"
RebootPrompt="no"
ElevatedCloseMessage="no" />
<Feature Id="ProductFeature" Title="WebDrop Bridge" Level="1">
<ComponentGroupRef Id="AppFiles" />
<ComponentRef Id="ProgramMenuShortcut" />
<ComponentRef Id="DesktopShortcut" />
</Feature>
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="ProgramFiles64Folder">
<Directory Id="INSTALLFOLDER" Name="WebDrop Bridge" />
</Directory>
<Directory Id="ProgramMenuFolder">
<Directory Id="ApplicationProgramsFolder" Name="WebDrop Bridge"/>
</Directory>
<Directory Id="DesktopFolder" />
</Directory>
<DirectoryRef Id="ApplicationProgramsFolder">
<Component Id="ProgramMenuShortcut" Guid="*">
<Shortcut Id="ApplicationStartMenuShortcut"
Name="WebDrop Bridge"
Description="Web Drag-and-Drop Bridge"
Target="[INSTALLFOLDER]WebDropBridge.exe"
Icon="AppIcon.ico"
IconIndex="0"
WorkingDirectory="INSTALLFOLDER" />
<RemoveFolder Id="ApplicationProgramsFolderRemove"
On="uninstall" />
<RegistryValue Root="HKCU"
Key="Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\WebDropBridge"
Name="installed"
Type="integer"
Value="1"
KeyPath="yes" />
</Component>
</DirectoryRef>
<DirectoryRef Id="DesktopFolder">
<Component Id="DesktopShortcut" Guid="*">
<Shortcut Id="DesktopApplicationShortcut"
Name="WebDrop Bridge"
Description="Web Drag-and-Drop Bridge"
Target="[INSTALLFOLDER]WebDropBridge.exe"
Icon="AppIcon.ico"
IconIndex="0"
WorkingDirectory="INSTALLFOLDER" />
<RegistryValue Root="HKCU"
Key="Software\\WebDropBridge"
Name="DesktopShortcut"
Type="integer"
Value="1"
KeyPath="yes" />
</Component>
</DirectoryRef>
</Product>
</Wix>
"""
wix_file = self.build_dir / "WebDropBridge.wxs"
wix_file.write_text(wix_content) wix_file.write_text(wix_content)
print(f" Created WiX source: {wix_file}") print(f" Created WiX source: {wix_file}")
return True return True
@ -539,7 +560,7 @@ class WindowsBuilder:
print(" Skipping code signing") print(" Skipping code signing")
return True return True
exe_path = self.dist_dir / "WebDropBridge.exe" exe_path = self.dist_dir / self.brand.exe_name / f"{self.brand.exe_name}.exe"
cmd = [ cmd = [
signtool, signtool,
"sign", "sign",
@ -572,7 +593,7 @@ class WindowsBuilder:
""" """
start_time = datetime.now() start_time = datetime.now()
print("=" * 60) print("=" * 60)
print("🚀 WebDrop Bridge Windows Build") print(f"🚀 {self.brand.display_name} Windows Build")
print("=" * 60) print("=" * 60)
self.clean() self.clean()
@ -616,6 +637,12 @@ def main() -> int:
default=None, default=None,
help="Path to .env file to bundle (default: project root .env)", help="Path to .env file to bundle (default: project root .env)",
) )
parser.add_argument(
"--brand",
type=str,
default=None,
help="Brand manifest name from build/brands (e.g. agravity)",
)
args = parser.parse_args() args = parser.parse_args()
@ -623,7 +650,7 @@ def main() -> int:
do_sync_version() do_sync_version()
try: try:
builder = WindowsBuilder(env_file=args.env_file) builder = WindowsBuilder(env_file=args.env_file, brand=args.brand)
except FileNotFoundError as e: except FileNotFoundError as e:
print(f"❌ Build failed: {e}") print(f"❌ Build failed: {e}")
return 1 return 1

View file

@ -1,70 +1,58 @@
# Create Forgejo Release with Binary Assets
# Usage: .\create_release.ps1 [-Version 1.0.0]
# If -Version is not provided, it will be read from src/webdrop_bridge/__init__.py
# Uses your Forgejo credentials (same as git)
# First run will prompt for credentials and save them to this session
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory = $false)]
[string]$Version, [string]$Version,
[Parameter(Mandatory=$false)] [Parameter(Mandatory = $false)]
[string[]]$Brands,
[Parameter(Mandatory = $false)]
[string]$ForgejoUser, [string]$ForgejoUser,
[Parameter(Mandatory=$false)] [Parameter(Mandatory = $false)]
[string]$ForgejoPW, [string]$ForgejoPW,
[switch]$ClearCredentials, [switch]$ClearCredentials,
[switch]$DryRun,
[switch]$SkipExe,
[string]$ForgejoUrl = "https://git.him-tools.de", [string]$ForgejoUrl = "https://git.him-tools.de",
[string]$Repo = "HIM-public/webdrop-bridge", [string]$Repo = "HIM-public/webdrop-bridge"
[string]$ExePath = "build\dist\windows\WebDropBridge\WebDropBridge.exe",
[string]$ChecksumPath = "build\dist\windows\WebDropBridge\WebDropBridge.exe.sha256"
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
# Get project root (PSScriptRoot is build/scripts, go up to project root with ..\..)
$projectRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..") $projectRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..")
$pythonExe = Join-Path $projectRoot ".venv\Scripts\python.exe"
# Resolve file paths relative to project root if (-not (Test-Path $pythonExe)) {
$ExePath = Join-Path $projectRoot $ExePath $pythonExe = "python"
$ChecksumPath = Join-Path $projectRoot $ChecksumPath }
$MsiPath = Join-Path $projectRoot $MsiPath
$brandHelper = Join-Path $projectRoot "build\scripts\brand_config.py"
# Function to read version from .env or .env.example $manifestOutput = Join-Path $projectRoot "build\dist\release-manifest.json"
function Get-VersionFromEnv { $localManifestPath = Join-Path $projectRoot "build\dist\release-manifest.local.json"
# Use already resolved project root $existingManifestPath = Join-Path $projectRoot "build\dist\release-manifest.existing.json"
# Try .env first (runtime config), then .env.example (template) function Get-CurrentVersion {
$envFile = Join-Path $projectRoot ".env" 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()
$envExampleFile = Join-Path $projectRoot ".env.example" }
# Check .env first function Get-LocalReleaseData {
if (Test-Path $envFile) { $arguments = @($brandHelper, "local-release-data", "--platform", "windows", "--version", $Version)
$content = Get-Content $envFile -Raw if ($Brands) {
if ($content -match 'APP_VERSION=([^\r\n]+)') { $arguments += "--brands"
Write-Host "Version read from .env" -ForegroundColor Gray $arguments += $Brands
return $matches[1].Trim() }
} return (& $pythonExe @arguments | ConvertFrom-Json)
} }
# Fall back to .env.example function Get-AssetMap {
if (Test-Path $envExampleFile) { param([object[]]$Assets)
$content = Get-Content $envExampleFile -Raw
if ($content -match 'APP_VERSION=([^\r\n]+)') { $map = @{}
Write-Host "Version read from .env.example" -ForegroundColor Gray foreach ($asset in ($Assets | Where-Object { $_ })) {
return $matches[1].Trim() $map[$asset.name] = $asset
} }
} return $map
Write-Host "ERROR: Could not find APP_VERSION in .env or .env.example" -ForegroundColor Red
exit 1
} }
# Handle --ClearCredentials flag
if ($ClearCredentials) { if ($ClearCredentials) {
Remove-Item env:FORGEJO_USER -ErrorAction SilentlyContinue Remove-Item env:FORGEJO_USER -ErrorAction SilentlyContinue
Remove-Item env:FORGEJO_PASS -ErrorAction SilentlyContinue Remove-Item env:FORGEJO_PASS -ErrorAction SilentlyContinue
@ -72,191 +60,228 @@ if ($ClearCredentials) {
exit 0 exit 0
} }
# Get credentials from sources (in order of priority) if (-not $Version) {
$Version = Get-CurrentVersion
}
$localData = Get-LocalReleaseData
$artifactPaths = New-Object System.Collections.Generic.List[string]
foreach ($artifact in $localData.artifacts) {
$artifactPaths.Add([string]$artifact)
if ((Test-Path $artifact) -and ((Get-Item $artifact).Extension -eq ".msi")) {
$msiSize = (Get-Item $artifact).Length / 1MB
Write-Host "Windows artifact: $([System.IO.Path]::GetFileName($artifact)) ($([math]::Round($msiSize, 2)) MB)"
}
}
if ($artifactPaths.Count -eq 0) {
Write-Host "ERROR: No local Windows artifacts found" -ForegroundColor Red
exit 1
}
$localManifestJson = $localData.manifest | ConvertTo-Json -Depth 10
[System.IO.File]::WriteAllText($localManifestPath, $localManifestJson, (New-Object System.Text.UTF8Encoding($false)))
if ($DryRun) {
Copy-Item $localManifestPath $manifestOutput -Force
$brandsText = if ($localData.brands.Count -gt 0) { $localData.brands -join ", " } else { "<none>" }
Write-Host "[DRY RUN] No network requests or uploads will be performed." -ForegroundColor Yellow
Write-Host "[DRY RUN] Release tag: v$Version"
Write-Host "[DRY RUN] Release URL: $ForgejoUrl/$Repo/releases/tag/v$Version"
Write-Host "[DRY RUN] Discovered brands: $brandsText"
Write-Host "[DRY RUN] Artifacts that would be uploaded:"
foreach ($artifact in $artifactPaths) {
Write-Host " - $artifact"
}
Write-Host "[DRY RUN] Local manifest preview: $manifestOutput"
exit 0
}
if (-not $ForgejoUser) { if (-not $ForgejoUser) {
$ForgejoUser = $env:FORGEJO_USER $ForgejoUser = $env:FORGEJO_USER
} }
if (-not $ForgejoPW) { if (-not $ForgejoPW) {
$ForgejoPW = $env:FORGEJO_PASS $ForgejoPW = $env:FORGEJO_PASS
} }
# If still no credentials, prompt user interactively
if (-not $ForgejoUser -or -not $ForgejoPW) { if (-not $ForgejoUser -or -not $ForgejoPW) {
Write-Host "Forgejo credentials not found. Enter your credentials:" -ForegroundColor Yellow Write-Host "Forgejo credentials not found. Enter your credentials:" -ForegroundColor Yellow
if (-not $ForgejoUser) { if (-not $ForgejoUser) {
$ForgejoUser = Read-Host "Username" $ForgejoUser = Read-Host "Username"
} }
if (-not $ForgejoPW) { if (-not $ForgejoPW) {
$securePass = Read-Host "Password" -AsSecureString $securePass = Read-Host "Password" -AsSecureString
$ForgejoPW = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToCoTaskMemUnicode($securePass)) $ForgejoPW = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToCoTaskMemUnicode($securePass))
} }
# Save credentials to environment for this session
$env:FORGEJO_USER = $ForgejoUser $env:FORGEJO_USER = $ForgejoUser
$env:FORGEJO_PASS = $ForgejoPW $env:FORGEJO_PASS = $ForgejoPW
Write-Host "[OK] Credentials saved to this PowerShell session" -ForegroundColor Green
Write-Host "Tip: Credentials will persist until you close PowerShell or run: .\create_release.ps1 -ClearCredentials" -ForegroundColor Gray
} }
# Verify Version parameter - if not provided, read from .env.example
if (-not $Version) {
Write-Host "Version not provided, reading from .env.example..." -ForegroundColor Cyan
$Version = Get-VersionFromEnv
Write-Host "Using version: $Version" -ForegroundColor Green
}
# Define MSI path with resolved version
$MsiPath = Join-Path $projectRoot "build\dist\windows\WebDropBridge-$Version-Setup.msi"
# Verify files exist (exe/checksum optional, MSI required)
if (-not $SkipExe) {
if (-not (Test-Path $ExePath)) {
Write-Host "WARNING: Executable not found at $ExePath" -ForegroundColor Yellow
Write-Host " Use -SkipExe flag to skip exe upload" -ForegroundColor Gray
$SkipExe = $true
}
if (-not $SkipExe -and -not (Test-Path $ChecksumPath)) {
Write-Host "WARNING: Checksum file not found at $ChecksumPath" -ForegroundColor Yellow
Write-Host " Exe will not be uploaded" -ForegroundColor Gray
$SkipExe = $true
}
}
# MSI is the primary release artifact
if (-not (Test-Path $MsiPath)) {
Write-Host "ERROR: MSI installer not found at $MsiPath" -ForegroundColor Red
Write-Host "Please build with MSI support:" -ForegroundColor Yellow
Write-Host " python build\scripts\build_windows.py --msi" -ForegroundColor Cyan
exit 1
}
Write-Host "Creating WebDropBridge $Version release on Forgejo..." -ForegroundColor Cyan
# Get file info
$msiSize = (Get-Item $MsiPath).Length / 1MB
Write-Host "Primary Artifact: WebDropBridge-$Version-Setup.msi ($([math]::Round($msiSize, 2)) MB)"
if (-not $SkipExe) {
$exeSize = (Get-Item $ExePath).Length / 1MB
$checksum = Get-Content $ChecksumPath -Raw
Write-Host "Optional Artifact: WebDropBridge.exe ($([math]::Round($exeSize, 2)) MB)"
Write-Host " Checksum: $($checksum.Substring(0, 16))..."
}
# Create basic auth header
$auth = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes("${ForgejoUser}:${ForgejoPW}")) $auth = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes("${ForgejoUser}:${ForgejoPW}"))
$headers = @{ $headers = @{
"Authorization" = "Basic $auth" "Authorization" = "Basic $auth"
"Content-Type" = "application/json" "Content-Type" = "application/json"
} }
# Step 1: Create release $releaseLookupUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/tags/v$Version"
Write-Host "`nCreating release v$Version..." -ForegroundColor Yellow
$releaseUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases" $releaseUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases"
# Build release body with checksum info if exe is being uploaded
$releaseBody = "WebDropBridge v$Version`n`n**Release Artifacts:**`n- MSI Installer (Windows Setup)`n"
if (-not $SkipExe) {
$checksum = Get-Content $ChecksumPath -Raw
$releaseBody += "- Portable Executable`n`n**Checksum:**`n$checksum`n"
}
$releaseData = @{ $releaseData = @{
tag_name = "v$Version" tag_name = "v$Version"
name = "WebDropBridge v$Version" name = "WebDropBridge v$Version"
body = $releaseBody body = "Shared branded release for WebDrop Bridge v$Version"
draft = $false draft = $false
prerelease = $false prerelease = $false
} | ConvertTo-Json } | ConvertTo-Json
try { try {
$response = Invoke-WebRequest -Uri $releaseUrl ` $releaseInfo = Invoke-RestMethod -Uri $releaseLookupUrl -Method GET -Headers $headers -TimeoutSec 30 -ErrorAction Stop
-Method POST ` $releaseId = $releaseInfo.id
-Headers $headers ` Write-Host "[OK] Using existing release (ID: $releaseId)" -ForegroundColor Green
-Body $releaseData ` }
-TimeoutSec 30 ` catch {
-UseBasicParsing ` $releaseInfo = Invoke-RestMethod -Uri $releaseUrl -Method POST -Headers $headers -Body $releaseData -TimeoutSec 30 -ErrorAction Stop
-ErrorAction Stop
$releaseInfo = $response.Content | ConvertFrom-Json
$releaseId = $releaseInfo.id $releaseId = $releaseInfo.id
Write-Host "[OK] Release created (ID: $releaseId)" -ForegroundColor Green Write-Host "[OK] Release created (ID: $releaseId)" -ForegroundColor Green
} }
catch {
Write-Host "ERROR creating release: $_" -ForegroundColor Red $assetMap = Get-AssetMap -Assets $releaseInfo.assets
exit 1 if ($assetMap.ContainsKey("release-manifest.json")) {
Invoke-WebRequest -Uri $assetMap["release-manifest.json"].browser_download_url -Method GET -Headers $headers -TimeoutSec 30 -OutFile $existingManifestPath | Out-Null
& $pythonExe $brandHelper merge-manifests --base $existingManifestPath --overlay $localManifestPath --output $manifestOutput | Out-Null
}
else {
Copy-Item $localManifestPath $manifestOutput -Force
} }
# Setup curl authentication # Ensure uploaded manifest is UTF-8 without BOM (for strict JSON parsers)
$curlAuth = "$ForgejoUser`:$ForgejoPW" if (Test-Path $manifestOutput) {
$uploadUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/$releaseId/assets" $manifestText = Get-Content -Raw -Path $manifestOutput
[System.IO.File]::WriteAllText($manifestOutput, $manifestText, (New-Object System.Text.UTF8Encoding($false)))
}
# Step 2: Upload MSI installer as primary artifact $artifactPaths.Add($manifestOutput)
Write-Host "`nUploading MSI installer (primary artifact)..." -ForegroundColor Yellow $assetMap = Get-AssetMap -Assets $releaseInfo.assets
$artifactsToUpload = New-Object System.Collections.Generic.List[string]
foreach ($artifact in $artifactPaths) {
$assetName = [System.IO.Path]::GetFileName($artifact)
$extension = [System.IO.Path]::GetExtension($artifact).ToLowerInvariant()
if ($extension -eq ".msi" -and $assetMap.ContainsKey($assetName)) {
$localSize = (Get-Item $artifact).Length
$remoteSize = [int64]$assetMap[$assetName].size
if ($localSize -eq $remoteSize) {
Write-Host "[OK] Skipping already uploaded MSI $assetName ($([math]::Round($localSize / 1MB, 2)) MB)" -ForegroundColor Cyan
continue
}
}
$artifactsToUpload.Add($artifact)
}
if ($artifactsToUpload.Count -eq 0) {
Write-Host "[OK] All release assets already uploaded." -ForegroundColor Green
Write-Host "View at: $ForgejoUrl/$Repo/releases/tag/v$Version" -ForegroundColor Cyan
exit 0
}
# Use Python requests library for more reliable large file uploads
$pythonUploadScript = @"
import sys
import requests
from requests.auth import HTTPBasicAuth
from pathlib import Path
import time
upload_url = sys.argv[1]
artifacts = sys.argv[2:]
username = '$ForgejoUser'
password = '$ForgejoPW'
delete_url_template = '${ForgejoUrl}/api/v1/repos/${Repo}/releases/$releaseId/assets/{}'
release_info_url = '${ForgejoUrl}/api/v1/repos/${Repo}/releases/$releaseId'
session = requests.Session()
session.auth = HTTPBasicAuth(username, password)
session.headers.update({'Connection': 'close'})
def upload_with_retry(artifact_path, max_retries=3):
asset_name = Path(artifact_path).name
# Check if asset already exists and delete it
try:
release_response = session.get(release_info_url, timeout=30)
release_response.raise_for_status()
for asset in release_response.json().get('assets', []):
if asset['name'] == asset_name:
delete_resp = session.delete(delete_url_template.format(asset['id']), timeout=30)
delete_resp.raise_for_status()
print(f'[OK] Replaced existing asset {asset_name}', file=sys.stderr)
break
except Exception as e:
print(f'Warning checking existing assets: {e}', file=sys.stderr)
# Upload file with streaming and retries
retryable_status_codes = {429, 502, 503, 504}
for attempt in range(max_retries):
try:
if attempt > 0:
print(f' Retry {attempt} of {max_retries}...', file=sys.stderr)
time.sleep(min(10, 2 * attempt))
with open(artifact_path, 'rb') as f:
files = {'attachment': (asset_name, f)}
response = session.post(
upload_url,
files=files,
timeout=900, # 15 minute timeout
stream=False
)
if response.status_code in [200, 201]:
print(f'[OK] Uploaded {asset_name}')
return True
if response.status_code in retryable_status_codes:
if attempt >= max_retries - 1:
print(f'ERROR uploading {asset_name} (HTTP {response.status_code} after {max_retries} retries)')
print(response.text)
sys.exit(1)
continue
print(f'ERROR uploading {asset_name} (HTTP {response.status_code})')
print(response.text)
sys.exit(1)
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:
if attempt >= max_retries - 1:
print(f'ERROR uploading {asset_name}: Connection failed after {max_retries} retries')
print(str(e))
sys.exit(1)
time.sleep(min(10, 2 * (attempt + 1)))
except Exception as e:
print(f'ERROR uploading {asset_name}: {e}')
sys.exit(1)
for artifact_path in artifacts:
upload_with_retry(artifact_path)
print(f'[OK] All files uploaded successfully')
"@
$uploadScriptPath = ([System.IO.Path]::GetTempFileName() -replace 'tmp$', 'py')
Set-Content -Path $uploadScriptPath -Value $pythonUploadScript -Encoding UTF8
try { try {
$response = curl.exe -s -X POST ` $uploadUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/$releaseId/assets"
-u $curlAuth ` & $pythonExe $uploadScriptPath $uploadUrl @artifactsToUpload
-F "attachment=@$MsiPath" ` if ($LASTEXITCODE -ne 0) {
$uploadUrl
if ($response -like "*error*" -or $response -like "*404*") {
Write-Host "ERROR uploading MSI: $response" -ForegroundColor Red
exit 1 exit 1
} }
Write-Host "[OK] MSI installer uploaded" -ForegroundColor Green
} }
catch { finally {
Write-Host "ERROR uploading MSI: $_" -ForegroundColor Red Remove-Item $uploadScriptPath -ErrorAction SilentlyContinue
exit 1
}
# Step 3: Upload executable as optional artifact (if available)
if (-not $SkipExe) {
Write-Host "`nUploading executable (optional portable version)..." -ForegroundColor Yellow
try {
$response = curl.exe -s -X POST `
-u $curlAuth `
-F "attachment=@$ExePath" `
$uploadUrl
if ($response -like "*error*" -or $response -like "*404*") {
Write-Host "WARNING: Could not upload executable: $response" -ForegroundColor Yellow
}
else {
Write-Host "[OK] Executable uploaded" -ForegroundColor Green
}
}
catch {
Write-Host "WARNING: Could not upload executable: $_" -ForegroundColor Yellow
}
# Step 4: Upload checksum as asset
Write-Host "Uploading checksum..." -ForegroundColor Yellow
try {
$response = curl.exe -s -X POST `
-u $curlAuth `
-F "attachment=@$ChecksumPath" `
$uploadUrl
if ($response -like "*error*" -or $response -like "*404*") {
Write-Host "WARNING: Could not upload checksum: $response" -ForegroundColor Yellow
}
else {
Write-Host "[OK] Checksum uploaded" -ForegroundColor Green
}
}
catch {
Write-Host "WARNING: Could not upload checksum: $_" -ForegroundColor Yellow
}
} }
Write-Host "`n[OK] Release complete!" -ForegroundColor Green Write-Host "`n[OK] Release complete!" -ForegroundColor Green

View file

@ -1,31 +1,34 @@
#!/bin/bash #!/bin/bash
# Create Forgejo Release with Binary Assets # Create or update a shared Forgejo release with branded macOS assets.
# Usage: ./create_release.sh -v 1.0.0
# Uses your Forgejo credentials (same as git)
# First run will prompt for credentials and save them to this session
set -e set -e
# Parse arguments
VERSION="" VERSION=""
FORGEJO_USER="" BRANDS=()
FORGEJO_PASS="" FORGEJO_USER="${FORGEJO_USER}"
FORGEJO_PASS="${FORGEJO_PASS}"
FORGEJO_URL="https://git.him-tools.de" FORGEJO_URL="https://git.him-tools.de"
REPO="HIM-public/webdrop-bridge" REPO="HIM-public/webdrop-bridge"
DMG_PATH="build/dist/macos/WebDropBridge.dmg"
CHECKSUM_PATH="build/dist/macos/WebDropBridge.dmg.sha256"
CLEAR_CREDS=false CLEAR_CREDS=false
DRY_RUN=false
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
BRAND_HELPER="$PROJECT_ROOT/build/scripts/brand_config.py"
MANIFEST_OUTPUT="$PROJECT_ROOT/build/dist/release-manifest.json"
LOCAL_MANIFEST_OUTPUT="$PROJECT_ROOT/build/dist/release-manifest.local.json"
EXISTING_MANIFEST_OUTPUT="$PROJECT_ROOT/build/dist/release-manifest.existing.json"
LOCAL_DATA_OUTPUT="$PROJECT_ROOT/build/dist/release-data.local.json"
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case $1 in case $1 in
-v|--version) VERSION="$2"; shift 2;; -v|--version) VERSION="$2"; shift 2 ;;
-u|--url) FORGEJO_URL="$2"; shift 2;; -u|--url) FORGEJO_URL="$2"; shift 2 ;;
--clear-credentials) CLEAR_CREDS=true; shift;; --brand) BRANDS+=("$2"); shift 2 ;;
*) echo "Unknown option: $1"; exit 1;; --clear-credentials) CLEAR_CREDS=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac esac
done done
# Handle --clear-credentials flag
if [ "$CLEAR_CREDS" = true ]; then if [ "$CLEAR_CREDS" = true ]; then
unset FORGEJO_USER unset FORGEJO_USER
unset FORGEJO_PASS unset FORGEJO_PASS
@ -33,127 +36,193 @@ if [ "$CLEAR_CREDS" = true ]; then
exit 0 exit 0
fi fi
# Load credentials from environment
FORGEJO_USER="${FORGEJO_USER}"
FORGEJO_PASS="${FORGEJO_PASS}"
# Verify required parameters
if [ -z "$VERSION" ]; then if [ -z "$VERSION" ]; then
echo "ERROR: Version parameter required" >&2 VERSION="$(python3 -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$PROJECT_ROOT/build/scripts').resolve())); from version_utils import get_current_version; print(get_current_version())")"
echo "Usage: $0 -v VERSION [-u FORGEJO_URL]" >&2 fi
echo "Example: $0 -v 1.0.0" >&2
LOCAL_ARGS=("$BRAND_HELPER" "local-release-data" "--platform" "macos" "--version" "$VERSION")
if [ ${#BRANDS[@]} -gt 0 ]; then
LOCAL_ARGS+=("--brands" "${BRANDS[@]}")
fi
python3 "${LOCAL_ARGS[@]}" > "$LOCAL_DATA_OUTPUT"
mapfile -t ARTIFACTS < <(python3 - "$LOCAL_DATA_OUTPUT" "$LOCAL_MANIFEST_OUTPUT" <<'PY'
import json
import sys
from pathlib import Path
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
Path(sys.argv[2]).write_text(json.dumps(data["manifest"], indent=2), encoding="utf-8")
for artifact in data["artifacts"]:
print(artifact)
PY
)
for ARTIFACT in "${ARTIFACTS[@]}"; do
if [ -f "$ARTIFACT" ] && [ "${ARTIFACT##*.}" = "dmg" ]; then
DMG_SIZE=$(du -m "$ARTIFACT" | cut -f1)
echo "macOS artifact: $(basename "$ARTIFACT") ($DMG_SIZE MB)"
fi
done
if [ ${#ARTIFACTS[@]} -eq 0 ]; then
echo "ERROR: No local macOS artifacts found"
exit 1 exit 1
fi fi
# If no credentials, prompt user interactively if [ "$DRY_RUN" = true ]; then
cp "$LOCAL_MANIFEST_OUTPUT" "$MANIFEST_OUTPUT"
DISCOVERED_BRANDS=$(python3 - "$LOCAL_DATA_OUTPUT" <<'PY'
import json
import sys
from pathlib import Path
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
print(", ".join(data.get("brands", [])) or "<none>")
PY
)
echo "[DRY RUN] No network requests or uploads will be performed."
echo "[DRY RUN] Release tag: v$VERSION"
echo "[DRY RUN] Release URL: $FORGEJO_URL/$REPO/releases/tag/v$VERSION"
echo "[DRY RUN] Discovered brands: $DISCOVERED_BRANDS"
echo "[DRY RUN] Artifacts that would be uploaded:"
for ARTIFACT in "${ARTIFACTS[@]}"; do
echo " - $ARTIFACT"
done
echo "[DRY RUN] Local manifest preview: $MANIFEST_OUTPUT"
exit 0
fi
if [ -z "$FORGEJO_USER" ] || [ -z "$FORGEJO_PASS" ]; then if [ -z "$FORGEJO_USER" ] || [ -z "$FORGEJO_PASS" ]; then
echo "Forgejo credentials not found. Enter your credentials:" echo "Forgejo credentials not found. Enter your credentials:"
if [ -z "$FORGEJO_USER" ]; then if [ -z "$FORGEJO_USER" ]; then
read -p "Username: " FORGEJO_USER read -r -p "Username: " FORGEJO_USER
fi fi
if [ -z "$FORGEJO_PASS" ]; then if [ -z "$FORGEJO_PASS" ]; then
read -sp "Password: " FORGEJO_PASS read -r -s -p "Password: " FORGEJO_PASS
echo "" echo ""
fi fi
# Export for this session
export FORGEJO_USER export FORGEJO_USER
export FORGEJO_PASS export FORGEJO_PASS
echo "[OK] Credentials saved to this shell session"
echo "Tip: Credentials will persist until you close the terminal or run: $0 --clear-credentials"
fi fi
# Verify files exist
if [ ! -f "$DMG_PATH" ]; then
echo "ERROR: DMG file not found at $DMG_PATH"
exit 1
fi
if [ ! -f "$CHECKSUM_PATH" ]; then
echo "ERROR: Checksum file not found at $CHECKSUM_PATH"
exit 1
fi
echo "Creating WebDropBridge $VERSION release on Forgejo..."
# Get file info
DMG_SIZE=$(du -m "$DMG_PATH" | cut -f1)
CHECKSUM=$(cat "$CHECKSUM_PATH")
echo "File: WebDropBridge.dmg ($DMG_SIZE MB)"
echo "Checksum: ${CHECKSUM:0:16}..."
# Create basic auth
BASIC_AUTH=$(echo -n "${FORGEJO_USER}:${FORGEJO_PASS}" | base64) BASIC_AUTH=$(echo -n "${FORGEJO_USER}:${FORGEJO_PASS}" | base64)
# Step 1: Create release
echo ""
echo "Creating release v$VERSION..."
RELEASE_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases" RELEASE_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases"
RELEASE_LOOKUP_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases/tags/v$VERSION"
RELEASE_DATA=$(cat <<EOF RELEASE_RESPONSE_FILE=$(mktemp)
HTTP_CODE=$(curl -s -o "$RELEASE_RESPONSE_FILE" -w "%{http_code}" -H "Authorization: Basic $BASIC_AUTH" "$RELEASE_LOOKUP_URL")
if [ "$HTTP_CODE" = "200" ]; then
RELEASE_ID=$(python3 - "$RELEASE_RESPONSE_FILE" <<'PY'
import json
import sys
from pathlib import Path
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
print(payload.get("id", ""))
PY
)
else
RELEASE_ID=""
fi
if [ -z "$RELEASE_ID" ]; then
RELEASE_DATA=$(cat <<EOF
{ {
"tag_name": "v$VERSION", "tag_name": "v$VERSION",
"name": "WebDropBridge v$VERSION", "name": "WebDropBridge v$VERSION",
"body": "WebDropBridge v$VERSION\n\nChecksum: $CHECKSUM", "body": "Shared branded release for WebDrop Bridge v$VERSION",
"draft": false, "draft": false,
"prerelease": false "prerelease": false
} }
EOF EOF
) )
HTTP_CODE=$(curl -s -o "$RELEASE_RESPONSE_FILE" -w "%{http_code}" -X POST \
-H "Authorization: Basic $BASIC_AUTH" \
-H "Content-Type: application/json" \
-d "$RELEASE_DATA" \
"$RELEASE_URL")
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "201" ]; then
RELEASE_ID=$(python3 - "$RELEASE_RESPONSE_FILE" <<'PY'
import json
import sys
from pathlib import Path
RESPONSE=$(curl -s -X POST \ payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
-H "Authorization: Basic $BASIC_AUTH" \ print(payload.get("id", ""))
-H "Content-Type: application/json" \ PY
-d "$RELEASE_DATA" \ )
"$RELEASE_URL") fi
fi
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
if [ -z "$RELEASE_ID" ]; then if [ -z "$RELEASE_ID" ]; then
echo "ERROR creating release:" echo "ERROR creating or finding release"
echo "$RESPONSE" cat "$RELEASE_RESPONSE_FILE"
exit 1 exit 1
fi fi
echo "[OK] Release created (ID: $RELEASE_ID)" MANIFEST_URL=$(python3 - "$RELEASE_RESPONSE_FILE" <<'PY'
import json
import sys
from pathlib import Path
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
for asset in payload.get("assets", []):
if asset.get("name") == "release-manifest.json":
print(asset.get("browser_download_url", ""))
break
PY
)
if [ -n "$MANIFEST_URL" ]; then
curl -s -H "Authorization: Basic $BASIC_AUTH" "$MANIFEST_URL" -o "$EXISTING_MANIFEST_OUTPUT"
python3 "$BRAND_HELPER" merge-manifests --base "$EXISTING_MANIFEST_OUTPUT" --overlay "$LOCAL_MANIFEST_OUTPUT" --output "$MANIFEST_OUTPUT" >/dev/null
else
cp "$LOCAL_MANIFEST_OUTPUT" "$MANIFEST_OUTPUT"
fi
ARTIFACTS+=("$MANIFEST_OUTPUT")
# Step 2: Upload DMG as asset
echo "Uploading executable asset..."
UPLOAD_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets" UPLOAD_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets"
for ARTIFACT in "${ARTIFACTS[@]}"; do
ASSET_NAME="$(basename "$ARTIFACT")"
EXISTING_ASSET_ID=$(python3 - "$RELEASE_RESPONSE_FILE" "$ASSET_NAME" <<'PY'
import json
import sys
from pathlib import Path
HTTP_CODE=$(curl -s -w "%{http_code}" -X POST \ payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
-H "Authorization: Basic $BASIC_AUTH" \ asset_name = sys.argv[2]
-F "attachment=@$DMG_PATH" \ for asset in payload.get("assets", []):
"$UPLOAD_URL" \ if asset.get("name") == asset_name:
-o /tmp/curl_response.txt) print(asset.get("id", ""))
break
PY
)
if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 200 ]; then if [ -n "$EXISTING_ASSET_ID" ]; then
echo "[OK] DMG uploaded" curl -s -X DELETE \
else -H "Authorization: Basic $BASIC_AUTH" \
echo "ERROR uploading DMG (HTTP $HTTP_CODE)" "$FORGEJO_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets/$EXISTING_ASSET_ID" >/dev/null
cat /tmp/curl_response.txt echo "[OK] Replaced existing asset $ASSET_NAME"
exit 1 fi
fi
# Step 3: Upload checksum as asset HTTP_CODE=$(curl -s -w "%{http_code}" -X POST \
echo "Uploading checksum asset..." -H "Authorization: Basic $BASIC_AUTH" \
-F "attachment=@$ARTIFACT" \
"$UPLOAD_URL" \
-o /tmp/curl_response.txt)
HTTP_CODE=$(curl -s -w "%{http_code}" -X POST \ if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 200 ]; then
-H "Authorization: Basic $BASIC_AUTH" \ echo "[OK] Uploaded $ASSET_NAME"
-F "attachment=@$CHECKSUM_PATH" \ else
"$UPLOAD_URL" \ echo "ERROR uploading $ASSET_NAME (HTTP $HTTP_CODE)"
-o /tmp/curl_response.txt) cat /tmp/curl_response.txt
exit 1
if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 200 ]; then fi
echo "[OK] Checksum uploaded" done
else
echo "ERROR uploading checksum (HTTP $HTTP_CODE)"
cat /tmp/curl_response.txt
exit 1
fi
echo "" echo ""
echo "[OK] Release complete!" echo "[OK] Release complete!"

View file

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

View file

@ -1,6 +1,12 @@
{ {
"app_name": "WebDrop Bridge", "brand_id": "agravity",
"config_dir_name": "agravity_bridge",
"app_name": "Agravity Bridge",
"webapp_url": "https://dev.agravity.io/", "webapp_url": "https://dev.agravity.io/",
"update_base_url": "https://git.him-tools.de",
"update_repo": "HIM-public/webdrop-bridge",
"update_channel": "stable",
"update_manifest_name": "release-manifest.json",
"url_mappings": [ "url_mappings": [
{ {
"url_prefix": "https://devagravitystg.file.core.windows.net/devagravitysync/", "url_prefix": "https://devagravitystg.file.core.windows.net/devagravitysync/",

View file

@ -1,5 +1,9 @@
# Architecture Guide # Architecture Guide
## Related Docs
- [Translations Guide (i18n)](TRANSLATIONS_GUIDE.md)
## High-Level Design ## High-Level Design
``` ```

View file

@ -0,0 +1,488 @@
# Branding, Builds, and Releases
This document describes how branded builds work in this repository, how to add or edit a brand, how to build the default and branded variants, and how to publish releases.
## Overview
The project supports one default product and any number of branded variants from the same codebase.
- The default product is defined by built-in defaults in `build/scripts/brand_config.py`.
- The default product identifier is `webdrop_bridge`.
- Additional brands are defined by JSON manifests in `build/brands/`.
- Runtime behavior can also be branded through application config values such as `brand_id`, `config_dir_name`, `app_name`, and update settings.
- Windows and macOS installers are built as separate artifacts per brand.
- Releases are shared by version. A single Forgejo release can contain installers for the default product and multiple brands.
## Branding Model
There are two layers to branding:
1. Packaging identity
Controls installer name, executable/app bundle name, product display name, bundle identifier, MSI upgrade code, installer artwork, and related metadata.
2. Runtime configuration
Controls app name shown in the UI, config directory name, update feed settings, URL mappings, allowed roots, and similar application behavior.
Packaging identity lives in `build/brands/<brand>.json`.
Runtime configuration lives in app config files loaded by the application. See `config.example.json` for the current branded example.
## Important Files
- `build/scripts/brand_config.py`: central helper for brand metadata, artifact naming, and release manifest generation
- `build/brands/agravity.json`: example branded manifest
- `build/scripts/build_windows.py`: Windows build entrypoint
- `build/scripts/build_macos.sh`: macOS build entrypoint
- `build/scripts/create_release.ps1`: Windows release uploader
- `build/scripts/create_release.sh`: macOS release uploader
- `config.example.json`: example runtime branding config
## Create a New Brand
To create a new brand, add a new manifest file under `build/brands/`.
Example:
1. Copy `build/brands/template.jsonc` to `build/brands/<new-brand>.json`.
2. Update the values for the new brand.
3. Add any brand-specific assets if you do not want to reuse the default icons/license assets.
Minimal example:
```json
{
"brand_id": "customerx",
"display_name": "Customer X Bridge",
"asset_prefix": "CustomerXBridge",
"exe_name": "CustomerXBridge",
"manufacturer": "Customer X",
"install_dir_name": "Customer X Bridge",
"shortcut_description": "Customer X drag-and-drop bridge",
"bundle_identifier": "com.customerx.bridge",
"config_dir_name": "customerx_bridge",
"msi_upgrade_code": "PUT-A-NEW-GUID-HERE",
"update_channel": "stable",
"icon_ico": "resources/icons/app.ico",
"icon_icns": "resources/icons/app.icns",
"dialog_bmp": "resources/icons/background.bmp",
"banner_bmp": "resources/icons/banner.bmp",
"license_rtf": "resources/license.rtf"
}
```
### Required Fields
- `brand_id`: internal identifier used for build output folders and release manifest entries
- `display_name`: user-facing product name
- `asset_prefix`: base name for installer artifacts and app bundle name
- `exe_name`: executable name for Windows and app bundle name base for macOS
- `manufacturer`: MSI manufacturer string
- `install_dir_name`: installation directory name shown to the OS
- `shortcut_description`: Windows shortcut description
- `bundle_identifier`: macOS bundle identifier
- `config_dir_name`: local app config/log/cache directory name
- `msi_upgrade_code`: stable GUID for Windows upgrades
- `update_channel`: currently typically `stable`
Generate a new `msi_upgrade_code` for a new brand once and keep it stable afterwards.
Examples:
```powershell
New-Guid
```
```bash
uuidgen
```
### Asset Fields
These can point at brand-specific files or default shared files:
- `icon_ico`
- `icon_icns`
- `dialog_bmp`
- `banner_bmp`
- `license_rtf`
Optional toolbar icon overrides:
- `toolbar_icon_home`
- `toolbar_icon_reload`
- `toolbar_icon_open`
- `toolbar_icon_openwith`
If a referenced asset path does not exist, the helper falls back to the default asset defined in `build/scripts/brand_config.py`.
For toolbar icons, the runtime looks for the configured paths in packaged and development layouts. If an icon is missing:
- Home falls back to a standard Qt home icon
- Reload/Open/OpenWith keep their existing icon behavior
### Identity Rules
Treat these values as long-lived product identity once a brand has shipped:
- `brand_id`
- `asset_prefix`
- `exe_name`
- `bundle_identifier`
- `config_dir_name`
- `msi_upgrade_code`
Changing them later can break one or more of the following:
- Windows upgrade behavior
- macOS app identity
- auto-update asset selection
- local config/log/cache continuity
- installer and artifact naming consistency
If the product is already in use, only change these values deliberately and with migration planning.
## Edit an Existing Brand
To edit a shipped or in-progress brand:
1. Update the brand manifest in `build/brands/<brand>.json`.
2. If needed, update brand-specific assets referenced by that manifest.
3. If runtime behavior should also change, update the relevant application config values.
4. Rebuild the affected platform artifacts.
5. Validate the result with a dry-run release before publishing.
Safe edits after release usually include:
- `display_name`
- `shortcut_description`
- artwork paths
- license text
- update channel, if release policy changes
High-risk edits after release are the identity fields listed above.
## Runtime Branding Configuration
Packaging branding alone is not enough if the app should also present a different name, use different local storage, or point to different update settings.
Relevant runtime config keys include:
- `brand_id`
- `config_dir_name`
- `app_name`
- `update_base_url`
- `update_repo`
- `update_channel`
- `update_manifest_name`
Toolbar icon env overrides (useful for packaged branding):
- `TOOLBAR_ICON_HOME`
- `TOOLBAR_ICON_RELOAD`
- `TOOLBAR_ICON_OPEN`
- `TOOLBAR_ICON_OPENWITH`
The current example in `config.example.json` shows the Agravity runtime setup.
When adding a new brand, make sure the runtime config matches the packaging manifest at least for:
- `brand_id`
- `config_dir_name`
- `app_name`
## Build the Default Product
### Windows
Build the default executable only:
```powershell
python .\build\scripts\build_windows.py
```
Build the default Windows MSI:
```powershell
python .\build\scripts\build_windows.py --msi
```
Build with a specific `.env` file:
```powershell
python .\build\scripts\build_windows.py --msi --env-file .\.env
```
### macOS
Build the default macOS app and DMG:
```bash
bash build/scripts/build_macos.sh
```
Build with a specific `.env` file:
```bash
bash build/scripts/build_macos.sh --env-file .env
```
## Build a Brand
### Windows
Build a branded executable only:
```powershell
python .\build\scripts\build_windows.py --brand agravity
```
Build a branded MSI:
```powershell
python .\build\scripts\build_windows.py --brand agravity --msi
```
### macOS
Build a branded macOS app and DMG:
```bash
bash build/scripts/build_macos.sh --brand agravity
```
## Build Output Locations
Windows artifacts are written to:
- `build/dist/windows/webdrop_bridge/` for the default product
- `build/dist/windows/<brand_id>/` for branded products
macOS artifacts are written to:
- `build/dist/macos/webdrop_bridge/` for the default product
- `build/dist/macos/<brand_id>/` for branded products
Typical artifact names:
- Windows MSI: `<asset_prefix>-<version>-win-x64.msi`
- Windows checksum: `<asset_prefix>-<version>-win-x64.msi.sha256`
- macOS DMG: `<asset_prefix>-<version>-macos-universal.dmg`
- macOS checksum: `<asset_prefix>-<version>-macos-universal.dmg.sha256`
## Create a Release
Releases are shared by version. The release scripts scan local build outputs on the current machine and upload every artifact they find for that platform.
This means:
- a Windows machine can upload all locally built MSIs for the current version
- a macOS machine can later upload all locally built DMGs for the same version
- both runs contribute to the same Forgejo release tag
- `release-manifest.json` is merged so later runs do not wipe earlier platform entries
### Windows Release
Dry run first:
```powershell
.\build\scripts\create_release.ps1 -DryRun
```
Publish all locally built Windows variants for the current version:
```powershell
.\build\scripts\create_release.ps1
```
Publish only selected brands:
```powershell
.\build\scripts\create_release.ps1 -Brands agravity
```
Publish only the default product:
```powershell
.\build\scripts\create_release.ps1 -Brands webdrop_bridge
```
Publish a specific version:
```powershell
.\build\scripts\create_release.ps1 -Version 0.8.4
```
### macOS Release
Dry run first:
```bash
bash build/scripts/create_release.sh --dry-run
```
Publish all locally built macOS variants for the current version:
```bash
bash build/scripts/create_release.sh
```
Publish only selected brands:
```bash
bash build/scripts/create_release.sh --brand agravity
```
Publish only the default product:
```bash
bash build/scripts/create_release.sh --brand webdrop_bridge
```
Publish a specific version:
```bash
bash build/scripts/create_release.sh --version 0.8.4
```
### Credentials
Both release scripts use Forgejo credentials from environment variables when available:
- `FORGEJO_USER`
- `FORGEJO_PASS`
If they are not set and you are not in dry-run mode, the script prompts for them.
Both scripts also support clearing credentials from the current shell session:
- Windows: `-ClearCredentials`
- macOS: `--clear-credentials`
## Dry Run Behavior
Dry-run mode is the preferred validation step before publishing.
Dry-run mode:
- discovers the local artifacts exactly like a real release run
- prints the release tag and target release URL
- prints the brands that were discovered locally
- prints the artifact paths that would be uploaded
- writes a local manifest preview to `build/dist/release-manifest.json`
- does not prompt for credentials
- does not perform network requests
- does not delete or upload assets
## Release Manifest
The release scripts generate and upload `release-manifest.json`.
This file is used by the updater to select the correct installer and checksum for a given brand and platform.
Current platform keys are:
- `windows-x64`
- `macos-universal`
The manifest is built from local artifacts and merged with any existing manifest already attached to the release.
## First Manual Download (Before Auto-Update)
After creating a release, a user can manually download the first installer directly from Forgejo. Once installed, auto-update handles later versions.
Base repository URL:
- `https://git.him-tools.de/HIM-public/webdrop-bridge`
Release page pattern:
- `https://git.him-tools.de/HIM-public/webdrop-bridge/releases/tag/v<version>`
Direct asset download pattern:
- `https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v<version>/<asset-file-name>`
Example asset names:
- `WebDropBridge-0.8.4-win-x64.msi`
- `WebDropBridge-0.8.4-macos-universal.dmg`
- `AgravityBridge-0.8.4-win-x64.msi`
- `AgravityBridge-0.8.4-macos-universal.dmg`
### wget Examples
```bash
# Default Windows installer
wget "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/WebDropBridge-0.8.4-win-x64.msi"
# Agravity macOS installer
wget "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/AgravityBridge-0.8.4-macos-universal.dmg"
```
### curl Examples
```bash
# Default macOS installer
curl -L -o WebDropBridge-0.8.4-macos-universal.dmg \
"https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/WebDropBridge-0.8.4-macos-universal.dmg"
# Agravity Windows installer
curl -L -o AgravityBridge-0.8.4-win-x64.msi \
"https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/AgravityBridge-0.8.4-win-x64.msi"
```
### PowerShell Example
```powershell
Invoke-WebRequest `
-Uri "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/WebDropBridge-0.8.4-win-x64.msi" `
-OutFile "WebDropBridge-0.8.4-win-x64.msi"
```
You can inspect `release-manifest.json` on the release to see the exact file names for each brand and platform.
## Recommended Workflow for a New Brand
1. Create `build/brands/<brand>.json`.
2. Add or update brand-specific assets if needed.
3. Prepare matching runtime config values.
4. Build the brand on Windows and/or macOS.
5. Run the release script in dry-run mode.
6. Verify artifact names, discovered brands, and manifest contents.
7. Run the actual release script.
8. Validate update behavior against the shared release.
## Troubleshooting Notes
### Brand not discovered by release script
Check that:
- the build completed successfully
- the artifact is under the expected platform folder
- the artifact name matches the `asset_prefix` and current version
- the version used by the release script matches the built artifact version
### Windows upgrade behavior is wrong
Check that the brand has its own stable `msi_upgrade_code`. Reusing or changing it incorrectly will break expected MSI upgrade semantics.
### App uses the wrong local config folder
Check that runtime config uses the intended `config_dir_name`, and that it matches the packaging brand you expect.
### Auto-update downloads the wrong installer
Check that:
- the release contains the correct installer files
- `release-manifest.json` includes the correct brand and platform entry
- runtime update settings point to the expected repo/channel/manifest
## Current Example Brand
The first branded variant currently in the repository is:
- `build/brands/agravity.json`
Use it as the template for future branded variants.

205
docs/TRANSLATIONS_GUIDE.md Normal file
View file

@ -0,0 +1,205 @@
# Translations Guide (i18n)
This document explains how to:
- add a new language
- edit an existing language
- update translations when new text is added in the app
The app uses JSON-based translations loaded from:
- resources/translations/
## 1. Translation System Overview
Main components:
- src/webdrop_bridge/utils/i18n.py
- Loads language JSON files
- Provides tr("key", **kwargs)
- Falls back to English if a key is missing
- src/webdrop_bridge/main.py
- Initializes i18n at app startup
- src/webdrop_bridge/config.py
- Stores selected language in config (language field)
- src/webdrop_bridge/ui/settings_dialog.py
- Language selector in Settings -> General
Current language files:
- resources/translations/en.json
- resources/translations/de.json
- resources/translations/fr.json
- resources/translations/it.json
- resources/translations/ru.json
- resources/translations/zh.json
## 2. Add a New Language
Example: add Spanish (es).
1. Create a new file:
- resources/translations/es.json
2. Copy the full structure from English:
- Copy resources/translations/en.json to resources/translations/es.json
3. Translate all values in es.json:
- Keep all keys exactly the same
- Only change text values
- Keep placeholders unchanged, for example:
- {name}
- {version}
- {error}
4. Add language display name in i18n helper:
- Edit src/webdrop_bridge/utils/i18n.py
- In Translator.BUILTIN_LANGUAGES add:
- "es": "Español"
5. Start app and test:
- Choose language in Settings -> General
- Restart app when prompted
- Verify tooltips, dialogs, status texts, settings labels, update dialogs
## 3. Edit an Existing Language
1. Open the language file, for example:
- resources/translations/de.json
2. Update only text values.
3. Do not:
- remove keys
- rename keys
- change placeholder names
4. Validate JSON formatting:
- Must be valid JSON
- Keep UTF-8 encoding
5. Test in app:
- Select language in Settings
- Restart and verify changed text appears
## 4. When New Text Is Added in the App
Whenever new UI text is introduced in code, follow this process.
### Step A: Add a new translation key in code
Instead of hardcoded text, use tr("...") with a key.
Example:
- Before: QLabel("Check for Updates")
- After: QLabel(tr("toolbar.tooltip.check_updates"))
If dynamic text is needed:
- tr("update.status.available", version=release.version)
### Step B: Add the key to English first
1. Add the new key in:
- resources/translations/en.json
2. Use clear key naming by area, for example:
- toolbar.tooltip.*
- dialog.*
- settings.*
- update.*
- status.*
- worker.*
### Step C: Add the same key to all other language files
Update each file in resources/translations:
- de.json
- fr.json
- it.json
- ru.json
- zh.json
- and any new language file
If translation is not ready yet, copy English temporarily (better than missing key text in UI).
### Step D: Test fallback and real translations
1. Run app in English and verify new text.
2. Run app in other languages and verify translated text.
3. Confirm no raw key appears in UI (for example: dialog.my_new_key).
## 5. Placeholder Rules
Placeholders must match exactly between code and translation values.
If code uses:
- tr("status.opened", name=file_name)
Then translation must contain:
- "status.opened": "Opened: {name}"
Common mistakes:
- wrong placeholder name ({filename} vs {name})
- missing placeholder
- extra placeholder not passed by code
## 6. Recommended Workflow for Translation Updates
1. Implement UI text with tr("key") in code.
2. Add key to en.json.
3. Copy key to all language files.
4. Run tests.
5. Smoke test manually in app.
Useful test command:
- python -m pytest tests/unit/test_i18n.py -q
Recommended additional checks when UI changed:
- python -m pytest tests/unit/test_settings_dialog.py tests/unit/test_update_manager_ui.py tests/unit/test_startup_check.py -q
## 7. Troubleshooting
### Problem: Language changed in settings but UI language did not change
Expected behavior:
- language is applied after restart
Check:
- language value saved in config file
- restart prompt appears after changing language
- selected language JSON file exists and is valid
### Problem: UI shows translation key text instead of real text
Example shown in UI:
- settings.title
Cause:
- key missing in selected language and missing in en.json fallback
Fix:
- add key to en.json
- add key to selected language file
### Problem: Text formatting errors
Cause:
- placeholder mismatch
Fix:
- compare tr(...) arguments in code with placeholders in translation string
## 8. Best Practices
- Keep en.json as complete source of truth.
- Keep key names stable once released.
- Group keys by feature area.
- Prefer short, user-friendly text in UI.
- Use formal, consistent tone per language.
- Review non-Latin languages (RU/ZH) with a native speaker when possible.
## 9. Quick Checklist
When adding new text:
- Add tr("new.key") in code
- Add key in en.json
- Add key in all other language files
- Verify placeholders
- Run i18n and impacted UI tests
- Manual in-app check with at least one non-English language

BIN
resources/icons/home.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

BIN
resources/icons/open.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

BIN
resources/icons/reload.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

View file

@ -0,0 +1,172 @@
{
"toolbar.tooltip.open_drop": "Datei hier ablegen, um sie mit der Standardanwendung zu \u00f6ffnen",
"toolbar.tooltip.open_with_drop": "Datei hier ablegen, um die \u00d6ffnen-mit-App auszuw\u00e4hlen",
"toolbar.tooltip.home": "Startseite",
"toolbar.tooltip.about": "\u00dcber WebDrop Bridge",
"toolbar.tooltip.settings": "Einstellungen",
"toolbar.tooltip.check_updates": "Nach Updates suchen",
"toolbar.tooltip.clear_cache": "Cache und Cookies l\u00f6schen",
"toolbar.tooltip.open_log": "Protokolldatei \u00f6ffnen",
"toolbar.tooltip.dev_tools": "Entwicklerwerkzeuge (F12)",
"status.ready": "Bereit",
"status.opened": "Ge\u00f6ffnet: {name}",
"status.choose_app": "App ausw\u00e4hlen f\u00fcr: {name}",
"status.download_started": "\ud83d\udce5 Download: {filename}",
"status.download_completed": "Download abgeschlossen: {name}",
"status.download_cancelled": "\u26a0\ufe0f Download abgebrochen: {name}",
"status.download_failed": "\u274c Download fehlgeschlagen: {name}",
"status.download_error": "Downloadfehler: {error}",
"update.status.checking": "Suche nach Updates",
"update.status.ready": "Bereit",
"update.status.available": "Update verf\u00fcgbar: v{version}",
"update.status.deferred": "Update verschoben",
"update.status.downloading": "Lade v{version} herunter",
"update.status.verifying": "Pr\u00fcfe Download",
"update.status.download_failed": "Download fehlgeschlagen",
"update.status.verification_failed": "Pr\u00fcfung fehlgeschlagen",
"update.status.timed_out": "Zeitüberschreitung",
"update.status.ready_to_install": "Bereit zur Installation",
"update.status.installation_started": "Installation gestartet",
"update.status.installation_failed": "Installation fehlgeschlagen",
"update.status.check_timed_out": "Zeitüberschreitung \u2013 keine Serverantwort",
"update.status.check_failed": "Fehler: {error}",
"update.status.download_timed_out": "Zeitüberschreitung beim Download",
"dialog.error.title": "Fehler",
"dialog.log_not_found.title": "Protokolldatei nicht gefunden",
"dialog.log_not_found.msg": "Keine Protokolldatei gefunden unter:\n{log_file}",
"dialog.cache_cleared.title": "Cache geleert",
"dialog.cache_cleared.msg": "Browser-Cache und Cookies wurden erfolgreich geleert.\n\nBitte laden Sie die Seite neu oder starten Sie die Anwendung neu, damit die \u00c4nderungen wirksam werden.",
"dialog.cache_clear_failed.title": "Fehler",
"dialog.cache_clear_failed.msg": "Fehler beim Leeren von Cache und Cookies: {error}",
"dialog.drag_error.title": "Drag-and-Drop-Fehler",
"dialog.drag_error.msg": "Der Drag-and-Drop-Vorgang konnte nicht abgeschlossen werden.\n\nFehler: {error}",
"dialog.open_file_error.title": "Fehler beim \u00d6ffnen",
"dialog.open_file_error.msg": "Die Datei konnte nicht mit der Standardanwendung ge\u00f6ffnet werden.\n\nDatei: {file_path}\nFehler: {error}",
"dialog.open_with_error.title": "\u00d6ffnen mit \u2013 Fehler",
"dialog.open_with_error.msg": "Auf dieser Plattform konnte kein Anwendungsauswahldialog ge\u00f6ffnet werden.",
"dialog.dev_tools.window_title": "\ud83d\udd27 Entwicklerwerkzeuge",
"dialog.dev_tools.error_title": "Entwicklerwerkzeuge",
"dialog.dev_tools.error_msg": "Entwicklerwerkzeuge konnten nicht ge\u00f6ffnet werden:\n{error}",
"dialog.domain_changed.title": "Domain ge\u00e4ndert \u2013 Neustart empfohlen",
"dialog.domain_changed.msg": "Die Web-Anwendungs-Domain wurde ge\u00e4ndert\n\nSie haben zu einer anderen Domain gewechselt. F\u00fcr maximale Stabilit\u00e4t und korrekte Authentifizierung sollte die Anwendung neu gestartet werden.\n\nProfil und Cache wurden geleert, aber ein Neustart wird empfohlen.",
"dialog.domain_changed.restart_now": "Jetzt neu starten",
"dialog.domain_changed.restart_later": "Sp\u00e4ter neu starten",
"dialog.language_changed.title": "Sprache ge\u00e4ndert",
"dialog.language_changed.msg": "Die Spracheinstellung wurde aktualisiert. Starten Sie jetzt neu, um die ausgew\u00e4hlte Sprache \u00fcberall anzuwenden.",
"dialog.language_changed.restart_now": "Jetzt neu starten",
"dialog.language_changed.restart_later": "Sp\u00e4ter neu starten",
"dialog.restart_failed.title": "Neustart fehlgeschlagen",
"dialog.restart_failed.msg": "Die Anwendung konnte nicht automatisch neu gestartet werden:\n\n{error}\n\nBitte starten Sie manuell neu.",
"dialog.update_timeout.title": "Zeitüberschreitung bei der Update-Pr\u00fcfung",
"dialog.update_timeout.msg": "Der Server hat nicht innerhalb von 30 Sekunden geantwortet.\n\nM\u00f6glicherweise liegt ein Netzwerkproblem oder eine Serverunavailability vor.\n\nBitte \u00fcberpr\u00fcfen Sie Ihre Verbindung und versuchen Sie es erneut.",
"dialog.update_failed.title": "Update-Pr\u00fcfung fehlgeschlagen",
"dialog.update_failed.msg": "Updates konnten nicht gepr\u00fcft werden:\n\n{error}\n\nBitte versuchen Sie es sp\u00e4ter erneut.",
"dialog.download_failed.title": "Download fehlgeschlagen",
"dialog.download_failed.msg": "Das Update konnte nicht heruntergeladen werden:\n\n{error}\n\nBitte versuchen Sie es sp\u00e4ter erneut.",
"dialog.checkout.title": "Asset auschecken",
"dialog.checkout.msg": "M\u00f6chten Sie dieses Asset auschecken?\n\n{filename}",
"about.title": "\u00dcber {app_name}",
"about.version": "Version: {version}",
"about.description": "Verbindet webbasierte Drag-and-Drop-Workflows mit nativen Dateioperationen f\u00fcr professionelle Desktop-Anwendungen.",
"about.drop_zones_title": "Toolbar-Ablagezonen:",
"about.open_icon_desc": "\u00d6ffnen-Symbol: \u00d6ffnet abgelegte Dateien mit der Standard-App.",
"about.open_with_icon_desc": "\u00d6ffnen-mit-Symbol: Zeigt einen App-Auswahldialog f\u00fcr abgelegte Dateien.",
"about.product_of": "Ein Produkt von:",
"about.rights": "\u00a9 2026 h\u00f6rl Information Management GmbH. Alle Rechte vorbehalten.",
"settings.title": "Einstellungen",
"settings.tab.web_source": "Web-Quelle",
"settings.tab.paths": "Pfade",
"settings.tab.urls": "URLs",
"settings.tab.logging": "Protokollierung",
"settings.tab.window": "Fenster",
"settings.tab.profiles": "Profile",
"settings.tab.general": "Allgemein",
"settings.web_url.label": "Web-Anwendungs-URL:",
"settings.web_url.placeholder": "z.B. http://localhost:8080 oder file:///./webapp/index.html",
"settings.web_url.open_btn": "\u00d6ffnen",
"settings.url_mappings.label": "URL-Zuordnungen (Azure Blob Storage \u2192 Lokale Pfade):",
"settings.url_mappings.col_prefix": "URL-Pr\u00e4fix",
"settings.url_mappings.col_path": "Lokaler Pfad",
"settings.url_mappings.add_btn": "Zuordnung hinzuf\u00fcgen",
"settings.url_mappings.edit_btn": "Auswahl bearbeiten",
"settings.url_mappings.remove_btn": "Auswahl entfernen",
"settings.paths.label": "Erlaubte Stammverzeichnisse f\u00fcr den Dateizugriff:",
"settings.paths.add_btn": "Pfad hinzuf\u00fcgen",
"settings.paths.remove_btn": "Auswahl entfernen",
"settings.urls.label": "Erlaubte Web-URLs (unterst\u00fctzt Platzhalter wie http://*.example.com):",
"settings.urls.add_btn": "URL hinzuf\u00fcgen",
"settings.urls.remove_btn": "Auswahl entfernen",
"settings.log_level.label": "Protokollstufe:",
"settings.log_file.label": "Protokolldatei (optional):",
"settings.log_file.browse_btn": "Durchsuchen...",
"settings.window.width_label": "Fensterbreite:",
"settings.window.height_label": "Fensterh\u00f6he:",
"settings.profiles.label": "Gespeicherte Konfigurationsprofile:",
"settings.profiles.save_btn": "Als Profil speichern",
"settings.profiles.load_btn": "Profil laden",
"settings.profiles.delete_btn": "Profil l\u00f6schen",
"settings.profiles.export_btn": "Konfiguration exportieren",
"settings.profiles.import_btn": "Konfiguration importieren",
"settings.general.language_label": "Sprache:",
"settings.general.language_auto": "Systemstandard (Auto)",
"settings.general.language_restart_note": "Sprach\u00e4nderung wirksam nach Neustart.",
"settings.add_mapping.url_title": "URL-Zuordnung hinzuf\u00fcgen",
"settings.add_mapping.url_prompt": "Azure Blob Storage URL-Pr\u00e4fix eingeben:\n(z.B. https://myblob.blob.core.windows.net/container/)",
"settings.add_mapping.path_prompt": "Lokalen Dateisystempfad eingeben:\n(z.B. C:\\Freigabe oder /mnt/share)",
"settings.edit_mapping.title": "URL-Zuordnung bearbeiten",
"settings.edit_mapping.url_prompt": "Azure Blob Storage URL-Pr\u00e4fix eingeben:",
"settings.edit_mapping.path_prompt": "Lokalen Dateisystempfad eingeben:",
"settings.add_url.title": "URL hinzuf\u00fcgen",
"settings.add_url.prompt": "URL-Muster eingeben (z.B. http://example.com oder http://*.example.com):",
"settings.profile.save.title": "Profil speichern",
"settings.profile.save.prompt": "Profilnamen eingeben (z.B. Arbeit, Privat):",
"settings.select_directory.title": "Verzeichnis ausw\u00e4hlen",
"settings.select_log_file.title": "Protokolldatei ausw\u00e4hlen",
"settings.export_config.title": "Konfiguration exportieren",
"settings.import_config.title": "Konfiguration importieren",
"settings.error.select_mapping": "Bitte w\u00e4hlen Sie eine Zuordnung zur Bearbeitung aus",
"settings.error.select_profile_load": "Bitte w\u00e4hlen Sie ein Profil zum Laden aus",
"settings.error.select_profile_delete": "Bitte w\u00e4hlen Sie ein Profil zum L\u00f6schen aus",
"update.checking.title": "Update-Pr\u00fcfung",
"update.checking.label": "Suche nach Updates...",
"update.checking.timeout_info": "Dies kann bis zu 10 Sekunden dauern",
"update.available.title": "Update verf\u00fcgbar",
"update.available.header": "WebDrop Bridge v{version} ist verf\u00fcgbar",
"update.available.changelog_label": "Versionshinweise:",
"update.available.update_now_btn": "Jetzt aktualisieren",
"update.available.later_btn": "Sp\u00e4ter",
"update.downloading.title": "Update wird heruntergeladen",
"update.downloading.header": "Update wird heruntergeladen...",
"update.downloading.preparing": "Download wird vorbereitet",
"update.downloading.filename": "Lade herunter: {filename}",
"update.downloading.cancel_btn": "Abbrechen",
"update.install.title": "Update installieren",
"update.install.header": "Bereit zur Installation",
"update.install.message": "Das Update ist zur Installation bereit. Die Anwendung wird neu gestartet.",
"update.install.warning": "\u26a0\ufe0f Bitte speichern Sie alle nicht gespeicherten Arbeiten vor dem Fortfahren.\nDie Anwendung wird geschlossen und neu gestartet.",
"update.install.now_btn": "Jetzt installieren",
"update.install.cancel_btn": "Abbrechen",
"update.no_update.title": "Keine Updates verf\u00fcgbar",
"update.no_update.message": "\u2713 Sie verwenden die neueste Version",
"update.no_update.info": "WebDrop Bridge ist auf dem neuesten Stand.",
"update.no_update.ok_btn": "OK",
"update.error.title": "Update fehlgeschlagen",
"update.error.header": "\u26a0\ufe0f Update fehlgeschlagen",
"update.error.info": "Bitte versuchen Sie es erneut oder besuchen Sie die Website, um das Update manuell herunterzuladen.",
"update.error.retry_btn": "Wiederholen",
"update.error.manual_btn": "Manuell herunterladen",
"update.error.cancel_btn": "Abbrechen",
"worker.server_not_responding": "Server antwortet nicht \u2013 bitte sp\u00e4ter erneut pr\u00fcfen",
"worker.no_installer": "Kein Installationspaket in der Version gefunden",
"worker.checksum_failed": "Pr\u00fcfsummenverifizierung fehlgeschlagen",
"worker.download_timed_out": "Zeitüberschreitung beim Download oder der Verifizierung",
"worker.download_error": "Downloadfehler: {error}",
"worker.check_failed": "Pr\u00fcfung fehlgeschlagen: {error}"
}

View file

@ -0,0 +1,172 @@
{
"toolbar.tooltip.open_drop": "Drop a file here to open it with its default application",
"toolbar.tooltip.open_with_drop": "Drop a file here to choose which app should open it",
"toolbar.tooltip.home": "Home",
"toolbar.tooltip.about": "About WebDrop Bridge",
"toolbar.tooltip.settings": "Settings",
"toolbar.tooltip.check_updates": "Check for Updates",
"toolbar.tooltip.clear_cache": "Clear Cache and Cookies",
"toolbar.tooltip.open_log": "Open Log File",
"toolbar.tooltip.dev_tools": "Developer Tools (F12)",
"status.ready": "Ready",
"status.opened": "Opened: {name}",
"status.choose_app": "Choose app for: {name}",
"status.download_started": "\ud83d\udce5 Download: {filename}",
"status.download_completed": "Download completed: {name}",
"status.download_cancelled": "\u26a0\ufe0f Download cancelled: {name}",
"status.download_failed": "\u274c Download failed: {name}",
"status.download_error": "Download error: {error}",
"update.status.checking": "Checking for updates",
"update.status.ready": "Ready",
"update.status.available": "Update available: v{version}",
"update.status.deferred": "Update deferred",
"update.status.downloading": "Downloading v{version}",
"update.status.verifying": "Verifying download",
"update.status.download_failed": "Download failed",
"update.status.verification_failed": "Verification failed",
"update.status.timed_out": "Operation timed out",
"update.status.ready_to_install": "Ready to install",
"update.status.installation_started": "Installation started",
"update.status.installation_failed": "Installation failed",
"update.status.check_timed_out": "Check timed out - no server response",
"update.status.check_failed": "Check failed: {error}",
"update.status.download_timed_out": "Download timed out - no server response",
"dialog.error.title": "Error",
"dialog.log_not_found.title": "Log File Not Found",
"dialog.log_not_found.msg": "No log file found at:\n{log_file}",
"dialog.cache_cleared.title": "Cache Cleared",
"dialog.cache_cleared.msg": "Browser cache and cookies have been cleared successfully.\n\nYou may need to reload the page or restart the application for changes to take effect.",
"dialog.cache_clear_failed.title": "Error",
"dialog.cache_clear_failed.msg": "Failed to clear cache and cookies: {error}",
"dialog.drag_error.title": "Drag-and-Drop Error",
"dialog.drag_error.msg": "Could not complete the drag-and-drop operation.\n\nError: {error}",
"dialog.open_file_error.title": "Open File Error",
"dialog.open_file_error.msg": "Could not open the file with its default application.\n\nFile: {file_path}\nError: {error}",
"dialog.open_with_error.title": "Open With Error",
"dialog.open_with_error.msg": "Could not open an application chooser for this file on your platform.",
"dialog.dev_tools.window_title": "\ud83d\udd27 Developer Tools",
"dialog.dev_tools.error_title": "Developer Tools",
"dialog.dev_tools.error_msg": "Could not open Developer Tools:\n{error}",
"dialog.domain_changed.title": "Domain Changed - Restart Recommended",
"dialog.domain_changed.msg": "Web Application Domain Has Changed\n\nYou've switched to a different domain. For maximum stability and to ensure proper authentication, the application should be restarted.\n\nThe profile and cache have been cleared, but we recommend restarting.",
"dialog.domain_changed.restart_now": "Restart Now",
"dialog.domain_changed.restart_later": "Restart Later",
"dialog.language_changed.title": "Language Changed",
"dialog.language_changed.msg": "The language setting was updated. Restart now to apply the selected language everywhere.",
"dialog.language_changed.restart_now": "Restart Now",
"dialog.language_changed.restart_later": "Restart Later",
"dialog.restart_failed.title": "Restart Failed",
"dialog.restart_failed.msg": "Could not automatically restart the application:\n\n{error}\n\nPlease restart manually.",
"dialog.update_timeout.title": "Update Check Timeout",
"dialog.update_timeout.msg": "The server did not respond within 30 seconds.\n\nThis may be due to a network issue or server unavailability.\n\nPlease check your connection and try again.",
"dialog.update_failed.title": "Update Check Failed",
"dialog.update_failed.msg": "Could not check for updates:\n\n{error}\n\nPlease try again later.",
"dialog.download_failed.title": "Download Failed",
"dialog.download_failed.msg": "Could not download the update:\n\n{error}\n\nPlease try again later.",
"dialog.checkout.title": "Checkout Asset",
"dialog.checkout.msg": "Do you want to check out this asset?\n\n{filename}",
"about.title": "About {app_name}",
"about.version": "Version: {version}",
"about.description": "Bridges web-based drag-and-drop workflows with native file operations for professional desktop applications.",
"about.drop_zones_title": "Toolbar Drop Zones:",
"about.open_icon_desc": "Open icon: Opens dropped files with the system default app.",
"about.open_with_icon_desc": "Open-with icon: Shows an app chooser for dropped files.",
"about.product_of": "Product of:",
"about.rights": "\u00a9 2026 h\u00f6rl Information Management GmbH. All rights reserved.",
"settings.title": "Settings",
"settings.tab.web_source": "Web Source",
"settings.tab.paths": "Paths",
"settings.tab.urls": "URLs",
"settings.tab.logging": "Logging",
"settings.tab.window": "Window",
"settings.tab.profiles": "Profiles",
"settings.tab.general": "General",
"settings.web_url.label": "Web Application URL:",
"settings.web_url.placeholder": "e.g., http://localhost:8080 or file:///./webapp/index.html",
"settings.web_url.open_btn": "Open",
"settings.url_mappings.label": "URL Mappings (Azure Blob Storage \u2192 Local Paths):",
"settings.url_mappings.col_prefix": "URL Prefix",
"settings.url_mappings.col_path": "Local Path",
"settings.url_mappings.add_btn": "Add Mapping",
"settings.url_mappings.edit_btn": "Edit Selected",
"settings.url_mappings.remove_btn": "Remove Selected",
"settings.paths.label": "Allowed root directories for file access:",
"settings.paths.add_btn": "Add Path",
"settings.paths.remove_btn": "Remove Selected",
"settings.urls.label": "Allowed web URLs (supports wildcards like http://*.example.com):",
"settings.urls.add_btn": "Add URL",
"settings.urls.remove_btn": "Remove Selected",
"settings.log_level.label": "Log Level:",
"settings.log_file.label": "Log File (optional):",
"settings.log_file.browse_btn": "Browse...",
"settings.window.width_label": "Window Width:",
"settings.window.height_label": "Window Height:",
"settings.profiles.label": "Saved Configuration Profiles:",
"settings.profiles.save_btn": "Save as Profile",
"settings.profiles.load_btn": "Load Profile",
"settings.profiles.delete_btn": "Delete Profile",
"settings.profiles.export_btn": "Export Configuration",
"settings.profiles.import_btn": "Import Configuration",
"settings.general.language_label": "Language:",
"settings.general.language_auto": "System Default (Auto)",
"settings.general.language_restart_note": "Language change takes effect after restart.",
"settings.add_mapping.url_title": "Add URL Mapping",
"settings.add_mapping.url_prompt": "Enter Azure Blob Storage URL prefix:\n(e.g., https://myblob.blob.core.windows.net/container/)",
"settings.add_mapping.path_prompt": "Enter local file system path:\n(e.g., C:\\Share or /mnt/share)",
"settings.edit_mapping.title": "Edit URL Mapping",
"settings.edit_mapping.url_prompt": "Enter Azure Blob Storage URL prefix:",
"settings.edit_mapping.path_prompt": "Enter local file system path:",
"settings.add_url.title": "Add URL",
"settings.add_url.prompt": "Enter URL pattern (e.g., http://example.com or http://*.example.com):",
"settings.profile.save.title": "Save Profile",
"settings.profile.save.prompt": "Enter profile name (e.g., work, personal):",
"settings.select_directory.title": "Select Directory to Allow",
"settings.select_log_file.title": "Select Log File",
"settings.export_config.title": "Export Configuration",
"settings.import_config.title": "Import Configuration",
"settings.error.select_mapping": "Please select a mapping to edit",
"settings.error.select_profile_load": "Please select a profile to load",
"settings.error.select_profile_delete": "Please select a profile to delete",
"update.checking.title": "Checking for Updates",
"update.checking.label": "Checking for updates...",
"update.checking.timeout_info": "This may take up to 10 seconds",
"update.available.title": "Update Available",
"update.available.header": "WebDrop Bridge v{version} is available",
"update.available.changelog_label": "Release Notes:",
"update.available.update_now_btn": "Update Now",
"update.available.later_btn": "Later",
"update.downloading.title": "Downloading Update",
"update.downloading.header": "Downloading update...",
"update.downloading.preparing": "Preparing download",
"update.downloading.filename": "Downloading: {filename}",
"update.downloading.cancel_btn": "Cancel",
"update.install.title": "Install Update",
"update.install.header": "Ready to Install",
"update.install.message": "The update is ready to install. The application will restart.",
"update.install.warning": "\u26a0\ufe0f Please save any unsaved work before continuing.\nThe application will close and restart.",
"update.install.now_btn": "Install Now",
"update.install.cancel_btn": "Cancel",
"update.no_update.title": "No Updates Available",
"update.no_update.message": "\u2713 You're using the latest version",
"update.no_update.info": "WebDrop Bridge is up to date.",
"update.no_update.ok_btn": "OK",
"update.error.title": "Update Failed",
"update.error.header": "\u26a0\ufe0f Update Failed",
"update.error.info": "Please try again or visit the website to download the update manually.",
"update.error.retry_btn": "Retry",
"update.error.manual_btn": "Download Manually",
"update.error.cancel_btn": "Cancel",
"worker.server_not_responding": "Server not responding - check again later",
"worker.no_installer": "No installer found in release",
"worker.checksum_failed": "Checksum verification failed",
"worker.download_timed_out": "Download or verification timed out (no response from server)",
"worker.download_error": "Download error: {error}",
"worker.check_failed": "Check failed: {error}"
}

View file

@ -0,0 +1,172 @@
{
"toolbar.tooltip.open_drop": "D\u00e9posez un fichier ici pour l'ouvrir avec son application par d\u00e9faut",
"toolbar.tooltip.open_with_drop": "D\u00e9posez un fichier ici pour choisir l'application qui doit l'ouvrir",
"toolbar.tooltip.home": "Accueil",
"toolbar.tooltip.about": "\u00c0 propos de WebDrop Bridge",
"toolbar.tooltip.settings": "Param\u00e8tres",
"toolbar.tooltip.check_updates": "Rechercher des mises \u00e0 jour",
"toolbar.tooltip.clear_cache": "Vider le cache et les cookies",
"toolbar.tooltip.open_log": "Ouvrir le fichier journal",
"toolbar.tooltip.dev_tools": "Outils de d\u00e9veloppement (F12)",
"status.ready": "Pr\u00eat",
"status.opened": "Ouvert\u00a0: {name}",
"status.choose_app": "Choisir une app pour\u00a0: {name}",
"status.download_started": "\ud83d\udce5 T\u00e9l\u00e9chargement\u00a0: {filename}",
"status.download_completed": "T\u00e9l\u00e9chargement termin\u00e9\u00a0: {name}",
"status.download_cancelled": "\u26a0\ufe0f T\u00e9l\u00e9chargement annul\u00e9\u00a0: {name}",
"status.download_failed": "\u274c T\u00e9l\u00e9chargement \u00e9chou\u00e9\u00a0: {name}",
"status.download_error": "Erreur de t\u00e9l\u00e9chargement\u00a0: {error}",
"update.status.checking": "Recherche de mises \u00e0 jour",
"update.status.ready": "Pr\u00eat",
"update.status.available": "Mise \u00e0 jour disponible\u00a0: v{version}",
"update.status.deferred": "Mise \u00e0 jour diff\u00e9r\u00e9e",
"update.status.downloading": "T\u00e9l\u00e9chargement de v{version}",
"update.status.verifying": "V\u00e9rification du t\u00e9l\u00e9chargement",
"update.status.download_failed": "\u00c9chec du t\u00e9l\u00e9chargement",
"update.status.verification_failed": "\u00c9chec de la v\u00e9rification",
"update.status.timed_out": "D\u00e9lai d'attente d\u00e9pass\u00e9",
"update.status.ready_to_install": "Pr\u00eat \u00e0 installer",
"update.status.installation_started": "Installation d\u00e9marr\u00e9e",
"update.status.installation_failed": "\u00c9chec de l'installation",
"update.status.check_timed_out": "D\u00e9lai d\u00e9pass\u00e9 \u2013 aucune r\u00e9ponse du serveur",
"update.status.check_failed": "\u00c9chec\u00a0: {error}",
"update.status.download_timed_out": "D\u00e9lai d\u00e9pass\u00e9 lors du t\u00e9l\u00e9chargement",
"dialog.error.title": "Erreur",
"dialog.log_not_found.title": "Fichier journal introuvable",
"dialog.log_not_found.msg": "Aucun fichier journal trouv\u00e9 \u00e0\u00a0:\n{log_file}",
"dialog.cache_cleared.title": "Cache vid\u00e9",
"dialog.cache_cleared.msg": "Le cache et les cookies du navigateur ont \u00e9t\u00e9 vid\u00e9s avec succ\u00e8s.\n\nVous devrez peut-\u00eatre recharger la page ou red\u00e9marrer l'application pour que les modifications prennent effet.",
"dialog.cache_clear_failed.title": "Erreur",
"dialog.cache_clear_failed.msg": "Impossible de vider le cache et les cookies\u00a0: {error}",
"dialog.drag_error.title": "Erreur de glisser-d\u00e9poser",
"dialog.drag_error.msg": "Impossible de terminer l'op\u00e9ration de glisser-d\u00e9poser.\n\nErreur\u00a0: {error}",
"dialog.open_file_error.title": "Erreur d'ouverture",
"dialog.open_file_error.msg": "Impossible d'ouvrir le fichier avec son application par d\u00e9faut.\n\nFichier\u00a0: {file_path}\nErreur\u00a0: {error}",
"dialog.open_with_error.title": "Erreur Ouvrir avec",
"dialog.open_with_error.msg": "Impossible d'ouvrir un s\u00e9lecteur d'application sur cette plate-forme.",
"dialog.dev_tools.window_title": "\ud83d\udd27 Outils de d\u00e9veloppement",
"dialog.dev_tools.error_title": "Outils de d\u00e9veloppement",
"dialog.dev_tools.error_msg": "Impossible d'ouvrir les outils de d\u00e9veloppement\u00a0:\n{error}",
"dialog.domain_changed.title": "Domaine modifi\u00e9 \u2013 Red\u00e9marrage recommand\u00e9",
"dialog.domain_changed.msg": "Le domaine de l'application web a chang\u00e9\n\nVous avez chang\u00e9 de domaine. Pour une stabilit\u00e9 maximale et une authentification correcte, il est recommand\u00e9 de red\u00e9marrer l'application.\n\nLe profil et le cache ont \u00e9t\u00e9 vid\u00e9s, mais un red\u00e9marrage est recommand\u00e9.",
"dialog.domain_changed.restart_now": "Red\u00e9marrer maintenant",
"dialog.domain_changed.restart_later": "Red\u00e9marrer plus tard",
"dialog.language_changed.title": "Langue modifi\u00e9e",
"dialog.language_changed.msg": "Le param\u00e8tre de langue a \u00e9t\u00e9 mis \u00e0 jour. Red\u00e9marrez maintenant pour appliquer la langue s\u00e9lectionn\u00e9e partout.",
"dialog.language_changed.restart_now": "Red\u00e9marrer maintenant",
"dialog.language_changed.restart_later": "Red\u00e9marrer plus tard",
"dialog.restart_failed.title": "\u00c9chec du red\u00e9marrage",
"dialog.restart_failed.msg": "Impossible de red\u00e9marrer automatiquement l'application\u00a0:\n\n{error}\n\nVeuillez red\u00e9marrer manuellement.",
"dialog.update_timeout.title": "D\u00e9lai de v\u00e9rification des mises \u00e0 jour d\u00e9pass\u00e9",
"dialog.update_timeout.msg": "Le serveur n'a pas r\u00e9pondu dans les 30 secondes.\n\nCela peut \u00eatre d\u00fb \u00e0 un probl\u00e8me r\u00e9seau ou \u00e0 une indisponibilit\u00e9 du serveur.\n\nV\u00e9rifiez votre connexion et r\u00e9essayez.",
"dialog.update_failed.title": "\u00c9chec de la v\u00e9rification des mises \u00e0 jour",
"dialog.update_failed.msg": "Impossible de v\u00e9rifier les mises \u00e0 jour\u00a0:\n\n{error}\n\nVeuillez r\u00e9essayer plus tard.",
"dialog.download_failed.title": "\u00c9chec du t\u00e9l\u00e9chargement",
"dialog.download_failed.msg": "Impossible de t\u00e9l\u00e9charger la mise \u00e0 jour\u00a0:\n\n{error}\n\nVeuillez r\u00e9essayer plus tard.",
"dialog.checkout.title": "Extraire l'actif",
"dialog.checkout.msg": "Voulez-vous extraire cet actif\u00a0?\n\n{filename}",
"about.title": "\u00c0 propos de {app_name}",
"about.version": "Version\u00a0: {version}",
"about.description": "Connecte les flux de travail de glisser-d\u00e9poser web aux op\u00e9rations de fichiers natives pour les applications de bureau professionnelles.",
"about.drop_zones_title": "Zones de d\u00e9p\u00f4t de la barre d'outils\u00a0:",
"about.open_icon_desc": "Ic\u00f4ne Ouvrir\u00a0: ouvre les fichiers d\u00e9pos\u00e9s avec l'application par d\u00e9faut.",
"about.open_with_icon_desc": "Ic\u00f4ne Ouvrir avec\u00a0: affiche un s\u00e9lecteur d'application pour les fichiers d\u00e9pos\u00e9s.",
"about.product_of": "Un produit de\u00a0:",
"about.rights": "\u00a9 2026 h\u00f6rl Information Management GmbH. Tous droits r\u00e9serv\u00e9s.",
"settings.title": "Param\u00e8tres",
"settings.tab.web_source": "Source web",
"settings.tab.paths": "Chemins",
"settings.tab.urls": "URLs",
"settings.tab.logging": "Journalisation",
"settings.tab.window": "Fen\u00eatre",
"settings.tab.profiles": "Profils",
"settings.tab.general": "G\u00e9n\u00e9ral",
"settings.web_url.label": "URL de l'application web\u00a0:",
"settings.web_url.placeholder": "p.ex. http://localhost:8080 ou file:///./webapp/index.html",
"settings.web_url.open_btn": "Ouvrir",
"settings.url_mappings.label": "Mappages d'URL (Azure Blob Storage \u2192 Chemins locaux)\u00a0:",
"settings.url_mappings.col_prefix": "Pr\u00e9fixe URL",
"settings.url_mappings.col_path": "Chemin local",
"settings.url_mappings.add_btn": "Ajouter un mappage",
"settings.url_mappings.edit_btn": "Modifier la s\u00e9lection",
"settings.url_mappings.remove_btn": "Supprimer la s\u00e9lection",
"settings.paths.label": "R\u00e9pertoires racines autoris\u00e9s pour l'acc\u00e8s aux fichiers\u00a0:",
"settings.paths.add_btn": "Ajouter un chemin",
"settings.paths.remove_btn": "Supprimer la s\u00e9lection",
"settings.urls.label": "URLs web autoris\u00e9es (prise en charge des caract\u00e8res g\u00e9n\u00e9riques comme http://*.example.com)\u00a0:",
"settings.urls.add_btn": "Ajouter une URL",
"settings.urls.remove_btn": "Supprimer la s\u00e9lection",
"settings.log_level.label": "Niveau de journalisation\u00a0:",
"settings.log_file.label": "Fichier journal (facultatif)\u00a0:",
"settings.log_file.browse_btn": "Parcourir...",
"settings.window.width_label": "Largeur de la fen\u00eatre\u00a0:",
"settings.window.height_label": "Hauteur de la fen\u00eatre\u00a0:",
"settings.profiles.label": "Profils de configuration enregistr\u00e9s\u00a0:",
"settings.profiles.save_btn": "Enregistrer comme profil",
"settings.profiles.load_btn": "Charger le profil",
"settings.profiles.delete_btn": "Supprimer le profil",
"settings.profiles.export_btn": "Exporter la configuration",
"settings.profiles.import_btn": "Importer la configuration",
"settings.general.language_label": "Langue\u00a0:",
"settings.general.language_auto": "Par d\u00e9faut du syst\u00e8me (Auto)",
"settings.general.language_restart_note": "Le changement de langue prend effet apr\u00e8s red\u00e9marrage.",
"settings.add_mapping.url_title": "Ajouter un mappage d'URL",
"settings.add_mapping.url_prompt": "Entrez le pr\u00e9fixe URL Azure Blob Storage\u00a0:\n(p.ex. https://myblob.blob.core.windows.net/container/)",
"settings.add_mapping.path_prompt": "Entrez le chemin du syst\u00e8me de fichiers local\u00a0:\n(p.ex. C:\\Partage ou /mnt/partage)",
"settings.edit_mapping.title": "Modifier le mappage d'URL",
"settings.edit_mapping.url_prompt": "Entrez le pr\u00e9fixe URL Azure Blob Storage\u00a0:",
"settings.edit_mapping.path_prompt": "Entrez le chemin du syst\u00e8me de fichiers local\u00a0:",
"settings.add_url.title": "Ajouter une URL",
"settings.add_url.prompt": "Entrez le mod\u00e8le d'URL (p.ex. http://example.com ou http://*.example.com)\u00a0:",
"settings.profile.save.title": "Enregistrer le profil",
"settings.profile.save.prompt": "Entrez le nom du profil (p.ex. travail, personnel)\u00a0:",
"settings.select_directory.title": "S\u00e9lectionner un r\u00e9pertoire autoris\u00e9",
"settings.select_log_file.title": "S\u00e9lectionner le fichier journal",
"settings.export_config.title": "Exporter la configuration",
"settings.import_config.title": "Importer la configuration",
"settings.error.select_mapping": "Veuillez s\u00e9lectionner un mappage \u00e0 modifier",
"settings.error.select_profile_load": "Veuillez s\u00e9lectionner un profil \u00e0 charger",
"settings.error.select_profile_delete": "Veuillez s\u00e9lectionner un profil \u00e0 supprimer",
"update.checking.title": "V\u00e9rification des mises \u00e0 jour",
"update.checking.label": "Recherche de mises \u00e0 jour...",
"update.checking.timeout_info": "Cela peut prendre jusqu'\u00e0 10 secondes",
"update.available.title": "Mise \u00e0 jour disponible",
"update.available.header": "WebDrop Bridge v{version} est disponible",
"update.available.changelog_label": "Notes de version\u00a0:",
"update.available.update_now_btn": "Mettre \u00e0 jour maintenant",
"update.available.later_btn": "Plus tard",
"update.downloading.title": "T\u00e9l\u00e9chargement de la mise \u00e0 jour",
"update.downloading.header": "T\u00e9l\u00e9chargement en cours...",
"update.downloading.preparing": "Pr\u00e9paration du t\u00e9l\u00e9chargement",
"update.downloading.filename": "T\u00e9l\u00e9chargement\u00a0: {filename}",
"update.downloading.cancel_btn": "Annuler",
"update.install.title": "Installer la mise \u00e0 jour",
"update.install.header": "Pr\u00eat \u00e0 installer",
"update.install.message": "La mise \u00e0 jour est pr\u00eate \u00e0 \u00eatre install\u00e9e. L'application va red\u00e9marrer.",
"update.install.warning": "\u26a0\ufe0f Veuillez enregistrer tout travail non sauvegard\u00e9 avant de continuer.\nL'application va se fermer et red\u00e9marrer.",
"update.install.now_btn": "Installer maintenant",
"update.install.cancel_btn": "Annuler",
"update.no_update.title": "Aucune mise \u00e0 jour disponible",
"update.no_update.message": "\u2713 Vous utilisez la derni\u00e8re version",
"update.no_update.info": "WebDrop Bridge est \u00e0 jour.",
"update.no_update.ok_btn": "OK",
"update.error.title": "\u00c9chec de la mise \u00e0 jour",
"update.error.header": "\u26a0\ufe0f \u00c9chec de la mise \u00e0 jour",
"update.error.info": "Veuillez r\u00e9essayer ou visiter le site web pour t\u00e9l\u00e9charger la mise \u00e0 jour manuellement.",
"update.error.retry_btn": "R\u00e9essayer",
"update.error.manual_btn": "T\u00e9l\u00e9charger manuellement",
"update.error.cancel_btn": "Annuler",
"worker.server_not_responding": "Le serveur ne r\u00e9pond pas \u2013 v\u00e9rifiez plus tard",
"worker.no_installer": "Aucun programme d'installation trouv\u00e9 dans la version",
"worker.checksum_failed": "\u00c9chec de la v\u00e9rification de la somme de contr\u00f4le",
"worker.download_timed_out": "D\u00e9lai d\u00e9pass\u00e9 lors du t\u00e9l\u00e9chargement ou de la v\u00e9rification",
"worker.download_error": "Erreur de t\u00e9l\u00e9chargement\u00a0: {error}",
"worker.check_failed": "\u00c9chec de la v\u00e9rification\u00a0: {error}"
}

View file

@ -0,0 +1,172 @@
{
"toolbar.tooltip.open_drop": "Trascina qui un file per aprirlo con l'app predefinita",
"toolbar.tooltip.open_with_drop": "Trascina qui un file per scegliere con quale app aprirlo",
"toolbar.tooltip.home": "Home",
"toolbar.tooltip.about": "Informazioni su WebDrop Bridge",
"toolbar.tooltip.settings": "Impostazioni",
"toolbar.tooltip.check_updates": "Controlla aggiornamenti",
"toolbar.tooltip.clear_cache": "Cancella cache e cookie",
"toolbar.tooltip.open_log": "Apri file di log",
"toolbar.tooltip.dev_tools": "Strumenti sviluppatore (F12)",
"status.ready": "Pronto",
"status.opened": "Aperto: {name}",
"status.choose_app": "Scegli app per: {name}",
"status.download_started": "📥 Download: {filename}",
"status.download_completed": "Download completato: {name}",
"status.download_cancelled": "⚠️ Download annullato: {name}",
"status.download_failed": "❌ Download non riuscito: {name}",
"status.download_error": "Errore download: {error}",
"update.status.checking": "Controllo aggiornamenti",
"update.status.ready": "Pronto",
"update.status.available": "Aggiornamento disponibile: v{version}",
"update.status.deferred": "Aggiornamento rimandato",
"update.status.downloading": "Download v{version}",
"update.status.verifying": "Verifica download",
"update.status.download_failed": "Download non riuscito",
"update.status.verification_failed": "Verifica non riuscita",
"update.status.timed_out": "Operazione scaduta",
"update.status.ready_to_install": "Pronto per l'installazione",
"update.status.installation_started": "Installazione avviata",
"update.status.installation_failed": "Installazione non riuscita",
"update.status.check_timed_out": "Controllo scaduto - nessuna risposta dal server",
"update.status.check_failed": "Controllo non riuscito: {error}",
"update.status.download_timed_out": "Download scaduto - nessuna risposta dal server",
"dialog.error.title": "Errore",
"dialog.log_not_found.title": "File di log non trovato",
"dialog.log_not_found.msg": "Nessun file di log trovato in:\n{log_file}",
"dialog.cache_cleared.title": "Cache cancellata",
"dialog.cache_cleared.msg": "Cache del browser e cookie cancellati con successo.\n\nPotrebbe essere necessario ricaricare la pagina o riavviare l'applicazione.",
"dialog.cache_clear_failed.title": "Errore",
"dialog.cache_clear_failed.msg": "Impossibile cancellare cache e cookie: {error}",
"dialog.drag_error.title": "Errore drag-and-drop",
"dialog.drag_error.msg": "Impossibile completare l'operazione drag-and-drop.\n\nErrore: {error}",
"dialog.open_file_error.title": "Errore apertura file",
"dialog.open_file_error.msg": "Impossibile aprire il file con l'applicazione predefinita.\n\nFile: {file_path}\nErrore: {error}",
"dialog.open_with_error.title": "Errore Apri con",
"dialog.open_with_error.msg": "Impossibile aprire un selettore applicazioni su questa piattaforma.",
"dialog.dev_tools.window_title": "🔧 Strumenti sviluppatore",
"dialog.dev_tools.error_title": "Strumenti sviluppatore",
"dialog.dev_tools.error_msg": "Impossibile aprire gli Strumenti sviluppatore:\n{error}",
"dialog.domain_changed.title": "Dominio cambiato - riavvio consigliato",
"dialog.domain_changed.msg": "Il dominio dell'app web è cambiato\n\nHai cambiato dominio. Per massima stabilità e corretta autenticazione, è consigliato riavviare l'applicazione.\n\nProfilo e cache sono stati puliti, ma consigliamo il riavvio.",
"dialog.domain_changed.restart_now": "Riavvia ora",
"dialog.domain_changed.restart_later": "Riavvia più tardi",
"dialog.language_changed.title": "Lingua cambiata",
"dialog.language_changed.msg": "La lingua è stata aggiornata. Riavvia ora per applicarla ovunque.",
"dialog.language_changed.restart_now": "Riavvia ora",
"dialog.language_changed.restart_later": "Riavvia più tardi",
"dialog.restart_failed.title": "Riavvio non riuscito",
"dialog.restart_failed.msg": "Impossibile riavviare automaticamente l'applicazione:\n\n{error}\n\nRiavvia manualmente.",
"dialog.update_timeout.title": "Timeout controllo aggiornamenti",
"dialog.update_timeout.msg": "Il server non ha risposto entro 30 secondi.\n\nPotrebbe trattarsi di un problema di rete o indisponibilità del server.\n\nControlla la connessione e riprova.",
"dialog.update_failed.title": "Controllo aggiornamenti non riuscito",
"dialog.update_failed.msg": "Impossibile controllare gli aggiornamenti:\n\n{error}\n\nRiprova più tardi.",
"dialog.download_failed.title": "Download non riuscito",
"dialog.download_failed.msg": "Impossibile scaricare l'aggiornamento:\n\n{error}\n\nRiprova più tardi.",
"dialog.checkout.title": "Checkout asset",
"dialog.checkout.msg": "Vuoi eseguire il checkout di questo asset?\n\n{filename}",
"about.title": "Informazioni su {app_name}",
"about.version": "Versione: {version}",
"about.description": "Collega i flussi drag-and-drop web alle operazioni file native per applicazioni desktop professionali.",
"about.drop_zones_title": "Zone di rilascio barra strumenti:",
"about.open_icon_desc": "Icona Apri: apre i file rilasciati con l'app predefinita.",
"about.open_with_icon_desc": "Icona Apri con: mostra un selettore app per i file rilasciati.",
"about.product_of": "Prodotto di:",
"about.rights": "© 2026 hörl Information Management GmbH. Tutti i diritti riservati.",
"settings.title": "Impostazioni",
"settings.tab.web_source": "Sorgente web",
"settings.tab.paths": "Percorsi",
"settings.tab.urls": "URL",
"settings.tab.logging": "Log",
"settings.tab.window": "Finestra",
"settings.tab.profiles": "Profili",
"settings.tab.general": "Generale",
"settings.web_url.label": "URL applicazione web:",
"settings.web_url.placeholder": "es. http://localhost:8080 o file:///./webapp/index.html",
"settings.web_url.open_btn": "Apri",
"settings.url_mappings.label": "Mappature URL (Azure Blob Storage → Percorsi locali):",
"settings.url_mappings.col_prefix": "Prefisso URL",
"settings.url_mappings.col_path": "Percorso locale",
"settings.url_mappings.add_btn": "Aggiungi mappatura",
"settings.url_mappings.edit_btn": "Modifica selezionato",
"settings.url_mappings.remove_btn": "Rimuovi selezionato",
"settings.paths.label": "Directory radice consentite per accesso file:",
"settings.paths.add_btn": "Aggiungi percorso",
"settings.paths.remove_btn": "Rimuovi selezionato",
"settings.urls.label": "URL web consentiti (supporta wildcard come http://*.example.com):",
"settings.urls.add_btn": "Aggiungi URL",
"settings.urls.remove_btn": "Rimuovi selezionato",
"settings.log_level.label": "Livello log:",
"settings.log_file.label": "File log (opzionale):",
"settings.log_file.browse_btn": "Sfoglia...",
"settings.window.width_label": "Larghezza finestra:",
"settings.window.height_label": "Altezza finestra:",
"settings.profiles.label": "Profili configurazione salvati:",
"settings.profiles.save_btn": "Salva come profilo",
"settings.profiles.load_btn": "Carica profilo",
"settings.profiles.delete_btn": "Elimina profilo",
"settings.profiles.export_btn": "Esporta configurazione",
"settings.profiles.import_btn": "Importa configurazione",
"settings.general.language_label": "Lingua:",
"settings.general.language_auto": "Predefinita sistema (Auto)",
"settings.general.language_restart_note": "La modifica lingua si applica dopo il riavvio.",
"settings.add_mapping.url_title": "Aggiungi mappatura URL",
"settings.add_mapping.url_prompt": "Inserisci prefisso URL Azure Blob Storage:\n(es. https://myblob.blob.core.windows.net/container/)",
"settings.add_mapping.path_prompt": "Inserisci percorso file system locale:\n(es. C:\\Share o /mnt/share)",
"settings.edit_mapping.title": "Modifica mappatura URL",
"settings.edit_mapping.url_prompt": "Inserisci prefisso URL Azure Blob Storage:",
"settings.edit_mapping.path_prompt": "Inserisci percorso file system locale:",
"settings.add_url.title": "Aggiungi URL",
"settings.add_url.prompt": "Inserisci pattern URL (es. http://example.com o http://*.example.com):",
"settings.profile.save.title": "Salva profilo",
"settings.profile.save.prompt": "Inserisci nome profilo (es. lavoro, personale):",
"settings.select_directory.title": "Seleziona directory da consentire",
"settings.select_log_file.title": "Seleziona file di log",
"settings.export_config.title": "Esporta configurazione",
"settings.import_config.title": "Importa configurazione",
"settings.error.select_mapping": "Seleziona una mappatura da modificare",
"settings.error.select_profile_load": "Seleziona un profilo da caricare",
"settings.error.select_profile_delete": "Seleziona un profilo da eliminare",
"update.checking.title": "Controllo aggiornamenti",
"update.checking.label": "Controllo aggiornamenti...",
"update.checking.timeout_info": "Può richiedere fino a 10 secondi",
"update.available.title": "Aggiornamento disponibile",
"update.available.header": "È disponibile WebDrop Bridge v{version}",
"update.available.changelog_label": "Note di rilascio:",
"update.available.update_now_btn": "Aggiorna ora",
"update.available.later_btn": "Più tardi",
"update.downloading.title": "Download aggiornamento",
"update.downloading.header": "Download aggiornamento...",
"update.downloading.preparing": "Preparazione download",
"update.downloading.filename": "Download: {filename}",
"update.downloading.cancel_btn": "Annulla",
"update.install.title": "Installa aggiornamento",
"update.install.header": "Pronto per installare",
"update.install.message": "L'aggiornamento è pronto per l'installazione. L'applicazione verrà riavviata.",
"update.install.warning": "⚠️ Salva eventuale lavoro non salvato prima di continuare.\nL'applicazione verrà chiusa e riavviata.",
"update.install.now_btn": "Installa ora",
"update.install.cancel_btn": "Annulla",
"update.no_update.title": "Nessun aggiornamento disponibile",
"update.no_update.message": "✓ Stai usando l'ultima versione",
"update.no_update.info": "WebDrop Bridge è aggiornato.",
"update.no_update.ok_btn": "OK",
"update.error.title": "Aggiornamento non riuscito",
"update.error.header": "⚠️ Aggiornamento non riuscito",
"update.error.info": "Riprova o visita il sito per scaricare manualmente l'aggiornamento.",
"update.error.retry_btn": "Riprova",
"update.error.manual_btn": "Scarica manualmente",
"update.error.cancel_btn": "Annulla",
"worker.server_not_responding": "Il server non risponde - riprova più tardi",
"worker.no_installer": "Nessun installer trovato nella release",
"worker.checksum_failed": "Verifica checksum non riuscita",
"worker.download_timed_out": "Download o verifica scaduti (nessuna risposta dal server)",
"worker.download_error": "Errore download: {error}",
"worker.check_failed": "Controllo non riuscito: {error}"
}

View file

@ -0,0 +1,172 @@
{
"toolbar.tooltip.open_drop": "Перетащите файл сюда, чтобы открыть его приложением по умолчанию",
"toolbar.tooltip.open_with_drop": "Перетащите файл сюда, чтобы выбрать приложение для его открытия",
"toolbar.tooltip.home": "Главная",
"toolbar.tooltip.about": "О WebDrop Bridge",
"toolbar.tooltip.settings": "Настройки",
"toolbar.tooltip.check_updates": "Проверить обновления",
"toolbar.tooltip.clear_cache": "Очистить кэш и cookie",
"toolbar.tooltip.open_log": "Открыть файл журнала",
"toolbar.tooltip.dev_tools": "Инструменты разработчика (F12)",
"status.ready": "Готово",
"status.opened": "Открыто: {name}",
"status.choose_app": "Выберите приложение для: {name}",
"status.download_started": "📥 Загрузка: {filename}",
"status.download_completed": "Загрузка завершена: {name}",
"status.download_cancelled": "⚠️ Загрузка отменена: {name}",
"status.download_failed": "❌ Ошибка загрузки: {name}",
"status.download_error": "Ошибка загрузки: {error}",
"update.status.checking": "Проверка обновлений",
"update.status.ready": "Готово",
"update.status.available": "Доступно обновление: v{version}",
"update.status.deferred": "Обновление отложено",
"update.status.downloading": "Загрузка v{version}",
"update.status.verifying": "Проверка загрузки",
"update.status.download_failed": "Ошибка загрузки",
"update.status.verification_failed": "Ошибка проверки",
"update.status.timed_out": "Время ожидания истекло",
"update.status.ready_to_install": "Готово к установке",
"update.status.installation_started": "Установка начата",
"update.status.installation_failed": "Ошибка установки",
"update.status.check_timed_out": "Проверка прервана по таймауту - нет ответа сервера",
"update.status.check_failed": "Ошибка проверки: {error}",
"update.status.download_timed_out": "Загрузка прервана по таймауту - нет ответа сервера",
"dialog.error.title": "Ошибка",
"dialog.log_not_found.title": "Файл журнала не найден",
"dialog.log_not_found.msg": "Файл журнала не найден по пути:\n{log_file}",
"dialog.cache_cleared.title": "Кэш очищен",
"dialog.cache_cleared.msg": "Кэш браузера и файлы cookie успешно очищены.\n\nВозможно, потребуется перезагрузить страницу или перезапустить приложение.",
"dialog.cache_clear_failed.title": "Ошибка",
"dialog.cache_clear_failed.msg": "Не удалось очистить кэш и файлы cookie: {error}",
"dialog.drag_error.title": "Ошибка drag-and-drop",
"dialog.drag_error.msg": "Не удалось завершить операцию drag-and-drop.\n\nОшибка: {error}",
"dialog.open_file_error.title": "Ошибка открытия файла",
"dialog.open_file_error.msg": "Не удалось открыть файл приложением по умолчанию.\n\nФайл: {file_path}\nОшибка: {error}",
"dialog.open_with_error.title": "Ошибка «Открыть с помощью»",
"dialog.open_with_error.msg": "Невозможно открыть выбор приложения на этой платформе.",
"dialog.dev_tools.window_title": "🔧 Инструменты разработчика",
"dialog.dev_tools.error_title": "Инструменты разработчика",
"dialog.dev_tools.error_msg": "Не удалось открыть инструменты разработчика:\n{error}",
"dialog.domain_changed.title": "Домен изменен — рекомендуется перезапуск",
"dialog.domain_changed.msg": "Домен веб-приложения изменился\n\nВы переключились на другой домен. Для максимальной стабильности и корректной аутентификации рекомендуется перезапустить приложение.\n\nПрофиль и кэш очищены, но перезапуск по-прежнему рекомендуется.",
"dialog.domain_changed.restart_now": "Перезапустить сейчас",
"dialog.domain_changed.restart_later": "Перезапустить позже",
"dialog.language_changed.title": "Язык изменен",
"dialog.language_changed.msg": "Настройка языка обновлена. Перезапустите сейчас, чтобы применить язык везде.",
"dialog.language_changed.restart_now": "Перезапустить сейчас",
"dialog.language_changed.restart_later": "Перезапустить позже",
"dialog.restart_failed.title": "Сбой перезапуска",
"dialog.restart_failed.msg": "Не удалось автоматически перезапустить приложение:\n\n{error}\n\nПерезапустите вручную.",
"dialog.update_timeout.title": "Таймаут проверки обновлений",
"dialog.update_timeout.msg": "Сервер не ответил в течение 30 секунд.\n\nВозможна проблема сети или недоступность сервера.\n\nПроверьте соединение и попробуйте снова.",
"dialog.update_failed.title": "Ошибка проверки обновлений",
"dialog.update_failed.msg": "Не удалось проверить обновления:\n\n{error}\n\nПовторите позже.",
"dialog.download_failed.title": "Ошибка загрузки",
"dialog.download_failed.msg": "Не удалось скачать обновление:\n\n{error}\n\nПовторите позже.",
"dialog.checkout.title": "Выдача ресурса",
"dialog.checkout.msg": "Выполнить выдачу этого ресурса?\n\n{filename}",
"about.title": "О программе {app_name}",
"about.version": "Версия: {version}",
"about.description": "Связывает веб-сценарии drag-and-drop с нативными файловыми операциями для профессиональных настольных приложений.",
"about.drop_zones_title": "Зоны перетаскивания на панели:",
"about.open_icon_desc": "Иконка «Открыть»: открывает перетащенные файлы приложением по умолчанию.",
"about.open_with_icon_desc": "Иконка «Открыть с помощью»: показывает выбор приложения для перетащенных файлов.",
"about.product_of": "Продукт компании:",
"about.rights": "© 2026 hörl Information Management GmbH. Все права защищены.",
"settings.title": "Настройки",
"settings.tab.web_source": "Веб-источник",
"settings.tab.paths": "Пути",
"settings.tab.urls": "URL",
"settings.tab.logging": "Логирование",
"settings.tab.window": "Окно",
"settings.tab.profiles": "Профили",
"settings.tab.general": "Общие настройки",
"settings.web_url.label": "URL веб-приложения:",
"settings.web_url.placeholder": "например, http://localhost:8080 или file:///./webapp/index.html",
"settings.web_url.open_btn": "Открыть",
"settings.url_mappings.label": "Сопоставления URL (Azure Blob Storage → локальные пути):",
"settings.url_mappings.col_prefix": "Префикс URL",
"settings.url_mappings.col_path": "Локальный путь",
"settings.url_mappings.add_btn": "Добавить сопоставление",
"settings.url_mappings.edit_btn": "Изменить выбранное",
"settings.url_mappings.remove_btn": "Удалить выбранное",
"settings.paths.label": "Разрешенные корневые каталоги для доступа к файлам:",
"settings.paths.add_btn": "Добавить путь",
"settings.paths.remove_btn": "Удалить выбранное",
"settings.urls.label": "Разрешенные веб URL (поддержка масок, напр. http://*.example.com):",
"settings.urls.add_btn": "Добавить URL",
"settings.urls.remove_btn": "Удалить выбранное",
"settings.log_level.label": "Уровень логирования:",
"settings.log_file.label": "Файл журнала (необязательно):",
"settings.log_file.browse_btn": "Обзор...",
"settings.window.width_label": "Ширина окна:",
"settings.window.height_label": "Высота окна:",
"settings.profiles.label": "Сохраненные профили конфигурации:",
"settings.profiles.save_btn": "Сохранить как профиль",
"settings.profiles.load_btn": "Загрузить профиль",
"settings.profiles.delete_btn": "Удалить профиль",
"settings.profiles.export_btn": "Экспорт конфигурации",
"settings.profiles.import_btn": "Импорт конфигурации",
"settings.general.language_label": "Язык:",
"settings.general.language_auto": "Системный язык (авто)",
"settings.general.language_restart_note": "Изменение языка вступает в силу после перезапуска.",
"settings.add_mapping.url_title": "Добавить сопоставление URL",
"settings.add_mapping.url_prompt": "Введите префикс URL Azure Blob Storage:\n(например, https://myblob.blob.core.windows.net/container/)",
"settings.add_mapping.path_prompt": "Введите локальный путь файловой системы:\n(например, C:\\Share или /mnt/share)",
"settings.edit_mapping.title": "Изменить сопоставление URL",
"settings.edit_mapping.url_prompt": "Введите префикс URL Azure Blob Storage:",
"settings.edit_mapping.path_prompt": "Введите локальный путь файловой системы:",
"settings.add_url.title": "Добавить URL",
"settings.add_url.prompt": "Введите шаблон URL (например, http://example.com или http://*.example.com):",
"settings.profile.save.title": "Сохранить профиль",
"settings.profile.save.prompt": "Введите имя профиля (например, работа, личный):",
"settings.select_directory.title": "Выберите разрешенную папку",
"settings.select_log_file.title": "Выберите файл журнала",
"settings.export_config.title": "Экспорт конфигурации",
"settings.import_config.title": "Импорт конфигурации",
"settings.error.select_mapping": "Выберите сопоставление для редактирования",
"settings.error.select_profile_load": "Выберите профиль для загрузки",
"settings.error.select_profile_delete": "Выберите профиль для удаления",
"update.checking.title": "Проверка обновлений",
"update.checking.label": "Проверка обновлений...",
"update.checking.timeout_info": "Это может занять до 10 секунд",
"update.available.title": "Доступно обновление",
"update.available.header": "Доступна версия WebDrop Bridge v{version}",
"update.available.changelog_label": "Примечания к релизу:",
"update.available.update_now_btn": "Обновить сейчас",
"update.available.later_btn": "Позже",
"update.downloading.title": "Загрузка обновления",
"update.downloading.header": "Загрузка обновления...",
"update.downloading.preparing": "Подготовка загрузки",
"update.downloading.filename": "Загрузка: {filename}",
"update.downloading.cancel_btn": "Отмена",
"update.install.title": "Установить обновление",
"update.install.header": "Готово к установке",
"update.install.message": "Обновление готово к установке. Приложение будет перезапущено.",
"update.install.warning": "⚠️ Сохраните несохраненные данные перед продолжением.\nПриложение будет закрыто и перезапущено.",
"update.install.now_btn": "Установить сейчас",
"update.install.cancel_btn": "Отмена",
"update.no_update.title": "Обновлений нет",
"update.no_update.message": "✓ У вас установлена последняя версия",
"update.no_update.info": "WebDrop Bridge уже обновлен.",
"update.no_update.ok_btn": "OK",
"update.error.title": "Ошибка обновления",
"update.error.header": "⚠️ Ошибка обновления",
"update.error.info": "Повторите попытку или загрузите обновление вручную с сайта.",
"update.error.retry_btn": "Повторить",
"update.error.manual_btn": "Скачать вручную",
"update.error.cancel_btn": "Отмена",
"worker.server_not_responding": "Сервер не отвечает — попробуйте позже",
"worker.no_installer": "В релизе не найден установщик",
"worker.checksum_failed": "Проверка контрольной суммы не пройдена",
"worker.download_timed_out": "Таймаут загрузки или проверки (нет ответа сервера)",
"worker.download_error": "Ошибка загрузки: {error}",
"worker.check_failed": "Ошибка проверки: {error}"
}

View file

@ -0,0 +1,172 @@
{
"toolbar.tooltip.open_drop": "将文件拖到此处以使用默认应用打开",
"toolbar.tooltip.open_with_drop": "将文件拖到此处以选择用于打开的应用",
"toolbar.tooltip.home": "主页",
"toolbar.tooltip.about": "关于 WebDrop Bridge",
"toolbar.tooltip.settings": "设置",
"toolbar.tooltip.check_updates": "检查更新",
"toolbar.tooltip.clear_cache": "清除缓存和 Cookie",
"toolbar.tooltip.open_log": "打开日志文件",
"toolbar.tooltip.dev_tools": "开发者工具 (F12)",
"status.ready": "就绪",
"status.opened": "已打开: {name}",
"status.choose_app": "为此文件选择应用: {name}",
"status.download_started": "📥 下载: {filename}",
"status.download_completed": "下载完成: {name}",
"status.download_cancelled": "⚠️ 下载已取消: {name}",
"status.download_failed": "❌ 下载失败: {name}",
"status.download_error": "下载错误: {error}",
"update.status.checking": "正在检查更新",
"update.status.ready": "就绪",
"update.status.available": "有可用更新: v{version}",
"update.status.deferred": "更新已延后",
"update.status.downloading": "正在下载 v{version}",
"update.status.verifying": "正在验证下载",
"update.status.download_failed": "下载失败",
"update.status.verification_failed": "验证失败",
"update.status.timed_out": "操作超时",
"update.status.ready_to_install": "准备安装",
"update.status.installation_started": "已开始安装",
"update.status.installation_failed": "安装失败",
"update.status.check_timed_out": "检查超时 - 服务器无响应",
"update.status.check_failed": "检查失败: {error}",
"update.status.download_timed_out": "下载超时 - 服务器无响应",
"dialog.error.title": "错误",
"dialog.log_not_found.title": "未找到日志文件",
"dialog.log_not_found.msg": "在以下位置未找到日志文件:\n{log_file}",
"dialog.cache_cleared.title": "缓存已清除",
"dialog.cache_cleared.msg": "浏览器缓存和 Cookie 已成功清除。\n\n你可能需要刷新页面或重启应用以使更改生效。",
"dialog.cache_clear_failed.title": "错误",
"dialog.cache_clear_failed.msg": "清除缓存和 Cookie 失败: {error}",
"dialog.drag_error.title": "拖放错误",
"dialog.drag_error.msg": "无法完成拖放操作。\n\n错误: {error}",
"dialog.open_file_error.title": "打开文件错误",
"dialog.open_file_error.msg": "无法使用默认应用打开该文件。\n\n文件: {file_path}\n错误: {error}",
"dialog.open_with_error.title": "“打开方式”错误",
"dialog.open_with_error.msg": "当前平台无法打开应用选择器。",
"dialog.dev_tools.window_title": "🔧 开发者工具",
"dialog.dev_tools.error_title": "开发者工具",
"dialog.dev_tools.error_msg": "无法打开开发者工具:\n{error}",
"dialog.domain_changed.title": "域名已变更 - 建议重启",
"dialog.domain_changed.msg": "Web 应用域名已变更\n\n你已切换到其他域名。为保证稳定性与认证正确性建议重启应用。\n\n配置与缓存已清理但仍建议重启。",
"dialog.domain_changed.restart_now": "立即重启",
"dialog.domain_changed.restart_later": "稍后重启",
"dialog.language_changed.title": "语言已更改",
"dialog.language_changed.msg": "语言设置已更新。立即重启可在所有界面生效。",
"dialog.language_changed.restart_now": "立即重启",
"dialog.language_changed.restart_later": "稍后重启",
"dialog.restart_failed.title": "重启失败",
"dialog.restart_failed.msg": "无法自动重启应用:\n\n{error}\n\n请手动重启。",
"dialog.update_timeout.title": "更新检查超时",
"dialog.update_timeout.msg": "服务器在 30 秒内未响应。\n\n可能是网络问题或服务器不可用。\n\n请检查连接后重试。",
"dialog.update_failed.title": "更新检查失败",
"dialog.update_failed.msg": "无法检查更新:\n\n{error}\n\n请稍后重试。",
"dialog.download_failed.title": "下载失败",
"dialog.download_failed.msg": "无法下载更新:\n\n{error}\n\n请稍后重试。",
"dialog.checkout.title": "签出资产",
"dialog.checkout.msg": "是否签出该资产?\n\n{filename}",
"about.title": "关于 {app_name}",
"about.version": "版本: {version}",
"about.description": "将基于 Web 的拖放流程与桌面原生文件操作无缝衔接。",
"about.drop_zones_title": "工具栏拖放区域:",
"about.open_icon_desc": "打开图标: 使用系统默认应用打开拖入文件。",
"about.open_with_icon_desc": "打开方式图标: 为拖入文件显示应用选择器。",
"about.product_of": "产品提供方:",
"about.rights": "© 2026 hörl Information Management GmbH. 保留所有权利。",
"settings.title": "设置",
"settings.tab.web_source": "Web 来源",
"settings.tab.paths": "路径",
"settings.tab.urls": "URL",
"settings.tab.logging": "日志",
"settings.tab.window": "窗口",
"settings.tab.profiles": "配置档案",
"settings.tab.general": "通用",
"settings.web_url.label": "Web 应用 URL:",
"settings.web_url.placeholder": "例如: http://localhost:8080 或 file:///./webapp/index.html",
"settings.web_url.open_btn": "打开",
"settings.url_mappings.label": "URL 映射Azure Blob Storage → 本地路径):",
"settings.url_mappings.col_prefix": "URL 前缀",
"settings.url_mappings.col_path": "本地路径",
"settings.url_mappings.add_btn": "添加映射",
"settings.url_mappings.edit_btn": "编辑所选",
"settings.url_mappings.remove_btn": "删除所选",
"settings.paths.label": "允许访问文件的根目录:",
"settings.paths.add_btn": "添加路径",
"settings.paths.remove_btn": "删除所选",
"settings.urls.label": "允许的 Web URL支持通配符例如 http://*.example.com:",
"settings.urls.add_btn": "添加 URL",
"settings.urls.remove_btn": "删除所选",
"settings.log_level.label": "日志级别:",
"settings.log_file.label": "日志文件(可选):",
"settings.log_file.browse_btn": "浏览...",
"settings.window.width_label": "窗口宽度:",
"settings.window.height_label": "窗口高度:",
"settings.profiles.label": "已保存配置档案:",
"settings.profiles.save_btn": "保存为档案",
"settings.profiles.load_btn": "加载档案",
"settings.profiles.delete_btn": "删除档案",
"settings.profiles.export_btn": "导出配置",
"settings.profiles.import_btn": "导入配置",
"settings.general.language_label": "语言:",
"settings.general.language_auto": "跟随系统(自动)",
"settings.general.language_restart_note": "语言更改将在重启后生效。",
"settings.add_mapping.url_title": "添加 URL 映射",
"settings.add_mapping.url_prompt": "输入 Azure Blob Storage URL 前缀:\n(例如: https://myblob.blob.core.windows.net/container/)",
"settings.add_mapping.path_prompt": "输入本地文件系统路径:\n(例如: C:\\Share 或 /mnt/share)",
"settings.edit_mapping.title": "编辑 URL 映射",
"settings.edit_mapping.url_prompt": "输入 Azure Blob Storage URL 前缀:",
"settings.edit_mapping.path_prompt": "输入本地文件系统路径:",
"settings.add_url.title": "添加 URL",
"settings.add_url.prompt": "输入 URL 模式(例如: http://example.com 或 http://*.example.com:",
"settings.profile.save.title": "保存档案",
"settings.profile.save.prompt": "输入配置档案名称(例如: 工作, 个人):",
"settings.select_directory.title": "选择允许目录",
"settings.select_log_file.title": "选择日志文件",
"settings.export_config.title": "导出配置",
"settings.import_config.title": "导入配置",
"settings.error.select_mapping": "请选择要编辑的映射",
"settings.error.select_profile_load": "请选择要加载的档案",
"settings.error.select_profile_delete": "请选择要删除的档案",
"update.checking.title": "检查更新",
"update.checking.label": "正在检查更新...",
"update.checking.timeout_info": "这可能需要最多 10 秒",
"update.available.title": "有可用更新",
"update.available.header": "检测到可用版本WebDrop Bridge v{version}",
"update.available.changelog_label": "更新说明:",
"update.available.update_now_btn": "立即更新",
"update.available.later_btn": "稍后",
"update.downloading.title": "正在下载更新",
"update.downloading.header": "正在下载更新...",
"update.downloading.preparing": "准备下载",
"update.downloading.filename": "正在下载: {filename}",
"update.downloading.cancel_btn": "取消",
"update.install.title": "安装更新",
"update.install.header": "准备安装",
"update.install.message": "更新已准备好安装。应用将重启。",
"update.install.warning": "⚠️ 继续前请保存未保存的工作。\n应用将关闭并重启。",
"update.install.now_btn": "立即安装",
"update.install.cancel_btn": "取消",
"update.no_update.title": "无可用更新",
"update.no_update.message": "✓ 你正在使用最新版本",
"update.no_update.info": "WebDrop Bridge 已为最新版本。",
"update.no_update.ok_btn": "确定",
"update.error.title": "更新失败",
"update.error.header": "⚠️ 更新失败",
"update.error.info": "请重试,或前往网站手动下载更新包。",
"update.error.retry_btn": "重试",
"update.error.manual_btn": "手动下载",
"update.error.cancel_btn": "取消",
"worker.server_not_responding": "服务器无响应,请稍后再试",
"worker.no_installer": "发布包中未找到安装程序",
"worker.checksum_failed": "校验和验证失败",
"worker.download_timed_out": "下载或验证超时(服务器无响应)",
"worker.download_error": "下载错误: {error}",
"worker.check_failed": "检查失败: {error}"
}

View file

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

View file

@ -3,6 +3,7 @@
import json import json
import logging import logging
import os import os
import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import List from typing import List
@ -11,6 +12,13 @@ from dotenv import load_dotenv
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_BRAND_ID = "webdrop_bridge"
DEFAULT_CONFIG_DIR_NAME = "webdrop_bridge"
DEFAULT_UPDATE_BASE_URL = "https://git.him-tools.de"
DEFAULT_UPDATE_REPO = "HIM-public/webdrop-bridge"
DEFAULT_UPDATE_CHANNEL = "stable"
DEFAULT_UPDATE_MANIFEST_NAME = "release-manifest.json"
class ConfigurationError(Exception): class ConfigurationError(Exception):
"""Raised when configuration is invalid.""" """Raised when configuration is invalid."""
@ -60,6 +68,12 @@ class Config:
enable_logging: Whether to write logs to file enable_logging: Whether to write logs to file
enable_checkout: Whether to check asset checkout status and show checkout dialog enable_checkout: Whether to check asset checkout status and show checkout dialog
on drag. Disabled by default as checkout support is optional. on drag. Disabled by default as checkout support is optional.
brand_id: Stable brand identifier used for packaging and update selection
config_dir_name: AppData/config directory name for this branded variant
update_base_url: Base Forgejo URL used for release checks
update_repo: Forgejo repository containing shared releases
update_channel: Update channel name used by release manifest selection
update_manifest_name: Asset name of the shared release manifest
Raises: Raises:
ConfigurationError: If configuration values are invalid ConfigurationError: If configuration values are invalid
@ -81,6 +95,13 @@ class Config:
window_title: str = "" window_title: str = ""
enable_logging: bool = True enable_logging: bool = True
enable_checkout: bool = False enable_checkout: bool = False
language: str = "auto"
brand_id: str = DEFAULT_BRAND_ID
config_dir_name: str = DEFAULT_CONFIG_DIR_NAME
update_base_url: str = DEFAULT_UPDATE_BASE_URL
update_repo: str = DEFAULT_UPDATE_REPO
update_channel: str = DEFAULT_UPDATE_CHANNEL
update_manifest_name: str = DEFAULT_UPDATE_MANIFEST_NAME
@classmethod @classmethod
def from_file(cls, config_path: Path) -> "Config": def from_file(cls, config_path: Path) -> "Config":
@ -123,6 +144,9 @@ class Config:
elif not root.is_dir(): elif not root.is_dir():
raise ConfigurationError(f"Allowed root is not a directory: {root}") raise ConfigurationError(f"Allowed root is not a directory: {root}")
brand_id = data.get("brand_id", DEFAULT_BRAND_ID)
config_dir_name = data.get("config_dir_name", cls._slugify_config_dir_name(brand_id))
# Get log file path # Get log file path
log_file = None log_file = None
if data.get("enable_logging", True): if data.get("enable_logging", True):
@ -131,10 +155,10 @@ class Config:
log_file = Path(log_file_str) log_file = Path(log_file_str)
# If relative path, resolve relative to app data directory instead of cwd # If relative path, resolve relative to app data directory instead of cwd
if not log_file.is_absolute(): if not log_file.is_absolute():
log_file = Config.get_default_log_dir() / log_file log_file = Config.get_default_log_dir(config_dir_name) / log_file
else: else:
# Use default log path in app data # Use default log path in app data
log_file = Config.get_default_log_path() log_file = Config.get_default_log_path(config_dir_name)
app_name = data.get("app_name", "WebDrop Bridge") app_name = data.get("app_name", "WebDrop Bridge")
stored_window_title = data.get("window_title", "") stored_window_title = data.get("window_title", "")
@ -172,6 +196,13 @@ class Config:
window_title=window_title, window_title=window_title,
enable_logging=data.get("enable_logging", True), enable_logging=data.get("enable_logging", True),
enable_checkout=data.get("enable_checkout", False), enable_checkout=data.get("enable_checkout", False),
language=data.get("language", "auto"),
brand_id=brand_id,
config_dir_name=config_dir_name,
update_base_url=data.get("update_base_url", DEFAULT_UPDATE_BASE_URL),
update_repo=data.get("update_repo", DEFAULT_UPDATE_REPO),
update_channel=data.get("update_channel", DEFAULT_UPDATE_CHANNEL),
update_manifest_name=data.get("update_manifest_name", DEFAULT_UPDATE_MANIFEST_NAME),
) )
@classmethod @classmethod
@ -199,6 +230,8 @@ class Config:
from webdrop_bridge import __version__ from webdrop_bridge import __version__
app_version = __version__ app_version = __version__
brand_id = os.getenv("BRAND_ID", DEFAULT_BRAND_ID)
config_dir_name = os.getenv("APP_CONFIG_DIR_NAME", cls._slugify_config_dir_name(brand_id))
log_level = os.getenv("LOG_LEVEL", "INFO").upper() log_level = os.getenv("LOG_LEVEL", "INFO").upper()
log_file_str = os.getenv("LOG_FILE", None) log_file_str = os.getenv("LOG_FILE", None)
@ -212,6 +245,11 @@ class Config:
window_title = os.getenv("WINDOW_TITLE", default_title) window_title = os.getenv("WINDOW_TITLE", default_title)
enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true" enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true"
enable_checkout = os.getenv("ENABLE_CHECKOUT", "false").lower() == "true" enable_checkout = os.getenv("ENABLE_CHECKOUT", "false").lower() == "true"
language = os.getenv("LANGUAGE", "auto")
update_base_url = os.getenv("UPDATE_BASE_URL", DEFAULT_UPDATE_BASE_URL)
update_repo = os.getenv("UPDATE_REPO", DEFAULT_UPDATE_REPO)
update_channel = os.getenv("UPDATE_CHANNEL", DEFAULT_UPDATE_CHANNEL)
update_manifest_name = os.getenv("UPDATE_MANIFEST_NAME", DEFAULT_UPDATE_MANIFEST_NAME)
# Validate log level # Validate log level
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
@ -251,10 +289,10 @@ class Config:
log_file = Path(log_file_str) log_file = Path(log_file_str)
# If relative path, resolve relative to app data directory instead of cwd # If relative path, resolve relative to app data directory instead of cwd
if not log_file.is_absolute(): if not log_file.is_absolute():
log_file = Config.get_default_log_dir() / log_file log_file = Config.get_default_log_dir(config_dir_name) / log_file
else: else:
# Use default log path in app data # Use default log path in app data
log_file = Config.get_default_log_path() log_file = Config.get_default_log_path(config_dir_name)
# Validate webapp URL is not empty # Validate webapp URL is not empty
if not webapp_url: if not webapp_url:
@ -304,6 +342,13 @@ class Config:
window_title=window_title, window_title=window_title,
enable_logging=enable_logging, enable_logging=enable_logging,
enable_checkout=enable_checkout, enable_checkout=enable_checkout,
language=language,
brand_id=brand_id,
config_dir_name=config_dir_name,
update_base_url=update_base_url,
update_repo=update_repo,
update_channel=update_channel,
update_manifest_name=update_manifest_name,
) )
def to_file(self, config_path: Path) -> None: def to_file(self, config_path: Path) -> None:
@ -332,6 +377,13 @@ class Config:
"window_title": self.window_title, "window_title": self.window_title,
"enable_logging": self.enable_logging, "enable_logging": self.enable_logging,
"enable_checkout": self.enable_checkout, "enable_checkout": self.enable_checkout,
"language": self.language,
"brand_id": self.brand_id,
"config_dir_name": self.config_dir_name,
"update_base_url": self.update_base_url,
"update_repo": self.update_repo,
"update_channel": self.update_channel,
"update_manifest_name": self.update_manifest_name,
} }
config_path.parent.mkdir(parents=True, exist_ok=True) config_path.parent.mkdir(parents=True, exist_ok=True)
@ -339,7 +391,57 @@ class Config:
json.dump(data, f, indent=2) json.dump(data, f, indent=2)
@staticmethod @staticmethod
def get_default_config_path() -> Path: def load_bootstrap_env(env_file: str | None = None) -> Path | None:
"""Load a bootstrap .env before configuration path lookup.
This lets branded builds decide their config directory before the main
config file is loaded.
Args:
env_file: Optional explicit .env path
Returns:
Path to the loaded .env file, or None if nothing was loaded
"""
candidate_paths: list[Path] = []
if env_file:
candidate_paths.append(Path(env_file).resolve())
else:
if getattr(sys, "frozen", False):
exe_dir = Path(sys.executable).resolve().parent
# One-folder fallback: some packagers place data files in _internal.
candidate_paths.append(exe_dir / ".env")
candidate_paths.append(exe_dir / "_internal" / ".env")
# PyInstaller runtime extraction directory (one-file and one-folder).
meipass = getattr(sys, "_MEIPASS", None)
if meipass:
candidate_paths.append(Path(meipass).resolve() / ".env")
candidate_paths.append(Path.cwd() / ".env")
candidate_paths.append(Path(__file__).resolve().parents[2] / ".env")
for path in candidate_paths:
if path.exists():
load_dotenv(path, override=False)
logger.debug(f"Loaded bootstrap environment from {path}")
return path
return None
@staticmethod
def _slugify_config_dir_name(value: str) -> str:
"""Convert brand-like identifiers into a filesystem-safe directory name."""
sanitized = "".join(c.lower() if c.isalnum() else "_" for c in value).strip("_")
return sanitized or DEFAULT_CONFIG_DIR_NAME
@staticmethod
def get_default_config_dir_name() -> str:
"""Get the default config directory name from environment or fallback."""
return os.getenv("APP_CONFIG_DIR_NAME", DEFAULT_CONFIG_DIR_NAME)
@staticmethod
def get_default_config_path(config_dir_name: str | None = None) -> Path:
"""Get the default configuration file path. """Get the default configuration file path.
Returns: Returns:
@ -351,10 +453,10 @@ class Config:
base = Path.home() / "AppData" / "Roaming" base = Path.home() / "AppData" / "Roaming"
else: else:
base = Path.home() / ".config" base = Path.home() / ".config"
return base / "webdrop_bridge" / "config.json" return base / (config_dir_name or Config.get_default_config_dir_name()) / "config.json"
@staticmethod @staticmethod
def get_default_log_dir() -> Path: def get_default_log_dir(config_dir_name: str | None = None) -> Path:
"""Get the default directory for log files. """Get the default directory for log files.
Always uses user's AppData directory to ensure permissions work Always uses user's AppData directory to ensure permissions work
@ -369,21 +471,31 @@ class Config:
base = Path.home() / "AppData" / "Roaming" base = Path.home() / "AppData" / "Roaming"
else: else:
base = Path.home() / ".local" / "share" base = Path.home() / ".local" / "share"
return base / "webdrop_bridge" / "logs" return base / (config_dir_name or Config.get_default_config_dir_name()) / "logs"
@staticmethod @staticmethod
def get_default_log_path() -> Path: def get_default_log_path(config_dir_name: str | None = None) -> Path:
"""Get the default log file path. """Get the default log file path.
Returns: Returns:
Path to default log file in user's AppData/Roaming/webdrop_bridge/logs Path to default log file in user's AppData/Roaming/webdrop_bridge/logs
""" """
return Config.get_default_log_dir() / "webdrop_bridge.log" dir_name = config_dir_name or Config.get_default_config_dir_name()
return Config.get_default_log_dir(dir_name) / f"{dir_name}.log"
def get_config_path(self) -> Path:
"""Get the default config file path for this configured brand."""
return self.get_default_config_path(self.config_dir_name)
def get_cache_dir(self) -> Path:
"""Get the update/cache directory for this configured brand."""
return self.get_default_config_path(self.config_dir_name).parent / "cache"
def __repr__(self) -> str: def __repr__(self) -> str:
"""Return developer-friendly representation.""" """Return developer-friendly representation."""
return ( return (
f"Config(app={self.app_name} v{self.app_version}, " f"Config(app={self.app_name} v{self.app_version}, "
f"brand={self.brand_id}, "
f"log_level={self.log_level}, " f"log_level={self.log_level}, "
f"allowed_roots={len(self.allowed_roots)} dirs, " f"allowed_roots={len(self.allowed_roots)} dirs, "
f"window={self.window_width}x{self.window_height})" f"window={self.window_width}x{self.window_height})"

View file

@ -101,14 +101,13 @@ class ConfigValidator:
class ConfigProfile: class ConfigProfile:
"""Manages named configuration profiles. """Manages named configuration profiles.
Profiles are stored in ~/.webdrop_bridge/profiles/ directory as JSON files. Profiles are stored in the brand-specific app config directory.
""" """
PROFILES_DIR = Path.home() / ".webdrop_bridge" / "profiles" def __init__(self, config_dir_name: str = "webdrop_bridge") -> None:
def __init__(self) -> None:
"""Initialize profile manager.""" """Initialize profile manager."""
self.PROFILES_DIR.mkdir(parents=True, exist_ok=True) self.profiles_dir = Config.get_default_config_path(config_dir_name).parent / "profiles"
self.profiles_dir.mkdir(parents=True, exist_ok=True)
def save_profile(self, profile_name: str, config: Config) -> Path: def save_profile(self, profile_name: str, config: Config) -> Path:
"""Save configuration as a named profile. """Save configuration as a named profile.
@ -126,7 +125,7 @@ class ConfigProfile:
if not profile_name or "/" in profile_name or "\\" in profile_name: if not profile_name or "/" in profile_name or "\\" in profile_name:
raise ConfigurationError(f"Invalid profile name: {profile_name}") raise ConfigurationError(f"Invalid profile name: {profile_name}")
profile_path = self.PROFILES_DIR / f"{profile_name}.json" profile_path = self.profiles_dir / f"{profile_name}.json"
config_data = { config_data = {
"app_name": config.app_name, "app_name": config.app_name,
@ -160,7 +159,7 @@ class ConfigProfile:
Raises: Raises:
ConfigurationError: If profile not found or invalid ConfigurationError: If profile not found or invalid
""" """
profile_path = self.PROFILES_DIR / f"{profile_name}.json" profile_path = self.profiles_dir / f"{profile_name}.json"
if not profile_path.exists(): if not profile_path.exists():
raise ConfigurationError(f"Profile not found: {profile_name}") raise ConfigurationError(f"Profile not found: {profile_name}")
@ -179,10 +178,10 @@ class ConfigProfile:
Returns: Returns:
List of profile names (without .json extension) List of profile names (without .json extension)
""" """
if not self.PROFILES_DIR.exists(): if not self.profiles_dir.exists():
return [] return []
return sorted([p.stem for p in self.PROFILES_DIR.glob("*.json")]) return sorted([p.stem for p in self.profiles_dir.glob("*.json")])
def delete_profile(self, profile_name: str) -> None: def delete_profile(self, profile_name: str) -> None:
"""Delete a profile. """Delete a profile.
@ -193,7 +192,7 @@ class ConfigProfile:
Raises: Raises:
ConfigurationError: If profile not found ConfigurationError: If profile not found
""" """
profile_path = self.PROFILES_DIR / f"{profile_name}.json" profile_path = self.profiles_dir / f"{profile_name}.json"
if not profile_path.exists(): if not profile_path.exists():
raise ConfigurationError(f"Profile not found: {profile_name}") raise ConfigurationError(f"Profile not found: {profile_name}")

View file

@ -5,9 +5,11 @@ verifying checksums from Forgejo releases.
""" """
import asyncio import asyncio
import fnmatch
import hashlib import hashlib
import json import json
import logging import logging
import platform
import socket import socket
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -34,7 +36,16 @@ class Release:
class UpdateManager: class UpdateManager:
"""Manages auto-updates via Forgejo releases API.""" """Manages auto-updates via Forgejo releases API."""
def __init__(self, current_version: str, config_dir: Optional[Path] = None): def __init__(
self,
current_version: str,
config_dir: Optional[Path] = None,
brand_id: str = "webdrop_bridge",
forgejo_url: str = "https://git.him-tools.de",
repo: str = "HIM-public/webdrop-bridge",
update_channel: str = "stable",
manifest_name: str = "release-manifest.json",
):
"""Initialize update manager. """Initialize update manager.
Args: Args:
@ -42,8 +53,11 @@ class UpdateManager:
config_dir: Directory for storing update cache. Defaults to temp. config_dir: Directory for storing update cache. Defaults to temp.
""" """
self.current_version = current_version self.current_version = current_version
self.forgejo_url = "https://git.him-tools.de" self.brand_id = brand_id
self.repo = "HIM-public/webdrop-bridge" self.forgejo_url = forgejo_url.rstrip("/")
self.repo = repo
self.update_channel = update_channel
self.manifest_name = manifest_name
self.api_endpoint = f"{self.forgejo_url}/api/v1/repos/{self.repo}/releases/latest" self.api_endpoint = f"{self.forgejo_url}/api/v1/repos/{self.repo}/releases/latest"
# Cache management # Cache management
@ -52,6 +66,150 @@ class UpdateManager:
self.cache_file = self.cache_dir / "update_check.json" self.cache_file = self.cache_dir / "update_check.json"
self.cache_ttl = timedelta(hours=24) self.cache_ttl = timedelta(hours=24)
def _get_platform_key(self) -> str:
"""Return the release-manifest platform key for the current system."""
system = platform.system()
machine = platform.machine().lower()
if system == "Windows":
arch = "x64" if machine in {"amd64", "x86_64"} else machine
return f"windows-{arch}"
if system == "Darwin":
return "macos-universal"
return f"{system.lower()}-{machine}"
def _find_asset(self, assets: list[dict], asset_name: str) -> Optional[dict]:
"""Find an asset by exact name."""
for asset in assets:
if asset.get("name") == asset_name:
return asset
return None
def _find_manifest_asset(self, release: Release) -> Optional[dict]:
"""Find the shared release manifest asset if present."""
return self._find_asset(release.assets, self.manifest_name)
def _download_json_asset(self, url: str) -> Optional[dict]:
"""Download and parse a JSON asset from a release."""
try:
with urlopen(url, timeout=10) as response:
# Some release pipelines may upload JSON files with UTF-8 BOM.
# Use utf-8-sig to transparently handle both BOM and non-BOM files.
return json.loads(response.read().decode("utf-8-sig"))
except (URLError, json.JSONDecodeError) as e:
logger.error(f"Failed to download JSON asset: {e}")
return None
async def _load_release_manifest(self, release: Release) -> Optional[dict]:
"""Load the shared release manifest if present."""
manifest_asset = self._find_manifest_asset(release)
if not manifest_asset:
return None
loop = asyncio.get_event_loop()
return await asyncio.wait_for(
loop.run_in_executor(
None, self._download_json_asset, manifest_asset["browser_download_url"]
),
timeout=15,
)
def _resolve_assets_from_manifest(
self, release: Release, manifest: dict
) -> tuple[Optional[dict], Optional[dict]]:
"""Resolve installer and checksum assets from a shared release manifest."""
if manifest.get("channel") not in {None, "", self.update_channel}:
logger.info(
"Release manifest channel %s does not match configured channel %s",
manifest.get("channel"),
self.update_channel,
)
return None, None
brand_entry = manifest.get("brands", {}).get(self.brand_id, {})
platform_entry = brand_entry.get(self._get_platform_key(), {})
installer_name = platform_entry.get("installer")
checksum_name = platform_entry.get("checksum")
if not installer_name:
logger.warning(
"No installer entry found for brand=%s platform=%s in release manifest",
self.brand_id,
self._get_platform_key(),
)
return None, None
return self._find_asset(release.assets, installer_name), self._find_asset(
release.assets, checksum_name
)
def _resolve_assets_legacy(self, release: Release) -> tuple[Optional[dict], Optional[dict]]:
"""Resolve installer and checksum assets using legacy filename matching."""
is_windows = platform.system() == "Windows"
extension = ".msi" if is_windows else ".dmg"
brand_prefix = f"{self.brand_id}-*"
installer_asset = None
# Prefer brand-specific naming when possible.
if self.brand_id == "webdrop_bridge":
preferred_patterns = ["webdropbridge-*.msi", "webdropbridge*.msi"]
else:
preferred_patterns = [f"{self.brand_id.lower()}-*.msi", f"{self.brand_id.lower()}*.msi"]
# 1) Try strict brand-pattern match first
for asset in release.assets:
asset_name = asset.get("name", "")
asset_name_lower = asset_name.lower()
if not asset_name_lower.endswith(extension):
continue
if any(fnmatch.fnmatch(asset_name_lower, pattern) for pattern in preferred_patterns):
installer_asset = asset
break
# 2) Fallback: preserve previous behavior (first installer for platform)
for asset in release.assets:
if installer_asset:
break
asset_name = asset.get("name", "")
if not asset_name.endswith(extension):
continue
if self.brand_id != "webdrop_bridge" and fnmatch.fnmatch(
asset_name.lower(), brand_prefix.lower()
):
installer_asset = asset
break
if self.brand_id == "webdrop_bridge":
installer_asset = asset
break
if not installer_asset:
return None, None
checksum_asset = self._find_asset(release.assets, f"{installer_asset['name']}.sha256")
return installer_asset, checksum_asset
async def _resolve_release_assets(
self, release: Release
) -> tuple[Optional[dict], Optional[dict]]:
"""Resolve installer and checksum assets for the configured brand."""
try:
manifest = await self._load_release_manifest(release)
except asyncio.TimeoutError:
logger.warning(
"Timed out while loading release manifest, falling back to legacy lookup"
)
manifest = None
if manifest:
installer_asset, checksum_asset = self._resolve_assets_from_manifest(release, manifest)
if installer_asset:
return installer_asset, checksum_asset
return self._resolve_assets_legacy(release)
def _parse_version(self, version_str: str) -> tuple[int, int, int]: def _parse_version(self, version_str: str) -> tuple[int, int, int]:
"""Parse semantic version string to tuple. """Parse semantic version string to tuple.
@ -253,12 +411,7 @@ class UpdateManager:
logger.error("No assets found in release") logger.error("No assets found in release")
return None return None
# Find .msi or .dmg file installer_asset, _ = await self._resolve_release_assets(release)
installer_asset = None
for asset in release.assets:
if asset["name"].endswith((".msi", ".dmg")):
installer_asset = asset
break
if not installer_asset: if not installer_asset:
logger.error("No installer found in release assets") logger.error("No installer found in release assets")
@ -345,14 +498,11 @@ class UpdateManager:
Returns: Returns:
True if checksum matches, False otherwise True if checksum matches, False otherwise
""" """
# Find .sha256 file matching the installer name (e.g. Setup.msi.sha256) installer_asset, checksum_asset = await self._resolve_release_assets(release)
# Fall back to any .sha256 only if no specific match exists installer_name = installer_asset["name"] if installer_asset else file_path.name
installer_name = file_path.name
checksum_asset = None if not checksum_asset:
for asset in release.assets: checksum_asset = self._find_asset(release.assets, f"{installer_name}.sha256")
if asset["name"] == f"{installer_name}.sha256":
checksum_asset = asset
break
if not checksum_asset: if not checksum_asset:
logger.warning("No checksum file found in release") logger.warning("No checksum file found in release")

View file

@ -15,6 +15,8 @@ from PySide6.QtWidgets import QApplication
from webdrop_bridge.config import Config, ConfigurationError from webdrop_bridge.config import Config, ConfigurationError
from webdrop_bridge.ui.main_window import MainWindow from webdrop_bridge.ui.main_window import MainWindow
from webdrop_bridge.utils.i18n import get_translations_dir
from webdrop_bridge.utils.i18n import initialize as i18n_init
from webdrop_bridge.utils.logging import get_logger, setup_logging from webdrop_bridge.utils.logging import get_logger, setup_logging
@ -28,6 +30,8 @@ def main() -> int:
int: Exit code (0 for success, non-zero for error) int: Exit code (0 for success, non-zero for error)
""" """
try: try:
Config.load_bootstrap_env()
# Load configuration from file if it exists, otherwise from environment # Load configuration from file if it exists, otherwise from environment
config_path = Config.get_default_config_path() config_path = Config.get_default_config_path()
if config_path.exists(): if config_path.exists():
@ -50,6 +54,11 @@ def main() -> int:
logger.info(f"Starting {config.app_name} v{config.app_version}") logger.info(f"Starting {config.app_name} v{config.app_version}")
logger.debug(f"Configuration: {config}") logger.debug(f"Configuration: {config}")
# Initialize internationalization
translations_dir = get_translations_dir()
i18n_init(config.language, translations_dir)
logger.debug(f"i18n initialized: language={config.language}, dir={translations_dir}")
except ConfigurationError as e: except ConfigurationError as e:
print(f"Configuration error: {e}", file=sys.stderr) print(f"Configuration error: {e}", file=sys.stderr)
return 1 return 1

View file

@ -3,7 +3,9 @@
import asyncio import asyncio
import json import json
import logging import logging
import os
import re import re
import subprocess
import sys import sys
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -42,6 +44,7 @@ from webdrop_bridge.config import Config
from webdrop_bridge.core.drag_interceptor import DragInterceptor from webdrop_bridge.core.drag_interceptor import DragInterceptor
from webdrop_bridge.core.validator import PathValidator from webdrop_bridge.core.validator import PathValidator
from webdrop_bridge.ui.restricted_web_view import RestrictedWebEngineView from webdrop_bridge.ui.restricted_web_view import RestrictedWebEngineView
from webdrop_bridge.utils.i18n import tr
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -244,12 +247,18 @@ class OpenDropZone(QWidget):
self._icon_label.setPixmap(pixmap) self._icon_label.setPixmap(pixmap)
self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._icon_label.setStyleSheet(self._NORMAL_STYLE) self._icon_label.setStyleSheet(self._NORMAL_STYLE)
self._icon_label.setToolTip("Drop a file here to open it with its default application") self._icon_label.setToolTip(tr("toolbar.tooltip.open_drop"))
layout.addWidget(self._icon_label) layout.addWidget(self._icon_label)
self.setMinimumSize(QSize(44, 44)) self.setMinimumSize(QSize(44, 44))
self.setMaximumSize(QSize(48, 48)) self.setMaximumSize(QSize(48, 48))
def set_icon(self, icon: QIcon) -> None:
"""Set the displayed icon for the drop zone widget."""
if icon.isNull():
return
self._icon_label.setPixmap(icon.pixmap(QSize(32, 32)))
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Drop handling # Drop handling
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -295,6 +304,46 @@ class OpenDropZone(QWidget):
logger.debug(f"OpenDropZone: skipping non-local URL {url.toString()}") logger.debug(f"OpenDropZone: skipping non-local URL {url.toString()}")
class OpenWithDropZone(OpenDropZone):
"""Drop target widget that opens files via an app chooser dialog.
When a file is dropped, this widget emits ``file_open_with_requested`` for
each local file so the main window can invoke platform-specific
"Open With" behavior.
Signals:
file_open_with_requested (str): Emitted with the local file path.
"""
file_open_with_requested = Signal(str)
def __init__(self, parent: Optional[QWidget] = None) -> None:
"""Initialize the OpenWithDropZone widget.
Args:
parent: Parent widget.
"""
super().__init__(parent)
self._icon_label.setToolTip(tr("toolbar.tooltip.open_with_drop"))
def dropEvent(self, event) -> None: # type: ignore[override]
"""Emit dropped local files for app-chooser handling."""
self._icon_label.setStyleSheet(self._NORMAL_STYLE)
mime = event.mimeData()
if not mime.hasUrls():
event.ignore()
return
event.acceptProposedAction()
for url in mime.urls():
if url.isLocalFile():
file_path = url.toLocalFile()
logger.info(f"OpenWithDropZone: request app chooser for '{file_path}'")
self.file_open_with_requested.emit(file_path)
else:
logger.debug(f"OpenWithDropZone: skipping non-local URL {url.toString()}")
class _DragBridge(QObject): class _DragBridge(QObject):
"""JavaScript bridge for drag operations via QWebChannel. """JavaScript bridge for drag operations via QWebChannel.
@ -938,8 +987,8 @@ class MainWindow(QMainWindow):
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
"Checkout Asset", tr("dialog.checkout.title"),
f"Do you want to check out this asset?\n\n{filename}", tr("dialog.checkout.msg", filename=filename),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes, QMessageBox.StandardButton.Yes,
) )
@ -1043,8 +1092,8 @@ class MainWindow(QMainWindow):
# Show error dialog to user # Show error dialog to user
QMessageBox.warning( QMessageBox.warning(
self, self,
"Drag-and-Drop Error", tr("dialog.drag_error.title"),
f"Could not complete the drag-and-drop operation.\n\nError: {error}", tr("dialog.drag_error.msg", error=error),
) )
def _on_file_opened_via_drop(self, file_path: str) -> None: def _on_file_opened_via_drop(self, file_path: str) -> None:
@ -1054,7 +1103,7 @@ class MainWindow(QMainWindow):
file_path: Local file path that was opened. file_path: Local file path that was opened.
""" """
logger.info(f"Opened via drop zone: {file_path}") logger.info(f"Opened via drop zone: {file_path}")
self.statusBar().showMessage(f"Opened: {Path(file_path).name}", 4000) self.statusBar().showMessage(tr("status.opened", name=Path(file_path).name), 4000)
def _on_file_open_failed_via_drop(self, file_path: str, error: str) -> None: def _on_file_open_failed_via_drop(self, file_path: str, error: str) -> None:
"""Handle a failure to open a file dropped on the OpenDropZone. """Handle a failure to open a file dropped on the OpenDropZone.
@ -1066,11 +1115,82 @@ class MainWindow(QMainWindow):
logger.warning(f"Failed to open via drop zone '{file_path}': {error}") logger.warning(f"Failed to open via drop zone '{file_path}': {error}")
QMessageBox.warning( QMessageBox.warning(
self, self,
"Open File Error", tr("dialog.open_file_error.title"),
f"Could not open the file with its default application.\n\n" tr("dialog.open_file_error.msg", file_path=file_path, error=error),
f"File: {file_path}\nError: {error}",
) )
def _on_file_open_with_requested(self, file_path: str) -> None:
"""Handle a file dropped on the OpenWithDropZone.
Args:
file_path: Local file path to open using an app chooser.
"""
if self._open_with_app_chooser(file_path):
self.statusBar().showMessage(tr("status.choose_app", name=Path(file_path).name), 4000)
logger.info(f"Opened app chooser for '{file_path}'")
return
logger.warning(f"Could not open app chooser for '{file_path}'")
QMessageBox.warning(
self,
tr("dialog.open_with_error.title"),
tr("dialog.open_with_error.msg"),
)
def _open_with_app_chooser(self, file_path: str) -> bool:
"""Open OS-specific app chooser for a local file.
Args:
file_path: Local file path.
Returns:
True if the chooser command was started successfully, False otherwise.
"""
try:
normalized_path = str(Path(file_path))
if not Path(normalized_path).exists():
logger.warning(f"Open-with target does not exist: {normalized_path}")
return False
if sys.platform.startswith("win"):
# First try the native shell "openas" verb.
import ctypes
result = ctypes.windll.shell32.ShellExecuteW(
None, "openas", normalized_path, None, None, 1
)
if result > 32:
return True
logger.warning(f"ShellExecuteW(openas) failed with code {result}; trying fallback")
# Fallback for systems where openas verb is not available/reliable.
subprocess.Popen(["rundll32.exe", "shell32.dll,OpenAs_RunDLL", normalized_path])
return True
if sys.platform == "darwin":
# Prompt for an app and open the file with the selected app.
script = (
"on run argv\n"
"set targetFile to POSIX file (item 1 of argv)\n"
"set chosenApp to choose application\n"
'tell application "Finder" to open targetFile using chosenApp\n'
"end run"
)
result = subprocess.run(
["osascript", "-e", script, file_path],
check=False,
capture_output=True,
text=True,
)
return result.returncode == 0
logger.warning(f"Open-with chooser not implemented for platform: {sys.platform}")
return False
except Exception as e:
logger.warning(f"Failed to open app chooser for '{file_path}': {e}")
return False
def _on_download_requested(self, download: QWebEngineDownloadRequest) -> None: def _on_download_requested(self, download: QWebEngineDownloadRequest) -> None:
"""Handle download requests from the embedded web view. """Handle download requests from the embedded web view.
@ -1115,7 +1235,7 @@ class MainWindow(QMainWindow):
logger.info(f"Download started: {filename}") logger.info(f"Download started: {filename}")
# Update status bar (temporarily) # Update status bar (temporarily)
self.status_bar.showMessage(f"📥 Download: {filename}", 3000) self.status_bar.showMessage(tr("status.download_started", filename=filename), 3000)
# Connect to state changed for progress tracking # Connect to state changed for progress tracking
download.stateChanged.connect( download.stateChanged.connect(
@ -1129,7 +1249,7 @@ class MainWindow(QMainWindow):
except Exception as e: except Exception as e:
logger.error(f"Error handling download: {e}", exc_info=True) logger.error(f"Error handling download: {e}", exc_info=True)
self.status_bar.showMessage(f"Download error: {e}", 5000) self.status_bar.showMessage(tr("status.download_error", error=str(e)), 5000)
def _on_download_finished(self, download: QWebEngineDownloadRequest, file_path: Path) -> None: def _on_download_finished(self, download: QWebEngineDownloadRequest, file_path: Path) -> None:
"""Handle download completion. """Handle download completion.
@ -1147,13 +1267,17 @@ class MainWindow(QMainWindow):
if state == QWebEngineDownloadRequest.DownloadState.DownloadCompleted: if state == QWebEngineDownloadRequest.DownloadState.DownloadCompleted:
logger.info(f"Download completed: {file_path.name}") logger.info(f"Download completed: {file_path.name}")
self.status_bar.showMessage(f"Download completed: {file_path.name}", 5000) self.status_bar.showMessage(
tr("status.download_completed", name=file_path.name), 5000
)
elif state == QWebEngineDownloadRequest.DownloadState.DownloadCancelled: elif state == QWebEngineDownloadRequest.DownloadState.DownloadCancelled:
logger.info(f"Download cancelled: {file_path.name}") logger.info(f"Download cancelled: {file_path.name}")
self.status_bar.showMessage(f"⚠️ Download abgebrochen: {file_path.name}", 3000) self.status_bar.showMessage(
tr("status.download_cancelled", name=file_path.name), 3000
)
elif state == QWebEngineDownloadRequest.DownloadState.DownloadInterrupted: elif state == QWebEngineDownloadRequest.DownloadState.DownloadInterrupted:
logger.warning(f"Download interrupted: {file_path.name}") logger.warning(f"Download interrupted: {file_path.name}")
self.status_bar.showMessage(f"❌ Download fehlgeschlagen: {file_path.name}", 5000) self.status_bar.showMessage(tr("status.download_failed", name=file_path.name), 5000)
except Exception as e: except Exception as e:
logger.error(f"Error in download finished handler: {e}", exc_info=True) logger.error(f"Error in download finished handler: {e}", exc_info=True)
@ -1264,24 +1388,54 @@ class MainWindow(QMainWindow):
toolbar.addSeparator() toolbar.addSeparator()
# Home button # Home button
home_action = toolbar.addAction( home_icon_path = self._resolve_toolbar_icon_path(
self.style().standardIcon(self.style().StandardPixmap.SP_DirHomeIcon), "" os.getenv("TOOLBAR_ICON_HOME", "resources/icons/home.ico")
) )
home_action.setToolTip("Home") home_icon = (
QIcon(str(home_icon_path))
if home_icon_path is not None
else self.style().standardIcon(self.style().StandardPixmap.SP_DirHomeIcon)
)
home_action = toolbar.addAction(home_icon, "")
home_action.setToolTip(tr("toolbar.tooltip.home"))
home_action.triggered.connect(self._navigate_home) home_action.triggered.connect(self._navigate_home)
# Refresh button # Refresh button
refresh_action = self.web_view.pageAction(self.web_view.page().WebAction.Reload) refresh_action = self.web_view.pageAction(self.web_view.page().WebAction.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:
refresh_action.setIcon(QIcon(str(reload_icon_path)))
toolbar.addAction(refresh_action) toolbar.addAction(refresh_action)
# 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(
os.getenv("TOOLBAR_ICON_OPEN", "resources/icons/open.ico")
)
if open_icon_path is not None:
self._open_drop_zone.set_icon(QIcon(str(open_icon_path)))
self._open_drop_zone.file_opened.connect(self._on_file_opened_via_drop) self._open_drop_zone.file_opened.connect(self._on_file_opened_via_drop)
self._open_drop_zone.file_open_failed.connect(self._on_file_open_failed_via_drop) self._open_drop_zone.file_open_failed.connect(self._on_file_open_failed_via_drop)
open_drop_action = QWidgetAction(toolbar) open_drop_action = QWidgetAction(toolbar)
open_drop_action.setDefaultWidget(self._open_drop_zone) open_drop_action.setDefaultWidget(self._open_drop_zone)
toolbar.addAction(open_drop_action) toolbar.addAction(open_drop_action)
# Open-with chooser drop zone (right of Open-with-default-app)
self._open_with_drop_zone = OpenWithDropZone()
open_with_icon_path = self._resolve_toolbar_icon_path(
os.getenv("TOOLBAR_ICON_OPENWITH", "resources/icons/openwith.ico")
)
if open_with_icon_path is not None:
self._open_with_drop_zone.set_icon(QIcon(str(open_with_icon_path)))
self._open_with_drop_zone.file_open_with_requested.connect(
self._on_file_open_with_requested
)
open_with_drop_action = QWidgetAction(toolbar)
open_with_drop_action.setDefaultWidget(self._open_with_drop_zone)
toolbar.addAction(open_with_drop_action)
# Add stretch spacer to push help buttons to the right # Add stretch spacer to push help buttons to the right
spacer = QWidget() spacer = QWidget()
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
@ -1289,34 +1443,60 @@ class MainWindow(QMainWindow):
# About button (info icon) on the right # About button (info icon) on the right
about_action = toolbar.addAction("") about_action = toolbar.addAction("")
about_action.setToolTip("About WebDrop Bridge") about_action.setToolTip(tr("toolbar.tooltip.about"))
about_action.triggered.connect(self._show_about_dialog) about_action.triggered.connect(self._show_about_dialog)
# Settings button on the right # Settings button on the right
settings_action = toolbar.addAction("⚙️") settings_action = toolbar.addAction("⚙️")
settings_action.setToolTip("Settings") settings_action.setToolTip(tr("toolbar.tooltip.settings"))
settings_action.triggered.connect(self._show_settings_dialog) settings_action.triggered.connect(self._show_settings_dialog)
# Check for Updates button on the right # Check for Updates button on the right
check_updates_action = toolbar.addAction("🔄") check_updates_action = toolbar.addAction("🔄")
check_updates_action.setToolTip("Check for Updates") check_updates_action.setToolTip(tr("toolbar.tooltip.check_updates"))
check_updates_action.triggered.connect(self._on_manual_check_for_updates) check_updates_action.triggered.connect(self._on_manual_check_for_updates)
# Clear cache button on the right # Clear cache button on the right
clear_cache_action = toolbar.addAction("🗑️") clear_cache_action = toolbar.addAction("🗑️")
clear_cache_action.setToolTip("Clear Cache and Cookies") clear_cache_action.setToolTip(tr("toolbar.tooltip.clear_cache"))
clear_cache_action.triggered.connect(self._clear_cache_and_cookies) clear_cache_action.triggered.connect(self._clear_cache_and_cookies)
# Log file button on the right # Log file button on the right
log_action = toolbar.addAction("📋") log_action = toolbar.addAction("📋")
log_action.setToolTip("Open Log File") log_action.setToolTip(tr("toolbar.tooltip.open_log"))
log_action.triggered.connect(self._open_log_file) log_action.triggered.connect(self._open_log_file)
# Developer Tools button on the right # Developer Tools button on the right
dev_tools_action = toolbar.addAction("🔧") dev_tools_action = toolbar.addAction("🔧")
dev_tools_action.setToolTip("Developer Tools (F12)") dev_tools_action.setToolTip(tr("toolbar.tooltip.dev_tools"))
dev_tools_action.triggered.connect(self._open_developer_tools) dev_tools_action.triggered.connect(self._open_developer_tools)
def _resolve_toolbar_icon_path(self, configured_path: str) -> Path | None:
"""Resolve configured toolbar icon path in both dev and packaged layouts."""
icon_path = Path(configured_path)
candidates: list[Path] = []
if icon_path.is_absolute():
candidates.append(icon_path)
else:
if hasattr(sys, "_MEIPASS"):
meipass = Path(sys._MEIPASS) # type: ignore[attr-defined]
candidates.append(meipass / icon_path)
exe_dir = Path(sys.executable).resolve().parent
candidates.append(exe_dir / icon_path)
candidates.append(exe_dir / "_internal" / icon_path)
project_root = Path(__file__).parent.parent.parent.parent
candidates.append(project_root / icon_path)
for candidate in candidates:
if candidate.exists():
return candidate
logger.warning(f"Toolbar icon not found for configured path: {configured_path}")
return None
def _open_log_file(self) -> None: def _open_log_file(self) -> None:
"""Open the application log file in the system default text editor. """Open the application log file in the system default text editor.
@ -1340,8 +1520,8 @@ class MainWindow(QMainWindow):
else: else:
QMessageBox.information( QMessageBox.information(
self, self,
"Log File Not Found", tr("dialog.log_not_found.title"),
f"No log file found at:\n{log_file}", tr("dialog.log_not_found.msg", log_file=str(log_file)),
) )
def _open_developer_tools(self) -> None: def _open_developer_tools(self) -> None:
@ -1358,7 +1538,7 @@ class MainWindow(QMainWindow):
# Create new window # Create new window
self._dev_tools_window = QMainWindow() self._dev_tools_window = QMainWindow()
self._dev_tools_window.setWindowTitle("🔧 Developer Tools") self._dev_tools_window.setWindowTitle(tr("dialog.dev_tools.window_title"))
self._dev_tools_window.setGeometry(100, 100, 1200, 700) self._dev_tools_window.setGeometry(100, 100, 1200, 700)
self._dev_tools_window.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self._dev_tools_window.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
@ -1384,8 +1564,8 @@ class MainWindow(QMainWindow):
logger.error(f"Failed to open Developer Tools window: {e}", exc_info=True) logger.error(f"Failed to open Developer Tools window: {e}", exc_info=True)
QMessageBox.warning( QMessageBox.warning(
self, self,
"Developer Tools", tr("dialog.dev_tools.error_title"),
f"Could not open Developer Tools:\n{e}", tr("dialog.dev_tools.error_msg", error=str(e)),
) )
def _create_status_bar(self) -> None: def _create_status_bar(self) -> None:
@ -1393,7 +1573,7 @@ class MainWindow(QMainWindow):
self.status_bar = self.statusBar() self.status_bar = self.statusBar()
# Update status label # Update status label
self.update_status_label = QLabel("Ready") self.update_status_label = QLabel(tr("status.ready"))
self.update_status_label.setStyleSheet("margin-right: 10px;") self.update_status_label.setStyleSheet("margin-right: 10px;")
self.status_bar.addPermanentWidget(self.update_status_label) self.status_bar.addPermanentWidget(self.update_status_label)
@ -1404,10 +1584,26 @@ class MainWindow(QMainWindow):
status: Status text to display status: Status text to display
emoji: Optional emoji prefix (rotating, checkmark, download, warning symbols) emoji: Optional emoji prefix (rotating, checkmark, download, warning symbols)
""" """
# Map known internal status strings to translated display text
_STATIC_STATUS_MAP = {
"Checking for updates": "update.status.checking",
"Ready": "update.status.ready",
"Update deferred": "update.status.deferred",
"Ready to install": "update.status.ready_to_install",
"Installation started": "update.status.installation_started",
"Installation failed": "update.status.installation_failed",
"Download failed": "update.status.download_failed",
"Verification failed": "update.status.verification_failed",
"Operation timed out": "update.status.timed_out",
"Check timed out - no server response": "update.status.check_timed_out",
"Download timed out - no server response": "update.status.download_timed_out",
}
tr_key = _STATIC_STATUS_MAP.get(status)
display = tr(tr_key) if tr_key else status
if emoji: if emoji:
self.update_status_label.setText(f"{emoji} {status}") self.update_status_label.setText(f"{emoji} {display}")
else: else:
self.update_status_label.setText(status) self.update_status_label.setText(display)
def _on_manual_check_for_updates(self) -> None: def _on_manual_check_for_updates(self) -> None:
"""Handle manual check for updates from menu. """Handle manual check for updates from menu.
@ -1443,17 +1639,16 @@ class MainWindow(QMainWindow):
# Show confirmation message # Show confirmation message
QMessageBox.information( QMessageBox.information(
self, self,
"Cache Cleared", tr("dialog.cache_cleared.title"),
"Browser cache and cookies have been cleared successfully.\n\n" tr("dialog.cache_cleared.msg"),
"You may need to reload the page or restart the application for changes to take effect.",
) )
logger.info("Cache and cookies cleared successfully") logger.info("Cache and cookies cleared successfully")
except Exception as e: except Exception as e:
logger.error(f"Failed to clear cache and cookies: {e}") logger.error(f"Failed to clear cache and cookies: {e}")
QMessageBox.warning( QMessageBox.warning(
self, self,
"Error", tr("dialog.cache_clear_failed.title"),
f"Failed to clear cache and cookies: {str(e)}", tr("dialog.cache_clear_failed.msg", error=str(e)),
) )
def _show_about_dialog(self) -> None: def _show_about_dialog(self) -> None:
@ -1462,12 +1657,15 @@ class MainWindow(QMainWindow):
about_text = ( about_text = (
f"<b>{self.config.app_name}</b><br>" f"<b>{self.config.app_name}</b><br>"
f"Version: {self.config.app_version}<br>" f"{tr('about.version', version=self.config.app_version)}<br>"
f"<br>" f"<br>"
f"Bridges web-based drag-and-drop workflows with native file operations " f"{tr('about.description')}<br>"
f"for professional desktop applications.<br>"
f"<br>" f"<br>"
f"<b>Product of:</b><br>" f"<b>{tr('about.drop_zones_title')}</b><br>"
f"{tr('about.open_icon_desc')}<br>"
f"{tr('about.open_with_icon_desc')}<br>"
f"<br>"
f"<b>{tr('about.product_of')}</b><br>"
f"<b>hörl Information Management GmbH</b><br>" f"<b>hörl Information Management GmbH</b><br>"
f"Silberburgstraße 126<br>" f"Silberburgstraße 126<br>"
f"70176 Stuttgart, Germany<br>" f"70176 Stuttgart, Germany<br>"
@ -1478,10 +1676,10 @@ class MainWindow(QMainWindow):
f"<b>Web:</b> <a href='https://www.hoerl-im.de/'>https://www.hoerl-im.de/</a><br>" f"<b>Web:</b> <a href='https://www.hoerl-im.de/'>https://www.hoerl-im.de/</a><br>"
f"</small>" f"</small>"
f"<br>" f"<br>"
f"<small>© 2026 hörl Information Management GmbH. All rights reserved.</small>" f"<small>{tr('about.rights')}</small>"
) )
QMessageBox.about(self, f"About {self.config.app_name}", about_text) QMessageBox.about(self, tr("about.title", app_name=self.config.app_name), about_text)
def _show_settings_dialog(self) -> None: def _show_settings_dialog(self) -> None:
"""Show Settings dialog for configuration management. """Show Settings dialog for configuration management.
@ -1494,6 +1692,7 @@ class MainWindow(QMainWindow):
# Store current URL before opening dialog # Store current URL before opening dialog
old_webapp_url = self.config.webapp_url old_webapp_url = self.config.webapp_url
old_language = self.config.language
# Show dialog # Show dialog
dialog = SettingsDialog(self.config, self) dialog = SettingsDialog(self.config, self)
@ -1501,6 +1700,7 @@ 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
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}")
@ -1517,6 +1717,10 @@ class MainWindow(QMainWindow):
self.web_view.clear_cache_and_cookies() self.web_view.clear_cache_and_cookies()
QTimer.singleShot(100, self._navigate_home) QTimer.singleShot(100, self._navigate_home)
if language_changed:
logger.info(f"Language changed: {old_language}{self.config.language}")
self._handle_language_change_restart()
def _check_domain_changed(self, old_url: str, new_url: str) -> bool: def _check_domain_changed(self, old_url: str, new_url: str) -> bool:
"""Check if the domain/host has changed between two URLs. """Check if the domain/host has changed between two URLs.
@ -1554,18 +1758,17 @@ class MainWindow(QMainWindow):
from PySide6.QtWidgets import QMessageBox from PySide6.QtWidgets import QMessageBox
msg = QMessageBox(self) msg = QMessageBox(self)
msg.setWindowTitle("Domain Changed - Restart Recommended") msg.setWindowTitle(tr("dialog.domain_changed.title"))
msg.setIcon(QMessageBox.Icon.Warning) msg.setIcon(QMessageBox.Icon.Warning)
msg.setText( msg.setText(tr("dialog.domain_changed.msg"))
"Web Application Domain Has Changed\n\n"
"You've switched to a different domain. For maximum stability and "
"to ensure proper authentication, the application should be restarted.\n\n"
"The profile and cache have been cleared, but we recommend restarting."
)
# Add custom buttons # Add custom buttons
restart_now_btn = msg.addButton("Restart Now", QMessageBox.ButtonRole.AcceptRole) restart_now_btn = msg.addButton(
restart_later_btn = msg.addButton("Restart Later", QMessageBox.ButtonRole.RejectRole) tr("dialog.domain_changed.restart_now"), QMessageBox.ButtonRole.AcceptRole
)
restart_later_btn = msg.addButton(
tr("dialog.domain_changed.restart_later"), QMessageBox.ButtonRole.RejectRole
)
msg.exec() msg.exec()
@ -1578,6 +1781,27 @@ 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_language_change_restart(self) -> None:
"""Handle language change by prompting for an optional restart."""
from PySide6.QtWidgets import QMessageBox
msg = QMessageBox(self)
msg.setWindowTitle(tr("dialog.language_changed.title"))
msg.setIcon(QMessageBox.Icon.Information)
msg.setText(tr("dialog.language_changed.msg"))
restart_now_btn = msg.addButton(
tr("dialog.language_changed.restart_now"), QMessageBox.ButtonRole.AcceptRole
)
msg.addButton(
tr("dialog.language_changed.restart_later"), QMessageBox.ButtonRole.RejectRole
)
msg.exec()
if msg.clickedButton() == restart_now_btn:
self._restart_application()
def _restart_application(self) -> None: def _restart_application(self) -> None:
"""Restart the application automatically. """Restart the application automatically.
@ -1619,9 +1843,8 @@ class MainWindow(QMainWindow):
QMessageBox.warning( QMessageBox.warning(
self, self,
"Restart Failed", tr("dialog.restart_failed.title"),
f"Could not automatically restart the application:\n\n{str(e)}\n\n" tr("dialog.restart_failed.msg", error=str(e)),
"Please restart manually.",
) )
def _navigate_home(self) -> None: def _navigate_home(self) -> None:
@ -1679,8 +1902,16 @@ class MainWindow(QMainWindow):
try: try:
# Create update manager # Create update manager
cache_dir = Path.home() / ".webdrop_bridge" cache_dir = self.config.get_cache_dir()
manager = UpdateManager(current_version=self.config.app_version, config_dir=cache_dir) manager = UpdateManager(
current_version=self.config.app_version,
config_dir=cache_dir,
brand_id=self.config.brand_id,
forgejo_url=self.config.update_base_url,
repo=self.config.update_repo,
update_channel=self.config.update_channel,
manifest_name=self.config.update_manifest_name,
)
# Run async check in background # Run async check in background
self._run_async_check(manager) self._run_async_check(manager)
@ -1730,10 +1961,8 @@ class MainWindow(QMainWindow):
QMessageBox.warning( QMessageBox.warning(
self, self,
"Update Check Timeout", tr("dialog.update_timeout.title"),
"The server did not respond within 30 seconds.\n\n" tr("dialog.update_timeout.msg"),
"This may be due to a network issue or server unavailability.\n\n"
"Please check your connection and try again.",
) )
safety_timer = QTimer() safety_timer = QTimer()
@ -1810,7 +2039,7 @@ class MainWindow(QMainWindow):
error_message: Error description error_message: Error description
""" """
logger.error(f"Update check failed: {error_message}") logger.error(f"Update check failed: {error_message}")
self.set_update_status(f"Check failed: {error_message}", emoji="") self.set_update_status(tr("update.status.check_failed", error=error_message), emoji="")
self._is_manual_check = False self._is_manual_check = False
# Close checking dialog first, then show error # Close checking dialog first, then show error
@ -1821,8 +2050,8 @@ class MainWindow(QMainWindow):
QMessageBox.warning( QMessageBox.warning(
self, self,
"Update Check Failed", tr("dialog.update_failed.title"),
f"Could not check for updates:\n\n{error_message}\n\nPlease try again later.", tr("dialog.update_failed.msg", error=error_message),
) )
def _on_update_available(self, release) -> None: def _on_update_available(self, release) -> None:
@ -1838,7 +2067,7 @@ class MainWindow(QMainWindow):
self._is_manual_check = False self._is_manual_check = False
# Update status to show update available # Update status to show update available
self.set_update_status(f"Update available: v{release.version}", emoji="") self.set_update_status(tr("update.status.available", version=release.version), emoji="")
# Show update available dialog # Show update available dialog
from webdrop_bridge.ui.update_manager_ui import UpdateAvailableDialog from webdrop_bridge.ui.update_manager_ui import UpdateAvailableDialog
@ -1866,7 +2095,7 @@ class MainWindow(QMainWindow):
def _on_user_update_later(self) -> None: def _on_user_update_later(self) -> None:
"""Handle user clicking 'Later' button.""" """Handle user clicking 'Later' button."""
logger.info("User deferred update") logger.info("User deferred update")
self.set_update_status("Update deferred", emoji="") self.set_update_status(tr("update.status.deferred"), emoji="")
def _start_update_download(self, release) -> None: def _start_update_download(self, release) -> None:
"""Start downloading the update in background thread. """Start downloading the update in background thread.
@ -1875,7 +2104,7 @@ class MainWindow(QMainWindow):
release: Release object to download release: Release object to download
""" """
logger.info(f"Starting download for v{release.version}") logger.info(f"Starting download for v{release.version}")
self.set_update_status(f"Downloading v{release.version}", emoji="⬇️") self.set_update_status(tr("update.status.downloading", version=release.version), emoji="⬇️")
# Show download progress dialog # Show download progress dialog
from webdrop_bridge.ui.update_manager_ui import DownloadingDialog from webdrop_bridge.ui.update_manager_ui import DownloadingDialog
@ -1899,7 +2128,13 @@ class MainWindow(QMainWindow):
# Create update manager # Create update manager
manager = UpdateManager( manager = UpdateManager(
current_version=self.config.app_version, config_dir=Path.home() / ".webdrop_bridge" current_version=self.config.app_version,
config_dir=self.config.get_cache_dir(),
brand_id=self.config.brand_id,
forgejo_url=self.config.update_base_url,
repo=self.config.update_repo,
update_channel=self.config.update_channel,
manifest_name=self.config.update_manifest_name,
) )
# Create and start background thread # Create and start background thread
@ -1989,7 +2224,7 @@ class MainWindow(QMainWindow):
self.downloading_dialog = None self.downloading_dialog = None
logger.info(f"Download complete: {installer_path}") logger.info(f"Download complete: {installer_path}")
self.set_update_status("Ready to install", emoji="") self.set_update_status(tr("update.status.ready_to_install"), emoji="")
# Show install confirmation dialog # Show install confirmation dialog
install_dialog = InstallDialog(parent=self) install_dialog = InstallDialog(parent=self)
@ -2013,8 +2248,8 @@ class MainWindow(QMainWindow):
QMessageBox.critical( QMessageBox.critical(
self, self,
"Download Failed", tr("dialog.download_failed.title"),
f"Could not download the update:\n\n{error}\n\nPlease try again later.", tr("dialog.download_failed.msg", error=error),
) )
def _on_download_progress(self, downloaded: int, total: int) -> None: def _on_download_progress(self, downloaded: int, total: int) -> None:
@ -2038,14 +2273,20 @@ class MainWindow(QMainWindow):
from webdrop_bridge.core.updater import UpdateManager from webdrop_bridge.core.updater import UpdateManager
manager = UpdateManager( manager = UpdateManager(
current_version=self.config.app_version, config_dir=Path.home() / ".webdrop_bridge" current_version=self.config.app_version,
config_dir=self.config.get_cache_dir(),
brand_id=self.config.brand_id,
forgejo_url=self.config.update_base_url,
repo=self.config.update_repo,
update_channel=self.config.update_channel,
manifest_name=self.config.update_manifest_name,
) )
if manager.install_update(installer_path): if manager.install_update(installer_path):
self.set_update_status("Installation started", emoji="") self.set_update_status(tr("update.status.installation_started"), emoji="")
logger.info("Update installer launched successfully") logger.info("Update installer launched successfully")
else: else:
self.set_update_status("Installation failed", emoji="") self.set_update_status(tr("update.status.installation_failed"), emoji="")
logger.error("Failed to launch update installer") logger.error("Failed to launch update installer")
@ -2076,7 +2317,7 @@ class UpdateCheckWorker(QObject):
logger.debug("UpdateCheckWorker.run() starting") logger.debug("UpdateCheckWorker.run() starting")
# Notify checking status # Notify checking status
self.update_status.emit("Checking for updates", "🔄") self.update_status.emit("Checking for updates", "🔄") # Translated by set_update_status
# Create a fresh event loop for this thread # Create a fresh event loop for this thread
logger.debug("Creating new event loop for worker thread") logger.debug("Creating new event loop for worker thread")
@ -2098,15 +2339,17 @@ class UpdateCheckWorker(QObject):
else: else:
# No update available - show ready status # No update available - show ready status
logger.info("No update available") logger.info("No update available")
self.update_status.emit("Ready", "") self.update_status.emit(
"Ready", ""
) # English sentinel; _on_update_status compares this
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.warning("Update check timed out - server not responding") logger.warning("Update check timed out - server not responding")
self.check_failed.emit("Server not responding - check again later") self.check_failed.emit(tr("worker.server_not_responding"))
except Exception as e: except Exception as e:
logger.error(f"Update check failed: {e}", exc_info=True) logger.error(f"Update check failed: {e}", exc_info=True)
self.check_failed.emit(f"Check failed: {str(e)[:50]}") self.check_failed.emit(tr("worker.check_failed", error=str(e)[:50]))
finally: finally:
# Properly close the event loop # Properly close the event loop
if loop is not None: if loop is not None:
@ -2147,7 +2390,9 @@ class UpdateDownloadWorker(QObject):
loop = None loop = None
try: try:
# Download the update # Download the update
self.update_status.emit(f"Downloading v{self.release.version}", "⬇️") self.update_status.emit(
tr("update.status.downloading", version=self.release.version), "⬇️"
)
# Create a fresh event loop for this thread # Create a fresh event loop for this thread
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
@ -2169,8 +2414,10 @@ class UpdateDownloadWorker(QObject):
) )
if not installer_path: if not installer_path:
self.update_status.emit("Download failed", "") self.update_status.emit(
self.download_failed.emit("No installer found in release") "Download failed", ""
) # Translated by set_update_status
self.download_failed.emit(tr("worker.no_installer"))
logger.error("Download failed - no installer found") logger.error("Download failed - no installer found")
return return
@ -2187,7 +2434,7 @@ class UpdateDownloadWorker(QObject):
if not checksum_ok: if not checksum_ok:
self.update_status.emit("Verification failed", "") self.update_status.emit("Verification failed", "")
self.download_failed.emit("Checksum verification failed") self.download_failed.emit(tr("worker.checksum_failed"))
logger.error("Checksum verification failed") logger.error("Checksum verification failed")
return return
@ -2196,17 +2443,17 @@ class UpdateDownloadWorker(QObject):
except asyncio.TimeoutError as e: except asyncio.TimeoutError as e:
logger.error(f"Download/verification timed out: {e}") logger.error(f"Download/verification timed out: {e}")
self.update_status.emit("Operation timed out", "⏱️") self.update_status.emit(
self.download_failed.emit( "Operation timed out", "⏱️"
"Download or verification timed out (no response from server)" ) # Translated by set_update_status
) self.download_failed.emit(tr("worker.download_timed_out"))
except Exception as e: except Exception as e:
logger.error(f"Error during download: {e}") logger.error(f"Error during download: {e}")
self.download_failed.emit(f"Download error: {str(e)[:50]}") self.download_failed.emit(tr("worker.download_error", error=str(e)[:50]))
except Exception as e: except Exception as e:
logger.error(f"Download worker failed: {e}") logger.error(f"Download worker failed: {e}")
self.download_failed.emit(f"Download error: {str(e)[:50]}") self.download_failed.emit(tr("worker.download_error", error=str(e)[:50]))
finally: finally:
# Properly close the event loop # Properly close the event loop
if loop is not None: if loop is not None:

View file

@ -2,9 +2,8 @@
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QComboBox,
QDialog, QDialog,
@ -14,7 +13,6 @@ from PySide6.QtWidgets import (
QLabel, QLabel,
QLineEdit, QLineEdit,
QListWidget, QListWidget,
QListWidgetItem,
QPushButton, QPushButton,
QSpinBox, QSpinBox,
QTableWidget, QTableWidget,
@ -26,21 +24,14 @@ from PySide6.QtWidgets import (
from webdrop_bridge.config import Config, ConfigurationError from webdrop_bridge.config import Config, ConfigurationError
from webdrop_bridge.core.config_manager import ConfigExporter, ConfigProfile, ConfigValidator from webdrop_bridge.core.config_manager import ConfigExporter, ConfigProfile, ConfigValidator
from webdrop_bridge.utils.i18n import get_available_languages, tr
from webdrop_bridge.utils.logging import reconfigure_logging from webdrop_bridge.utils.logging import reconfigure_logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class SettingsDialog(QDialog): class SettingsDialog(QDialog):
"""Dialog for managing application settings and configuration. """Dialog for managing application settings and configuration."""
Provides tabs for:
- Paths: Manage allowed root directories
- URLs: Manage allowed web URLs
- Logging: Configure logging settings
- Window: Manage window size and behavior
- Profiles: Save/load/delete configuration profiles
"""
def __init__(self, config: Config, parent: Optional[QWidget] = None): def __init__(self, config: Config, parent: Optional[QWidget] = None):
"""Initialize the settings dialog. """Initialize the settings dialog.
@ -51,8 +42,8 @@ class SettingsDialog(QDialog):
""" """
super().__init__(parent) super().__init__(parent)
self.config = config self.config = config
self.profile_manager = ConfigProfile() self.profile_manager = ConfigProfile(config.config_dir_name)
self.setWindowTitle("Settings") self.setWindowTitle(tr("settings.title"))
self.setGeometry(100, 100, 600, 500) self.setGeometry(100, 100, 600, 500)
self.setup_ui() self.setup_ui()
@ -61,20 +52,16 @@ class SettingsDialog(QDialog):
"""Set up the dialog UI with tabs.""" """Set up the dialog UI with tabs."""
layout = QVBoxLayout() layout = QVBoxLayout()
# Create tab widget
self.tabs = QTabWidget() self.tabs = QTabWidget()
self.tabs.addTab(self._create_general_tab(), tr("settings.tab.general"))
# Add tabs self.tabs.addTab(self._create_web_source_tab(), tr("settings.tab.web_source"))
self.tabs.addTab(self._create_web_source_tab(), "Web Source") self.tabs.addTab(self._create_paths_tab(), tr("settings.tab.paths"))
self.tabs.addTab(self._create_paths_tab(), "Paths") self.tabs.addTab(self._create_urls_tab(), tr("settings.tab.urls"))
self.tabs.addTab(self._create_urls_tab(), "URLs") self.tabs.addTab(self._create_logging_tab(), tr("settings.tab.logging"))
self.tabs.addTab(self._create_logging_tab(), "Logging") self.tabs.addTab(self._create_window_tab(), tr("settings.tab.window"))
self.tabs.addTab(self._create_window_tab(), "Window") self.tabs.addTab(self._create_profiles_tab(), tr("settings.tab.profiles"))
self.tabs.addTab(self._create_profiles_tab(), "Profiles")
layout.addWidget(self.tabs) layout.addWidget(self.tabs)
# Add buttons
button_box = QDialogButtonBox( button_box = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
) )
@ -85,17 +72,10 @@ class SettingsDialog(QDialog):
self.setLayout(layout) self.setLayout(layout)
def accept(self) -> None: def accept(self) -> None:
"""Handle OK button - save configuration changes to file. """Handle OK button - save configuration changes to file."""
Validates configuration and saves to the default config path.
Applies log level changes immediately in the running application.
If validation or save fails, shows error and stays in dialog.
"""
try: try:
# Get updated configuration data from UI
config_data = self.get_config_data() config_data = self.get_config_data()
# Convert URL mappings from dict to URLMapping objects
from webdrop_bridge.config import URLMapping from webdrop_bridge.config import URLMapping
url_mappings = [ url_mappings = [
@ -103,8 +83,8 @@ class SettingsDialog(QDialog):
for m in config_data["url_mappings"] for m in config_data["url_mappings"]
] ]
# Update the config object with new values
old_log_level = self.config.log_level old_log_level = self.config.log_level
self.config.language = config_data["language"]
self.config.log_level = config_data["log_level"] self.config.log_level = config_data["log_level"]
self.config.log_file = ( self.config.log_file = (
Path(config_data["log_file"]) if config_data["log_file"] else None Path(config_data["log_file"]) if config_data["log_file"] else None
@ -116,25 +96,21 @@ class SettingsDialog(QDialog):
self.config.window_width = config_data["window_width"] self.config.window_width = config_data["window_width"]
self.config.window_height = config_data["window_height"] self.config.window_height = config_data["window_height"]
# Save to file (creates parent dirs if needed) config_path = self.config.get_config_path()
config_path = Config.get_default_config_path()
self.config.to_file(config_path) self.config.to_file(config_path)
logger.info(f"Configuration saved to {config_path}") logger.info(f"Configuration saved to {config_path}")
logger.info(f" Log level: {self.config.log_level} (was: {old_log_level})") logger.info(f" Log level: {self.config.log_level} (was: {old_log_level})")
logger.info(f" Window size: {self.config.window_width}x{self.config.window_height}") logger.info(f" Window size: {self.config.window_width}x{self.config.window_height}")
# Apply log level change immediately to running application
if old_log_level != self.config.log_level: if old_log_level != self.config.log_level:
logger.info(f"🔄 Updating log level: {old_log_level}{self.config.log_level}")
reconfigure_logging( reconfigure_logging(
logger_name="webdrop_bridge", logger_name="webdrop_bridge",
level=self.config.log_level, level=self.config.log_level,
log_file=self.config.log_file, log_file=self.config.log_file,
) )
logger.info(f"Log level updated to {self.config.log_level}") logger.info(f"Log level updated to {self.config.log_level}")
# Call parent accept to close dialog
super().accept() super().accept()
except ConfigurationError as e: except ConfigurationError as e:
@ -144,15 +120,43 @@ class SettingsDialog(QDialog):
logger.error(f"Failed to save configuration: {e}", exc_info=True) logger.error(f"Failed to save configuration: {e}", exc_info=True)
self._show_error(f"Failed to save configuration:\n\n{e}") self._show_error(f"Failed to save configuration:\n\n{e}")
def _create_web_source_tab(self) -> QWidget: def _create_general_tab(self) -> QWidget:
"""Create web source configuration tab.""" """Create general settings tab with language selector."""
from PySide6.QtWidgets import QTableWidget, QTableWidgetItem
widget = QWidget() widget = QWidget()
layout = QVBoxLayout() layout = QVBoxLayout()
# Webapp URL configuration lang_layout = QHBoxLayout()
layout.addWidget(QLabel("Web Application URL:")) lang_layout.addWidget(QLabel(tr("settings.general.language_label")))
self.language_combo = QComboBox()
self.language_combo.addItem(tr("settings.general.language_auto"), "auto")
available = get_available_languages()
current_lang = self.config.language
for code, name in available.items():
self.language_combo.addItem(name, code)
idx = self.language_combo.findData(current_lang)
if idx >= 0:
self.language_combo.setCurrentIndex(idx)
lang_layout.addWidget(self.language_combo)
lang_layout.addStretch()
layout.addLayout(lang_layout)
note = QLabel(tr("settings.general.language_restart_note"))
note.setStyleSheet("color: gray; font-size: 11px;")
layout.addWidget(note)
layout.addStretch()
widget.setLayout(layout)
return widget
def _create_web_source_tab(self) -> QWidget:
"""Create web source configuration tab."""
widget = QWidget()
layout = QVBoxLayout()
layout.addWidget(QLabel(tr("settings.web_source.url_label")))
url_layout = QHBoxLayout() url_layout = QHBoxLayout()
self.webapp_url_input = QLineEdit() self.webapp_url_input = QLineEdit()
@ -162,22 +166,24 @@ class SettingsDialog(QDialog):
) )
url_layout.addWidget(self.webapp_url_input) url_layout.addWidget(self.webapp_url_input)
open_btn = QPushButton("Open") open_btn = QPushButton(tr("settings.web_source.open_btn"))
open_btn.clicked.connect(self._open_webapp_url) open_btn.clicked.connect(self._open_webapp_url)
url_layout.addWidget(open_btn) url_layout.addWidget(open_btn)
layout.addLayout(url_layout) layout.addLayout(url_layout)
# URL Mappings (Azure Blob URL → Local Path) layout.addWidget(QLabel(tr("settings.web_source.url_mappings_label")))
layout.addWidget(QLabel("URL Mappings (Azure Blob Storage → Local Paths):"))
# Create table for URL mappings
self.url_mappings_table = QTableWidget() self.url_mappings_table = QTableWidget()
self.url_mappings_table.setColumnCount(2) self.url_mappings_table.setColumnCount(2)
self.url_mappings_table.setHorizontalHeaderLabels(["URL Prefix", "Local Path"]) self.url_mappings_table.setHorizontalHeaderLabels(
[
tr("settings.web_source.col_url_prefix"),
tr("settings.web_source.col_local_path"),
]
)
self.url_mappings_table.horizontalHeader().setStretchLastSection(True) self.url_mappings_table.horizontalHeader().setStretchLastSection(True)
# Populate from config
for mapping in self.config.url_mappings: for mapping in self.config.url_mappings:
row = self.url_mappings_table.rowCount() row = self.url_mappings_table.rowCount()
self.url_mappings_table.insertRow(row) self.url_mappings_table.insertRow(row)
@ -186,18 +192,17 @@ class SettingsDialog(QDialog):
layout.addWidget(self.url_mappings_table) layout.addWidget(self.url_mappings_table)
# Buttons for URL mapping management
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
add_mapping_btn = QPushButton("Add Mapping") add_mapping_btn = QPushButton(tr("settings.web_source.add_mapping_btn"))
add_mapping_btn.clicked.connect(self._add_url_mapping) add_mapping_btn.clicked.connect(self._add_url_mapping)
button_layout.addWidget(add_mapping_btn) button_layout.addWidget(add_mapping_btn)
edit_mapping_btn = QPushButton("Edit Selected") edit_mapping_btn = QPushButton(tr("settings.web_source.edit_mapping_btn"))
edit_mapping_btn.clicked.connect(self._edit_url_mapping) edit_mapping_btn.clicked.connect(self._edit_url_mapping)
button_layout.addWidget(edit_mapping_btn) button_layout.addWidget(edit_mapping_btn)
remove_mapping_btn = QPushButton("Remove Selected") remove_mapping_btn = QPushButton(tr("settings.web_source.remove_mapping_btn"))
remove_mapping_btn.clicked.connect(self._remove_url_mapping) remove_mapping_btn.clicked.connect(self._remove_url_mapping)
button_layout.addWidget(remove_mapping_btn) button_layout.addWidget(remove_mapping_btn)
@ -212,13 +217,13 @@ class SettingsDialog(QDialog):
import webbrowser import webbrowser
url = self.webapp_url_input.text().strip() url = self.webapp_url_input.text().strip()
if url: if not url:
# Handle file:// URLs return
try: try:
webbrowser.open(url) webbrowser.open(url)
except Exception as e: except Exception as e:
logger.error(f"Failed to open URL: {e}") logger.error(f"Failed to open URL: {e}")
self._show_error(f"Failed to open URL:\n\n{e}") self._show_error(f"Failed to open URL:\n\n{e}")
def _add_url_mapping(self) -> None: def _add_url_mapping(self) -> None:
"""Add new URL mapping.""" """Add new URL mapping."""
@ -226,15 +231,15 @@ class SettingsDialog(QDialog):
url_prefix, ok1 = QInputDialog.getText( url_prefix, ok1 = QInputDialog.getText(
self, self,
"Add URL Mapping", tr("settings.web_source.add_mapping_title"),
"Enter Azure Blob Storage URL prefix:\n(e.g., https://myblob.blob.core.windows.net/container/)", tr("settings.web_source.add_mapping_url_prompt"),
) )
if ok1 and url_prefix: if ok1 and url_prefix:
local_path, ok2 = QInputDialog.getText( local_path, ok2 = QInputDialog.getText(
self, self,
"Add URL Mapping", tr("settings.web_source.add_mapping_title"),
"Enter local file system path:\n(e.g., C:\\Share or /mnt/share)", tr("settings.web_source.add_mapping_path_prompt"),
) )
if ok2 and local_path: if ok2 and local_path:
@ -249,19 +254,25 @@ class SettingsDialog(QDialog):
current_row = self.url_mappings_table.currentRow() current_row = self.url_mappings_table.currentRow()
if current_row < 0: if current_row < 0:
self._show_error("Please select a mapping to edit") self._show_error(tr("settings.web_source.select_mapping_to_edit"))
return return
url_prefix = self.url_mappings_table.item(current_row, 0).text() # type: ignore url_prefix = self.url_mappings_table.item(current_row, 0).text() # type: ignore
local_path = self.url_mappings_table.item(current_row, 1).text() # type: ignore local_path = self.url_mappings_table.item(current_row, 1).text() # type: ignore
new_url_prefix, ok1 = QInputDialog.getText( new_url_prefix, ok1 = QInputDialog.getText(
self, "Edit URL Mapping", "Enter Azure Blob Storage URL prefix:", text=url_prefix self,
tr("settings.web_source.edit_mapping_title"),
tr("settings.web_source.edit_mapping_url_prompt"),
text=url_prefix,
) )
if ok1 and new_url_prefix: if ok1 and new_url_prefix:
new_local_path, ok2 = QInputDialog.getText( new_local_path, ok2 = QInputDialog.getText(
self, "Edit URL Mapping", "Enter local file system path:", text=local_path self,
tr("settings.web_source.edit_mapping_title"),
tr("settings.web_source.edit_mapping_path_prompt"),
text=local_path,
) )
if ok2 and new_local_path: if ok2 and new_local_path:
@ -279,22 +290,20 @@ class SettingsDialog(QDialog):
widget = QWidget() widget = QWidget()
layout = QVBoxLayout() layout = QVBoxLayout()
layout.addWidget(QLabel("Allowed root directories for file access:")) layout.addWidget(QLabel(tr("settings.paths.label")))
# List widget for paths
self.paths_list = QListWidget() self.paths_list = QListWidget()
for path in self.config.allowed_roots: for path in self.config.allowed_roots:
self.paths_list.addItem(str(path)) self.paths_list.addItem(str(path))
layout.addWidget(self.paths_list) layout.addWidget(self.paths_list)
# Buttons for path management
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
add_path_btn = QPushButton("Add Path") add_path_btn = QPushButton(tr("settings.paths.add_btn"))
add_path_btn.clicked.connect(self._add_path) add_path_btn.clicked.connect(self._add_path)
button_layout.addWidget(add_path_btn) button_layout.addWidget(add_path_btn)
remove_path_btn = QPushButton("Remove Selected") remove_path_btn = QPushButton(tr("settings.paths.remove_btn"))
remove_path_btn.clicked.connect(self._remove_path) remove_path_btn.clicked.connect(self._remove_path)
button_layout.addWidget(remove_path_btn) button_layout.addWidget(remove_path_btn)
@ -309,22 +318,20 @@ class SettingsDialog(QDialog):
widget = QWidget() widget = QWidget()
layout = QVBoxLayout() layout = QVBoxLayout()
layout.addWidget(QLabel("Allowed web URLs (supports wildcards like http://*.example.com):")) layout.addWidget(QLabel(tr("settings.urls.label")))
# List widget for URLs
self.urls_list = QListWidget() self.urls_list = QListWidget()
for url in self.config.allowed_urls: for url in self.config.allowed_urls:
self.urls_list.addItem(url) self.urls_list.addItem(url)
layout.addWidget(self.urls_list) layout.addWidget(self.urls_list)
# Buttons for URL management
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
add_url_btn = QPushButton("Add URL") add_url_btn = QPushButton(tr("settings.urls.add_btn"))
add_url_btn.clicked.connect(self._add_url) add_url_btn.clicked.connect(self._add_url)
button_layout.addWidget(add_url_btn) button_layout.addWidget(add_url_btn)
remove_url_btn = QPushButton("Remove Selected") remove_url_btn = QPushButton(tr("settings.urls.remove_btn"))
remove_url_btn.clicked.connect(self._remove_url) remove_url_btn.clicked.connect(self._remove_url)
button_layout.addWidget(remove_url_btn) button_layout.addWidget(remove_url_btn)
@ -339,27 +346,22 @@ class SettingsDialog(QDialog):
widget = QWidget() widget = QWidget()
layout = QVBoxLayout() layout = QVBoxLayout()
# Log level selection layout.addWidget(QLabel(tr("settings.logging.level_label")))
layout.addWidget(QLabel("Log Level:"))
from PySide6.QtWidgets import QComboBox
self.log_level_combo: QComboBox = self._create_log_level_widget() self.log_level_combo: QComboBox = self._create_log_level_widget()
layout.addWidget(self.log_level_combo) layout.addWidget(self.log_level_combo)
# Log file path layout.addWidget(QLabel(tr("settings.logging.file_label")))
layout.addWidget(QLabel("Log File (optional):"))
log_file_layout = QHBoxLayout() log_file_layout = QHBoxLayout()
self.log_file_input = QLineEdit() self.log_file_input = QLineEdit()
self.log_file_input.setText(str(self.config.log_file) if self.config.log_file else "") self.log_file_input.setText(str(self.config.log_file) if self.config.log_file else "")
log_file_layout.addWidget(self.log_file_input) log_file_layout.addWidget(self.log_file_input)
browse_btn = QPushButton("Browse...") browse_btn = QPushButton(tr("settings.logging.browse_btn"))
browse_btn.clicked.connect(self._browse_log_file) browse_btn.clicked.connect(self._browse_log_file)
log_file_layout.addWidget(browse_btn) log_file_layout.addWidget(browse_btn)
layout.addLayout(log_file_layout) layout.addLayout(log_file_layout)
layout.addStretch() layout.addStretch()
widget.setLayout(layout) widget.setLayout(layout)
return widget return widget
@ -369,9 +371,8 @@ class SettingsDialog(QDialog):
widget = QWidget() widget = QWidget()
layout = QVBoxLayout() layout = QVBoxLayout()
# Window width
width_layout = QHBoxLayout() width_layout = QHBoxLayout()
width_layout.addWidget(QLabel("Window Width:")) width_layout.addWidget(QLabel(tr("settings.window.width_label")))
self.width_spin = QSpinBox() self.width_spin = QSpinBox()
self.width_spin.setMinimum(400) self.width_spin.setMinimum(400)
self.width_spin.setMaximum(5000) self.width_spin.setMaximum(5000)
@ -380,9 +381,8 @@ class SettingsDialog(QDialog):
width_layout.addStretch() width_layout.addStretch()
layout.addLayout(width_layout) layout.addLayout(width_layout)
# Window height
height_layout = QHBoxLayout() height_layout = QHBoxLayout()
height_layout.addWidget(QLabel("Window Height:")) height_layout.addWidget(QLabel(tr("settings.window.height_label")))
self.height_spin = QSpinBox() self.height_spin = QSpinBox()
self.height_spin.setMinimum(300) self.height_spin.setMinimum(300)
self.height_spin.setMaximum(5000) self.height_spin.setMaximum(5000)
@ -400,38 +400,35 @@ class SettingsDialog(QDialog):
widget = QWidget() widget = QWidget()
layout = QVBoxLayout() layout = QVBoxLayout()
layout.addWidget(QLabel("Saved Configuration Profiles:")) layout.addWidget(QLabel(tr("settings.profiles.label")))
# List of profiles
self.profiles_list = QListWidget() self.profiles_list = QListWidget()
self._refresh_profiles_list() self._refresh_profiles_list()
layout.addWidget(self.profiles_list) layout.addWidget(self.profiles_list)
# Profile management buttons
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
save_profile_btn = QPushButton("Save as Profile") save_profile_btn = QPushButton(tr("settings.profiles.save_btn"))
save_profile_btn.clicked.connect(self._save_profile) save_profile_btn.clicked.connect(self._save_profile)
button_layout.addWidget(save_profile_btn) button_layout.addWidget(save_profile_btn)
load_profile_btn = QPushButton("Load Profile") load_profile_btn = QPushButton(tr("settings.profiles.load_btn"))
load_profile_btn.clicked.connect(self._load_profile) load_profile_btn.clicked.connect(self._load_profile)
button_layout.addWidget(load_profile_btn) button_layout.addWidget(load_profile_btn)
delete_profile_btn = QPushButton("Delete Profile") delete_profile_btn = QPushButton(tr("settings.profiles.delete_btn"))
delete_profile_btn.clicked.connect(self._delete_profile) delete_profile_btn.clicked.connect(self._delete_profile)
button_layout.addWidget(delete_profile_btn) button_layout.addWidget(delete_profile_btn)
layout.addLayout(button_layout) layout.addLayout(button_layout)
# Export/Import buttons
export_layout = QHBoxLayout() export_layout = QHBoxLayout()
export_btn = QPushButton("Export Configuration") export_btn = QPushButton(tr("settings.profiles.export_btn"))
export_btn.clicked.connect(self._export_config) export_btn.clicked.connect(self._export_config)
export_layout.addWidget(export_btn) export_layout.addWidget(export_btn)
import_btn = QPushButton("Import Configuration") import_btn = QPushButton(tr("settings.profiles.import_btn"))
import_btn.clicked.connect(self._import_config) import_btn.clicked.connect(self._import_config)
export_layout.addWidget(import_btn) export_layout.addWidget(import_btn)
@ -451,7 +448,7 @@ class SettingsDialog(QDialog):
def _add_path(self) -> None: def _add_path(self) -> None:
"""Add a new allowed path.""" """Add a new allowed path."""
path = QFileDialog.getExistingDirectory(self, "Select Directory to Allow") path = QFileDialog.getExistingDirectory(self, tr("settings.paths.select_dir_title"))
if path: if path:
self.paths_list.addItem(path) self.paths_list.addItem(path)
@ -465,7 +462,7 @@ class SettingsDialog(QDialog):
from PySide6.QtWidgets import QInputDialog from PySide6.QtWidgets import QInputDialog
url, ok = QInputDialog.getText( url, ok = QInputDialog.getText(
self, "Add URL", "Enter URL pattern (e.g., http://example.com or http://*.example.com):" self, tr("settings.urls.add_title"), tr("settings.urls.add_prompt")
) )
if ok and url: if ok and url:
self.urls_list.addItem(url) self.urls_list.addItem(url)
@ -478,7 +475,10 @@ class SettingsDialog(QDialog):
def _browse_log_file(self) -> None: def _browse_log_file(self) -> None:
"""Browse for log file location.""" """Browse for log file location."""
file_path, _ = QFileDialog.getSaveFileName( file_path, _ = QFileDialog.getSaveFileName(
self, "Select Log File", str(Path.home()), "Log Files (*.log);;All Files (*)" self,
tr("settings.logging.select_file_title"),
str(Path.home()),
"Log Files (*.log);;All Files (*)",
) )
if file_path: if file_path:
self.log_file_input.setText(file_path) self.log_file_input.setText(file_path)
@ -494,7 +494,7 @@ class SettingsDialog(QDialog):
from PySide6.QtWidgets import QInputDialog from PySide6.QtWidgets import QInputDialog
profile_name, ok = QInputDialog.getText( profile_name, ok = QInputDialog.getText(
self, "Save Profile", "Enter profile name (e.g., work, personal):" self, tr("settings.profiles.save_title"), tr("settings.profiles.save_prompt")
) )
if ok and profile_name: if ok and profile_name:
@ -508,7 +508,7 @@ class SettingsDialog(QDialog):
"""Load a saved profile.""" """Load a saved profile."""
current_item = self.profiles_list.currentItem() current_item = self.profiles_list.currentItem()
if not current_item: if not current_item:
self._show_error("Please select a profile to load") self._show_error(tr("settings.profiles.select_to_load"))
return return
profile_name = current_item.text() profile_name = current_item.text()
@ -522,7 +522,7 @@ class SettingsDialog(QDialog):
"""Delete a saved profile.""" """Delete a saved profile."""
current_item = self.profiles_list.currentItem() current_item = self.profiles_list.currentItem()
if not current_item: if not current_item:
self._show_error("Please select a profile to delete") self._show_error(tr("settings.profiles.select_to_delete"))
return return
profile_name = current_item.text() profile_name = current_item.text()
@ -535,7 +535,10 @@ class SettingsDialog(QDialog):
def _export_config(self) -> None: def _export_config(self) -> None:
"""Export configuration to file.""" """Export configuration to file."""
file_path, _ = QFileDialog.getSaveFileName( file_path, _ = QFileDialog.getSaveFileName(
self, "Export Configuration", str(Path.home()), "JSON Files (*.json);;All Files (*)" self,
tr("settings.profiles.export_title"),
str(Path.home()),
"JSON Files (*.json);;All Files (*)",
) )
if file_path: if file_path:
@ -547,7 +550,10 @@ class SettingsDialog(QDialog):
def _import_config(self) -> None: def _import_config(self) -> None:
"""Import configuration from file.""" """Import configuration from file."""
file_path, _ = QFileDialog.getOpenFileName( file_path, _ = QFileDialog.getOpenFileName(
self, "Import Configuration", str(Path.home()), "JSON Files (*.json);;All Files (*)" self,
tr("settings.profiles.import_title"),
str(Path.home()),
"JSON Files (*.json);;All Files (*)",
) )
if file_path: if file_path:
@ -563,25 +569,26 @@ class SettingsDialog(QDialog):
Args: Args:
config_data: Configuration dictionary config_data: Configuration dictionary
""" """
# Apply paths
self.paths_list.clear() self.paths_list.clear()
for path in config_data.get("allowed_roots", []): for path in config_data.get("allowed_roots", []):
self.paths_list.addItem(str(path)) self.paths_list.addItem(str(path))
# Apply URLs
self.urls_list.clear() self.urls_list.clear()
for url in config_data.get("allowed_urls", []): for url in config_data.get("allowed_urls", []):
self.urls_list.addItem(url) self.urls_list.addItem(url)
# Apply logging settings
self.log_level_combo.setCurrentText(config_data.get("log_level", "INFO")) self.log_level_combo.setCurrentText(config_data.get("log_level", "INFO"))
log_file = config_data.get("log_file") log_file = config_data.get("log_file")
self.log_file_input.setText(str(log_file) if log_file else "") self.log_file_input.setText(str(log_file) if log_file else "")
# Apply window settings
self.width_spin.setValue(config_data.get("window_width", 800)) self.width_spin.setValue(config_data.get("window_width", 800))
self.height_spin.setValue(config_data.get("window_height", 600)) self.height_spin.setValue(config_data.get("window_height", 600))
language = config_data.get("language", "auto")
idx = self.language_combo.findData(language)
if idx >= 0:
self.language_combo.setCurrentIndex(idx)
def get_config_data(self) -> Dict[str, Any]: def get_config_data(self) -> Dict[str, Any]:
"""Get updated configuration data from dialog. """Get updated configuration data from dialog.
@ -591,13 +598,14 @@ class SettingsDialog(QDialog):
Raises: Raises:
ConfigurationError: If configuration is invalid ConfigurationError: If configuration is invalid
""" """
if self.url_mappings_table: url_mappings_table_count = (
url_mappings_table_count = self.url_mappings_table.rowCount() or 0 self.url_mappings_table.rowCount() if self.url_mappings_table else 0
else: )
url_mappings_table_count = 0
config_data = { config_data = {
"app_name": self.config.app_name, "app_name": self.config.app_name,
"app_version": self.config.app_version, "app_version": self.config.app_version,
"language": self.language_combo.currentData(),
"log_level": self.log_level_combo.currentText(), "log_level": self.log_level_combo.currentText(),
"log_file": self.log_file_input.text() or None, "log_file": self.log_file_input.text() or None,
"allowed_roots": [ "allowed_roots": [
@ -607,8 +615,16 @@ class SettingsDialog(QDialog):
"webapp_url": self.webapp_url_input.text().strip(), "webapp_url": self.webapp_url_input.text().strip(),
"url_mappings": [ "url_mappings": [
{ {
"url_prefix": self.url_mappings_table.item(i, 0).text() if self.url_mappings_table.item(i, 0) else "", # type: ignore "url_prefix": (
"local_path": self.url_mappings_table.item(i, 1).text() if self.url_mappings_table.item(i, 1) else "", # type: ignore self.url_mappings_table.item(i, 0).text() # type: ignore
if self.url_mappings_table.item(i, 0)
else ""
),
"local_path": (
self.url_mappings_table.item(i, 1).text() # type: ignore
if self.url_mappings_table.item(i, 1)
else ""
),
} }
for i in range(url_mappings_table_count) for i in range(url_mappings_table_count)
], ],
@ -617,9 +633,7 @@ class SettingsDialog(QDialog):
"enable_logging": self.config.enable_logging, "enable_logging": self.config.enable_logging,
} }
# Validate
ConfigValidator.validate_or_raise(config_data) ConfigValidator.validate_or_raise(config_data)
return config_data return config_data
def _show_error(self, message: str) -> None: def _show_error(self, message: str) -> None:
@ -630,4 +644,4 @@ class SettingsDialog(QDialog):
""" """
from PySide6.QtWidgets import QMessageBox from PySide6.QtWidgets import QMessageBox
QMessageBox.critical(self, "Error", message) QMessageBox.critical(self, tr("dialog.error.title"), message)

View file

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

View file

@ -0,0 +1,292 @@
"""Internationalization (i18n) support for WebDrop Bridge.
Provides a simple JSON-based translation system. Translation files are stored
in ``resources/translations/`` (e.g. ``en.json``, ``de.json``, ``fr.json``).
Usage::
from webdrop_bridge.utils.i18n import tr
# Simple lookup
self.setWindowTitle(tr("settings.title"))
# With named format arguments
label.setText(tr("status.opened", name="file.pdf"))
To add a new language, place a JSON file named ``<code>.json`` in
``resources/translations/`` and optionally add an entry to
:attr:`Translator.BUILTIN_LANGUAGES` for a nicer display name.
"""
import json
import logging
import sys
from pathlib import Path
from typing import Dict, Optional
logger = logging.getLogger(__name__)
class Translator:
"""Manages translations for the application.
Loads translations from UTF-8 JSON files that use dot-notation string keys.
Falls back to the English translation (and ultimately to the bare key) when
a translation is missing.
Attributes:
BUILTIN_LANGUAGES: Mapping of language code display name for languages
that ship with the application. Add entries here when including new
translation files.
"""
#: Human-readable display names for supported language codes.
#: Unknown codes fall back to their uppercase code string.
BUILTIN_LANGUAGES: Dict[str, str] = {
"en": "English",
"de": "Deutsch",
"fr": "Français",
"it": "Italiano",
"ru": "Русский",
"zh": "中文",
}
def __init__(self) -> None:
self._language: str = "en"
self._translations: Dict[str, str] = {}
self._fallback: Dict[str, str] = {}
self._translations_dir: Optional[Path] = None
def initialize(self, language: str, translations_dir: Path) -> None:
"""Initialize the translator with a language and translations directory.
Args:
language: Language code (e.g. ``"en"``, ``"de"``, ``"fr"``) or
``"auto"`` to detect from the system locale.
translations_dir: Directory containing the ``.json`` translation files.
"""
self._translations_dir = translations_dir
# Resolve "auto" to system locale
if language == "auto":
language = self._detect_system_language()
logger.debug(f"Auto-detected language: {language}")
# Load English as fallback first
en_path = translations_dir / "en.json"
if en_path.exists():
self._fallback = self._load_file(en_path)
logger.debug(f"Loaded English fallback translations ({len(self._fallback)} keys)")
else:
logger.warning(f"English translation file not found at {en_path}")
# Load requested language
self._language = language
if language != "en":
lang_path = translations_dir / f"{language}.json"
if lang_path.exists():
self._translations = self._load_file(lang_path)
logger.debug(f"Loaded '{language}' translations ({len(self._translations)} keys)")
else:
logger.warning(
f"Translation file not found for language '{language}', "
"falling back to English"
)
self._translations = {}
else:
self._translations = self._fallback
def tr(self, key: str, **kwargs: str) -> str:
"""Get translated string for the given key.
Args:
key: Translation key using dot-notation (e.g. ``"toolbar.home"``).
**kwargs: Named format arguments applied to the translated string.
Returns:
Translated and formatted string. Returns the *key* itself when no
translation is found, so missing keys are always visible.
"""
text = self._translations.get(key) or self._fallback.get(key) or key
if kwargs:
try:
text = text.format(**kwargs)
except (KeyError, ValueError) as e:
logger.debug(f"Translation format error for key '{key}': {e}")
return text
def get_current_language(self) -> str:
"""Get the currently active language code (e.g. ``"de"``)."""
return self._language
def get_available_languages(self) -> Dict[str, str]:
"""Return available languages as ``{code: display_name}``.
Discovers language files at runtime so newly added JSON files are
automatically included without code changes.
Returns:
Ordered dict mapping language code human-readable display name.
"""
if self._translations_dir is None:
return {"en": "English"}
languages: Dict[str, str] = {}
for lang_file in sorted(self._translations_dir.glob("*.json")):
code = lang_file.stem
name = self.BUILTIN_LANGUAGES.get(code, code.upper())
languages[code] = name
return languages
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
def _load_file(self, path: Path) -> Dict[str, str]:
"""Load a JSON translation file.
Args:
path: Path to the UTF-8 encoded JSON translation file.
Returns:
Dictionary of translation keys to translated strings, or an empty
dict when the file cannot be read or parsed.
"""
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, IOError) as e:
logger.error(f"Failed to load translation file {path}: {e}")
return {}
def _detect_system_language(self) -> str:
"""Detect system language from locale or platform settings.
On Windows, attempts to read the UI language via the WinAPI before
falling back to the ``locale`` module.
Returns:
Best-matching supported language code, or ``"en"`` as fallback.
"""
import locale
try:
lang_code: Optional[str] = None
if sys.platform.startswith("win"):
# Windows: use GetUserDefaultUILanguage for accuracy
try:
import ctypes
lcid = ctypes.windll.kernel32.GetUserDefaultUILanguage() # type: ignore[attr-defined]
# Subset of LCID → ISO 639-1 mappings
lcid_map: Dict[int, str] = {
0x0407: "de", # German (Germany)
0x0C07: "de", # German (Austria)
0x0807: "de", # German (Switzerland)
0x040C: "fr", # French (France)
0x080C: "fr", # French (Belgium)
0x0C0C: "fr", # French (Canada)
0x100C: "fr", # French (Switzerland)
0x0410: "it", # Italian (Italy)
0x0810: "it", # Italian (Switzerland)
0x0419: "ru", # Russian
0x0804: "zh", # Chinese Simplified
0x0404: "zh", # Chinese Traditional
0x0409: "en", # English (US)
0x0809: "en", # English (UK)
}
lang_code = lcid_map.get(lcid)
except Exception:
pass
if not lang_code:
raw = locale.getdefaultlocale()[0] or ""
lang_code = raw.split("_")[0].lower() if raw else None
if lang_code and lang_code in self.BUILTIN_LANGUAGES:
return lang_code
except Exception as e:
logger.debug(f"Language auto-detection failed: {e}")
return "en"
# ---------------------------------------------------------------------------
# Module-level singleton and public API
# ---------------------------------------------------------------------------
_translator = Translator()
def _ensure_initialized() -> None:
"""Initialize translator lazily with default settings if needed."""
if _translator._translations_dir is not None: # type: ignore[attr-defined]
return
_translator.initialize("en", get_translations_dir())
def initialize(language: str, translations_dir: Path) -> None:
"""Initialize the global translator.
Should be called **once at application startup**, before any UI is shown.
Args:
language: Language code (e.g. ``"de"``) or ``"auto"`` for system
locale detection.
translations_dir: Directory containing the ``.json`` translation files.
"""
_translator.initialize(language, translations_dir)
def tr(key: str, **kwargs: str) -> str:
"""Translate a string by key.
Args:
key: Translation key (e.g. ``"toolbar.home"``).
**kwargs: Named format arguments (e.g. ``name="file.pdf"``).
Returns:
Translated string with any format substitutions applied.
"""
_ensure_initialized()
text = _translator.tr(key, **kwargs)
# If lookup failed and translator points to a non-default directory (e.g. tests
# overriding translator state), retry from default bundled translations.
if text == key:
default_dir = get_translations_dir()
current_dir = _translator._translations_dir # type: ignore[attr-defined]
if current_dir != default_dir:
_translator.initialize("en", default_dir)
text = _translator.tr(key, **kwargs)
return text
def get_current_language() -> str:
"""Return the currently active language code (e.g. ``"de"``)."""
return _translator.get_current_language()
def get_available_languages() -> Dict[str, str]:
"""Return all available languages as ``{code: display_name}``."""
_ensure_initialized()
return _translator.get_available_languages()
def get_translations_dir() -> Path:
"""Resolve the translations directory for the current runtime context.
Handles development mode, PyInstaller bundles, and MSI installations
by searching the known candidate paths in order.
Returns:
Path to the ``resources/translations`` directory.
"""
if hasattr(sys, "_MEIPASS"):
# PyInstaller bundle
return Path(sys._MEIPASS) / "resources" / "translations" # type: ignore[attr-defined]
# Development mode or installed Python package
return Path(__file__).parent.parent.parent.parent / "resources" / "translations"

View file

@ -0,0 +1,147 @@
"""Tests for brand-aware build configuration helpers."""
import json
import sys
from pathlib import Path
BUILD_SCRIPTS_DIR = Path(__file__).resolve().parents[2] / "build" / "scripts"
if str(BUILD_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(BUILD_SCRIPTS_DIR))
from brand_config import (
DEFAULT_BRAND_ID,
collect_local_release_data,
generate_release_manifest,
load_brand_config,
merge_release_manifests,
)
def test_load_agravity_brand_config():
"""Test loading the Agravity brand manifest."""
brand = load_brand_config("agravity")
assert brand.brand_id == "agravity"
assert brand.display_name == "Agravity Bridge"
assert brand.asset_prefix == "AgravityBridge"
assert brand.exe_name == "AgravityBridge"
assert brand.toolbar_icon_home == "resources/icons/home.ico"
assert brand.toolbar_icon_reload == "resources/icons/reload.ico"
assert brand.toolbar_icon_open == "resources/icons/open.ico"
assert brand.toolbar_icon_openwith == "resources/icons/openwith.ico"
assert brand.windows_installer_name("0.8.4") == "AgravityBridge-0.8.4-win-x64.msi"
def test_generate_release_manifest_for_agravity(tmp_path):
"""Test generating a shared release manifest from local artifacts."""
project_root = tmp_path
(project_root / "build" / "brands").mkdir(parents=True)
(project_root / "build" / "dist" / "windows" / "agravity").mkdir(parents=True)
(project_root / "build" / "dist" / "macos" / "agravity").mkdir(parents=True)
source_manifest = Path(__file__).resolve().parents[2] / "build" / "brands" / "agravity.json"
(project_root / "build" / "brands" / "agravity.json").write_text(
source_manifest.read_text(encoding="utf-8"),
encoding="utf-8",
)
win_installer = (
project_root
/ "build"
/ "dist"
/ "windows"
/ "agravity"
/ "AgravityBridge-0.8.4-win-x64.msi"
)
win_installer.write_bytes(b"msi")
(win_installer.parent / f"{win_installer.name}.sha256").write_text("abc", encoding="utf-8")
mac_installer = (
project_root
/ "build"
/ "dist"
/ "macos"
/ "agravity"
/ "AgravityBridge-0.8.4-macos-universal.dmg"
)
mac_installer.write_bytes(b"dmg")
(mac_installer.parent / f"{mac_installer.name}.sha256").write_text("def", encoding="utf-8")
output_path = project_root / "build" / "dist" / "release-manifest.json"
generate_release_manifest(
"0.8.4",
["agravity"],
output_path=output_path,
root=project_root,
)
manifest = json.loads(output_path.read_text(encoding="utf-8"))
assert manifest["version"] == "0.8.4"
assert (
manifest["brands"]["agravity"]["windows-x64"]["installer"]
== "AgravityBridge-0.8.4-win-x64.msi"
)
assert (
manifest["brands"]["agravity"]["macos-universal"]["installer"]
== "AgravityBridge-0.8.4-macos-universal.dmg"
)
def test_collect_local_release_data_includes_default_brand(tmp_path):
"""Test discovering local artifacts for the default Windows build."""
project_root = tmp_path
installer_dir = project_root / "build" / "dist" / "windows" / DEFAULT_BRAND_ID
installer_dir.mkdir(parents=True)
installer = installer_dir / "WebDropBridge-0.8.4-win-x64.msi"
installer.write_bytes(b"msi")
checksum = installer_dir / f"{installer.name}.sha256"
checksum.write_text("abc", encoding="utf-8")
data = collect_local_release_data("0.8.4", platform="windows", root=project_root)
assert data["brands"] == [DEFAULT_BRAND_ID]
assert str(installer) in data["artifacts"]
assert str(checksum) in data["artifacts"]
assert (
data["manifest"]["brands"][DEFAULT_BRAND_ID]["windows-x64"]["installer"] == installer.name
)
def test_merge_release_manifests_preserves_existing_platforms():
"""Test merging platform-specific manifest entries from separate upload runs."""
base_manifest = {
"version": "0.8.4",
"channel": "stable",
"brands": {
"agravity": {
"windows-x64": {
"installer": "AgravityBridge-0.8.4-win-x64.msi",
"checksum": "AgravityBridge-0.8.4-win-x64.msi.sha256",
}
}
},
}
overlay_manifest = {
"version": "0.8.4",
"channel": "stable",
"brands": {
"agravity": {
"macos-universal": {
"installer": "AgravityBridge-0.8.4-macos-universal.dmg",
"checksum": "AgravityBridge-0.8.4-macos-universal.dmg.sha256",
}
}
},
}
merged = merge_release_manifests(base_manifest, overlay_manifest)
assert (
merged["brands"]["agravity"]["windows-x64"]["installer"]
== "AgravityBridge-0.8.4-win-x64.msi"
)
assert (
merged["brands"]["agravity"]["macos-universal"]["installer"]
== "AgravityBridge-0.8.4-macos-universal.dmg"
)

View file

@ -1,6 +1,7 @@
"""Unit tests for configuration system.""" """Unit tests for configuration system."""
import os import os
import sys
import pytest import pytest
@ -12,14 +13,26 @@ 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()
# Clear relevant variables # Clear relevant variables
for key in list(os.environ.keys()): for key in list(os.environ.keys()):
if key.startswith(('APP_', 'LOG_', 'ALLOWED_', 'WEBAPP_', 'WINDOW_', 'ENABLE_')): if key.startswith(
(
"APP_",
"LOG_",
"ALLOWED_",
"WEBAPP_",
"WINDOW_",
"ENABLE_",
"BRAND_",
"UPDATE_",
"LANGUAGE",
)
):
del os.environ[key] del os.environ[key]
yield yield
# Restore env (cleanup) # Restore env (cleanup)
os.environ.clear() os.environ.clear()
os.environ.update(saved_env) os.environ.update(saved_env)
@ -64,6 +77,28 @@ class TestConfigFromEnv:
assert config.window_width == 1200 assert config.window_width == 1200
assert config.window_height == 800 assert config.window_height == 800
def test_from_env_with_branding_values(self, tmp_path):
"""Test loading branding and update metadata from environment."""
env_file = tmp_path / ".env"
root1 = tmp_path / "root1"
root1.mkdir()
env_file.write_text(
f"BRAND_ID=agravity\n"
f"APP_CONFIG_DIR_NAME=agravity_bridge\n"
f"UPDATE_REPO=HIM-public/webdrop-bridge\n"
f"UPDATE_CHANNEL=stable\n"
f"UPDATE_MANIFEST_NAME=release-manifest.json\n"
f"ALLOWED_ROOTS={root1}\n"
)
config = Config.from_env(str(env_file))
assert config.brand_id == "agravity"
assert config.config_dir_name == "agravity_bridge"
assert config.update_repo == "HIM-public/webdrop-bridge"
assert config.update_channel == "stable"
assert config.update_manifest_name == "release-manifest.json"
def test_from_env_with_defaults(self, tmp_path): def test_from_env_with_defaults(self, tmp_path):
"""Test loading config uses defaults when env vars not set.""" """Test loading config uses defaults when env vars not set."""
# Create empty .env file # Create empty .env file
@ -73,8 +108,11 @@ class TestConfigFromEnv:
config = Config.from_env(str(env_file)) config = Config.from_env(str(env_file))
assert config.app_name == "WebDrop Bridge" assert config.app_name == "WebDrop Bridge"
assert config.brand_id == "webdrop_bridge"
assert config.config_dir_name == "webdrop_bridge"
# Version should come from __init__.py (dynamic, not hardcoded) # Version should come from __init__.py (dynamic, not hardcoded)
from webdrop_bridge import __version__ from webdrop_bridge import __version__
assert config.app_version == __version__ assert config.app_version == __version__
assert config.log_level == "INFO" assert config.log_level == "INFO"
assert config.window_width == 1024 assert config.window_width == 1024
@ -187,3 +225,30 @@ class TestConfigValidation:
config = Config.from_env(str(env_file)) config = Config.from_env(str(env_file))
assert config.allowed_urls == ["example.com", "test.org"] assert config.allowed_urls == ["example.com", "test.org"]
def test_brand_specific_default_paths(self):
"""Test brand-specific config and log directories."""
config_path = Config.get_default_config_path("agravity_bridge")
log_path = Config.get_default_log_path("agravity_bridge")
assert config_path.parts[-2:] == ("agravity_bridge", "config.json")
assert log_path.parts[-2:] == ("logs", "agravity_bridge.log")
class TestBootstrapEnvLoading:
"""Test bootstrap .env loading behavior for packaged builds."""
def test_load_bootstrap_env_reads_meipass_dotenv(self, tmp_path, monkeypatch):
"""Packaged app should load .env from PyInstaller runtime directory."""
meipass_dir = tmp_path / "runtime"
meipass_dir.mkdir(parents=True)
env_path = meipass_dir / ".env"
env_path.write_text("APP_NAME=Agravity Bridge\n", encoding="utf-8")
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "_MEIPASS", str(meipass_dir), raising=False)
loaded_path = Config.load_bootstrap_env()
assert loaded_path == env_path
assert os.getenv("APP_NAME") == "Agravity Bridge"

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

@ -0,0 +1,60 @@
"""Unit tests for i18n translation helper."""
import json
from pathlib import Path
from webdrop_bridge.utils import i18n
class TestI18n:
"""Tests for translation lookup and fallback behavior."""
def test_tr_lazy_initialization_uses_english_defaults(self):
"""Translator should lazily initialize and resolve known keys."""
# Force a fresh singleton state for this test.
i18n._translator = i18n.Translator() # type: ignore[attr-defined]
assert i18n.tr("settings.title") == "Settings"
def test_initialize_with_language_falls_back_to_english(self, tmp_path: Path):
"""Missing keys in selected language should fall back to English."""
translations = tmp_path / "translations"
translations.mkdir(parents=True, exist_ok=True)
(translations / "en.json").write_text(
json.dumps(
{
"greeting": "Hello {name}",
"settings.title": "Settings",
}
),
encoding="utf-8",
)
(translations / "de.json").write_text(
json.dumps(
{
"settings.title": "Einstellungen",
}
),
encoding="utf-8",
)
i18n._translator = i18n.Translator() # type: ignore[attr-defined]
i18n.initialize("de", translations)
assert i18n.tr("settings.title") == "Einstellungen"
assert i18n.tr("greeting", name="Alex") == "Hello Alex"
def test_get_available_languages_reads_translation_files(self, tmp_path: Path):
"""Available languages should be discovered from JSON files."""
translations = tmp_path / "translations"
translations.mkdir(parents=True, exist_ok=True)
(translations / "en.json").write_text("{}", encoding="utf-8")
(translations / "fr.json").write_text("{}", encoding="utf-8")
i18n._translator = i18n.Translator() # type: ignore[attr-defined]
i18n.initialize("en", translations)
available = i18n.get_available_languages()
assert "en" in available
assert "fr" in available

View file

@ -149,3 +149,74 @@ class TestMainWindowURLWhitelist:
# web_view should have allowed_urls configured # web_view should have allowed_urls configured
assert window.web_view.allowed_urls == sample_config.allowed_urls assert window.web_view.allowed_urls == sample_config.allowed_urls
class TestMainWindowOpenWith:
"""Test Open With chooser behavior."""
def test_open_with_app_chooser_windows(self, qtbot, sample_config):
"""Windows should use ShellExecuteW with the openas verb."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
test_file = sample_config.allowed_roots[0] / "open_with_test.txt"
test_file.write_text("test")
with patch("webdrop_bridge.ui.main_window.sys.platform", "win32"):
with patch("ctypes.windll.shell32.ShellExecuteW", return_value=33) as mock_shell:
assert window._open_with_app_chooser(str(test_file)) is True
mock_shell.assert_called_once_with(
None,
"openas",
str(test_file),
None,
None,
1,
)
def test_open_with_app_chooser_windows_shellexecute_failure(self, qtbot, sample_config):
"""Windows should fall back to OpenAs_RunDLL when ShellExecuteW fails."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
test_file = sample_config.allowed_roots[0] / "open_with_fallback.txt"
test_file.write_text("test")
with patch("webdrop_bridge.ui.main_window.sys.platform", "win32"):
with patch("ctypes.windll.shell32.ShellExecuteW", return_value=31):
with patch("webdrop_bridge.ui.main_window.subprocess.Popen") as mock_popen:
assert window._open_with_app_chooser(str(test_file)) is True
mock_popen.assert_called_once_with(
["rundll32.exe", "shell32.dll,OpenAs_RunDLL", str(test_file)]
)
def test_open_with_app_chooser_missing_file(self, qtbot, sample_config):
"""Missing files should fail before platform-specific invocation."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
with patch("webdrop_bridge.ui.main_window.sys.platform", "win32"):
assert window._open_with_app_chooser("C:/tmp/does_not_exist.txt") is False
def test_open_with_app_chooser_macos_success(self, qtbot, sample_config):
"""macOS should return True when osascript exits successfully."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
test_file = sample_config.allowed_roots[0] / "open_with_macos.txt"
test_file.write_text("test")
class _Result:
returncode = 0
with patch("webdrop_bridge.ui.main_window.sys.platform", "darwin"):
with patch("webdrop_bridge.ui.main_window.subprocess.run", return_value=_Result()):
assert window._open_with_app_chooser(str(test_file)) is True
def test_open_with_app_chooser_unsupported_platform(self, qtbot, sample_config):
"""Unsupported platforms should return False."""
window = MainWindow(sample_config)
qtbot.addWidget(window)
with patch("webdrop_bridge.ui.main_window.sys.platform", "linux"):
assert window._open_with_app_chooser("/tmp/test.txt") is False

View file

@ -34,7 +34,7 @@ class TestSettingsDialogInitialization:
"""Test dialog can be created.""" """Test dialog can be created."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog is not None assert dialog is not None
assert dialog.windowTitle() == "Settings" assert dialog.windowTitle() == "Settings"
@ -42,51 +42,58 @@ class TestSettingsDialogInitialization:
"""Test dialog has all required tabs.""" """Test dialog has all required tabs."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.tabs is not None assert dialog.tabs is not None
assert dialog.tabs.count() == 6 # Web Source, Paths, URLs, Logging, Window, Profiles assert dialog.tabs.count() == 7 # General + previous 6 tabs
def test_dialog_has_general_tab(self, qtbot, sample_config):
"""Test General tab exists."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog.tabs.tabText(0) == "General"
def test_dialog_has_web_source_tab(self, qtbot, sample_config): 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(0) == "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(1) == "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(2) == "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(3) == "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(4) == "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 Profiles tab exists.""" """Test Profiles tab exists."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.tabs.tabText(5) == "Profiles" assert dialog.tabs.tabText(6) == "Profiles"
class TestPathsTab: class TestPathsTab:
@ -96,7 +103,7 @@ class TestPathsTab:
"""Test paths are loaded from configuration.""" """Test paths are loaded from configuration."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
items = [dialog.paths_list.item(i).text() for i in range(dialog.paths_list.count())] items = [dialog.paths_list.item(i).text() for i in range(dialog.paths_list.count())]
assert len(items) == 2 assert len(items) == 2
# Paths are normalized (backslashes on Windows) # Paths are normalized (backslashes on Windows)
@ -107,7 +114,7 @@ class TestPathsTab:
"""Test Add Path button exists.""" """Test Add Path button exists."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.tabs.currentWidget() is not None assert dialog.tabs.currentWidget() is not None
@ -118,7 +125,7 @@ class TestURLsTab:
"""Test URLs are loaded from configuration.""" """Test URLs are loaded from configuration."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
items = [dialog.urls_list.item(i).text() for i in range(dialog.urls_list.count())] items = [dialog.urls_list.item(i).text() for i in range(dialog.urls_list.count())]
assert len(items) == 2 assert len(items) == 2
assert "http://example.com" in items assert "http://example.com" in items
@ -132,14 +139,14 @@ class TestLoggingTab:
"""Test log level is set from configuration.""" """Test log level is set from configuration."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.log_level_combo.currentText() == "INFO" assert dialog.log_level_combo.currentText() == "INFO"
def test_log_levels_available(self, qtbot, sample_config): def test_log_levels_available(self, qtbot, sample_config):
"""Test all log levels are available.""" """Test all log levels are available."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
levels = [dialog.log_level_combo.itemText(i) for i in range(dialog.log_level_combo.count())] levels = [dialog.log_level_combo.itemText(i) for i in range(dialog.log_level_combo.count())]
assert "DEBUG" in levels assert "DEBUG" in levels
assert "INFO" in levels assert "INFO" in levels
@ -155,21 +162,21 @@ class TestWindowTab:
"""Test window width is set from configuration.""" """Test window width is set from configuration."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.width_spin.value() == 800 assert dialog.width_spin.value() == 800
def test_window_height_set_from_config(self, qtbot, sample_config): def test_window_height_set_from_config(self, qtbot, sample_config):
"""Test window height is set from configuration.""" """Test window height is set from configuration."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.height_spin.value() == 600 assert dialog.height_spin.value() == 600
def test_window_width_has_min_max(self, qtbot, sample_config): def test_window_width_has_min_max(self, qtbot, sample_config):
"""Test window width spinbox has min/max.""" """Test window width spinbox has min/max."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.width_spin.minimum() == 400 assert dialog.width_spin.minimum() == 400
assert dialog.width_spin.maximum() == 5000 assert dialog.width_spin.maximum() == 5000
@ -177,7 +184,7 @@ class TestWindowTab:
"""Test window height spinbox has min/max.""" """Test window height spinbox has min/max."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.height_spin.minimum() == 300 assert dialog.height_spin.minimum() == 300
assert dialog.height_spin.maximum() == 5000 assert dialog.height_spin.maximum() == 5000
@ -189,7 +196,7 @@ class TestProfilesTab:
"""Test profiles list is initialized.""" """Test profiles list is initialized."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.profiles_list is not None assert dialog.profiles_list is not None
@ -200,9 +207,9 @@ class TestConfigDataRetrieval:
"""Test retrieving configuration data from dialog.""" """Test retrieving configuration data from dialog."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
config_data = dialog.get_config_data() config_data = dialog.get_config_data()
assert config_data["app_name"] == "WebDrop Bridge" assert config_data["app_name"] == "WebDrop Bridge"
assert config_data["log_level"] == "INFO" assert config_data["log_level"] == "INFO"
assert config_data["window_width"] == 800 assert config_data["window_width"] == 800
@ -212,7 +219,7 @@ class TestConfigDataRetrieval:
"""Test get_config_data returns valid configuration data.""" """Test get_config_data returns valid configuration data."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
# All default values are valid # All default values are valid
config_data = dialog.get_config_data() config_data = dialog.get_config_data()
assert config_data is not None assert config_data is not None
@ -222,14 +229,14 @@ class TestConfigDataRetrieval:
"""Test get_config_data returns modified values.""" """Test get_config_data returns modified values."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
# Modify values # Modify values
dialog.width_spin.setValue(1024) dialog.width_spin.setValue(1024)
dialog.height_spin.setValue(768) dialog.height_spin.setValue(768)
dialog.log_level_combo.setCurrentText("DEBUG") dialog.log_level_combo.setCurrentText("DEBUG")
config_data = dialog.get_config_data() config_data = dialog.get_config_data()
assert config_data["window_width"] == 1024 assert config_data["window_width"] == 1024
assert config_data["window_height"] == 768 assert config_data["window_height"] == 768
assert config_data["log_level"] == "DEBUG" assert config_data["log_level"] == "DEBUG"
@ -242,7 +249,7 @@ class TestApplyConfigData:
"""Test applying config data updates paths.""" """Test applying config data updates paths."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
new_config = { new_config = {
"app_name": "Test", "app_name": "Test",
"app_version": "1.0.0", "app_version": "1.0.0",
@ -255,9 +262,9 @@ class TestApplyConfigData:
"window_height": 600, "window_height": 600,
"enable_logging": True, "enable_logging": True,
} }
dialog._apply_config_data(new_config) dialog._apply_config_data(new_config)
items = [dialog.paths_list.item(i).text() for i in range(dialog.paths_list.count())] items = [dialog.paths_list.item(i).text() for i in range(dialog.paths_list.count())]
assert "/new/path" in items assert "/new/path" in items
assert "/another/path" in items assert "/another/path" in items
@ -266,7 +273,7 @@ class TestApplyConfigData:
"""Test applying config data updates URLs.""" """Test applying config data updates URLs."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
new_config = { new_config = {
"app_name": "Test", "app_name": "Test",
"app_version": "1.0.0", "app_version": "1.0.0",
@ -279,9 +286,9 @@ class TestApplyConfigData:
"window_height": 600, "window_height": 600,
"enable_logging": True, "enable_logging": True,
} }
dialog._apply_config_data(new_config) dialog._apply_config_data(new_config)
items = [dialog.urls_list.item(i).text() for i in range(dialog.urls_list.count())] items = [dialog.urls_list.item(i).text() for i in range(dialog.urls_list.count())]
assert "http://new.com" in items assert "http://new.com" in items
assert "http://test.org" in items assert "http://test.org" in items
@ -290,7 +297,7 @@ class TestApplyConfigData:
"""Test applying config data updates window size.""" """Test applying config data updates window size."""
dialog = SettingsDialog(sample_config) dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
new_config = { new_config = {
"app_name": "Test", "app_name": "Test",
"app_version": "1.0.0", "app_version": "1.0.0",
@ -303,8 +310,8 @@ class TestApplyConfigData:
"window_height": 1024, "window_height": 1024,
"enable_logging": True, "enable_logging": True,
} }
dialog._apply_config_data(new_config) dialog._apply_config_data(new_config)
assert dialog.width_spin.value() == 1280 assert dialog.width_spin.value() == 1280
assert dialog.height_spin.value() == 1024 assert dialog.height_spin.value() == 1024

View file

@ -16,6 +16,17 @@ def update_manager(tmp_path):
return UpdateManager(current_version="0.0.1", config_dir=tmp_path) return UpdateManager(current_version="0.0.1", config_dir=tmp_path)
@pytest.fixture
def agravity_update_manager(tmp_path):
"""Create a brand-aware UpdateManager instance for Agravity Bridge."""
return UpdateManager(
current_version="0.0.1",
config_dir=tmp_path,
brand_id="agravity",
update_channel="stable",
)
@pytest.fixture @pytest.fixture
def sample_release(): def sample_release():
"""Sample release data from API.""" """Sample release data from API."""
@ -252,6 +263,143 @@ class TestDownloading:
assert result is None assert result is None
@pytest.mark.asyncio
async def test_download_update_uses_release_manifest(self, agravity_update_manager, tmp_path):
"""Test branded download selection from a shared release manifest."""
release = Release(
tag_name="v0.0.2",
name="WebDropBridge v0.0.2",
version="0.0.2",
body="Release notes",
assets=[
{
"name": "AgravityBridge-0.0.2-win-x64.msi",
"browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi",
},
{
"name": "AgravityBridge-0.0.2-win-x64.msi.sha256",
"browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi.sha256",
},
{
"name": "OtherBridge-0.0.2-win-x64.msi",
"browser_download_url": "https://example.com/OtherBridge-0.0.2-win-x64.msi",
},
{
"name": "release-manifest.json",
"browser_download_url": "https://example.com/release-manifest.json",
},
],
published_at="2026-01-29T10:00:00Z",
)
manifest = {
"version": "0.0.2",
"channel": "stable",
"brands": {
"agravity": {
"windows-x64": {
"installer": "AgravityBridge-0.0.2-win-x64.msi",
"checksum": "AgravityBridge-0.0.2-win-x64.msi.sha256",
}
}
},
}
with (
patch.object(UpdateManager, "_download_json_asset", return_value=manifest),
patch.object(UpdateManager, "_download_file", return_value=True) as mock_download,
):
result = await agravity_update_manager.download_update(release, tmp_path)
assert result is not None
assert result.name == "AgravityBridge-0.0.2-win-x64.msi"
mock_download.assert_called_once()
@pytest.mark.asyncio
async def test_download_update_falls_back_to_brand_prefix_without_manifest(
self, agravity_update_manager, tmp_path
):
"""Test branded download selection still works when the manifest is unavailable."""
release = Release(
tag_name="v0.0.2",
name="WebDropBridge v0.0.2",
version="0.0.2",
body="Release notes",
assets=[
{
"name": "WebDropBridge-0.0.2-win-x64.msi",
"browser_download_url": "https://example.com/WebDropBridge-0.0.2-win-x64.msi",
},
{
"name": "AgravityBridge-0.0.2-win-x64.msi",
"browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi",
},
{
"name": "AgravityBridge-0.0.2-win-x64.msi.sha256",
"browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi.sha256",
},
],
published_at="2026-01-29T10:00:00Z",
)
with patch.object(UpdateManager, "_download_file", return_value=True) as mock_download:
result = await agravity_update_manager.download_update(release, tmp_path)
assert result is not None
assert result.name == "AgravityBridge-0.0.2-win-x64.msi"
mock_download.assert_called_once()
@pytest.mark.asyncio
async def test_verify_checksum_uses_release_manifest(self, agravity_update_manager, tmp_path):
"""Test branded checksum selection from a shared release manifest."""
test_file = tmp_path / "AgravityBridge-0.0.2-win-x64.msi"
test_file.write_bytes(b"test content")
import hashlib
checksum = hashlib.sha256(b"test content").hexdigest()
release = Release(
tag_name="v0.0.2",
name="WebDropBridge v0.0.2",
version="0.0.2",
body="Release notes",
assets=[
{
"name": "AgravityBridge-0.0.2-win-x64.msi",
"browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi",
},
{
"name": "AgravityBridge-0.0.2-win-x64.msi.sha256",
"browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi.sha256",
},
{
"name": "release-manifest.json",
"browser_download_url": "https://example.com/release-manifest.json",
},
],
published_at="2026-01-29T10:00:00Z",
)
manifest = {
"version": "0.0.2",
"channel": "stable",
"brands": {
"agravity": {
"windows-x64": {
"installer": "AgravityBridge-0.0.2-win-x64.msi",
"checksum": "AgravityBridge-0.0.2-win-x64.msi.sha256",
}
}
},
}
with (
patch.object(UpdateManager, "_download_json_asset", return_value=manifest),
patch.object(UpdateManager, "_download_checksum", return_value=checksum),
):
result = await agravity_update_manager.verify_checksum(test_file, release)
assert result is True
class TestChecksumVerification: class TestChecksumVerification:
"""Test checksum verification.""" """Test checksum verification."""