Compare commits
19 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a135dd0d96 | |||
| 25ebe5d7ea | |||
| 1de604e7e2 | |||
| ef96184dc3 | |||
| 5cb3bf9f76 | |||
| 8e07e7e855 | |||
| 093b196d0d | |||
| 44124595d8 | |||
| c80128118e | |||
| df76cb9b36 | |||
| eab1009d8c | |||
| de6e9838e5 | |||
| 67bfe4a600 | |||
| fd69996c53 | |||
| b988532aaa | |||
| baf56e040f | |||
| b4c8692738 | |||
| a48cc01254 | |||
| 7daec731ca |
40 changed files with 4187 additions and 12620 deletions
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
# Application
|
||||
APP_NAME=WebDrop Bridge
|
||||
APP_VERSION=0.8.3
|
||||
APP_VERSION=0.8.6
|
||||
|
||||
# Web App
|
||||
WEBAPP_URL=file:///./webapp/index.html
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -143,6 +143,12 @@ ehthumbs.db
|
|||
|
||||
# Build outputs
|
||||
build/dist/
|
||||
build/build_output.log
|
||||
build/test.txt
|
||||
build/*.wixobj
|
||||
build/*.wixpdb
|
||||
build/*_Files.wxs
|
||||
build/*.generated.wxs
|
||||
*.msi
|
||||
*.exe
|
||||
*.dmg
|
||||
|
|
|
|||
1
.tmp-update-check/update_check.json
Normal file
1
.tmp-update-check/update_check.json
Normal 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"}}
|
||||
|
|
@ -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 multi-brand packaging and release workflows, see [BRANDING_AND_RELEASES.md](docs/BRANDING_AND_RELEASES.md).
|
||||
|
||||
### Installation from Source
|
||||
|
||||
```bash
|
||||
|
|
@ -141,6 +143,11 @@ webdrop-bridge/
|
|||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Architecture Guide](docs/ARCHITECTURE.md)
|
||||
- [Translations Guide (i18n)](docs/TRANSLATIONS_GUIDE.md)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -2,23 +2,23 @@
|
|||
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"
|
||||
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui"
|
||||
xmlns:util="http://schemas.microsoft.com/wix/UtilExtension">
|
||||
<Product Id="*" Name="WebDrop Bridge" Language="1033" Version="0.8.3"
|
||||
Manufacturer="HIM-Tools"
|
||||
UpgradeCode="12345678-1234-1234-1234-123456789012">
|
||||
<Product Id="*" Name="{product_name_with_version}" Language="1033" Version="{version}"
|
||||
Manufacturer="{manufacturer}"
|
||||
UpgradeCode="{upgrade_code}">
|
||||
|
||||
<Package InstallerVersion="200" Compressed="yes" InstallScope="perMachine" Platform="x64" />
|
||||
<Media Id="1" Cabinet="WebDropBridge.cab" EmbedCab="yes" />
|
||||
<Media Id="1" Cabinet="{asset_prefix}.cab" EmbedCab="yes" />
|
||||
|
||||
<!-- Required property for WixUI_InstallDir dialog set -->
|
||||
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />
|
||||
|
||||
<!-- Application Icon -->
|
||||
<Icon Id="AppIcon.ico" SourceFile="$(var.ResourcesDir)\icons\app.ico" />
|
||||
<Icon Id="AppIcon.ico" SourceFile="{icon_ico}" />
|
||||
|
||||
<!-- Custom branding for InstallDir dialog set -->
|
||||
<WixVariable Id="WixUIDialogBmp" Value="$(var.ResourcesDir)\icons\background.bmp" />
|
||||
<WixVariable Id="WixUIBannerBmp" Value="$(var.ResourcesDir)\icons\banner.bmp" />
|
||||
<WixVariable Id="WixUILicenseRtf" Value="$(var.ResourcesDir)\license.rtf" />
|
||||
<WixVariable Id="WixUIDialogBmp" Value="{dialog_bmp}" />
|
||||
<WixVariable Id="WixUIBannerBmp" Value="{banner_bmp}" />
|
||||
<WixVariable Id="WixUILicenseRtf" Value="{license_rtf}" />
|
||||
|
||||
<!-- Installation UI dialogs -->
|
||||
<UIRef Id="WixUI_InstallDir" />
|
||||
|
|
@ -26,12 +26,12 @@
|
|||
|
||||
<!-- Close running application before installation -->
|
||||
<util:CloseApplication
|
||||
Target="WebDropBridge.exe"
|
||||
Target="{exe_name}.exe"
|
||||
CloseMessage="yes"
|
||||
RebootPrompt="no"
|
||||
ElevatedCloseMessage="no" />
|
||||
|
||||
<Feature Id="ProductFeature" Title="WebDrop Bridge" Level="1">
|
||||
<Feature Id="ProductFeature" Title="{product_name}" Level="1">
|
||||
<ComponentGroupRef Id="AppFiles" />
|
||||
<ComponentRef Id="ProgramMenuShortcut" />
|
||||
<ComponentRef Id="DesktopShortcut" />
|
||||
|
|
@ -39,10 +39,10 @@
|
|||
|
||||
<Directory Id="TARGETDIR" Name="SourceDir">
|
||||
<Directory Id="ProgramFiles64Folder">
|
||||
<Directory Id="INSTALLFOLDER" Name="WebDrop Bridge" />
|
||||
<Directory Id="INSTALLFOLDER" Name="{install_dir_name}" />
|
||||
</Directory>
|
||||
<Directory Id="ProgramMenuFolder">
|
||||
<Directory Id="ApplicationProgramsFolder" Name="WebDrop Bridge"/>
|
||||
<Directory Id="ApplicationProgramsFolder" Name="{product_name}"/>
|
||||
</Directory>
|
||||
<Directory Id="DesktopFolder" />
|
||||
</Directory>
|
||||
|
|
@ -50,16 +50,16 @@
|
|||
<DirectoryRef Id="ApplicationProgramsFolder">
|
||||
<Component Id="ProgramMenuShortcut" Guid="*">
|
||||
<Shortcut Id="ApplicationStartMenuShortcut"
|
||||
Name="WebDrop Bridge"
|
||||
Description="Web Drag-and-Drop Bridge"
|
||||
Target="[INSTALLFOLDER]WebDropBridge.exe"
|
||||
Name="{product_name}"
|
||||
Description="{shortcut_description}"
|
||||
Target="[INSTALLFOLDER]{exe_name}.exe"
|
||||
Icon="AppIcon.ico"
|
||||
IconIndex="0"
|
||||
WorkingDirectory="INSTALLFOLDER" />
|
||||
<RemoveFolder Id="ApplicationProgramsFolderRemove"
|
||||
On="uninstall" />
|
||||
<RegistryValue Root="HKCU"
|
||||
Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\WebDropBridge"
|
||||
Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\{exe_name}"
|
||||
Name="installed"
|
||||
Type="integer"
|
||||
Value="1"
|
||||
|
|
@ -70,14 +70,14 @@
|
|||
<DirectoryRef Id="DesktopFolder">
|
||||
<Component Id="DesktopShortcut" Guid="*">
|
||||
<Shortcut Id="DesktopApplicationShortcut"
|
||||
Name="WebDrop Bridge"
|
||||
Description="Web Drag-and-Drop Bridge"
|
||||
Target="[INSTALLFOLDER]WebDropBridge.exe"
|
||||
Name="{product_name}"
|
||||
Description="{shortcut_description}"
|
||||
Target="[INSTALLFOLDER]{exe_name}.exe"
|
||||
Icon="AppIcon.ico"
|
||||
IconIndex="0"
|
||||
WorkingDirectory="INSTALLFOLDER" />
|
||||
<RegistryValue Root="HKCU"
|
||||
Key="Software\WebDropBridge"
|
||||
Key="Software\{exe_name}"
|
||||
Name="DesktopShortcut"
|
||||
Type="integer"
|
||||
Value="1"
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
22
build/brands/agravity.json
Normal file
22
build/brands/agravity.json
Normal 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"
|
||||
}
|
||||
24
build/brands/template.jsonc
Normal file
24
build/brands/template.jsonc
Normal 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"
|
||||
}
|
||||
397
build/scripts/brand_config.py
Normal file
397
build/scripts/brand_config.py
Normal 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())
|
||||
|
|
@ -28,10 +28,13 @@ DIST_DIR="$BUILD_DIR/dist/macos"
|
|||
TEMP_BUILD="$BUILD_DIR/temp/macos"
|
||||
SPECS_DIR="$BUILD_DIR/specs"
|
||||
SPEC_FILE="$BUILD_DIR/webdrop_bridge.spec"
|
||||
BRAND_HELPER="$BUILD_DIR/scripts/brand_config.py"
|
||||
|
||||
BRAND=""
|
||||
APP_NAME="WebDropBridge"
|
||||
DMG_VOLUME_NAME="WebDrop Bridge"
|
||||
VERSION="1.0.0"
|
||||
BUNDLE_IDENTIFIER="de.him_tools.webdrop-bridge"
|
||||
VERSION=""
|
||||
|
||||
# Default .env file
|
||||
ENV_FILE="$PROJECT_ROOT/.env"
|
||||
|
|
@ -54,6 +57,10 @@ while [[ $# -gt 0 ]]; do
|
|||
ENV_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--brand)
|
||||
BRAND="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
|
|
@ -70,6 +77,23 @@ fi
|
|||
|
||||
echo "📋 Using configuration: $ENV_FILE"
|
||||
|
||||
if [ -z "$BRAND" ]; then
|
||||
BRAND="webdrop_bridge"
|
||||
fi
|
||||
|
||||
eval "$(python3 "$BRAND_HELPER" env --brand "$BRAND")"
|
||||
APP_NAME="$WEBDROP_ASSET_PREFIX"
|
||||
DMG_VOLUME_NAME="$WEBDROP_APP_DISPLAY_NAME"
|
||||
BUNDLE_IDENTIFIER="$WEBDROP_BUNDLE_ID"
|
||||
DIST_DIR="$BUILD_DIR/dist/macos/$WEBDROP_BRAND_ID"
|
||||
TEMP_BUILD="$BUILD_DIR/temp/macos/$WEBDROP_BRAND_ID"
|
||||
|
||||
if [ -n "$WEBDROP_APP_DISPLAY_NAME" ]; then
|
||||
echo "🏷️ Building brand: $WEBDROP_APP_DISPLAY_NAME ($WEBDROP_BRAND_ID)"
|
||||
fi
|
||||
|
||||
VERSION="$(python3 -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$BUILD_DIR/scripts').resolve())); from version_utils import get_current_version; print(get_current_version())")"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
|
|
@ -176,8 +200,27 @@ build_executable() {
|
|||
log_info "Building macOS executable with PyInstaller..."
|
||||
echo ""
|
||||
|
||||
# Create bundled runtime .env with brand defaults so first launch
|
||||
# uses brand-specific app name and config directory.
|
||||
BUNDLED_ENV_FILE="$TEMP_BUILD/.env"
|
||||
cp "$ENV_FILE" "$BUNDLED_ENV_FILE"
|
||||
{
|
||||
echo ""
|
||||
echo "# Brand-specific defaults added during packaging"
|
||||
echo "APP_NAME=\"$WEBDROP_APP_DISPLAY_NAME\""
|
||||
echo "BRAND_ID=\"$WEBDROP_BRAND_ID\""
|
||||
echo "APP_CONFIG_DIR_NAME=\"$WEBDROP_CONFIG_DIR_NAME\""
|
||||
echo "UPDATE_CHANNEL=\"$WEBDROP_UPDATE_CHANNEL\""
|
||||
echo "TOOLBAR_ICON_HOME=\"$WEBDROP_TOOLBAR_ICON_HOME\""
|
||||
echo "TOOLBAR_ICON_RELOAD=\"$WEBDROP_TOOLBAR_ICON_RELOAD\""
|
||||
echo "TOOLBAR_ICON_OPEN=\"$WEBDROP_TOOLBAR_ICON_OPEN\""
|
||||
echo "TOOLBAR_ICON_OPENWITH=\"$WEBDROP_TOOLBAR_ICON_OPENWITH\""
|
||||
} >> "$BUNDLED_ENV_FILE"
|
||||
|
||||
# Export env file for spec file to pick up
|
||||
export WEBDROP_ENV_FILE="$ENV_FILE"
|
||||
export WEBDROP_ENV_FILE="$BUNDLED_ENV_FILE"
|
||||
export WEBDROP_VERSION="$VERSION"
|
||||
export WEBDROP_BUNDLE_ID="$BUNDLE_IDENTIFIER"
|
||||
|
||||
python3 -m PyInstaller \
|
||||
--distpath="$DIST_DIR" \
|
||||
|
|
@ -199,7 +242,7 @@ create_dmg() {
|
|||
log_info "Creating DMG package..."
|
||||
echo ""
|
||||
|
||||
DMG_FILE="$DIST_DIR/${APP_NAME}-${VERSION}.dmg"
|
||||
DMG_FILE="$DIST_DIR/${APP_NAME}-${VERSION}-macos-universal.dmg"
|
||||
|
||||
# Remove existing DMG
|
||||
if [ -f "$DMG_FILE" ]; then
|
||||
|
|
@ -252,6 +295,8 @@ create_dmg() {
|
|||
SIZE=$(du -h "$DMG_FILE" | cut -f1)
|
||||
log_success "DMG created successfully"
|
||||
log_info "Output: $DMG_FILE (Size: $SIZE)"
|
||||
shasum -a 256 "$DMG_FILE" | awk '{print $1}' > "$DMG_FILE.sha256"
|
||||
log_info "Checksum: $DMG_FILE.sha256"
|
||||
echo ""
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,14 +38,17 @@ import argparse
|
|||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from dotenv import dotenv_values
|
||||
|
||||
# Import shared version utilities
|
||||
from brand_config import load_brand_config
|
||||
from sync_version import get_current_version, do_sync_version
|
||||
|
||||
|
||||
class WindowsBuilder:
|
||||
"""Build Windows installer using PyInstaller."""
|
||||
|
||||
def __init__(self, env_file: Path | None = None):
|
||||
def __init__(self, env_file: Path | None = None, brand: str | None = None):
|
||||
"""Initialize builder paths.
|
||||
|
||||
Args:
|
||||
|
|
@ -53,10 +56,12 @@ class WindowsBuilder:
|
|||
If that doesn't exist, raises error.
|
||||
"""
|
||||
self.project_root = Path(__file__).parent.parent.parent
|
||||
self.brand = load_brand_config(brand, root=self.project_root)
|
||||
self.build_dir = self.project_root / "build"
|
||||
self.dist_dir = self.build_dir / "dist" / "windows"
|
||||
self.temp_dir = self.build_dir / "temp" / "windows"
|
||||
self.dist_dir = self.build_dir / "dist" / "windows" / self.brand.brand_id
|
||||
self.temp_dir = self.build_dir / "temp" / "windows" / self.brand.brand_id
|
||||
self.spec_file = self.build_dir / "webdrop_bridge.spec"
|
||||
self.wix_template = self.build_dir / "WebDropBridge.wxs"
|
||||
self.version = get_current_version()
|
||||
|
||||
# Validate and set env file
|
||||
|
|
@ -74,6 +79,7 @@ class WindowsBuilder:
|
|||
|
||||
self.env_file = env_file
|
||||
print(f"📋 Using configuration: {self.env_file}")
|
||||
print(f"🏷️ Building brand: {self.brand.display_name} ({self.brand.brand_id})")
|
||||
|
||||
def _get_version(self) -> str:
|
||||
"""Get version from __init__.py.
|
||||
|
|
@ -91,6 +97,48 @@ class WindowsBuilder:
|
|||
shutil.rmtree(path)
|
||||
print(f" Removed {path}")
|
||||
|
||||
@staticmethod
|
||||
def _format_env_value(value: str) -> str:
|
||||
"""Format env values safely for .env files."""
|
||||
if any(ch in value for ch in [" ", "#", '"', "'", "\t"]):
|
||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
return value
|
||||
|
||||
def _create_bundled_env_file(self) -> Path:
|
||||
"""Create a bundled .env file with brand-specific runtime defaults."""
|
||||
values = dotenv_values(self.env_file)
|
||||
overrides = {
|
||||
"APP_NAME": self.brand.display_name,
|
||||
"BRAND_ID": self.brand.brand_id,
|
||||
"APP_CONFIG_DIR_NAME": self.brand.config_dir_name,
|
||||
"UPDATE_CHANNEL": self.brand.update_channel,
|
||||
"TOOLBAR_ICON_HOME": self.brand.toolbar_icon_home,
|
||||
"TOOLBAR_ICON_RELOAD": self.brand.toolbar_icon_reload,
|
||||
"TOOLBAR_ICON_OPEN": self.brand.toolbar_icon_open,
|
||||
"TOOLBAR_ICON_OPENWITH": self.brand.toolbar_icon_openwith,
|
||||
}
|
||||
|
||||
output_env = self.temp_dir / ".env"
|
||||
output_env.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
lines: list[str] = []
|
||||
for key, raw_value in values.items():
|
||||
if key in overrides:
|
||||
continue
|
||||
if raw_value is None:
|
||||
lines.append(key)
|
||||
else:
|
||||
lines.append(f"{key}={self._format_env_value(str(raw_value))}")
|
||||
|
||||
lines.append("")
|
||||
lines.append("# Brand-specific defaults added during packaging")
|
||||
for key, value in overrides.items():
|
||||
lines.append(f"{key}={self._format_env_value(value)}")
|
||||
|
||||
output_env.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return output_env
|
||||
|
||||
def build_executable(self) -> bool:
|
||||
"""Build executable using PyInstaller."""
|
||||
print("\n🔨 Building Windows executable with PyInstaller...")
|
||||
|
|
@ -115,7 +163,16 @@ class WindowsBuilder:
|
|||
|
||||
# Set environment variable for spec file to use
|
||||
env = os.environ.copy()
|
||||
env["WEBDROP_ENV_FILE"] = str(self.env_file)
|
||||
env["WEBDROP_ENV_FILE"] = str(self._create_bundled_env_file())
|
||||
env["WEBDROP_BRAND_ID"] = self.brand.brand_id
|
||||
env["WEBDROP_APP_DISPLAY_NAME"] = self.brand.display_name
|
||||
env["WEBDROP_ASSET_PREFIX"] = self.brand.asset_prefix
|
||||
env["WEBDROP_EXE_NAME"] = self.brand.exe_name
|
||||
env["WEBDROP_BUNDLE_ID"] = self.brand.bundle_identifier
|
||||
env["WEBDROP_CONFIG_DIR_NAME"] = self.brand.config_dir_name
|
||||
env["WEBDROP_ICON_ICO"] = str(self.brand.icon_ico)
|
||||
env["WEBDROP_ICON_ICNS"] = str(self.brand.icon_icns)
|
||||
env["WEBDROP_VERSION"] = self.version
|
||||
|
||||
result = subprocess.run(cmd, cwd=str(self.project_root), text=True, env=env)
|
||||
|
||||
|
|
@ -123,8 +180,8 @@ class WindowsBuilder:
|
|||
print("❌ PyInstaller build failed")
|
||||
return False
|
||||
|
||||
# Check if executable exists (now in WebDropBridge/WebDropBridge.exe due to COLLECT)
|
||||
exe_path = self.dist_dir / "WebDropBridge" / "WebDropBridge.exe"
|
||||
# Check if executable exists (inside the COLLECT directory)
|
||||
exe_path = self.dist_dir / self.brand.exe_name / f"{self.brand.exe_name}.exe"
|
||||
if not exe_path.exists():
|
||||
print(f"❌ Executable not found at {exe_path}")
|
||||
return False
|
||||
|
|
@ -134,7 +191,9 @@ class WindowsBuilder:
|
|||
|
||||
# Calculate total dist size
|
||||
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:
|
||||
print(f" Total size: {total_size / 1024 / 1024:.1f} MB")
|
||||
|
|
@ -249,7 +308,7 @@ class WindowsBuilder:
|
|||
|
||||
# Harvest application files using Heat
|
||||
print(f" Harvesting application files...")
|
||||
dist_folder = self.dist_dir / "WebDropBridge"
|
||||
dist_folder = self.dist_dir / self.brand.exe_name
|
||||
if not dist_folder.exists():
|
||||
print(f"❌ Distribution folder not found: {dist_folder}")
|
||||
return False
|
||||
|
|
@ -289,9 +348,9 @@ class WindowsBuilder:
|
|||
print(f" ✓ Marked components as 64-bit")
|
||||
|
||||
# Compile both WiX files
|
||||
wix_obj = self.build_dir / "WebDropBridge.wixobj"
|
||||
wix_obj = self.build_dir / "WebDropBridge.generated.wixobj"
|
||||
wix_files_obj = self.build_dir / "WebDropBridge_Files.wixobj"
|
||||
msi_output = self.dist_dir / f"WebDropBridge-{self.version}-Setup.msi"
|
||||
msi_output = self.dist_dir / self.brand.windows_installer_name(self.version)
|
||||
|
||||
# Run candle compiler - make sure to use correct source directory
|
||||
candle_cmd = [
|
||||
|
|
@ -301,11 +360,11 @@ class WindowsBuilder:
|
|||
"-ext",
|
||||
"WixUtilExtension",
|
||||
f"-dDistDir={self.dist_dir}",
|
||||
f"-dSourceDir={self.dist_dir}\\WebDropBridge", # Set SourceDir for Heat-generated files
|
||||
f"-dSourceDir={self.dist_dir / self.brand.exe_name}", # Set SourceDir for Heat-generated files
|
||||
f"-dResourcesDir={self.project_root}\\resources", # Set ResourcesDir for branding assets
|
||||
"-o",
|
||||
str(self.build_dir) + "\\",
|
||||
str(self.build_dir / "WebDropBridge.wxs"),
|
||||
str(self.build_dir / "WebDropBridge.generated.wxs"),
|
||||
]
|
||||
|
||||
if harvest_file.exists():
|
||||
|
|
@ -325,7 +384,7 @@ class WindowsBuilder:
|
|||
"-ext",
|
||||
"WixUtilExtension",
|
||||
"-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",
|
||||
str(msi_output),
|
||||
str(wix_obj),
|
||||
|
|
@ -353,6 +412,7 @@ class WindowsBuilder:
|
|||
print("✅ MSI installer created successfully")
|
||||
print(f"📦 Output: {msi_output}")
|
||||
print(f" Size: {msi_output.stat().st_size / 1024 / 1024:.1f} MB")
|
||||
self.generate_checksum(msi_output)
|
||||
|
||||
return True
|
||||
|
||||
|
|
@ -363,7 +423,7 @@ class WindowsBuilder:
|
|||
even if a previous PyInstaller run omitted them.
|
||||
"""
|
||||
src_icons_dir = self.project_root / "resources" / "icons"
|
||||
bundle_icons_dir = self.dist_dir / "WebDropBridge" / "_internal" / "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:
|
||||
|
|
@ -392,97 +452,24 @@ class WindowsBuilder:
|
|||
Creates per-machine installation (Program Files).
|
||||
Installation requires admin rights, but the app does not.
|
||||
"""
|
||||
wix_content = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"
|
||||
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui"
|
||||
xmlns:util="http://schemas.microsoft.com/wix/UtilExtension">
|
||||
<Product Id="*" Name="WebDrop Bridge" Language="1033" Version="{self.version}"
|
||||
Manufacturer="HIM-Tools"
|
||||
UpgradeCode="12345678-1234-1234-1234-123456789012">
|
||||
wix_template = self.wix_template.read_text(encoding="utf-8")
|
||||
wix_content = wix_template.format(
|
||||
product_name=self.brand.display_name,
|
||||
product_name_with_version=f"{self.brand.display_name} v{self.version}",
|
||||
version=self.version,
|
||||
manufacturer=self.brand.manufacturer,
|
||||
upgrade_code=self.brand.msi_upgrade_code,
|
||||
asset_prefix=self.brand.asset_prefix,
|
||||
icon_ico=str(self.brand.icon_ico),
|
||||
dialog_bmp=str(self.brand.dialog_bmp),
|
||||
banner_bmp=str(self.brand.banner_bmp),
|
||||
license_rtf=str(self.brand.license_rtf),
|
||||
exe_name=self.brand.exe_name,
|
||||
install_dir_name=self.brand.install_dir_name,
|
||||
shortcut_description=self.brand.shortcut_description,
|
||||
)
|
||||
|
||||
<Package InstallerVersion="200" Compressed="yes" InstallScope="perMachine" Platform="x64" />
|
||||
<Media Id="1" Cabinet="WebDropBridge.cab" EmbedCab="yes" />
|
||||
|
||||
<!-- Required property for WixUI_InstallDir dialog set -->
|
||||
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />
|
||||
|
||||
<!-- Application Icon -->
|
||||
<Icon Id="AppIcon.ico" SourceFile="$(var.ResourcesDir)\\icons\\app.ico" />
|
||||
|
||||
<!-- Custom branding for InstallDir dialog set -->
|
||||
<WixVariable Id="WixUIDialogBmp" Value="$(var.ResourcesDir)\\icons\\background.bmp" />
|
||||
<WixVariable Id="WixUIBannerBmp" Value="$(var.ResourcesDir)\\icons\\banner.bmp" />
|
||||
<WixVariable Id="WixUILicenseRtf" Value="$(var.ResourcesDir)\\license.rtf" />
|
||||
|
||||
<!-- Installation UI dialogs -->
|
||||
<UIRef Id="WixUI_InstallDir" />
|
||||
<UIRef Id="WixUI_ErrorProgressText" />
|
||||
|
||||
<!-- 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 = self.build_dir / "WebDropBridge.generated.wxs"
|
||||
wix_file.write_text(wix_content)
|
||||
print(f" Created WiX source: {wix_file}")
|
||||
return True
|
||||
|
|
@ -573,7 +560,7 @@ class WindowsBuilder:
|
|||
print(" Skipping code signing")
|
||||
return True
|
||||
|
||||
exe_path = self.dist_dir / "WebDropBridge.exe"
|
||||
exe_path = self.dist_dir / self.brand.exe_name / f"{self.brand.exe_name}.exe"
|
||||
cmd = [
|
||||
signtool,
|
||||
"sign",
|
||||
|
|
@ -606,7 +593,7 @@ class WindowsBuilder:
|
|||
"""
|
||||
start_time = datetime.now()
|
||||
print("=" * 60)
|
||||
print("🚀 WebDrop Bridge Windows Build")
|
||||
print(f"🚀 {self.brand.display_name} Windows Build")
|
||||
print("=" * 60)
|
||||
|
||||
self.clean()
|
||||
|
|
@ -650,6 +637,12 @@ def main() -> int:
|
|||
default=None,
|
||||
help="Path to .env file to bundle (default: project root .env)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--brand",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Brand manifest name from build/brands (e.g. agravity)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
|
|
@ -657,7 +650,7 @@ def main() -> int:
|
|||
do_sync_version()
|
||||
|
||||
try:
|
||||
builder = WindowsBuilder(env_file=args.env_file)
|
||||
builder = WindowsBuilder(env_file=args.env_file, brand=args.brand)
|
||||
except FileNotFoundError as e:
|
||||
print(f"❌ Build failed: {e}")
|
||||
return 1
|
||||
|
|
|
|||
|
|
@ -1,70 +1,58 @@
|
|||
# Create Forgejo Release with Binary Assets
|
||||
# Usage: .\create_release.ps1 [-Version 1.0.0]
|
||||
# If -Version is not provided, it will be read from src/webdrop_bridge/__init__.py
|
||||
# Uses your Forgejo credentials (same as git)
|
||||
# First run will prompt for credentials and save them to this session
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$Version,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string[]]$Brands,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$ForgejoUser,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$ForgejoPW,
|
||||
|
||||
[switch]$ClearCredentials,
|
||||
|
||||
[switch]$SkipExe,
|
||||
[switch]$DryRun,
|
||||
|
||||
[string]$ForgejoUrl = "https://git.him-tools.de",
|
||||
[string]$Repo = "HIM-public/webdrop-bridge",
|
||||
[string]$ExePath = "build\dist\windows\WebDropBridge\WebDropBridge.exe",
|
||||
[string]$ChecksumPath = "build\dist\windows\WebDropBridge\WebDropBridge.exe.sha256"
|
||||
[string]$Repo = "HIM-public/webdrop-bridge"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Get project root (PSScriptRoot is build/scripts, go up to project root with ..\..)
|
||||
$projectRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..")
|
||||
|
||||
# Resolve file paths relative to project root
|
||||
$ExePath = Join-Path $projectRoot $ExePath
|
||||
$ChecksumPath = Join-Path $projectRoot $ChecksumPath
|
||||
$MsiPath = Join-Path $projectRoot $MsiPath
|
||||
|
||||
# Function to read version from .env or .env.example
|
||||
function Get-VersionFromEnv {
|
||||
# Use already resolved project root
|
||||
|
||||
# Try .env first (runtime config), then .env.example (template)
|
||||
$envFile = Join-Path $projectRoot ".env"
|
||||
$envExampleFile = Join-Path $projectRoot ".env.example"
|
||||
|
||||
# Check .env first
|
||||
if (Test-Path $envFile) {
|
||||
$content = Get-Content $envFile -Raw
|
||||
if ($content -match 'APP_VERSION=([^\r\n]+)') {
|
||||
Write-Host "Version read from .env" -ForegroundColor Gray
|
||||
return $matches[1].Trim()
|
||||
}
|
||||
}
|
||||
|
||||
# Fall back to .env.example
|
||||
if (Test-Path $envExampleFile) {
|
||||
$content = Get-Content $envExampleFile -Raw
|
||||
if ($content -match 'APP_VERSION=([^\r\n]+)') {
|
||||
Write-Host "Version read from .env.example" -ForegroundColor Gray
|
||||
return $matches[1].Trim()
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "ERROR: Could not find APP_VERSION in .env or .env.example" -ForegroundColor Red
|
||||
exit 1
|
||||
$pythonExe = Join-Path $projectRoot ".venv\Scripts\python.exe"
|
||||
if (-not (Test-Path $pythonExe)) {
|
||||
$pythonExe = "python"
|
||||
}
|
||||
|
||||
$brandHelper = Join-Path $projectRoot "build\scripts\brand_config.py"
|
||||
$manifestOutput = Join-Path $projectRoot "build\dist\release-manifest.json"
|
||||
$localManifestPath = Join-Path $projectRoot "build\dist\release-manifest.local.json"
|
||||
$existingManifestPath = Join-Path $projectRoot "build\dist\release-manifest.existing.json"
|
||||
|
||||
function Get-CurrentVersion {
|
||||
return (& $pythonExe -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$projectRoot/build/scripts').resolve())); from version_utils import get_current_version; print(get_current_version())").Trim()
|
||||
}
|
||||
|
||||
function Get-LocalReleaseData {
|
||||
$arguments = @($brandHelper, "local-release-data", "--platform", "windows", "--version", $Version)
|
||||
if ($Brands) {
|
||||
$arguments += "--brands"
|
||||
$arguments += $Brands
|
||||
}
|
||||
return (& $pythonExe @arguments | ConvertFrom-Json)
|
||||
}
|
||||
|
||||
function Get-AssetMap {
|
||||
param([object[]]$Assets)
|
||||
|
||||
$map = @{}
|
||||
foreach ($asset in ($Assets | Where-Object { $_ })) {
|
||||
$map[$asset.name] = $asset
|
||||
}
|
||||
return $map
|
||||
}
|
||||
|
||||
# Handle --ClearCredentials flag
|
||||
if ($ClearCredentials) {
|
||||
Remove-Item env:FORGEJO_USER -ErrorAction SilentlyContinue
|
||||
Remove-Item env:FORGEJO_PASS -ErrorAction SilentlyContinue
|
||||
|
|
@ -72,191 +60,228 @@ if ($ClearCredentials) {
|
|||
exit 0
|
||||
}
|
||||
|
||||
# Get credentials from sources (in order of priority)
|
||||
if (-not $Version) {
|
||||
$Version = Get-CurrentVersion
|
||||
}
|
||||
|
||||
$localData = Get-LocalReleaseData
|
||||
$artifactPaths = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
foreach ($artifact in $localData.artifacts) {
|
||||
$artifactPaths.Add([string]$artifact)
|
||||
if ((Test-Path $artifact) -and ((Get-Item $artifact).Extension -eq ".msi")) {
|
||||
$msiSize = (Get-Item $artifact).Length / 1MB
|
||||
Write-Host "Windows artifact: $([System.IO.Path]::GetFileName($artifact)) ($([math]::Round($msiSize, 2)) MB)"
|
||||
}
|
||||
}
|
||||
|
||||
if ($artifactPaths.Count -eq 0) {
|
||||
Write-Host "ERROR: No local Windows artifacts found" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
$localManifestJson = $localData.manifest | ConvertTo-Json -Depth 10
|
||||
[System.IO.File]::WriteAllText($localManifestPath, $localManifestJson, (New-Object System.Text.UTF8Encoding($false)))
|
||||
|
||||
if ($DryRun) {
|
||||
Copy-Item $localManifestPath $manifestOutput -Force
|
||||
$brandsText = if ($localData.brands.Count -gt 0) { $localData.brands -join ", " } else { "<none>" }
|
||||
|
||||
Write-Host "[DRY RUN] No network requests or uploads will be performed." -ForegroundColor Yellow
|
||||
Write-Host "[DRY RUN] Release tag: v$Version"
|
||||
Write-Host "[DRY RUN] Release URL: $ForgejoUrl/$Repo/releases/tag/v$Version"
|
||||
Write-Host "[DRY RUN] Discovered brands: $brandsText"
|
||||
Write-Host "[DRY RUN] Artifacts that would be uploaded:"
|
||||
foreach ($artifact in $artifactPaths) {
|
||||
Write-Host " - $artifact"
|
||||
}
|
||||
Write-Host "[DRY RUN] Local manifest preview: $manifestOutput"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not $ForgejoUser) {
|
||||
$ForgejoUser = $env:FORGEJO_USER
|
||||
}
|
||||
|
||||
if (-not $ForgejoPW) {
|
||||
$ForgejoPW = $env:FORGEJO_PASS
|
||||
}
|
||||
|
||||
# If still no credentials, prompt user interactively
|
||||
if (-not $ForgejoUser -or -not $ForgejoPW) {
|
||||
Write-Host "Forgejo credentials not found. Enter your credentials:" -ForegroundColor Yellow
|
||||
|
||||
if (-not $ForgejoUser) {
|
||||
$ForgejoUser = Read-Host "Username"
|
||||
}
|
||||
|
||||
if (-not $ForgejoPW) {
|
||||
$securePass = Read-Host "Password" -AsSecureString
|
||||
$ForgejoPW = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToCoTaskMemUnicode($securePass))
|
||||
}
|
||||
|
||||
# Save credentials to environment for this session
|
||||
$env:FORGEJO_USER = $ForgejoUser
|
||||
$env:FORGEJO_PASS = $ForgejoPW
|
||||
Write-Host "[OK] Credentials saved to this PowerShell session" -ForegroundColor Green
|
||||
Write-Host "Tip: Credentials will persist until you close PowerShell or run: .\create_release.ps1 -ClearCredentials" -ForegroundColor Gray
|
||||
}
|
||||
|
||||
# Verify Version parameter - if not provided, read from .env.example
|
||||
if (-not $Version) {
|
||||
Write-Host "Version not provided, reading from .env.example..." -ForegroundColor Cyan
|
||||
$Version = Get-VersionFromEnv
|
||||
Write-Host "Using version: $Version" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# Define MSI path with resolved version
|
||||
$MsiPath = Join-Path $projectRoot "build\dist\windows\WebDropBridge-$Version-Setup.msi"
|
||||
|
||||
# Verify files exist (exe/checksum optional, MSI required)
|
||||
if (-not $SkipExe) {
|
||||
if (-not (Test-Path $ExePath)) {
|
||||
Write-Host "WARNING: Executable not found at $ExePath" -ForegroundColor Yellow
|
||||
Write-Host " Use -SkipExe flag to skip exe upload" -ForegroundColor Gray
|
||||
$SkipExe = $true
|
||||
}
|
||||
|
||||
if (-not $SkipExe -and -not (Test-Path $ChecksumPath)) {
|
||||
Write-Host "WARNING: Checksum file not found at $ChecksumPath" -ForegroundColor Yellow
|
||||
Write-Host " Exe will not be uploaded" -ForegroundColor Gray
|
||||
$SkipExe = $true
|
||||
}
|
||||
}
|
||||
|
||||
# MSI is the primary release artifact
|
||||
if (-not (Test-Path $MsiPath)) {
|
||||
Write-Host "ERROR: MSI installer not found at $MsiPath" -ForegroundColor Red
|
||||
Write-Host "Please build with MSI support:" -ForegroundColor Yellow
|
||||
Write-Host " python build\scripts\build_windows.py --msi" -ForegroundColor Cyan
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Creating WebDropBridge $Version release on Forgejo..." -ForegroundColor Cyan
|
||||
|
||||
# Get file info
|
||||
$msiSize = (Get-Item $MsiPath).Length / 1MB
|
||||
Write-Host "Primary Artifact: WebDropBridge-$Version-Setup.msi ($([math]::Round($msiSize, 2)) MB)"
|
||||
|
||||
if (-not $SkipExe) {
|
||||
$exeSize = (Get-Item $ExePath).Length / 1MB
|
||||
$checksum = Get-Content $ChecksumPath -Raw
|
||||
Write-Host "Optional Artifact: WebDropBridge.exe ($([math]::Round($exeSize, 2)) MB)"
|
||||
Write-Host " Checksum: $($checksum.Substring(0, 16))..."
|
||||
}
|
||||
|
||||
# Create basic auth header
|
||||
$auth = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes("${ForgejoUser}:${ForgejoPW}"))
|
||||
|
||||
$headers = @{
|
||||
"Authorization" = "Basic $auth"
|
||||
"Content-Type" = "application/json"
|
||||
}
|
||||
|
||||
# Step 1: Create release
|
||||
Write-Host "`nCreating release v$Version..." -ForegroundColor Yellow
|
||||
$releaseLookupUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/tags/v$Version"
|
||||
$releaseUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases"
|
||||
|
||||
# Build release body with checksum info if exe is being uploaded
|
||||
$releaseBody = "WebDropBridge v$Version`n`n**Release Artifacts:**`n- MSI Installer (Windows Setup)`n"
|
||||
if (-not $SkipExe) {
|
||||
$checksum = Get-Content $ChecksumPath -Raw
|
||||
$releaseBody += "- Portable Executable`n`n**Checksum:**`n$checksum`n"
|
||||
}
|
||||
|
||||
$releaseData = @{
|
||||
tag_name = "v$Version"
|
||||
name = "WebDropBridge v$Version"
|
||||
body = $releaseBody
|
||||
body = "Shared branded release for WebDrop Bridge v$Version"
|
||||
draft = $false
|
||||
prerelease = $false
|
||||
} | ConvertTo-Json
|
||||
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri $releaseUrl `
|
||||
-Method POST `
|
||||
-Headers $headers `
|
||||
-Body $releaseData `
|
||||
-TimeoutSec 30 `
|
||||
-UseBasicParsing `
|
||||
-ErrorAction Stop
|
||||
|
||||
$releaseInfo = $response.Content | ConvertFrom-Json
|
||||
$releaseInfo = Invoke-RestMethod -Uri $releaseLookupUrl -Method GET -Headers $headers -TimeoutSec 30 -ErrorAction Stop
|
||||
$releaseId = $releaseInfo.id
|
||||
Write-Host "[OK] Using existing release (ID: $releaseId)" -ForegroundColor Green
|
||||
}
|
||||
catch {
|
||||
$releaseInfo = Invoke-RestMethod -Uri $releaseUrl -Method POST -Headers $headers -Body $releaseData -TimeoutSec 30 -ErrorAction Stop
|
||||
$releaseId = $releaseInfo.id
|
||||
Write-Host "[OK] Release created (ID: $releaseId)" -ForegroundColor Green
|
||||
}
|
||||
catch {
|
||||
Write-Host "ERROR creating release: $_" -ForegroundColor Red
|
||||
exit 1
|
||||
|
||||
$assetMap = Get-AssetMap -Assets $releaseInfo.assets
|
||||
if ($assetMap.ContainsKey("release-manifest.json")) {
|
||||
Invoke-WebRequest -Uri $assetMap["release-manifest.json"].browser_download_url -Method GET -Headers $headers -TimeoutSec 30 -OutFile $existingManifestPath | Out-Null
|
||||
|
||||
& $pythonExe $brandHelper merge-manifests --base $existingManifestPath --overlay $localManifestPath --output $manifestOutput | Out-Null
|
||||
}
|
||||
else {
|
||||
Copy-Item $localManifestPath $manifestOutput -Force
|
||||
}
|
||||
|
||||
# Setup curl authentication
|
||||
$curlAuth = "$ForgejoUser`:$ForgejoPW"
|
||||
$uploadUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/$releaseId/assets"
|
||||
# Ensure uploaded manifest is UTF-8 without BOM (for strict JSON parsers)
|
||||
if (Test-Path $manifestOutput) {
|
||||
$manifestText = Get-Content -Raw -Path $manifestOutput
|
||||
[System.IO.File]::WriteAllText($manifestOutput, $manifestText, (New-Object System.Text.UTF8Encoding($false)))
|
||||
}
|
||||
|
||||
# Step 2: Upload MSI installer as primary artifact
|
||||
Write-Host "`nUploading MSI installer (primary artifact)..." -ForegroundColor Yellow
|
||||
$artifactPaths.Add($manifestOutput)
|
||||
$assetMap = Get-AssetMap -Assets $releaseInfo.assets
|
||||
|
||||
$artifactsToUpload = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($artifact in $artifactPaths) {
|
||||
$assetName = [System.IO.Path]::GetFileName($artifact)
|
||||
$extension = [System.IO.Path]::GetExtension($artifact).ToLowerInvariant()
|
||||
|
||||
if ($extension -eq ".msi" -and $assetMap.ContainsKey($assetName)) {
|
||||
$localSize = (Get-Item $artifact).Length
|
||||
$remoteSize = [int64]$assetMap[$assetName].size
|
||||
if ($localSize -eq $remoteSize) {
|
||||
Write-Host "[OK] Skipping already uploaded MSI $assetName ($([math]::Round($localSize / 1MB, 2)) MB)" -ForegroundColor Cyan
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
$artifactsToUpload.Add($artifact)
|
||||
}
|
||||
|
||||
if ($artifactsToUpload.Count -eq 0) {
|
||||
Write-Host "[OK] All release assets already uploaded." -ForegroundColor Green
|
||||
Write-Host "View at: $ForgejoUrl/$Repo/releases/tag/v$Version" -ForegroundColor Cyan
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Use Python requests library for more reliable large file uploads
|
||||
$pythonUploadScript = @"
|
||||
import sys
|
||||
import requests
|
||||
from requests.auth import HTTPBasicAuth
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
upload_url = sys.argv[1]
|
||||
artifacts = sys.argv[2:]
|
||||
username = '$ForgejoUser'
|
||||
password = '$ForgejoPW'
|
||||
delete_url_template = '${ForgejoUrl}/api/v1/repos/${Repo}/releases/$releaseId/assets/{}'
|
||||
release_info_url = '${ForgejoUrl}/api/v1/repos/${Repo}/releases/$releaseId'
|
||||
|
||||
session = requests.Session()
|
||||
session.auth = HTTPBasicAuth(username, password)
|
||||
session.headers.update({'Connection': 'close'})
|
||||
|
||||
def upload_with_retry(artifact_path, max_retries=3):
|
||||
asset_name = Path(artifact_path).name
|
||||
|
||||
# Check if asset already exists and delete it
|
||||
try:
|
||||
release_response = session.get(release_info_url, timeout=30)
|
||||
release_response.raise_for_status()
|
||||
for asset in release_response.json().get('assets', []):
|
||||
if asset['name'] == asset_name:
|
||||
delete_resp = session.delete(delete_url_template.format(asset['id']), timeout=30)
|
||||
delete_resp.raise_for_status()
|
||||
print(f'[OK] Replaced existing asset {asset_name}', file=sys.stderr)
|
||||
break
|
||||
except Exception as e:
|
||||
print(f'Warning checking existing assets: {e}', file=sys.stderr)
|
||||
|
||||
# Upload file with streaming and retries
|
||||
retryable_status_codes = {429, 502, 503, 504}
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
if attempt > 0:
|
||||
print(f' Retry {attempt} of {max_retries}...', file=sys.stderr)
|
||||
time.sleep(min(10, 2 * attempt))
|
||||
|
||||
with open(artifact_path, 'rb') as f:
|
||||
files = {'attachment': (asset_name, f)}
|
||||
response = session.post(
|
||||
upload_url,
|
||||
files=files,
|
||||
timeout=900, # 15 minute timeout
|
||||
stream=False
|
||||
)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
print(f'[OK] Uploaded {asset_name}')
|
||||
return True
|
||||
|
||||
if response.status_code in retryable_status_codes:
|
||||
if attempt >= max_retries - 1:
|
||||
print(f'ERROR uploading {asset_name} (HTTP {response.status_code} after {max_retries} retries)')
|
||||
print(response.text)
|
||||
sys.exit(1)
|
||||
continue
|
||||
|
||||
print(f'ERROR uploading {asset_name} (HTTP {response.status_code})')
|
||||
print(response.text)
|
||||
sys.exit(1)
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:
|
||||
if attempt >= max_retries - 1:
|
||||
print(f'ERROR uploading {asset_name}: Connection failed after {max_retries} retries')
|
||||
print(str(e))
|
||||
sys.exit(1)
|
||||
time.sleep(min(10, 2 * (attempt + 1)))
|
||||
except Exception as e:
|
||||
print(f'ERROR uploading {asset_name}: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
for artifact_path in artifacts:
|
||||
upload_with_retry(artifact_path)
|
||||
|
||||
print(f'[OK] All files uploaded successfully')
|
||||
"@
|
||||
|
||||
$uploadScriptPath = ([System.IO.Path]::GetTempFileName() -replace 'tmp$', 'py')
|
||||
Set-Content -Path $uploadScriptPath -Value $pythonUploadScript -Encoding UTF8
|
||||
|
||||
try {
|
||||
$response = curl.exe -s -X POST `
|
||||
-u $curlAuth `
|
||||
-F "attachment=@$MsiPath" `
|
||||
$uploadUrl
|
||||
|
||||
if ($response -like "*error*" -or $response -like "*404*") {
|
||||
Write-Host "ERROR uploading MSI: $response" -ForegroundColor Red
|
||||
$uploadUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/$releaseId/assets"
|
||||
& $pythonExe $uploadScriptPath $uploadUrl @artifactsToUpload
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "[OK] MSI installer uploaded" -ForegroundColor Green
|
||||
}
|
||||
catch {
|
||||
Write-Host "ERROR uploading MSI: $_" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Step 3: Upload executable as optional artifact (if available)
|
||||
if (-not $SkipExe) {
|
||||
Write-Host "`nUploading executable (optional portable version)..." -ForegroundColor Yellow
|
||||
|
||||
try {
|
||||
$response = curl.exe -s -X POST `
|
||||
-u $curlAuth `
|
||||
-F "attachment=@$ExePath" `
|
||||
$uploadUrl
|
||||
|
||||
if ($response -like "*error*" -or $response -like "*404*") {
|
||||
Write-Host "WARNING: Could not upload executable: $response" -ForegroundColor Yellow
|
||||
}
|
||||
else {
|
||||
Write-Host "[OK] Executable uploaded" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Host "WARNING: Could not upload executable: $_" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Step 4: Upload checksum as asset
|
||||
Write-Host "Uploading checksum..." -ForegroundColor Yellow
|
||||
|
||||
try {
|
||||
$response = curl.exe -s -X POST `
|
||||
-u $curlAuth `
|
||||
-F "attachment=@$ChecksumPath" `
|
||||
$uploadUrl
|
||||
|
||||
if ($response -like "*error*" -or $response -like "*404*") {
|
||||
Write-Host "WARNING: Could not upload checksum: $response" -ForegroundColor Yellow
|
||||
}
|
||||
else {
|
||||
Write-Host "[OK] Checksum uploaded" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Host "WARNING: Could not upload checksum: $_" -ForegroundColor Yellow
|
||||
}
|
||||
finally {
|
||||
Remove-Item $uploadScriptPath -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Host "`n[OK] Release complete!" -ForegroundColor Green
|
||||
|
|
|
|||
|
|
@ -1,31 +1,34 @@
|
|||
#!/bin/bash
|
||||
# Create Forgejo Release with Binary Assets
|
||||
# Usage: ./create_release.sh -v 1.0.0
|
||||
# Uses your Forgejo credentials (same as git)
|
||||
# First run will prompt for credentials and save them to this session
|
||||
# Create or update a shared Forgejo release with branded macOS assets.
|
||||
|
||||
set -e
|
||||
|
||||
# Parse arguments
|
||||
VERSION=""
|
||||
FORGEJO_USER=""
|
||||
FORGEJO_PASS=""
|
||||
BRANDS=()
|
||||
FORGEJO_USER="${FORGEJO_USER}"
|
||||
FORGEJO_PASS="${FORGEJO_PASS}"
|
||||
FORGEJO_URL="https://git.him-tools.de"
|
||||
REPO="HIM-public/webdrop-bridge"
|
||||
DMG_PATH="build/dist/macos/WebDropBridge.dmg"
|
||||
CHECKSUM_PATH="build/dist/macos/WebDropBridge.dmg.sha256"
|
||||
CLEAR_CREDS=false
|
||||
DRY_RUN=false
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
BRAND_HELPER="$PROJECT_ROOT/build/scripts/brand_config.py"
|
||||
MANIFEST_OUTPUT="$PROJECT_ROOT/build/dist/release-manifest.json"
|
||||
LOCAL_MANIFEST_OUTPUT="$PROJECT_ROOT/build/dist/release-manifest.local.json"
|
||||
EXISTING_MANIFEST_OUTPUT="$PROJECT_ROOT/build/dist/release-manifest.existing.json"
|
||||
LOCAL_DATA_OUTPUT="$PROJECT_ROOT/build/dist/release-data.local.json"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-v|--version) VERSION="$2"; shift 2;;
|
||||
-u|--url) FORGEJO_URL="$2"; shift 2;;
|
||||
--clear-credentials) CLEAR_CREDS=true; shift;;
|
||||
*) echo "Unknown option: $1"; exit 1;;
|
||||
-v|--version) VERSION="$2"; shift 2 ;;
|
||||
-u|--url) FORGEJO_URL="$2"; shift 2 ;;
|
||||
--brand) BRANDS+=("$2"); shift 2 ;;
|
||||
--clear-credentials) CLEAR_CREDS=true; shift ;;
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
*) echo "Unknown option: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Handle --clear-credentials flag
|
||||
if [ "$CLEAR_CREDS" = true ]; then
|
||||
unset FORGEJO_USER
|
||||
unset FORGEJO_PASS
|
||||
|
|
@ -33,127 +36,193 @@ if [ "$CLEAR_CREDS" = true ]; then
|
|||
exit 0
|
||||
fi
|
||||
|
||||
# Load credentials from environment
|
||||
FORGEJO_USER="${FORGEJO_USER}"
|
||||
FORGEJO_PASS="${FORGEJO_PASS}"
|
||||
|
||||
# Verify required parameters
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "ERROR: Version parameter required" >&2
|
||||
echo "Usage: $0 -v VERSION [-u FORGEJO_URL]" >&2
|
||||
echo "Example: $0 -v 1.0.0" >&2
|
||||
VERSION="$(python3 -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$PROJECT_ROOT/build/scripts').resolve())); from version_utils import get_current_version; print(get_current_version())")"
|
||||
fi
|
||||
|
||||
LOCAL_ARGS=("$BRAND_HELPER" "local-release-data" "--platform" "macos" "--version" "$VERSION")
|
||||
if [ ${#BRANDS[@]} -gt 0 ]; then
|
||||
LOCAL_ARGS+=("--brands" "${BRANDS[@]}")
|
||||
fi
|
||||
|
||||
python3 "${LOCAL_ARGS[@]}" > "$LOCAL_DATA_OUTPUT"
|
||||
|
||||
mapfile -t ARTIFACTS < <(python3 - "$LOCAL_DATA_OUTPUT" "$LOCAL_MANIFEST_OUTPUT" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
Path(sys.argv[2]).write_text(json.dumps(data["manifest"], indent=2), encoding="utf-8")
|
||||
for artifact in data["artifacts"]:
|
||||
print(artifact)
|
||||
PY
|
||||
)
|
||||
|
||||
for ARTIFACT in "${ARTIFACTS[@]}"; do
|
||||
if [ -f "$ARTIFACT" ] && [ "${ARTIFACT##*.}" = "dmg" ]; then
|
||||
DMG_SIZE=$(du -m "$ARTIFACT" | cut -f1)
|
||||
echo "macOS artifact: $(basename "$ARTIFACT") ($DMG_SIZE MB)"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#ARTIFACTS[@]} -eq 0 ]; then
|
||||
echo "ERROR: No local macOS artifacts found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# If no credentials, prompt user interactively
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
cp "$LOCAL_MANIFEST_OUTPUT" "$MANIFEST_OUTPUT"
|
||||
DISCOVERED_BRANDS=$(python3 - "$LOCAL_DATA_OUTPUT" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
print(", ".join(data.get("brands", [])) or "<none>")
|
||||
PY
|
||||
)
|
||||
|
||||
echo "[DRY RUN] No network requests or uploads will be performed."
|
||||
echo "[DRY RUN] Release tag: v$VERSION"
|
||||
echo "[DRY RUN] Release URL: $FORGEJO_URL/$REPO/releases/tag/v$VERSION"
|
||||
echo "[DRY RUN] Discovered brands: $DISCOVERED_BRANDS"
|
||||
echo "[DRY RUN] Artifacts that would be uploaded:"
|
||||
for ARTIFACT in "${ARTIFACTS[@]}"; do
|
||||
echo " - $ARTIFACT"
|
||||
done
|
||||
echo "[DRY RUN] Local manifest preview: $MANIFEST_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "$FORGEJO_USER" ] || [ -z "$FORGEJO_PASS" ]; then
|
||||
echo "Forgejo credentials not found. Enter your credentials:"
|
||||
|
||||
if [ -z "$FORGEJO_USER" ]; then
|
||||
read -p "Username: " FORGEJO_USER
|
||||
read -r -p "Username: " FORGEJO_USER
|
||||
fi
|
||||
|
||||
if [ -z "$FORGEJO_PASS" ]; then
|
||||
read -sp "Password: " FORGEJO_PASS
|
||||
read -r -s -p "Password: " FORGEJO_PASS
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Export for this session
|
||||
export FORGEJO_USER
|
||||
export FORGEJO_PASS
|
||||
echo "[OK] Credentials saved to this shell session"
|
||||
echo "Tip: Credentials will persist until you close the terminal or run: $0 --clear-credentials"
|
||||
fi
|
||||
|
||||
# Verify files exist
|
||||
if [ ! -f "$DMG_PATH" ]; then
|
||||
echo "ERROR: DMG file not found at $DMG_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$CHECKSUM_PATH" ]; then
|
||||
echo "ERROR: Checksum file not found at $CHECKSUM_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Creating WebDropBridge $VERSION release on Forgejo..."
|
||||
|
||||
# Get file info
|
||||
DMG_SIZE=$(du -m "$DMG_PATH" | cut -f1)
|
||||
CHECKSUM=$(cat "$CHECKSUM_PATH")
|
||||
|
||||
echo "File: WebDropBridge.dmg ($DMG_SIZE MB)"
|
||||
echo "Checksum: ${CHECKSUM:0:16}..."
|
||||
|
||||
# Create basic auth
|
||||
BASIC_AUTH=$(echo -n "${FORGEJO_USER}:${FORGEJO_PASS}" | base64)
|
||||
|
||||
# Step 1: Create release
|
||||
echo ""
|
||||
echo "Creating release v$VERSION..."
|
||||
RELEASE_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases"
|
||||
RELEASE_LOOKUP_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases/tags/v$VERSION"
|
||||
|
||||
RELEASE_DATA=$(cat <<EOF
|
||||
RELEASE_RESPONSE_FILE=$(mktemp)
|
||||
HTTP_CODE=$(curl -s -o "$RELEASE_RESPONSE_FILE" -w "%{http_code}" -H "Authorization: Basic $BASIC_AUTH" "$RELEASE_LOOKUP_URL")
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
RELEASE_ID=$(python3 - "$RELEASE_RESPONSE_FILE" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
print(payload.get("id", ""))
|
||||
PY
|
||||
)
|
||||
else
|
||||
RELEASE_ID=""
|
||||
fi
|
||||
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
RELEASE_DATA=$(cat <<EOF
|
||||
{
|
||||
"tag_name": "v$VERSION",
|
||||
"name": "WebDropBridge v$VERSION",
|
||||
"body": "WebDropBridge v$VERSION\n\nChecksum: $CHECKSUM",
|
||||
"body": "Shared branded release for WebDrop Bridge v$VERSION",
|
||||
"draft": false,
|
||||
"prerelease": false
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
RESPONSE=$(curl -s -X POST \
|
||||
HTTP_CODE=$(curl -s -o "$RELEASE_RESPONSE_FILE" -w "%{http_code}" -X POST \
|
||||
-H "Authorization: Basic $BASIC_AUTH" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$RELEASE_DATA" \
|
||||
"$RELEASE_URL")
|
||||
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "201" ]; then
|
||||
RELEASE_ID=$(python3 - "$RELEASE_RESPONSE_FILE" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
|
||||
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
print(payload.get("id", ""))
|
||||
PY
|
||||
)
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "ERROR creating release:"
|
||||
echo "$RESPONSE"
|
||||
echo "ERROR creating or finding release"
|
||||
cat "$RELEASE_RESPONSE_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[OK] Release created (ID: $RELEASE_ID)"
|
||||
MANIFEST_URL=$(python3 - "$RELEASE_RESPONSE_FILE" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
for asset in payload.get("assets", []):
|
||||
if asset.get("name") == "release-manifest.json":
|
||||
print(asset.get("browser_download_url", ""))
|
||||
break
|
||||
PY
|
||||
)
|
||||
|
||||
if [ -n "$MANIFEST_URL" ]; then
|
||||
curl -s -H "Authorization: Basic $BASIC_AUTH" "$MANIFEST_URL" -o "$EXISTING_MANIFEST_OUTPUT"
|
||||
python3 "$BRAND_HELPER" merge-manifests --base "$EXISTING_MANIFEST_OUTPUT" --overlay "$LOCAL_MANIFEST_OUTPUT" --output "$MANIFEST_OUTPUT" >/dev/null
|
||||
else
|
||||
cp "$LOCAL_MANIFEST_OUTPUT" "$MANIFEST_OUTPUT"
|
||||
fi
|
||||
|
||||
ARTIFACTS+=("$MANIFEST_OUTPUT")
|
||||
|
||||
# Step 2: Upload DMG as asset
|
||||
echo "Uploading executable asset..."
|
||||
UPLOAD_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets"
|
||||
for ARTIFACT in "${ARTIFACTS[@]}"; do
|
||||
ASSET_NAME="$(basename "$ARTIFACT")"
|
||||
EXISTING_ASSET_ID=$(python3 - "$RELEASE_RESPONSE_FILE" "$ASSET_NAME" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HTTP_CODE=$(curl -s -w "%{http_code}" -X POST \
|
||||
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
asset_name = sys.argv[2]
|
||||
for asset in payload.get("assets", []):
|
||||
if asset.get("name") == asset_name:
|
||||
print(asset.get("id", ""))
|
||||
break
|
||||
PY
|
||||
)
|
||||
|
||||
if [ -n "$EXISTING_ASSET_ID" ]; then
|
||||
curl -s -X DELETE \
|
||||
-H "Authorization: Basic $BASIC_AUTH" \
|
||||
-F "attachment=@$DMG_PATH" \
|
||||
"$FORGEJO_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets/$EXISTING_ASSET_ID" >/dev/null
|
||||
echo "[OK] Replaced existing asset $ASSET_NAME"
|
||||
fi
|
||||
|
||||
HTTP_CODE=$(curl -s -w "%{http_code}" -X POST \
|
||||
-H "Authorization: Basic $BASIC_AUTH" \
|
||||
-F "attachment=@$ARTIFACT" \
|
||||
"$UPLOAD_URL" \
|
||||
-o /tmp/curl_response.txt)
|
||||
|
||||
if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 200 ]; then
|
||||
echo "[OK] DMG uploaded"
|
||||
else
|
||||
echo "ERROR uploading DMG (HTTP $HTTP_CODE)"
|
||||
if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 200 ]; then
|
||||
echo "[OK] Uploaded $ASSET_NAME"
|
||||
else
|
||||
echo "ERROR uploading $ASSET_NAME (HTTP $HTTP_CODE)"
|
||||
cat /tmp/curl_response.txt
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 3: Upload checksum as asset
|
||||
echo "Uploading checksum asset..."
|
||||
|
||||
HTTP_CODE=$(curl -s -w "%{http_code}" -X POST \
|
||||
-H "Authorization: Basic $BASIC_AUTH" \
|
||||
-F "attachment=@$CHECKSUM_PATH" \
|
||||
"$UPLOAD_URL" \
|
||||
-o /tmp/curl_response.txt)
|
||||
|
||||
if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 200 ]; then
|
||||
echo "[OK] Checksum uploaded"
|
||||
else
|
||||
echo "ERROR uploading checksum (HTTP $HTTP_CODE)"
|
||||
cat /tmp/curl_response.txt
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "[OK] Release complete!"
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
WebDropBridge.wxs
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
{
|
||||
"app_name": "WebDrop Bridge",
|
||||
"brand_id": "agravity",
|
||||
"config_dir_name": "agravity_bridge",
|
||||
"app_name": "Agravity Bridge",
|
||||
"webapp_url": "https://dev.agravity.io/",
|
||||
"update_base_url": "https://git.him-tools.de",
|
||||
"update_repo": "HIM-public/webdrop-bridge",
|
||||
"update_channel": "stable",
|
||||
"update_manifest_name": "release-manifest.json",
|
||||
"url_mappings": [
|
||||
{
|
||||
"url_prefix": "https://devagravitystg.file.core.windows.net/devagravitysync/",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
# Architecture Guide
|
||||
|
||||
## Related Docs
|
||||
|
||||
- [Translations Guide (i18n)](TRANSLATIONS_GUIDE.md)
|
||||
|
||||
## High-Level Design
|
||||
|
||||
```
|
||||
|
|
|
|||
488
docs/BRANDING_AND_RELEASES.md
Normal file
488
docs/BRANDING_AND_RELEASES.md
Normal 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
205
docs/TRANSLATIONS_GUIDE.md
Normal 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
|
||||
172
resources/translations/de.json
Normal file
172
resources/translations/de.json
Normal 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}"
|
||||
}
|
||||
172
resources/translations/en.json
Normal file
172
resources/translations/en.json
Normal 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}"
|
||||
}
|
||||
172
resources/translations/fr.json
Normal file
172
resources/translations/fr.json
Normal 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}"
|
||||
}
|
||||
172
resources/translations/it.json
Normal file
172
resources/translations/it.json
Normal 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}"
|
||||
}
|
||||
172
resources/translations/ru.json
Normal file
172
resources/translations/ru.json
Normal 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}"
|
||||
}
|
||||
172
resources/translations/zh.json
Normal file
172
resources/translations/zh.json
Normal 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}"
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"""WebDrop Bridge - Qt-based desktop application for intelligent drag-and-drop file handling."""
|
||||
|
||||
__version__ = "0.8.3"
|
||||
__version__ = "0.8.6"
|
||||
__author__ = "WebDrop Team"
|
||||
__license__ = "MIT"
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
|
@ -11,6 +12,13 @@ from dotenv import load_dotenv
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_BRAND_ID = "webdrop_bridge"
|
||||
DEFAULT_CONFIG_DIR_NAME = "webdrop_bridge"
|
||||
DEFAULT_UPDATE_BASE_URL = "https://git.him-tools.de"
|
||||
DEFAULT_UPDATE_REPO = "HIM-public/webdrop-bridge"
|
||||
DEFAULT_UPDATE_CHANNEL = "stable"
|
||||
DEFAULT_UPDATE_MANIFEST_NAME = "release-manifest.json"
|
||||
|
||||
|
||||
class ConfigurationError(Exception):
|
||||
"""Raised when configuration is invalid."""
|
||||
|
|
@ -60,6 +68,12 @@ class Config:
|
|||
enable_logging: Whether to write logs to file
|
||||
enable_checkout: Whether to check asset checkout status and show checkout dialog
|
||||
on drag. Disabled by default as checkout support is optional.
|
||||
brand_id: Stable brand identifier used for packaging and update selection
|
||||
config_dir_name: AppData/config directory name for this branded variant
|
||||
update_base_url: Base Forgejo URL used for release checks
|
||||
update_repo: Forgejo repository containing shared releases
|
||||
update_channel: Update channel name used by release manifest selection
|
||||
update_manifest_name: Asset name of the shared release manifest
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If configuration values are invalid
|
||||
|
|
@ -81,6 +95,13 @@ class Config:
|
|||
window_title: str = ""
|
||||
enable_logging: bool = True
|
||||
enable_checkout: bool = False
|
||||
language: str = "auto"
|
||||
brand_id: str = DEFAULT_BRAND_ID
|
||||
config_dir_name: str = DEFAULT_CONFIG_DIR_NAME
|
||||
update_base_url: str = DEFAULT_UPDATE_BASE_URL
|
||||
update_repo: str = DEFAULT_UPDATE_REPO
|
||||
update_channel: str = DEFAULT_UPDATE_CHANNEL
|
||||
update_manifest_name: str = DEFAULT_UPDATE_MANIFEST_NAME
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, config_path: Path) -> "Config":
|
||||
|
|
@ -123,6 +144,9 @@ class Config:
|
|||
elif not root.is_dir():
|
||||
raise ConfigurationError(f"Allowed root is not a directory: {root}")
|
||||
|
||||
brand_id = data.get("brand_id", DEFAULT_BRAND_ID)
|
||||
config_dir_name = data.get("config_dir_name", cls._slugify_config_dir_name(brand_id))
|
||||
|
||||
# Get log file path
|
||||
log_file = None
|
||||
if data.get("enable_logging", True):
|
||||
|
|
@ -131,10 +155,10 @@ class Config:
|
|||
log_file = Path(log_file_str)
|
||||
# If relative path, resolve relative to app data directory instead of cwd
|
||||
if not log_file.is_absolute():
|
||||
log_file = Config.get_default_log_dir() / log_file
|
||||
log_file = Config.get_default_log_dir(config_dir_name) / log_file
|
||||
else:
|
||||
# Use default log path in app data
|
||||
log_file = Config.get_default_log_path()
|
||||
log_file = Config.get_default_log_path(config_dir_name)
|
||||
|
||||
app_name = data.get("app_name", "WebDrop Bridge")
|
||||
stored_window_title = data.get("window_title", "")
|
||||
|
|
@ -172,6 +196,13 @@ class Config:
|
|||
window_title=window_title,
|
||||
enable_logging=data.get("enable_logging", True),
|
||||
enable_checkout=data.get("enable_checkout", False),
|
||||
language=data.get("language", "auto"),
|
||||
brand_id=brand_id,
|
||||
config_dir_name=config_dir_name,
|
||||
update_base_url=data.get("update_base_url", DEFAULT_UPDATE_BASE_URL),
|
||||
update_repo=data.get("update_repo", DEFAULT_UPDATE_REPO),
|
||||
update_channel=data.get("update_channel", DEFAULT_UPDATE_CHANNEL),
|
||||
update_manifest_name=data.get("update_manifest_name", DEFAULT_UPDATE_MANIFEST_NAME),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
|
@ -199,6 +230,8 @@ class Config:
|
|||
from webdrop_bridge import __version__
|
||||
|
||||
app_version = __version__
|
||||
brand_id = os.getenv("BRAND_ID", DEFAULT_BRAND_ID)
|
||||
config_dir_name = os.getenv("APP_CONFIG_DIR_NAME", cls._slugify_config_dir_name(brand_id))
|
||||
|
||||
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
log_file_str = os.getenv("LOG_FILE", None)
|
||||
|
|
@ -212,6 +245,11 @@ class Config:
|
|||
window_title = os.getenv("WINDOW_TITLE", default_title)
|
||||
enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true"
|
||||
enable_checkout = os.getenv("ENABLE_CHECKOUT", "false").lower() == "true"
|
||||
language = os.getenv("LANGUAGE", "auto")
|
||||
update_base_url = os.getenv("UPDATE_BASE_URL", DEFAULT_UPDATE_BASE_URL)
|
||||
update_repo = os.getenv("UPDATE_REPO", DEFAULT_UPDATE_REPO)
|
||||
update_channel = os.getenv("UPDATE_CHANNEL", DEFAULT_UPDATE_CHANNEL)
|
||||
update_manifest_name = os.getenv("UPDATE_MANIFEST_NAME", DEFAULT_UPDATE_MANIFEST_NAME)
|
||||
|
||||
# Validate log level
|
||||
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
||||
|
|
@ -251,10 +289,10 @@ class Config:
|
|||
log_file = Path(log_file_str)
|
||||
# If relative path, resolve relative to app data directory instead of cwd
|
||||
if not log_file.is_absolute():
|
||||
log_file = Config.get_default_log_dir() / log_file
|
||||
log_file = Config.get_default_log_dir(config_dir_name) / log_file
|
||||
else:
|
||||
# Use default log path in app data
|
||||
log_file = Config.get_default_log_path()
|
||||
log_file = Config.get_default_log_path(config_dir_name)
|
||||
|
||||
# Validate webapp URL is not empty
|
||||
if not webapp_url:
|
||||
|
|
@ -304,6 +342,13 @@ class Config:
|
|||
window_title=window_title,
|
||||
enable_logging=enable_logging,
|
||||
enable_checkout=enable_checkout,
|
||||
language=language,
|
||||
brand_id=brand_id,
|
||||
config_dir_name=config_dir_name,
|
||||
update_base_url=update_base_url,
|
||||
update_repo=update_repo,
|
||||
update_channel=update_channel,
|
||||
update_manifest_name=update_manifest_name,
|
||||
)
|
||||
|
||||
def to_file(self, config_path: Path) -> None:
|
||||
|
|
@ -332,6 +377,13 @@ class Config:
|
|||
"window_title": self.window_title,
|
||||
"enable_logging": self.enable_logging,
|
||||
"enable_checkout": self.enable_checkout,
|
||||
"language": self.language,
|
||||
"brand_id": self.brand_id,
|
||||
"config_dir_name": self.config_dir_name,
|
||||
"update_base_url": self.update_base_url,
|
||||
"update_repo": self.update_repo,
|
||||
"update_channel": self.update_channel,
|
||||
"update_manifest_name": self.update_manifest_name,
|
||||
}
|
||||
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -339,7 +391,57 @@ class Config:
|
|||
json.dump(data, f, indent=2)
|
||||
|
||||
@staticmethod
|
||||
def get_default_config_path() -> Path:
|
||||
def load_bootstrap_env(env_file: str | None = None) -> Path | None:
|
||||
"""Load a bootstrap .env before configuration path lookup.
|
||||
|
||||
This lets branded builds decide their config directory before the main
|
||||
config file is loaded.
|
||||
|
||||
Args:
|
||||
env_file: Optional explicit .env path
|
||||
|
||||
Returns:
|
||||
Path to the loaded .env file, or None if nothing was loaded
|
||||
"""
|
||||
candidate_paths: list[Path] = []
|
||||
if env_file:
|
||||
candidate_paths.append(Path(env_file).resolve())
|
||||
else:
|
||||
if getattr(sys, "frozen", False):
|
||||
exe_dir = Path(sys.executable).resolve().parent
|
||||
# One-folder fallback: some packagers place data files in _internal.
|
||||
candidate_paths.append(exe_dir / ".env")
|
||||
candidate_paths.append(exe_dir / "_internal" / ".env")
|
||||
|
||||
# PyInstaller runtime extraction directory (one-file and one-folder).
|
||||
meipass = getattr(sys, "_MEIPASS", None)
|
||||
if meipass:
|
||||
candidate_paths.append(Path(meipass).resolve() / ".env")
|
||||
|
||||
candidate_paths.append(Path.cwd() / ".env")
|
||||
candidate_paths.append(Path(__file__).resolve().parents[2] / ".env")
|
||||
|
||||
for path in candidate_paths:
|
||||
if path.exists():
|
||||
load_dotenv(path, override=False)
|
||||
logger.debug(f"Loaded bootstrap environment from {path}")
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _slugify_config_dir_name(value: str) -> str:
|
||||
"""Convert brand-like identifiers into a filesystem-safe directory name."""
|
||||
sanitized = "".join(c.lower() if c.isalnum() else "_" for c in value).strip("_")
|
||||
return sanitized or DEFAULT_CONFIG_DIR_NAME
|
||||
|
||||
@staticmethod
|
||||
def get_default_config_dir_name() -> str:
|
||||
"""Get the default config directory name from environment or fallback."""
|
||||
return os.getenv("APP_CONFIG_DIR_NAME", DEFAULT_CONFIG_DIR_NAME)
|
||||
|
||||
@staticmethod
|
||||
def get_default_config_path(config_dir_name: str | None = None) -> Path:
|
||||
"""Get the default configuration file path.
|
||||
|
||||
Returns:
|
||||
|
|
@ -351,10 +453,10 @@ class Config:
|
|||
base = Path.home() / "AppData" / "Roaming"
|
||||
else:
|
||||
base = Path.home() / ".config"
|
||||
return base / "webdrop_bridge" / "config.json"
|
||||
return base / (config_dir_name or Config.get_default_config_dir_name()) / "config.json"
|
||||
|
||||
@staticmethod
|
||||
def get_default_log_dir() -> Path:
|
||||
def get_default_log_dir(config_dir_name: str | None = None) -> Path:
|
||||
"""Get the default directory for log files.
|
||||
|
||||
Always uses user's AppData directory to ensure permissions work
|
||||
|
|
@ -369,21 +471,31 @@ class Config:
|
|||
base = Path.home() / "AppData" / "Roaming"
|
||||
else:
|
||||
base = Path.home() / ".local" / "share"
|
||||
return base / "webdrop_bridge" / "logs"
|
||||
return base / (config_dir_name or Config.get_default_config_dir_name()) / "logs"
|
||||
|
||||
@staticmethod
|
||||
def get_default_log_path() -> Path:
|
||||
def get_default_log_path(config_dir_name: str | None = None) -> Path:
|
||||
"""Get the default log file path.
|
||||
|
||||
Returns:
|
||||
Path to default log file in user's AppData/Roaming/webdrop_bridge/logs
|
||||
"""
|
||||
return Config.get_default_log_dir() / "webdrop_bridge.log"
|
||||
dir_name = config_dir_name or Config.get_default_config_dir_name()
|
||||
return Config.get_default_log_dir(dir_name) / f"{dir_name}.log"
|
||||
|
||||
def get_config_path(self) -> Path:
|
||||
"""Get the default config file path for this configured brand."""
|
||||
return self.get_default_config_path(self.config_dir_name)
|
||||
|
||||
def get_cache_dir(self) -> Path:
|
||||
"""Get the update/cache directory for this configured brand."""
|
||||
return self.get_default_config_path(self.config_dir_name).parent / "cache"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return developer-friendly representation."""
|
||||
return (
|
||||
f"Config(app={self.app_name} v{self.app_version}, "
|
||||
f"brand={self.brand_id}, "
|
||||
f"log_level={self.log_level}, "
|
||||
f"allowed_roots={len(self.allowed_roots)} dirs, "
|
||||
f"window={self.window_width}x{self.window_height})"
|
||||
|
|
|
|||
|
|
@ -101,14 +101,13 @@ class ConfigValidator:
|
|||
class ConfigProfile:
|
||||
"""Manages named configuration profiles.
|
||||
|
||||
Profiles are stored in ~/.webdrop_bridge/profiles/ directory as JSON files.
|
||||
Profiles are stored in the brand-specific app config directory.
|
||||
"""
|
||||
|
||||
PROFILES_DIR = Path.home() / ".webdrop_bridge" / "profiles"
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, config_dir_name: str = "webdrop_bridge") -> None:
|
||||
"""Initialize profile manager."""
|
||||
self.PROFILES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
self.profiles_dir = Config.get_default_config_path(config_dir_name).parent / "profiles"
|
||||
self.profiles_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def save_profile(self, profile_name: str, config: Config) -> Path:
|
||||
"""Save configuration as a named profile.
|
||||
|
|
@ -126,7 +125,7 @@ class ConfigProfile:
|
|||
if not profile_name or "/" in profile_name or "\\" in profile_name:
|
||||
raise ConfigurationError(f"Invalid profile name: {profile_name}")
|
||||
|
||||
profile_path = self.PROFILES_DIR / f"{profile_name}.json"
|
||||
profile_path = self.profiles_dir / f"{profile_name}.json"
|
||||
|
||||
config_data = {
|
||||
"app_name": config.app_name,
|
||||
|
|
@ -160,7 +159,7 @@ class ConfigProfile:
|
|||
Raises:
|
||||
ConfigurationError: If profile not found or invalid
|
||||
"""
|
||||
profile_path = self.PROFILES_DIR / f"{profile_name}.json"
|
||||
profile_path = self.profiles_dir / f"{profile_name}.json"
|
||||
|
||||
if not profile_path.exists():
|
||||
raise ConfigurationError(f"Profile not found: {profile_name}")
|
||||
|
|
@ -179,10 +178,10 @@ class ConfigProfile:
|
|||
Returns:
|
||||
List of profile names (without .json extension)
|
||||
"""
|
||||
if not self.PROFILES_DIR.exists():
|
||||
if not self.profiles_dir.exists():
|
||||
return []
|
||||
|
||||
return sorted([p.stem for p in self.PROFILES_DIR.glob("*.json")])
|
||||
return sorted([p.stem for p in self.profiles_dir.glob("*.json")])
|
||||
|
||||
def delete_profile(self, profile_name: str) -> None:
|
||||
"""Delete a profile.
|
||||
|
|
@ -193,7 +192,7 @@ class ConfigProfile:
|
|||
Raises:
|
||||
ConfigurationError: If profile not found
|
||||
"""
|
||||
profile_path = self.PROFILES_DIR / f"{profile_name}.json"
|
||||
profile_path = self.profiles_dir / f"{profile_name}.json"
|
||||
|
||||
if not profile_path.exists():
|
||||
raise ConfigurationError(f"Profile not found: {profile_name}")
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ verifying checksums from Forgejo releases.
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import fnmatch
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import platform
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
|
|
@ -34,7 +36,16 @@ class Release:
|
|||
class UpdateManager:
|
||||
"""Manages auto-updates via Forgejo releases API."""
|
||||
|
||||
def __init__(self, current_version: str, config_dir: Optional[Path] = None):
|
||||
def __init__(
|
||||
self,
|
||||
current_version: str,
|
||||
config_dir: Optional[Path] = None,
|
||||
brand_id: str = "webdrop_bridge",
|
||||
forgejo_url: str = "https://git.him-tools.de",
|
||||
repo: str = "HIM-public/webdrop-bridge",
|
||||
update_channel: str = "stable",
|
||||
manifest_name: str = "release-manifest.json",
|
||||
):
|
||||
"""Initialize update manager.
|
||||
|
||||
Args:
|
||||
|
|
@ -42,8 +53,11 @@ class UpdateManager:
|
|||
config_dir: Directory for storing update cache. Defaults to temp.
|
||||
"""
|
||||
self.current_version = current_version
|
||||
self.forgejo_url = "https://git.him-tools.de"
|
||||
self.repo = "HIM-public/webdrop-bridge"
|
||||
self.brand_id = brand_id
|
||||
self.forgejo_url = forgejo_url.rstrip("/")
|
||||
self.repo = repo
|
||||
self.update_channel = update_channel
|
||||
self.manifest_name = manifest_name
|
||||
self.api_endpoint = f"{self.forgejo_url}/api/v1/repos/{self.repo}/releases/latest"
|
||||
|
||||
# Cache management
|
||||
|
|
@ -52,6 +66,150 @@ class UpdateManager:
|
|||
self.cache_file = self.cache_dir / "update_check.json"
|
||||
self.cache_ttl = timedelta(hours=24)
|
||||
|
||||
def _get_platform_key(self) -> str:
|
||||
"""Return the release-manifest platform key for the current system."""
|
||||
system = platform.system()
|
||||
machine = platform.machine().lower()
|
||||
|
||||
if system == "Windows":
|
||||
arch = "x64" if machine in {"amd64", "x86_64"} else machine
|
||||
return f"windows-{arch}"
|
||||
if system == "Darwin":
|
||||
return "macos-universal"
|
||||
return f"{system.lower()}-{machine}"
|
||||
|
||||
def _find_asset(self, assets: list[dict], asset_name: str) -> Optional[dict]:
|
||||
"""Find an asset by exact name."""
|
||||
for asset in assets:
|
||||
if asset.get("name") == asset_name:
|
||||
return asset
|
||||
return None
|
||||
|
||||
def _find_manifest_asset(self, release: Release) -> Optional[dict]:
|
||||
"""Find the shared release manifest asset if present."""
|
||||
return self._find_asset(release.assets, self.manifest_name)
|
||||
|
||||
def _download_json_asset(self, url: str) -> Optional[dict]:
|
||||
"""Download and parse a JSON asset from a release."""
|
||||
try:
|
||||
with urlopen(url, timeout=10) as response:
|
||||
# Some release pipelines may upload JSON files with UTF-8 BOM.
|
||||
# Use utf-8-sig to transparently handle both BOM and non-BOM files.
|
||||
return json.loads(response.read().decode("utf-8-sig"))
|
||||
except (URLError, json.JSONDecodeError) as e:
|
||||
logger.error(f"Failed to download JSON asset: {e}")
|
||||
return None
|
||||
|
||||
async def _load_release_manifest(self, release: Release) -> Optional[dict]:
|
||||
"""Load the shared release manifest if present."""
|
||||
manifest_asset = self._find_manifest_asset(release)
|
||||
if not manifest_asset:
|
||||
return None
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
None, self._download_json_asset, manifest_asset["browser_download_url"]
|
||||
),
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
def _resolve_assets_from_manifest(
|
||||
self, release: Release, manifest: dict
|
||||
) -> tuple[Optional[dict], Optional[dict]]:
|
||||
"""Resolve installer and checksum assets from a shared release manifest."""
|
||||
if manifest.get("channel") not in {None, "", self.update_channel}:
|
||||
logger.info(
|
||||
"Release manifest channel %s does not match configured channel %s",
|
||||
manifest.get("channel"),
|
||||
self.update_channel,
|
||||
)
|
||||
return None, None
|
||||
|
||||
brand_entry = manifest.get("brands", {}).get(self.brand_id, {})
|
||||
platform_entry = brand_entry.get(self._get_platform_key(), {})
|
||||
installer_name = platform_entry.get("installer")
|
||||
checksum_name = platform_entry.get("checksum")
|
||||
|
||||
if not installer_name:
|
||||
logger.warning(
|
||||
"No installer entry found for brand=%s platform=%s in release manifest",
|
||||
self.brand_id,
|
||||
self._get_platform_key(),
|
||||
)
|
||||
return None, None
|
||||
|
||||
return self._find_asset(release.assets, installer_name), self._find_asset(
|
||||
release.assets, checksum_name
|
||||
)
|
||||
|
||||
def _resolve_assets_legacy(self, release: Release) -> tuple[Optional[dict], Optional[dict]]:
|
||||
"""Resolve installer and checksum assets using legacy filename matching."""
|
||||
is_windows = platform.system() == "Windows"
|
||||
extension = ".msi" if is_windows else ".dmg"
|
||||
brand_prefix = f"{self.brand_id}-*"
|
||||
|
||||
installer_asset = None
|
||||
|
||||
# Prefer brand-specific naming when possible.
|
||||
if self.brand_id == "webdrop_bridge":
|
||||
preferred_patterns = ["webdropbridge-*.msi", "webdropbridge*.msi"]
|
||||
else:
|
||||
preferred_patterns = [f"{self.brand_id.lower()}-*.msi", f"{self.brand_id.lower()}*.msi"]
|
||||
|
||||
# 1) Try strict brand-pattern match first
|
||||
for asset in release.assets:
|
||||
asset_name = asset.get("name", "")
|
||||
asset_name_lower = asset_name.lower()
|
||||
if not asset_name_lower.endswith(extension):
|
||||
continue
|
||||
if any(fnmatch.fnmatch(asset_name_lower, pattern) for pattern in preferred_patterns):
|
||||
installer_asset = asset
|
||||
break
|
||||
|
||||
# 2) Fallback: preserve previous behavior (first installer for platform)
|
||||
for asset in release.assets:
|
||||
if installer_asset:
|
||||
break
|
||||
asset_name = asset.get("name", "")
|
||||
if not asset_name.endswith(extension):
|
||||
continue
|
||||
|
||||
if self.brand_id != "webdrop_bridge" and fnmatch.fnmatch(
|
||||
asset_name.lower(), brand_prefix.lower()
|
||||
):
|
||||
installer_asset = asset
|
||||
break
|
||||
|
||||
if self.brand_id == "webdrop_bridge":
|
||||
installer_asset = asset
|
||||
break
|
||||
|
||||
if not installer_asset:
|
||||
return None, None
|
||||
|
||||
checksum_asset = self._find_asset(release.assets, f"{installer_asset['name']}.sha256")
|
||||
return installer_asset, checksum_asset
|
||||
|
||||
async def _resolve_release_assets(
|
||||
self, release: Release
|
||||
) -> tuple[Optional[dict], Optional[dict]]:
|
||||
"""Resolve installer and checksum assets for the configured brand."""
|
||||
try:
|
||||
manifest = await self._load_release_manifest(release)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"Timed out while loading release manifest, falling back to legacy lookup"
|
||||
)
|
||||
manifest = None
|
||||
|
||||
if manifest:
|
||||
installer_asset, checksum_asset = self._resolve_assets_from_manifest(release, manifest)
|
||||
if installer_asset:
|
||||
return installer_asset, checksum_asset
|
||||
|
||||
return self._resolve_assets_legacy(release)
|
||||
|
||||
def _parse_version(self, version_str: str) -> tuple[int, int, int]:
|
||||
"""Parse semantic version string to tuple.
|
||||
|
||||
|
|
@ -253,12 +411,7 @@ class UpdateManager:
|
|||
logger.error("No assets found in release")
|
||||
return None
|
||||
|
||||
# Find .msi or .dmg file
|
||||
installer_asset = None
|
||||
for asset in release.assets:
|
||||
if asset["name"].endswith((".msi", ".dmg")):
|
||||
installer_asset = asset
|
||||
break
|
||||
installer_asset, _ = await self._resolve_release_assets(release)
|
||||
|
||||
if not installer_asset:
|
||||
logger.error("No installer found in release assets")
|
||||
|
|
@ -345,14 +498,11 @@ class UpdateManager:
|
|||
Returns:
|
||||
True if checksum matches, False otherwise
|
||||
"""
|
||||
# Find .sha256 file matching the installer name (e.g. Setup.msi.sha256)
|
||||
# Fall back to any .sha256 only if no specific match exists
|
||||
installer_name = file_path.name
|
||||
checksum_asset = None
|
||||
for asset in release.assets:
|
||||
if asset["name"] == f"{installer_name}.sha256":
|
||||
checksum_asset = asset
|
||||
break
|
||||
installer_asset, checksum_asset = await self._resolve_release_assets(release)
|
||||
installer_name = installer_asset["name"] if installer_asset else file_path.name
|
||||
|
||||
if not checksum_asset:
|
||||
checksum_asset = self._find_asset(release.assets, f"{installer_name}.sha256")
|
||||
|
||||
if not checksum_asset:
|
||||
logger.warning("No checksum file found in release")
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ from PySide6.QtWidgets import QApplication
|
|||
|
||||
from webdrop_bridge.config import Config, ConfigurationError
|
||||
from webdrop_bridge.ui.main_window import MainWindow
|
||||
from webdrop_bridge.utils.i18n import get_translations_dir
|
||||
from webdrop_bridge.utils.i18n import initialize as i18n_init
|
||||
from webdrop_bridge.utils.logging import get_logger, setup_logging
|
||||
|
||||
|
||||
|
|
@ -28,6 +30,8 @@ def main() -> int:
|
|||
int: Exit code (0 for success, non-zero for error)
|
||||
"""
|
||||
try:
|
||||
Config.load_bootstrap_env()
|
||||
|
||||
# Load configuration from file if it exists, otherwise from environment
|
||||
config_path = Config.get_default_config_path()
|
||||
if config_path.exists():
|
||||
|
|
@ -50,6 +54,11 @@ def main() -> int:
|
|||
logger.info(f"Starting {config.app_name} v{config.app_version}")
|
||||
logger.debug(f"Configuration: {config}")
|
||||
|
||||
# Initialize internationalization
|
||||
translations_dir = get_translations_dir()
|
||||
i18n_init(config.language, translations_dir)
|
||||
logger.debug(f"i18n initialized: language={config.language}, dir={translations_dir}")
|
||||
|
||||
except ConfigurationError as e:
|
||||
print(f"Configuration error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
|
@ -43,6 +44,7 @@ from webdrop_bridge.config import Config
|
|||
from webdrop_bridge.core.drag_interceptor import DragInterceptor
|
||||
from webdrop_bridge.core.validator import PathValidator
|
||||
from webdrop_bridge.ui.restricted_web_view import RestrictedWebEngineView
|
||||
from webdrop_bridge.utils.i18n import tr
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -245,7 +247,7 @@ class OpenDropZone(QWidget):
|
|||
self._icon_label.setPixmap(pixmap)
|
||||
self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
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)
|
||||
|
||||
self.setMinimumSize(QSize(44, 44))
|
||||
|
|
@ -322,7 +324,7 @@ class OpenWithDropZone(OpenDropZone):
|
|||
parent: Parent widget.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self._icon_label.setToolTip("Drop a file here to choose which app should open it")
|
||||
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."""
|
||||
|
|
@ -985,8 +987,8 @@ class MainWindow(QMainWindow):
|
|||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Checkout Asset",
|
||||
f"Do you want to check out this asset?\n\n{filename}",
|
||||
tr("dialog.checkout.title"),
|
||||
tr("dialog.checkout.msg", filename=filename),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.Yes,
|
||||
)
|
||||
|
|
@ -1090,8 +1092,8 @@ class MainWindow(QMainWindow):
|
|||
# Show error dialog to user
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Drag-and-Drop Error",
|
||||
f"Could not complete the drag-and-drop operation.\n\nError: {error}",
|
||||
tr("dialog.drag_error.title"),
|
||||
tr("dialog.drag_error.msg", error=error),
|
||||
)
|
||||
|
||||
def _on_file_opened_via_drop(self, file_path: str) -> None:
|
||||
|
|
@ -1101,7 +1103,7 @@ class MainWindow(QMainWindow):
|
|||
file_path: Local file path that was opened.
|
||||
"""
|
||||
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:
|
||||
"""Handle a failure to open a file dropped on the OpenDropZone.
|
||||
|
|
@ -1113,9 +1115,8 @@ class MainWindow(QMainWindow):
|
|||
logger.warning(f"Failed to open via drop zone '{file_path}': {error}")
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Open File Error",
|
||||
f"Could not open the file with its default application.\n\n"
|
||||
f"File: {file_path}\nError: {error}",
|
||||
tr("dialog.open_file_error.title"),
|
||||
tr("dialog.open_file_error.msg", file_path=file_path, error=error),
|
||||
)
|
||||
|
||||
def _on_file_open_with_requested(self, file_path: str) -> None:
|
||||
|
|
@ -1125,15 +1126,15 @@ class MainWindow(QMainWindow):
|
|||
file_path: Local file path to open using an app chooser.
|
||||
"""
|
||||
if self._open_with_app_chooser(file_path):
|
||||
self.statusBar().showMessage(f"Choose app for: {Path(file_path).name}", 4000)
|
||||
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,
|
||||
"Open With Error",
|
||||
"Could not open an application chooser for this file on your platform.",
|
||||
tr("dialog.open_with_error.title"),
|
||||
tr("dialog.open_with_error.msg"),
|
||||
)
|
||||
|
||||
def _open_with_app_chooser(self, file_path: str) -> bool:
|
||||
|
|
@ -1234,7 +1235,7 @@ class MainWindow(QMainWindow):
|
|||
logger.info(f"Download started: {filename}")
|
||||
|
||||
# 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
|
||||
download.stateChanged.connect(
|
||||
|
|
@ -1248,7 +1249,7 @@ class MainWindow(QMainWindow):
|
|||
|
||||
except Exception as e:
|
||||
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:
|
||||
"""Handle download completion.
|
||||
|
|
@ -1266,13 +1267,17 @@ class MainWindow(QMainWindow):
|
|||
|
||||
if state == QWebEngineDownloadRequest.DownloadState.DownloadCompleted:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
logger.error(f"Error in download finished handler: {e}", exc_info=True)
|
||||
|
||||
|
|
@ -1382,33 +1387,34 @@ class MainWindow(QMainWindow):
|
|||
# Separator
|
||||
toolbar.addSeparator()
|
||||
|
||||
if hasattr(sys, "_MEIPASS"):
|
||||
icons_dir = Path(sys._MEIPASS) / "resources" / "icons" # type: ignore[attr-defined]
|
||||
else:
|
||||
icons_dir = Path(__file__).parent.parent.parent.parent / "resources" / "icons"
|
||||
|
||||
# Home button
|
||||
home_icon_path = icons_dir / "home.ico"
|
||||
home_icon_path = self._resolve_toolbar_icon_path(
|
||||
os.getenv("TOOLBAR_ICON_HOME", "resources/icons/home.ico")
|
||||
)
|
||||
home_icon = (
|
||||
QIcon(str(home_icon_path))
|
||||
if home_icon_path.exists()
|
||||
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("Home")
|
||||
home_action.setToolTip(tr("toolbar.tooltip.home"))
|
||||
home_action.triggered.connect(self._navigate_home)
|
||||
|
||||
# Refresh button
|
||||
refresh_action = self.web_view.pageAction(self.web_view.page().WebAction.Reload)
|
||||
reload_icon_path = icons_dir / "reload.ico"
|
||||
if reload_icon_path.exists():
|
||||
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)
|
||||
|
||||
# Open-with-default-app drop zone (right of Reload)
|
||||
self._open_drop_zone = OpenDropZone()
|
||||
open_icon_path = icons_dir / "open.ico"
|
||||
if open_icon_path.exists():
|
||||
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_open_failed.connect(self._on_file_open_failed_via_drop)
|
||||
|
|
@ -1418,8 +1424,10 @@ class MainWindow(QMainWindow):
|
|||
|
||||
# Open-with chooser drop zone (right of Open-with-default-app)
|
||||
self._open_with_drop_zone = OpenWithDropZone()
|
||||
open_with_icon_path = icons_dir / "openwith.ico"
|
||||
if open_with_icon_path.exists():
|
||||
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
|
||||
|
|
@ -1435,34 +1443,60 @@ class MainWindow(QMainWindow):
|
|||
|
||||
# About button (info icon) on the right
|
||||
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)
|
||||
|
||||
# Settings button on the right
|
||||
settings_action = toolbar.addAction("⚙️")
|
||||
settings_action.setToolTip("Settings")
|
||||
settings_action.setToolTip(tr("toolbar.tooltip.settings"))
|
||||
settings_action.triggered.connect(self._show_settings_dialog)
|
||||
|
||||
# Check for Updates button on the right
|
||||
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)
|
||||
|
||||
# Clear cache button on the right
|
||||
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)
|
||||
|
||||
# Log file button on the right
|
||||
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)
|
||||
|
||||
# Developer Tools button on the right
|
||||
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)
|
||||
|
||||
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:
|
||||
"""Open the application log file in the system default text editor.
|
||||
|
||||
|
|
@ -1486,8 +1520,8 @@ class MainWindow(QMainWindow):
|
|||
else:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Log File Not Found",
|
||||
f"No log file found at:\n{log_file}",
|
||||
tr("dialog.log_not_found.title"),
|
||||
tr("dialog.log_not_found.msg", log_file=str(log_file)),
|
||||
)
|
||||
|
||||
def _open_developer_tools(self) -> None:
|
||||
|
|
@ -1504,7 +1538,7 @@ class MainWindow(QMainWindow):
|
|||
|
||||
# Create new window
|
||||
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.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||
|
||||
|
|
@ -1530,8 +1564,8 @@ class MainWindow(QMainWindow):
|
|||
logger.error(f"Failed to open Developer Tools window: {e}", exc_info=True)
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Developer Tools",
|
||||
f"Could not open Developer Tools:\n{e}",
|
||||
tr("dialog.dev_tools.error_title"),
|
||||
tr("dialog.dev_tools.error_msg", error=str(e)),
|
||||
)
|
||||
|
||||
def _create_status_bar(self) -> None:
|
||||
|
|
@ -1539,7 +1573,7 @@ class MainWindow(QMainWindow):
|
|||
self.status_bar = self.statusBar()
|
||||
|
||||
# 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.status_bar.addPermanentWidget(self.update_status_label)
|
||||
|
||||
|
|
@ -1550,10 +1584,26 @@ class MainWindow(QMainWindow):
|
|||
status: Status text to display
|
||||
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:
|
||||
self.update_status_label.setText(f"{emoji} {status}")
|
||||
self.update_status_label.setText(f"{emoji} {display}")
|
||||
else:
|
||||
self.update_status_label.setText(status)
|
||||
self.update_status_label.setText(display)
|
||||
|
||||
def _on_manual_check_for_updates(self) -> None:
|
||||
"""Handle manual check for updates from menu.
|
||||
|
|
@ -1589,17 +1639,16 @@ class MainWindow(QMainWindow):
|
|||
# Show confirmation message
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Cache Cleared",
|
||||
"Browser cache and cookies have been cleared successfully.\n\n"
|
||||
"You may need to reload the page or restart the application for changes to take effect.",
|
||||
tr("dialog.cache_cleared.title"),
|
||||
tr("dialog.cache_cleared.msg"),
|
||||
)
|
||||
logger.info("Cache and cookies cleared successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear cache and cookies: {e}")
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Error",
|
||||
f"Failed to clear cache and cookies: {str(e)}",
|
||||
tr("dialog.cache_clear_failed.title"),
|
||||
tr("dialog.cache_clear_failed.msg", error=str(e)),
|
||||
)
|
||||
|
||||
def _show_about_dialog(self) -> None:
|
||||
|
|
@ -1608,16 +1657,15 @@ class MainWindow(QMainWindow):
|
|||
|
||||
about_text = (
|
||||
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"Bridges web-based drag-and-drop workflows with native file operations "
|
||||
f"for professional desktop applications.<br>"
|
||||
f"{tr('about.description')}<br>"
|
||||
f"<br>"
|
||||
f"<b>Toolbar Drop Zones:</b><br>"
|
||||
f"Open icon: Opens dropped files with the system default app.<br>"
|
||||
f"Open-with icon: Shows an app chooser for dropped files.<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>Product of:</b><br>"
|
||||
f"<b>{tr('about.product_of')}</b><br>"
|
||||
f"<b>hörl Information Management GmbH</b><br>"
|
||||
f"Silberburgstraße 126<br>"
|
||||
f"70176 Stuttgart, Germany<br>"
|
||||
|
|
@ -1628,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"</small>"
|
||||
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:
|
||||
"""Show Settings dialog for configuration management.
|
||||
|
|
@ -1644,6 +1692,7 @@ class MainWindow(QMainWindow):
|
|||
|
||||
# Store current URL before opening dialog
|
||||
old_webapp_url = self.config.webapp_url
|
||||
old_language = self.config.language
|
||||
|
||||
# Show dialog
|
||||
dialog = SettingsDialog(self.config, self)
|
||||
|
|
@ -1651,6 +1700,7 @@ class MainWindow(QMainWindow):
|
|||
|
||||
# Check if webapp URL changed
|
||||
new_webapp_url = self.config.webapp_url
|
||||
language_changed = old_language != self.config.language
|
||||
if old_webapp_url != new_webapp_url:
|
||||
logger.info(f"Web application URL changed: {old_webapp_url} → {new_webapp_url}")
|
||||
|
||||
|
|
@ -1667,6 +1717,10 @@ class MainWindow(QMainWindow):
|
|||
self.web_view.clear_cache_and_cookies()
|
||||
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:
|
||||
"""Check if the domain/host has changed between two URLs.
|
||||
|
||||
|
|
@ -1704,18 +1758,17 @@ class MainWindow(QMainWindow):
|
|||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
msg = QMessageBox(self)
|
||||
msg.setWindowTitle("Domain Changed - Restart Recommended")
|
||||
msg.setWindowTitle(tr("dialog.domain_changed.title"))
|
||||
msg.setIcon(QMessageBox.Icon.Warning)
|
||||
msg.setText(
|
||||
"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."
|
||||
)
|
||||
msg.setText(tr("dialog.domain_changed.msg"))
|
||||
|
||||
# Add custom buttons
|
||||
restart_now_btn = msg.addButton("Restart Now", QMessageBox.ButtonRole.AcceptRole)
|
||||
restart_later_btn = msg.addButton("Restart Later", QMessageBox.ButtonRole.RejectRole)
|
||||
restart_now_btn = msg.addButton(
|
||||
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()
|
||||
|
||||
|
|
@ -1728,6 +1781,27 @@ class MainWindow(QMainWindow):
|
|||
self.web_view.clear_cache_and_cookies()
|
||||
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:
|
||||
"""Restart the application automatically.
|
||||
|
||||
|
|
@ -1769,9 +1843,8 @@ class MainWindow(QMainWindow):
|
|||
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Restart Failed",
|
||||
f"Could not automatically restart the application:\n\n{str(e)}\n\n"
|
||||
"Please restart manually.",
|
||||
tr("dialog.restart_failed.title"),
|
||||
tr("dialog.restart_failed.msg", error=str(e)),
|
||||
)
|
||||
|
||||
def _navigate_home(self) -> None:
|
||||
|
|
@ -1829,8 +1902,16 @@ class MainWindow(QMainWindow):
|
|||
|
||||
try:
|
||||
# Create update manager
|
||||
cache_dir = Path.home() / ".webdrop_bridge"
|
||||
manager = UpdateManager(current_version=self.config.app_version, config_dir=cache_dir)
|
||||
cache_dir = self.config.get_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
|
||||
self._run_async_check(manager)
|
||||
|
|
@ -1880,10 +1961,8 @@ class MainWindow(QMainWindow):
|
|||
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Update Check Timeout",
|
||||
"The server did not respond within 30 seconds.\n\n"
|
||||
"This may be due to a network issue or server unavailability.\n\n"
|
||||
"Please check your connection and try again.",
|
||||
tr("dialog.update_timeout.title"),
|
||||
tr("dialog.update_timeout.msg"),
|
||||
)
|
||||
|
||||
safety_timer = QTimer()
|
||||
|
|
@ -1960,7 +2039,7 @@ class MainWindow(QMainWindow):
|
|||
error_message: Error description
|
||||
"""
|
||||
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
|
||||
|
||||
# Close checking dialog first, then show error
|
||||
|
|
@ -1971,8 +2050,8 @@ class MainWindow(QMainWindow):
|
|||
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Update Check Failed",
|
||||
f"Could not check for updates:\n\n{error_message}\n\nPlease try again later.",
|
||||
tr("dialog.update_failed.title"),
|
||||
tr("dialog.update_failed.msg", error=error_message),
|
||||
)
|
||||
|
||||
def _on_update_available(self, release) -> None:
|
||||
|
|
@ -1988,7 +2067,7 @@ class MainWindow(QMainWindow):
|
|||
self._is_manual_check = False
|
||||
|
||||
# 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
|
||||
from webdrop_bridge.ui.update_manager_ui import UpdateAvailableDialog
|
||||
|
|
@ -2016,7 +2095,7 @@ class MainWindow(QMainWindow):
|
|||
def _on_user_update_later(self) -> None:
|
||||
"""Handle user clicking 'Later' button."""
|
||||
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:
|
||||
"""Start downloading the update in background thread.
|
||||
|
|
@ -2025,7 +2104,7 @@ class MainWindow(QMainWindow):
|
|||
release: Release object to download
|
||||
"""
|
||||
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
|
||||
from webdrop_bridge.ui.update_manager_ui import DownloadingDialog
|
||||
|
|
@ -2049,7 +2128,13 @@ class MainWindow(QMainWindow):
|
|||
|
||||
# Create update manager
|
||||
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
|
||||
|
|
@ -2139,7 +2224,7 @@ class MainWindow(QMainWindow):
|
|||
self.downloading_dialog = None
|
||||
|
||||
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
|
||||
install_dialog = InstallDialog(parent=self)
|
||||
|
|
@ -2163,8 +2248,8 @@ class MainWindow(QMainWindow):
|
|||
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"Download Failed",
|
||||
f"Could not download the update:\n\n{error}\n\nPlease try again later.",
|
||||
tr("dialog.download_failed.title"),
|
||||
tr("dialog.download_failed.msg", error=error),
|
||||
)
|
||||
|
||||
def _on_download_progress(self, downloaded: int, total: int) -> None:
|
||||
|
|
@ -2188,14 +2273,20 @@ class MainWindow(QMainWindow):
|
|||
from webdrop_bridge.core.updater import 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):
|
||||
self.set_update_status("Installation started", emoji="✅")
|
||||
self.set_update_status(tr("update.status.installation_started"), emoji="✅")
|
||||
logger.info("Update installer launched successfully")
|
||||
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")
|
||||
|
||||
|
||||
|
|
@ -2226,7 +2317,7 @@ class UpdateCheckWorker(QObject):
|
|||
logger.debug("UpdateCheckWorker.run() starting")
|
||||
|
||||
# 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
|
||||
logger.debug("Creating new event loop for worker thread")
|
||||
|
|
@ -2248,15 +2339,17 @@ class UpdateCheckWorker(QObject):
|
|||
else:
|
||||
# No update available - show ready status
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
# Properly close the event loop
|
||||
if loop is not None:
|
||||
|
|
@ -2297,7 +2390,9 @@ class UpdateDownloadWorker(QObject):
|
|||
loop = None
|
||||
try:
|
||||
# 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
|
||||
loop = asyncio.new_event_loop()
|
||||
|
|
@ -2319,8 +2414,10 @@ class UpdateDownloadWorker(QObject):
|
|||
)
|
||||
|
||||
if not installer_path:
|
||||
self.update_status.emit("Download failed", "❌")
|
||||
self.download_failed.emit("No installer found in release")
|
||||
self.update_status.emit(
|
||||
"Download failed", "❌"
|
||||
) # Translated by set_update_status
|
||||
self.download_failed.emit(tr("worker.no_installer"))
|
||||
logger.error("Download failed - no installer found")
|
||||
return
|
||||
|
||||
|
|
@ -2337,7 +2434,7 @@ class UpdateDownloadWorker(QObject):
|
|||
|
||||
if not checksum_ok:
|
||||
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")
|
||||
return
|
||||
|
||||
|
|
@ -2346,17 +2443,17 @@ class UpdateDownloadWorker(QObject):
|
|||
|
||||
except asyncio.TimeoutError as e:
|
||||
logger.error(f"Download/verification timed out: {e}")
|
||||
self.update_status.emit("Operation timed out", "⏱️")
|
||||
self.download_failed.emit(
|
||||
"Download or verification timed out (no response from server)"
|
||||
)
|
||||
self.update_status.emit(
|
||||
"Operation timed out", "⏱️"
|
||||
) # Translated by set_update_status
|
||||
self.download_failed.emit(tr("worker.download_timed_out"))
|
||||
except Exception as 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:
|
||||
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:
|
||||
# Properly close the event loop
|
||||
if loop is not None:
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@
|
|||
|
||||
import logging
|
||||
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 (
|
||||
QComboBox,
|
||||
QDialog,
|
||||
|
|
@ -14,7 +13,6 @@ from PySide6.QtWidgets import (
|
|||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QPushButton,
|
||||
QSpinBox,
|
||||
QTableWidget,
|
||||
|
|
@ -26,21 +24,14 @@ from PySide6.QtWidgets import (
|
|||
|
||||
from webdrop_bridge.config import Config, ConfigurationError
|
||||
from webdrop_bridge.core.config_manager import ConfigExporter, ConfigProfile, ConfigValidator
|
||||
from webdrop_bridge.utils.i18n import get_available_languages, tr
|
||||
from webdrop_bridge.utils.logging import reconfigure_logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
"""Dialog for managing application settings and configuration.
|
||||
|
||||
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
|
||||
"""
|
||||
"""Dialog for managing application settings and configuration."""
|
||||
|
||||
def __init__(self, config: Config, parent: Optional[QWidget] = None):
|
||||
"""Initialize the settings dialog.
|
||||
|
|
@ -51,8 +42,8 @@ class SettingsDialog(QDialog):
|
|||
"""
|
||||
super().__init__(parent)
|
||||
self.config = config
|
||||
self.profile_manager = ConfigProfile()
|
||||
self.setWindowTitle("Settings")
|
||||
self.profile_manager = ConfigProfile(config.config_dir_name)
|
||||
self.setWindowTitle(tr("settings.title"))
|
||||
self.setGeometry(100, 100, 600, 500)
|
||||
|
||||
self.setup_ui()
|
||||
|
|
@ -61,20 +52,16 @@ class SettingsDialog(QDialog):
|
|||
"""Set up the dialog UI with tabs."""
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Create tab widget
|
||||
self.tabs = QTabWidget()
|
||||
|
||||
# Add tabs
|
||||
self.tabs.addTab(self._create_web_source_tab(), "Web Source")
|
||||
self.tabs.addTab(self._create_paths_tab(), "Paths")
|
||||
self.tabs.addTab(self._create_urls_tab(), "URLs")
|
||||
self.tabs.addTab(self._create_logging_tab(), "Logging")
|
||||
self.tabs.addTab(self._create_window_tab(), "Window")
|
||||
self.tabs.addTab(self._create_profiles_tab(), "Profiles")
|
||||
|
||||
self.tabs.addTab(self._create_general_tab(), tr("settings.tab.general"))
|
||||
self.tabs.addTab(self._create_web_source_tab(), tr("settings.tab.web_source"))
|
||||
self.tabs.addTab(self._create_paths_tab(), tr("settings.tab.paths"))
|
||||
self.tabs.addTab(self._create_urls_tab(), tr("settings.tab.urls"))
|
||||
self.tabs.addTab(self._create_logging_tab(), tr("settings.tab.logging"))
|
||||
self.tabs.addTab(self._create_window_tab(), tr("settings.tab.window"))
|
||||
self.tabs.addTab(self._create_profiles_tab(), tr("settings.tab.profiles"))
|
||||
layout.addWidget(self.tabs)
|
||||
|
||||
# Add buttons
|
||||
button_box = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
|
|
@ -85,17 +72,10 @@ class SettingsDialog(QDialog):
|
|||
self.setLayout(layout)
|
||||
|
||||
def accept(self) -> None:
|
||||
"""Handle OK button - save configuration changes to file.
|
||||
|
||||
Validates configuration and saves to the default config path.
|
||||
Applies log level changes immediately in the running application.
|
||||
If validation or save fails, shows error and stays in dialog.
|
||||
"""
|
||||
"""Handle OK button - save configuration changes to file."""
|
||||
try:
|
||||
# Get updated configuration data from UI
|
||||
config_data = self.get_config_data()
|
||||
|
||||
# Convert URL mappings from dict to URLMapping objects
|
||||
from webdrop_bridge.config import URLMapping
|
||||
|
||||
url_mappings = [
|
||||
|
|
@ -103,8 +83,8 @@ class SettingsDialog(QDialog):
|
|||
for m in config_data["url_mappings"]
|
||||
]
|
||||
|
||||
# Update the config object with new values
|
||||
old_log_level = self.config.log_level
|
||||
self.config.language = config_data["language"]
|
||||
self.config.log_level = config_data["log_level"]
|
||||
self.config.log_file = (
|
||||
Path(config_data["log_file"]) if config_data["log_file"] else None
|
||||
|
|
@ -116,25 +96,21 @@ class SettingsDialog(QDialog):
|
|||
self.config.window_width = config_data["window_width"]
|
||||
self.config.window_height = config_data["window_height"]
|
||||
|
||||
# Save to file (creates parent dirs if needed)
|
||||
config_path = Config.get_default_config_path()
|
||||
config_path = self.config.get_config_path()
|
||||
self.config.to_file(config_path)
|
||||
|
||||
logger.info(f"Configuration saved to {config_path}")
|
||||
logger.info(f" Log level: {self.config.log_level} (was: {old_log_level})")
|
||||
logger.info(f" Window size: {self.config.window_width}x{self.config.window_height}")
|
||||
|
||||
# Apply log level change immediately to running application
|
||||
if old_log_level != self.config.log_level:
|
||||
logger.info(f"🔄 Updating log level: {old_log_level} → {self.config.log_level}")
|
||||
reconfigure_logging(
|
||||
logger_name="webdrop_bridge",
|
||||
level=self.config.log_level,
|
||||
log_file=self.config.log_file,
|
||||
)
|
||||
logger.info(f"✅ Log level updated to {self.config.log_level}")
|
||||
logger.info(f"Log level updated to {self.config.log_level}")
|
||||
|
||||
# Call parent accept to close dialog
|
||||
super().accept()
|
||||
|
||||
except ConfigurationError as e:
|
||||
|
|
@ -144,15 +120,43 @@ class SettingsDialog(QDialog):
|
|||
logger.error(f"Failed to save configuration: {e}", exc_info=True)
|
||||
self._show_error(f"Failed to save configuration:\n\n{e}")
|
||||
|
||||
def _create_web_source_tab(self) -> QWidget:
|
||||
"""Create web source configuration tab."""
|
||||
from PySide6.QtWidgets import QTableWidget, QTableWidgetItem
|
||||
|
||||
def _create_general_tab(self) -> QWidget:
|
||||
"""Create general settings tab with language selector."""
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Webapp URL configuration
|
||||
layout.addWidget(QLabel("Web Application URL:"))
|
||||
lang_layout = QHBoxLayout()
|
||||
lang_layout.addWidget(QLabel(tr("settings.general.language_label")))
|
||||
|
||||
self.language_combo = QComboBox()
|
||||
self.language_combo.addItem(tr("settings.general.language_auto"), "auto")
|
||||
available = get_available_languages()
|
||||
current_lang = self.config.language
|
||||
for code, name in available.items():
|
||||
self.language_combo.addItem(name, code)
|
||||
|
||||
idx = self.language_combo.findData(current_lang)
|
||||
if idx >= 0:
|
||||
self.language_combo.setCurrentIndex(idx)
|
||||
|
||||
lang_layout.addWidget(self.language_combo)
|
||||
lang_layout.addStretch()
|
||||
layout.addLayout(lang_layout)
|
||||
|
||||
note = QLabel(tr("settings.general.language_restart_note"))
|
||||
note.setStyleSheet("color: gray; font-size: 11px;")
|
||||
layout.addWidget(note)
|
||||
|
||||
layout.addStretch()
|
||||
widget.setLayout(layout)
|
||||
return widget
|
||||
|
||||
def _create_web_source_tab(self) -> QWidget:
|
||||
"""Create web source configuration tab."""
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout()
|
||||
|
||||
layout.addWidget(QLabel(tr("settings.web_source.url_label")))
|
||||
url_layout = QHBoxLayout()
|
||||
|
||||
self.webapp_url_input = QLineEdit()
|
||||
|
|
@ -162,22 +166,24 @@ class SettingsDialog(QDialog):
|
|||
)
|
||||
url_layout.addWidget(self.webapp_url_input)
|
||||
|
||||
open_btn = QPushButton("Open")
|
||||
open_btn = QPushButton(tr("settings.web_source.open_btn"))
|
||||
open_btn.clicked.connect(self._open_webapp_url)
|
||||
url_layout.addWidget(open_btn)
|
||||
|
||||
layout.addLayout(url_layout)
|
||||
|
||||
# URL Mappings (Azure Blob URL → Local Path)
|
||||
layout.addWidget(QLabel("URL Mappings (Azure Blob Storage → Local Paths):"))
|
||||
layout.addWidget(QLabel(tr("settings.web_source.url_mappings_label")))
|
||||
|
||||
# Create table for URL mappings
|
||||
self.url_mappings_table = QTableWidget()
|
||||
self.url_mappings_table.setColumnCount(2)
|
||||
self.url_mappings_table.setHorizontalHeaderLabels(["URL Prefix", "Local Path"])
|
||||
self.url_mappings_table.setHorizontalHeaderLabels(
|
||||
[
|
||||
tr("settings.web_source.col_url_prefix"),
|
||||
tr("settings.web_source.col_local_path"),
|
||||
]
|
||||
)
|
||||
self.url_mappings_table.horizontalHeader().setStretchLastSection(True)
|
||||
|
||||
# Populate from config
|
||||
for mapping in self.config.url_mappings:
|
||||
row = self.url_mappings_table.rowCount()
|
||||
self.url_mappings_table.insertRow(row)
|
||||
|
|
@ -186,18 +192,17 @@ class SettingsDialog(QDialog):
|
|||
|
||||
layout.addWidget(self.url_mappings_table)
|
||||
|
||||
# Buttons for URL mapping management
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
add_mapping_btn = QPushButton("Add Mapping")
|
||||
add_mapping_btn = QPushButton(tr("settings.web_source.add_mapping_btn"))
|
||||
add_mapping_btn.clicked.connect(self._add_url_mapping)
|
||||
button_layout.addWidget(add_mapping_btn)
|
||||
|
||||
edit_mapping_btn = QPushButton("Edit Selected")
|
||||
edit_mapping_btn = QPushButton(tr("settings.web_source.edit_mapping_btn"))
|
||||
edit_mapping_btn.clicked.connect(self._edit_url_mapping)
|
||||
button_layout.addWidget(edit_mapping_btn)
|
||||
|
||||
remove_mapping_btn = QPushButton("Remove Selected")
|
||||
remove_mapping_btn = QPushButton(tr("settings.web_source.remove_mapping_btn"))
|
||||
remove_mapping_btn.clicked.connect(self._remove_url_mapping)
|
||||
button_layout.addWidget(remove_mapping_btn)
|
||||
|
||||
|
|
@ -212,8 +217,8 @@ class SettingsDialog(QDialog):
|
|||
import webbrowser
|
||||
|
||||
url = self.webapp_url_input.text().strip()
|
||||
if url:
|
||||
# Handle file:// URLs
|
||||
if not url:
|
||||
return
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
except Exception as e:
|
||||
|
|
@ -226,15 +231,15 @@ class SettingsDialog(QDialog):
|
|||
|
||||
url_prefix, ok1 = QInputDialog.getText(
|
||||
self,
|
||||
"Add URL Mapping",
|
||||
"Enter Azure Blob Storage URL prefix:\n(e.g., https://myblob.blob.core.windows.net/container/)",
|
||||
tr("settings.web_source.add_mapping_title"),
|
||||
tr("settings.web_source.add_mapping_url_prompt"),
|
||||
)
|
||||
|
||||
if ok1 and url_prefix:
|
||||
local_path, ok2 = QInputDialog.getText(
|
||||
self,
|
||||
"Add URL Mapping",
|
||||
"Enter local file system path:\n(e.g., C:\\Share or /mnt/share)",
|
||||
tr("settings.web_source.add_mapping_title"),
|
||||
tr("settings.web_source.add_mapping_path_prompt"),
|
||||
)
|
||||
|
||||
if ok2 and local_path:
|
||||
|
|
@ -249,19 +254,25 @@ class SettingsDialog(QDialog):
|
|||
|
||||
current_row = self.url_mappings_table.currentRow()
|
||||
if current_row < 0:
|
||||
self._show_error("Please select a mapping to edit")
|
||||
self._show_error(tr("settings.web_source.select_mapping_to_edit"))
|
||||
return
|
||||
|
||||
url_prefix = self.url_mappings_table.item(current_row, 0).text() # type: ignore
|
||||
local_path = self.url_mappings_table.item(current_row, 1).text() # type: ignore
|
||||
|
||||
new_url_prefix, ok1 = QInputDialog.getText(
|
||||
self, "Edit URL Mapping", "Enter Azure Blob Storage URL prefix:", text=url_prefix
|
||||
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:
|
||||
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:
|
||||
|
|
@ -279,22 +290,20 @@ class SettingsDialog(QDialog):
|
|||
widget = QWidget()
|
||||
layout = QVBoxLayout()
|
||||
|
||||
layout.addWidget(QLabel("Allowed root directories for file access:"))
|
||||
layout.addWidget(QLabel(tr("settings.paths.label")))
|
||||
|
||||
# List widget for paths
|
||||
self.paths_list = QListWidget()
|
||||
for path in self.config.allowed_roots:
|
||||
self.paths_list.addItem(str(path))
|
||||
layout.addWidget(self.paths_list)
|
||||
|
||||
# Buttons for path management
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
add_path_btn = QPushButton("Add Path")
|
||||
add_path_btn = QPushButton(tr("settings.paths.add_btn"))
|
||||
add_path_btn.clicked.connect(self._add_path)
|
||||
button_layout.addWidget(add_path_btn)
|
||||
|
||||
remove_path_btn = QPushButton("Remove Selected")
|
||||
remove_path_btn = QPushButton(tr("settings.paths.remove_btn"))
|
||||
remove_path_btn.clicked.connect(self._remove_path)
|
||||
button_layout.addWidget(remove_path_btn)
|
||||
|
||||
|
|
@ -309,22 +318,20 @@ class SettingsDialog(QDialog):
|
|||
widget = QWidget()
|
||||
layout = QVBoxLayout()
|
||||
|
||||
layout.addWidget(QLabel("Allowed web URLs (supports wildcards like http://*.example.com):"))
|
||||
layout.addWidget(QLabel(tr("settings.urls.label")))
|
||||
|
||||
# List widget for URLs
|
||||
self.urls_list = QListWidget()
|
||||
for url in self.config.allowed_urls:
|
||||
self.urls_list.addItem(url)
|
||||
layout.addWidget(self.urls_list)
|
||||
|
||||
# Buttons for URL management
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
add_url_btn = QPushButton("Add URL")
|
||||
add_url_btn = QPushButton(tr("settings.urls.add_btn"))
|
||||
add_url_btn.clicked.connect(self._add_url)
|
||||
button_layout.addWidget(add_url_btn)
|
||||
|
||||
remove_url_btn = QPushButton("Remove Selected")
|
||||
remove_url_btn = QPushButton(tr("settings.urls.remove_btn"))
|
||||
remove_url_btn.clicked.connect(self._remove_url)
|
||||
button_layout.addWidget(remove_url_btn)
|
||||
|
||||
|
|
@ -339,27 +346,22 @@ class SettingsDialog(QDialog):
|
|||
widget = QWidget()
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Log level selection
|
||||
layout.addWidget(QLabel("Log Level:"))
|
||||
from PySide6.QtWidgets import QComboBox
|
||||
|
||||
layout.addWidget(QLabel(tr("settings.logging.level_label")))
|
||||
self.log_level_combo: QComboBox = self._create_log_level_widget()
|
||||
layout.addWidget(self.log_level_combo)
|
||||
|
||||
# Log file path
|
||||
layout.addWidget(QLabel("Log File (optional):"))
|
||||
layout.addWidget(QLabel(tr("settings.logging.file_label")))
|
||||
log_file_layout = QHBoxLayout()
|
||||
|
||||
self.log_file_input = QLineEdit()
|
||||
self.log_file_input.setText(str(self.config.log_file) if self.config.log_file else "")
|
||||
log_file_layout.addWidget(self.log_file_input)
|
||||
|
||||
browse_btn = QPushButton("Browse...")
|
||||
browse_btn = QPushButton(tr("settings.logging.browse_btn"))
|
||||
browse_btn.clicked.connect(self._browse_log_file)
|
||||
log_file_layout.addWidget(browse_btn)
|
||||
|
||||
layout.addLayout(log_file_layout)
|
||||
|
||||
layout.addStretch()
|
||||
widget.setLayout(layout)
|
||||
return widget
|
||||
|
|
@ -369,9 +371,8 @@ class SettingsDialog(QDialog):
|
|||
widget = QWidget()
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Window width
|
||||
width_layout = QHBoxLayout()
|
||||
width_layout.addWidget(QLabel("Window Width:"))
|
||||
width_layout.addWidget(QLabel(tr("settings.window.width_label")))
|
||||
self.width_spin = QSpinBox()
|
||||
self.width_spin.setMinimum(400)
|
||||
self.width_spin.setMaximum(5000)
|
||||
|
|
@ -380,9 +381,8 @@ class SettingsDialog(QDialog):
|
|||
width_layout.addStretch()
|
||||
layout.addLayout(width_layout)
|
||||
|
||||
# Window height
|
||||
height_layout = QHBoxLayout()
|
||||
height_layout.addWidget(QLabel("Window Height:"))
|
||||
height_layout.addWidget(QLabel(tr("settings.window.height_label")))
|
||||
self.height_spin = QSpinBox()
|
||||
self.height_spin.setMinimum(300)
|
||||
self.height_spin.setMaximum(5000)
|
||||
|
|
@ -400,38 +400,35 @@ class SettingsDialog(QDialog):
|
|||
widget = QWidget()
|
||||
layout = QVBoxLayout()
|
||||
|
||||
layout.addWidget(QLabel("Saved Configuration Profiles:"))
|
||||
layout.addWidget(QLabel(tr("settings.profiles.label")))
|
||||
|
||||
# List of profiles
|
||||
self.profiles_list = QListWidget()
|
||||
self._refresh_profiles_list()
|
||||
layout.addWidget(self.profiles_list)
|
||||
|
||||
# Profile management buttons
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
save_profile_btn = QPushButton("Save as Profile")
|
||||
save_profile_btn = QPushButton(tr("settings.profiles.save_btn"))
|
||||
save_profile_btn.clicked.connect(self._save_profile)
|
||||
button_layout.addWidget(save_profile_btn)
|
||||
|
||||
load_profile_btn = QPushButton("Load Profile")
|
||||
load_profile_btn = QPushButton(tr("settings.profiles.load_btn"))
|
||||
load_profile_btn.clicked.connect(self._load_profile)
|
||||
button_layout.addWidget(load_profile_btn)
|
||||
|
||||
delete_profile_btn = QPushButton("Delete Profile")
|
||||
delete_profile_btn = QPushButton(tr("settings.profiles.delete_btn"))
|
||||
delete_profile_btn.clicked.connect(self._delete_profile)
|
||||
button_layout.addWidget(delete_profile_btn)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
# Export/Import buttons
|
||||
export_layout = QHBoxLayout()
|
||||
|
||||
export_btn = QPushButton("Export Configuration")
|
||||
export_btn = QPushButton(tr("settings.profiles.export_btn"))
|
||||
export_btn.clicked.connect(self._export_config)
|
||||
export_layout.addWidget(export_btn)
|
||||
|
||||
import_btn = QPushButton("Import Configuration")
|
||||
import_btn = QPushButton(tr("settings.profiles.import_btn"))
|
||||
import_btn.clicked.connect(self._import_config)
|
||||
export_layout.addWidget(import_btn)
|
||||
|
||||
|
|
@ -451,7 +448,7 @@ class SettingsDialog(QDialog):
|
|||
|
||||
def _add_path(self) -> None:
|
||||
"""Add a new allowed path."""
|
||||
path = QFileDialog.getExistingDirectory(self, "Select Directory to Allow")
|
||||
path = QFileDialog.getExistingDirectory(self, tr("settings.paths.select_dir_title"))
|
||||
if path:
|
||||
self.paths_list.addItem(path)
|
||||
|
||||
|
|
@ -465,7 +462,7 @@ class SettingsDialog(QDialog):
|
|||
from PySide6.QtWidgets import QInputDialog
|
||||
|
||||
url, ok = QInputDialog.getText(
|
||||
self, "Add URL", "Enter URL pattern (e.g., http://example.com or http://*.example.com):"
|
||||
self, tr("settings.urls.add_title"), tr("settings.urls.add_prompt")
|
||||
)
|
||||
if ok and url:
|
||||
self.urls_list.addItem(url)
|
||||
|
|
@ -478,7 +475,10 @@ class SettingsDialog(QDialog):
|
|||
def _browse_log_file(self) -> None:
|
||||
"""Browse for log file location."""
|
||||
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:
|
||||
self.log_file_input.setText(file_path)
|
||||
|
|
@ -494,7 +494,7 @@ class SettingsDialog(QDialog):
|
|||
from PySide6.QtWidgets import QInputDialog
|
||||
|
||||
profile_name, ok = QInputDialog.getText(
|
||||
self, "Save Profile", "Enter profile name (e.g., work, personal):"
|
||||
self, tr("settings.profiles.save_title"), tr("settings.profiles.save_prompt")
|
||||
)
|
||||
|
||||
if ok and profile_name:
|
||||
|
|
@ -508,7 +508,7 @@ class SettingsDialog(QDialog):
|
|||
"""Load a saved profile."""
|
||||
current_item = self.profiles_list.currentItem()
|
||||
if not current_item:
|
||||
self._show_error("Please select a profile to load")
|
||||
self._show_error(tr("settings.profiles.select_to_load"))
|
||||
return
|
||||
|
||||
profile_name = current_item.text()
|
||||
|
|
@ -522,7 +522,7 @@ class SettingsDialog(QDialog):
|
|||
"""Delete a saved profile."""
|
||||
current_item = self.profiles_list.currentItem()
|
||||
if not current_item:
|
||||
self._show_error("Please select a profile to delete")
|
||||
self._show_error(tr("settings.profiles.select_to_delete"))
|
||||
return
|
||||
|
||||
profile_name = current_item.text()
|
||||
|
|
@ -535,7 +535,10 @@ class SettingsDialog(QDialog):
|
|||
def _export_config(self) -> None:
|
||||
"""Export configuration to file."""
|
||||
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:
|
||||
|
|
@ -547,7 +550,10 @@ class SettingsDialog(QDialog):
|
|||
def _import_config(self) -> None:
|
||||
"""Import configuration from file."""
|
||||
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:
|
||||
|
|
@ -563,25 +569,26 @@ class SettingsDialog(QDialog):
|
|||
Args:
|
||||
config_data: Configuration dictionary
|
||||
"""
|
||||
# Apply paths
|
||||
self.paths_list.clear()
|
||||
for path in config_data.get("allowed_roots", []):
|
||||
self.paths_list.addItem(str(path))
|
||||
|
||||
# Apply URLs
|
||||
self.urls_list.clear()
|
||||
for url in config_data.get("allowed_urls", []):
|
||||
self.urls_list.addItem(url)
|
||||
|
||||
# Apply logging settings
|
||||
self.log_level_combo.setCurrentText(config_data.get("log_level", "INFO"))
|
||||
log_file = config_data.get("log_file")
|
||||
self.log_file_input.setText(str(log_file) if log_file else "")
|
||||
|
||||
# Apply window settings
|
||||
self.width_spin.setValue(config_data.get("window_width", 800))
|
||||
self.height_spin.setValue(config_data.get("window_height", 600))
|
||||
|
||||
language = config_data.get("language", "auto")
|
||||
idx = self.language_combo.findData(language)
|
||||
if idx >= 0:
|
||||
self.language_combo.setCurrentIndex(idx)
|
||||
|
||||
def get_config_data(self) -> Dict[str, Any]:
|
||||
"""Get updated configuration data from dialog.
|
||||
|
||||
|
|
@ -591,13 +598,14 @@ class SettingsDialog(QDialog):
|
|||
Raises:
|
||||
ConfigurationError: If configuration is invalid
|
||||
"""
|
||||
if self.url_mappings_table:
|
||||
url_mappings_table_count = self.url_mappings_table.rowCount() or 0
|
||||
else:
|
||||
url_mappings_table_count = 0
|
||||
url_mappings_table_count = (
|
||||
self.url_mappings_table.rowCount() if self.url_mappings_table else 0
|
||||
)
|
||||
|
||||
config_data = {
|
||||
"app_name": self.config.app_name,
|
||||
"app_version": self.config.app_version,
|
||||
"language": self.language_combo.currentData(),
|
||||
"log_level": self.log_level_combo.currentText(),
|
||||
"log_file": self.log_file_input.text() or None,
|
||||
"allowed_roots": [
|
||||
|
|
@ -607,8 +615,16 @@ class SettingsDialog(QDialog):
|
|||
"webapp_url": self.webapp_url_input.text().strip(),
|
||||
"url_mappings": [
|
||||
{
|
||||
"url_prefix": self.url_mappings_table.item(i, 0).text() if self.url_mappings_table.item(i, 0) else "", # type: ignore
|
||||
"local_path": self.url_mappings_table.item(i, 1).text() if self.url_mappings_table.item(i, 1) else "", # type: ignore
|
||||
"url_prefix": (
|
||||
self.url_mappings_table.item(i, 0).text() # type: ignore
|
||||
if self.url_mappings_table.item(i, 0)
|
||||
else ""
|
||||
),
|
||||
"local_path": (
|
||||
self.url_mappings_table.item(i, 1).text() # type: ignore
|
||||
if self.url_mappings_table.item(i, 1)
|
||||
else ""
|
||||
),
|
||||
}
|
||||
for i in range(url_mappings_table_count)
|
||||
],
|
||||
|
|
@ -617,9 +633,7 @@ class SettingsDialog(QDialog):
|
|||
"enable_logging": self.config.enable_logging,
|
||||
}
|
||||
|
||||
# Validate
|
||||
ConfigValidator.validate_or_raise(config_data)
|
||||
|
||||
return config_data
|
||||
|
||||
def _show_error(self, message: str) -> None:
|
||||
|
|
@ -630,4 +644,4 @@ class SettingsDialog(QDialog):
|
|||
"""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
QMessageBox.critical(self, "Error", message)
|
||||
QMessageBox.critical(self, tr("dialog.error.title"), message)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ from PySide6.QtWidgets import (
|
|||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from webdrop_bridge.utils.i18n import tr
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -41,7 +43,7 @@ class CheckingDialog(QDialog):
|
|||
parent: Parent widget
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Checking for Updates")
|
||||
self.setWindowTitle(tr("update.checking.title"))
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(300)
|
||||
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
|
||||
|
|
@ -49,7 +51,7 @@ class CheckingDialog(QDialog):
|
|||
layout = QVBoxLayout()
|
||||
|
||||
# Status label
|
||||
self.label = QLabel("Checking for updates...")
|
||||
self.label = QLabel(tr("update.checking.label"))
|
||||
layout.addWidget(self.label)
|
||||
|
||||
# Animated progress bar
|
||||
|
|
@ -58,7 +60,7 @@ class CheckingDialog(QDialog):
|
|||
layout.addWidget(self.progress)
|
||||
|
||||
# Timeout info
|
||||
info_label = QLabel("This may take up to 10 seconds")
|
||||
info_label = QLabel(tr("update.checking.timeout_info"))
|
||||
info_label.setStyleSheet("color: gray; font-size: 11px;")
|
||||
layout.addWidget(info_label)
|
||||
|
||||
|
|
@ -88,7 +90,7 @@ class UpdateAvailableDialog(QDialog):
|
|||
parent: Parent widget
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Update Available")
|
||||
self.setWindowTitle(tr("update.available.title"))
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(400)
|
||||
self.setMinimumHeight(300)
|
||||
|
|
@ -96,12 +98,12 @@ class UpdateAvailableDialog(QDialog):
|
|||
layout = QVBoxLayout()
|
||||
|
||||
# Header
|
||||
header = QLabel(f"WebDrop Bridge v{version} is available")
|
||||
header = QLabel(tr("update.available.header", version=version))
|
||||
header.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||
layout.addWidget(header)
|
||||
|
||||
# Changelog
|
||||
changelog_label = QLabel("Release Notes:")
|
||||
changelog_label = QLabel(tr("update.available.changelog_label"))
|
||||
changelog_label.setStyleSheet("font-weight: bold; margin-top: 10px;")
|
||||
layout.addWidget(changelog_label)
|
||||
|
||||
|
|
@ -113,11 +115,11 @@ class UpdateAvailableDialog(QDialog):
|
|||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
self.update_now_btn = QPushButton("Update Now")
|
||||
self.update_now_btn = QPushButton(tr("update.available.update_now_btn"))
|
||||
self.update_now_btn.clicked.connect(self._on_update_now)
|
||||
button_layout.addWidget(self.update_now_btn)
|
||||
|
||||
self.update_later_btn = QPushButton("Later")
|
||||
self.update_later_btn = QPushButton(tr("update.available.later_btn"))
|
||||
self.update_later_btn.clicked.connect(self._on_update_later)
|
||||
button_layout.addWidget(self.update_later_btn)
|
||||
|
||||
|
|
@ -153,7 +155,7 @@ class DownloadingDialog(QDialog):
|
|||
parent: Parent widget
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Downloading Update")
|
||||
self.setWindowTitle(tr("update.downloading.title"))
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(350)
|
||||
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
|
||||
|
|
@ -161,12 +163,12 @@ class DownloadingDialog(QDialog):
|
|||
layout = QVBoxLayout()
|
||||
|
||||
# Header
|
||||
header = QLabel("Downloading update...")
|
||||
header = QLabel(tr("update.downloading.header"))
|
||||
header.setStyleSheet("font-weight: bold;")
|
||||
layout.addWidget(header)
|
||||
|
||||
# File label
|
||||
self.file_label = QLabel("Preparing download")
|
||||
self.file_label = QLabel(tr("update.downloading.preparing"))
|
||||
layout.addWidget(self.file_label)
|
||||
|
||||
# Progress bar
|
||||
|
|
@ -182,7 +184,7 @@ class DownloadingDialog(QDialog):
|
|||
layout.addWidget(self.size_label)
|
||||
|
||||
# Cancel button
|
||||
self.cancel_btn = QPushButton("Cancel")
|
||||
self.cancel_btn = QPushButton(tr("update.downloading.cancel_btn"))
|
||||
self.cancel_btn.clicked.connect(self._on_cancel)
|
||||
layout.addWidget(self.cancel_btn)
|
||||
|
||||
|
|
@ -210,7 +212,7 @@ class DownloadingDialog(QDialog):
|
|||
Args:
|
||||
filename: Name of file being downloaded
|
||||
"""
|
||||
self.file_label.setText(f"Downloading: {filename}")
|
||||
self.file_label.setText(tr("update.downloading.filename", filename=filename))
|
||||
|
||||
def _on_cancel(self):
|
||||
"""Handle cancel button click."""
|
||||
|
|
@ -236,26 +238,23 @@ class InstallDialog(QDialog):
|
|||
parent: Parent widget
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Install Update")
|
||||
self.setWindowTitle(tr("update.install.title"))
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(350)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Header
|
||||
header = QLabel("Ready to Install")
|
||||
header = QLabel(tr("update.install.header"))
|
||||
header.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||
layout.addWidget(header)
|
||||
|
||||
# Message
|
||||
message = QLabel("The update is ready to install. The application will restart.")
|
||||
message = QLabel(tr("update.install.message"))
|
||||
layout.addWidget(message)
|
||||
|
||||
# Warning
|
||||
warning = QLabel(
|
||||
"⚠️ Please save any unsaved work before continuing.\n"
|
||||
"The application will close and restart."
|
||||
)
|
||||
warning = QLabel(tr("update.install.warning"))
|
||||
warning.setStyleSheet("background-color: #fff3cd; padding: 10px; border-radius: 4px;")
|
||||
warning.setWordWrap(True)
|
||||
layout.addWidget(warning)
|
||||
|
|
@ -263,12 +262,12 @@ class InstallDialog(QDialog):
|
|||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
self.install_btn = QPushButton("Install Now")
|
||||
self.install_btn = QPushButton(tr("update.install.now_btn"))
|
||||
self.install_btn.setStyleSheet("background-color: #28a745; color: white;")
|
||||
self.install_btn.clicked.connect(self._on_install)
|
||||
button_layout.addWidget(self.install_btn)
|
||||
|
||||
self.cancel_btn = QPushButton("Cancel")
|
||||
self.cancel_btn = QPushButton(tr("update.install.cancel_btn"))
|
||||
self.cancel_btn.clicked.connect(self.reject)
|
||||
button_layout.addWidget(self.cancel_btn)
|
||||
|
||||
|
|
@ -294,22 +293,22 @@ class NoUpdateDialog(QDialog):
|
|||
parent: Parent widget
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("No Updates Available")
|
||||
self.setWindowTitle(tr("update.no_update.title"))
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(300)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Message
|
||||
message = QLabel("✓ You're using the latest version")
|
||||
message = QLabel(tr("update.no_update.message"))
|
||||
message.setStyleSheet("font-weight: bold; font-size: 12px; color: #28a745;")
|
||||
layout.addWidget(message)
|
||||
|
||||
info = QLabel("WebDrop Bridge is up to date.")
|
||||
info = QLabel(tr("update.no_update.info"))
|
||||
layout.addWidget(info)
|
||||
|
||||
# Close button
|
||||
close_btn = QPushButton("OK")
|
||||
close_btn = QPushButton(tr("update.no_update.ok_btn"))
|
||||
close_btn.clicked.connect(self.accept)
|
||||
layout.addWidget(close_btn)
|
||||
|
||||
|
|
@ -335,14 +334,14 @@ class ErrorDialog(QDialog):
|
|||
parent: Parent widget
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Update Failed")
|
||||
self.setWindowTitle(tr("update.error.title"))
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(350)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Header
|
||||
header = QLabel("⚠️ Update Failed")
|
||||
header = QLabel(tr("update.error.header"))
|
||||
header.setStyleSheet("font-weight: bold; font-size: 14px; color: #dc3545;")
|
||||
layout.addWidget(header)
|
||||
|
||||
|
|
@ -354,7 +353,7 @@ class ErrorDialog(QDialog):
|
|||
layout.addWidget(self.error_text)
|
||||
|
||||
# Info message
|
||||
info = QLabel("Please try again or visit the website to download the update manually.")
|
||||
info = QLabel(tr("update.error.info"))
|
||||
info.setWordWrap(True)
|
||||
info.setStyleSheet("color: gray; font-size: 11px;")
|
||||
layout.addWidget(info)
|
||||
|
|
@ -362,15 +361,15 @@ class ErrorDialog(QDialog):
|
|||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
self.retry_btn = QPushButton("Retry")
|
||||
self.retry_btn = QPushButton(tr("update.error.retry_btn"))
|
||||
self.retry_btn.clicked.connect(self._on_retry)
|
||||
button_layout.addWidget(self.retry_btn)
|
||||
|
||||
self.manual_btn = QPushButton("Download Manually")
|
||||
self.manual_btn = QPushButton(tr("update.error.manual_btn"))
|
||||
self.manual_btn.clicked.connect(self._on_manual)
|
||||
button_layout.addWidget(self.manual_btn)
|
||||
|
||||
self.cancel_btn = QPushButton("Cancel")
|
||||
self.cancel_btn = QPushButton(tr("update.error.cancel_btn"))
|
||||
self.cancel_btn.clicked.connect(self.reject)
|
||||
button_layout.addWidget(self.cancel_btn)
|
||||
|
||||
|
|
|
|||
292
src/webdrop_bridge/utils/i18n.py
Normal file
292
src/webdrop_bridge/utils/i18n.py
Normal 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"
|
||||
147
tests/unit/test_brand_config.py
Normal file
147
tests/unit/test_brand_config.py
Normal 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"
|
||||
)
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
"""Unit tests for configuration system."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -15,7 +16,19 @@ def clear_env():
|
|||
|
||||
# Clear relevant variables
|
||||
for key in list(os.environ.keys()):
|
||||
if key.startswith(('APP_', 'LOG_', 'ALLOWED_', 'WEBAPP_', 'WINDOW_', 'ENABLE_')):
|
||||
if key.startswith(
|
||||
(
|
||||
"APP_",
|
||||
"LOG_",
|
||||
"ALLOWED_",
|
||||
"WEBAPP_",
|
||||
"WINDOW_",
|
||||
"ENABLE_",
|
||||
"BRAND_",
|
||||
"UPDATE_",
|
||||
"LANGUAGE",
|
||||
)
|
||||
):
|
||||
del os.environ[key]
|
||||
|
||||
yield
|
||||
|
|
@ -64,6 +77,28 @@ class TestConfigFromEnv:
|
|||
assert config.window_width == 1200
|
||||
assert config.window_height == 800
|
||||
|
||||
def test_from_env_with_branding_values(self, tmp_path):
|
||||
"""Test loading branding and update metadata from environment."""
|
||||
env_file = tmp_path / ".env"
|
||||
root1 = tmp_path / "root1"
|
||||
root1.mkdir()
|
||||
env_file.write_text(
|
||||
f"BRAND_ID=agravity\n"
|
||||
f"APP_CONFIG_DIR_NAME=agravity_bridge\n"
|
||||
f"UPDATE_REPO=HIM-public/webdrop-bridge\n"
|
||||
f"UPDATE_CHANNEL=stable\n"
|
||||
f"UPDATE_MANIFEST_NAME=release-manifest.json\n"
|
||||
f"ALLOWED_ROOTS={root1}\n"
|
||||
)
|
||||
|
||||
config = Config.from_env(str(env_file))
|
||||
|
||||
assert config.brand_id == "agravity"
|
||||
assert config.config_dir_name == "agravity_bridge"
|
||||
assert config.update_repo == "HIM-public/webdrop-bridge"
|
||||
assert config.update_channel == "stable"
|
||||
assert config.update_manifest_name == "release-manifest.json"
|
||||
|
||||
def test_from_env_with_defaults(self, tmp_path):
|
||||
"""Test loading config uses defaults when env vars not set."""
|
||||
# Create empty .env file
|
||||
|
|
@ -73,8 +108,11 @@ class TestConfigFromEnv:
|
|||
config = Config.from_env(str(env_file))
|
||||
|
||||
assert config.app_name == "WebDrop Bridge"
|
||||
assert config.brand_id == "webdrop_bridge"
|
||||
assert config.config_dir_name == "webdrop_bridge"
|
||||
# Version should come from __init__.py (dynamic, not hardcoded)
|
||||
from webdrop_bridge import __version__
|
||||
|
||||
assert config.app_version == __version__
|
||||
assert config.log_level == "INFO"
|
||||
assert config.window_width == 1024
|
||||
|
|
@ -187,3 +225,30 @@ class TestConfigValidation:
|
|||
config = Config.from_env(str(env_file))
|
||||
|
||||
assert config.allowed_urls == ["example.com", "test.org"]
|
||||
|
||||
def test_brand_specific_default_paths(self):
|
||||
"""Test brand-specific config and log directories."""
|
||||
config_path = Config.get_default_config_path("agravity_bridge")
|
||||
log_path = Config.get_default_log_path("agravity_bridge")
|
||||
|
||||
assert config_path.parts[-2:] == ("agravity_bridge", "config.json")
|
||||
assert log_path.parts[-2:] == ("logs", "agravity_bridge.log")
|
||||
|
||||
|
||||
class TestBootstrapEnvLoading:
|
||||
"""Test bootstrap .env loading behavior for packaged builds."""
|
||||
|
||||
def test_load_bootstrap_env_reads_meipass_dotenv(self, tmp_path, monkeypatch):
|
||||
"""Packaged app should load .env from PyInstaller runtime directory."""
|
||||
meipass_dir = tmp_path / "runtime"
|
||||
meipass_dir.mkdir(parents=True)
|
||||
env_path = meipass_dir / ".env"
|
||||
env_path.write_text("APP_NAME=Agravity Bridge\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(sys, "frozen", True, raising=False)
|
||||
monkeypatch.setattr(sys, "_MEIPASS", str(meipass_dir), raising=False)
|
||||
|
||||
loaded_path = Config.load_bootstrap_env()
|
||||
|
||||
assert loaded_path == env_path
|
||||
assert os.getenv("APP_NAME") == "Agravity Bridge"
|
||||
|
|
|
|||
60
tests/unit/test_i18n.py
Normal file
60
tests/unit/test_i18n.py
Normal 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
|
||||
|
|
@ -44,49 +44,56 @@ class TestSettingsDialogInitialization:
|
|||
qtbot.addWidget(dialog)
|
||||
|
||||
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):
|
||||
"""Test Web Source tab exists."""
|
||||
dialog = SettingsDialog(sample_config)
|
||||
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):
|
||||
"""Test Paths tab exists."""
|
||||
dialog = SettingsDialog(sample_config)
|
||||
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):
|
||||
"""Test URLs tab exists."""
|
||||
dialog = SettingsDialog(sample_config)
|
||||
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):
|
||||
"""Test Logging tab exists."""
|
||||
dialog = SettingsDialog(sample_config)
|
||||
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):
|
||||
"""Test Window tab exists."""
|
||||
dialog = SettingsDialog(sample_config)
|
||||
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):
|
||||
"""Test Profiles tab exists."""
|
||||
dialog = SettingsDialog(sample_config)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
assert dialog.tabs.tabText(5) == "Profiles"
|
||||
assert dialog.tabs.tabText(6) == "Profiles"
|
||||
|
||||
|
||||
class TestPathsTab:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,17 @@ def update_manager(tmp_path):
|
|||
return UpdateManager(current_version="0.0.1", config_dir=tmp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agravity_update_manager(tmp_path):
|
||||
"""Create a brand-aware UpdateManager instance for Agravity Bridge."""
|
||||
return UpdateManager(
|
||||
current_version="0.0.1",
|
||||
config_dir=tmp_path,
|
||||
brand_id="agravity",
|
||||
update_channel="stable",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_release():
|
||||
"""Sample release data from API."""
|
||||
|
|
@ -252,6 +263,143 @@ class TestDownloading:
|
|||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_update_uses_release_manifest(self, agravity_update_manager, tmp_path):
|
||||
"""Test branded download selection from a shared release manifest."""
|
||||
release = Release(
|
||||
tag_name="v0.0.2",
|
||||
name="WebDropBridge v0.0.2",
|
||||
version="0.0.2",
|
||||
body="Release notes",
|
||||
assets=[
|
||||
{
|
||||
"name": "AgravityBridge-0.0.2-win-x64.msi",
|
||||
"browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi",
|
||||
},
|
||||
{
|
||||
"name": "AgravityBridge-0.0.2-win-x64.msi.sha256",
|
||||
"browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi.sha256",
|
||||
},
|
||||
{
|
||||
"name": "OtherBridge-0.0.2-win-x64.msi",
|
||||
"browser_download_url": "https://example.com/OtherBridge-0.0.2-win-x64.msi",
|
||||
},
|
||||
{
|
||||
"name": "release-manifest.json",
|
||||
"browser_download_url": "https://example.com/release-manifest.json",
|
||||
},
|
||||
],
|
||||
published_at="2026-01-29T10:00:00Z",
|
||||
)
|
||||
|
||||
manifest = {
|
||||
"version": "0.0.2",
|
||||
"channel": "stable",
|
||||
"brands": {
|
||||
"agravity": {
|
||||
"windows-x64": {
|
||||
"installer": "AgravityBridge-0.0.2-win-x64.msi",
|
||||
"checksum": "AgravityBridge-0.0.2-win-x64.msi.sha256",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(UpdateManager, "_download_json_asset", return_value=manifest),
|
||||
patch.object(UpdateManager, "_download_file", return_value=True) as mock_download,
|
||||
):
|
||||
result = await agravity_update_manager.download_update(release, tmp_path)
|
||||
|
||||
assert result is not None
|
||||
assert result.name == "AgravityBridge-0.0.2-win-x64.msi"
|
||||
mock_download.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_update_falls_back_to_brand_prefix_without_manifest(
|
||||
self, agravity_update_manager, tmp_path
|
||||
):
|
||||
"""Test branded download selection still works when the manifest is unavailable."""
|
||||
release = Release(
|
||||
tag_name="v0.0.2",
|
||||
name="WebDropBridge v0.0.2",
|
||||
version="0.0.2",
|
||||
body="Release notes",
|
||||
assets=[
|
||||
{
|
||||
"name": "WebDropBridge-0.0.2-win-x64.msi",
|
||||
"browser_download_url": "https://example.com/WebDropBridge-0.0.2-win-x64.msi",
|
||||
},
|
||||
{
|
||||
"name": "AgravityBridge-0.0.2-win-x64.msi",
|
||||
"browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi",
|
||||
},
|
||||
{
|
||||
"name": "AgravityBridge-0.0.2-win-x64.msi.sha256",
|
||||
"browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi.sha256",
|
||||
},
|
||||
],
|
||||
published_at="2026-01-29T10:00:00Z",
|
||||
)
|
||||
|
||||
with patch.object(UpdateManager, "_download_file", return_value=True) as mock_download:
|
||||
result = await agravity_update_manager.download_update(release, tmp_path)
|
||||
|
||||
assert result is not None
|
||||
assert result.name == "AgravityBridge-0.0.2-win-x64.msi"
|
||||
mock_download.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_checksum_uses_release_manifest(self, agravity_update_manager, tmp_path):
|
||||
"""Test branded checksum selection from a shared release manifest."""
|
||||
test_file = tmp_path / "AgravityBridge-0.0.2-win-x64.msi"
|
||||
test_file.write_bytes(b"test content")
|
||||
|
||||
import hashlib
|
||||
|
||||
checksum = hashlib.sha256(b"test content").hexdigest()
|
||||
release = Release(
|
||||
tag_name="v0.0.2",
|
||||
name="WebDropBridge v0.0.2",
|
||||
version="0.0.2",
|
||||
body="Release notes",
|
||||
assets=[
|
||||
{
|
||||
"name": "AgravityBridge-0.0.2-win-x64.msi",
|
||||
"browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi",
|
||||
},
|
||||
{
|
||||
"name": "AgravityBridge-0.0.2-win-x64.msi.sha256",
|
||||
"browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi.sha256",
|
||||
},
|
||||
{
|
||||
"name": "release-manifest.json",
|
||||
"browser_download_url": "https://example.com/release-manifest.json",
|
||||
},
|
||||
],
|
||||
published_at="2026-01-29T10:00:00Z",
|
||||
)
|
||||
manifest = {
|
||||
"version": "0.0.2",
|
||||
"channel": "stable",
|
||||
"brands": {
|
||||
"agravity": {
|
||||
"windows-x64": {
|
||||
"installer": "AgravityBridge-0.0.2-win-x64.msi",
|
||||
"checksum": "AgravityBridge-0.0.2-win-x64.msi.sha256",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(UpdateManager, "_download_json_asset", return_value=manifest),
|
||||
patch.object(UpdateManager, "_download_checksum", return_value=checksum),
|
||||
):
|
||||
result = await agravity_update_manager.verify_checksum(test_file, release)
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestChecksumVerification:
|
||||
"""Test checksum verification."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue