diff --git a/.env.example b/.env.example index 928d91a..6f87e30 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ # Application APP_NAME=WebDrop Bridge -APP_VERSION=0.8.4 +APP_VERSION=0.8.6 # Web App WEBAPP_URL=file:///./webapp/index.html diff --git a/.tmp-update-check/update_check.json b/.tmp-update-check/update_check.json new file mode 100644 index 0000000..2ec89ce --- /dev/null +++ b/.tmp-update-check/update_check.json @@ -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"}} \ No newline at end of file diff --git a/build/WebDropBridge.wxs b/build/WebDropBridge.wxs index 38af7a1..699861c 100644 --- a/build/WebDropBridge.wxs +++ b/build/WebDropBridge.wxs @@ -2,7 +2,7 @@ - diff --git a/build/scripts/build_windows.py b/build/scripts/build_windows.py index 8ae8a20..b60d257 100644 --- a/build/scripts/build_windows.py +++ b/build/scripts/build_windows.py @@ -348,7 +348,7 @@ 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 / self.brand.windows_installer_name(self.version) @@ -360,7 +360,7 @@ class WindowsBuilder: "-ext", "WixUtilExtension", f"-dDistDir={self.dist_dir}", - f"-dSourceDir={self.dist_dir}\{self.brand.exe_name}", # 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) + "\\", @@ -455,6 +455,7 @@ class WindowsBuilder: 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, diff --git a/build/scripts/create_release.ps1 b/build/scripts/create_release.ps1 index 49de30d..187c9aa 100644 --- a/build/scripts/create_release.ps1 +++ b/build/scripts/create_release.ps1 @@ -80,7 +80,8 @@ if ($artifactPaths.Count -eq 0) { exit 1 } -$localData.manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $localManifestPath -Encoding utf8 +$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 @@ -155,28 +156,133 @@ else { Copy-Item $localManifestPath $manifestOutput -Force } +# 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))) +} + $artifactPaths.Add($manifestOutput) $assetMap = Get-AssetMap -Assets $releaseInfo.assets -$curlAuth = "$ForgejoUser`:$ForgejoPW" -$uploadUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/$releaseId/assets" - +$artifactsToUpload = New-Object System.Collections.Generic.List[string] foreach ($artifact in $artifactPaths) { $assetName = [System.IO.Path]::GetFileName($artifact) - if ($assetMap.ContainsKey($assetName)) { - $existingAsset = $assetMap[$assetName] - Invoke-RestMethod -Uri "$ForgejoUrl/api/v1/repos/$Repo/releases/$releaseId/assets/$($existingAsset.id)" -Method DELETE -Headers $headers -TimeoutSec 30 | Out-Null - Write-Host "[OK] Replaced existing asset $assetName" -ForegroundColor Yellow + $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 + } } - $response = curl.exe -s -X POST -u $curlAuth -F "attachment=@$artifact" $uploadUrl - if ($response -like "*error*" -or $response -like "*404*") { - Write-Host "WARNING: Could not upload $artifact : $response" -ForegroundColor Yellow - } - else { - Write-Host "[OK] Uploaded $assetName" -ForegroundColor Green + $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 { + $uploadUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/$releaseId/assets" + & $pythonExe $uploadScriptPath $uploadUrl @artifactsToUpload + if ($LASTEXITCODE -ne 0) { + exit 1 } } +finally { + Remove-Item $uploadScriptPath -ErrorAction SilentlyContinue +} Write-Host "`n[OK] Release complete!" -ForegroundColor Green Write-Host "View at: $ForgejoUrl/$Repo/releases/tag/v$Version" -ForegroundColor Cyan diff --git a/src/webdrop_bridge/__init__.py b/src/webdrop_bridge/__init__.py index e64f31d..604705f 100644 --- a/src/webdrop_bridge/__init__.py +++ b/src/webdrop_bridge/__init__.py @@ -1,6 +1,6 @@ """WebDrop Bridge - Qt-based desktop application for intelligent drag-and-drop file handling.""" -__version__ = "0.8.4" +__version__ = "0.8.6" __author__ = "WebDrop Team" __license__ = "MIT" diff --git a/src/webdrop_bridge/core/updater.py b/src/webdrop_bridge/core/updater.py index 92fe794..c235caa 100644 --- a/src/webdrop_bridge/core/updater.py +++ b/src/webdrop_bridge/core/updater.py @@ -93,7 +93,9 @@ class UpdateManager: """Download and parse a JSON asset from a release.""" try: with urlopen(url, timeout=10) as response: - return json.loads(response.read().decode("utf-8")) + # 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 @@ -148,8 +150,28 @@ class UpdateManager: 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 diff --git a/tests/unit/test_updater.py b/tests/unit/test_updater.py index f3f09a4..db57ebc 100644 --- a/tests/unit/test_updater.py +++ b/tests/unit/test_updater.py @@ -315,6 +315,40 @@ class TestDownloading: 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."""