Compare commits

...

18 commits

Author SHA1 Message Date
7873d0a060 Fix import order by moving QTabWidget to the correct position in settings_dialog.py
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
2026-02-20 08:45:54 +01:00
6ba40ce25d Add web source configuration tab and URL mapping management to settings dialog 2026-02-20 08:45:41 +01:00
f34421bb18 Update about dialog with enhanced product description and contact information 2026-02-20 08:35:20 +01:00
bf7c7b5e5f Add executable versioning support for Windows builds
- Implemented `set_exe_version` method in `WindowsBuilder` to set the version information for the generated executable.
- This ensures proper MSI updates by comparing file versions.
- Added error handling for missing `pefile` dependency and version resource.
- Updated `requirements-dev.txt` to include `pefile` as a dependency for building.
2026-02-20 08:24:44 +01:00
a8aa54fa5e Refactor logging configuration to use AppData directory
- Updated config.example.json to set default log_file to null.
- Modified config.py to resolve log file paths relative to the AppData directory.
- Added methods to get default log directory and log file path in AppData.
- Ensured logging behavior is consistent whether a log_file is specified or not.
2026-02-20 07:45:21 +01:00
b3fd61aed2 Update WiX source generation for per-machine installation and clarify admin rights requirement 2026-02-19 15:53:01 +01:00
8f3f859e5b Refactor Windows installer configuration and improve logging functionality
- Changed installation scope from "perMachine" to "perUser" in the Windows installer configuration.
- Updated installation directory from "ProgramFiles64Folder" to "LocalAppDataFolder" for user-specific installations.
- Enhanced the configuration saving method to create parent directories if they don't exist.
- Improved the main window script loading logic to support multiple installation scenarios (development, PyInstaller, MSI).
- Added detailed logging for script loading failures and success messages.
- Implemented a new method to reconfigure logging settings at runtime, allowing dynamic updates from the settings dialog.
- Enhanced the settings dialog to handle configuration saving, including log level changes and error handling.
2026-02-19 15:48:59 +01:00
0c276b9022 Add support for PyInstaller bundle in main_window.py
- Updated icon path handling to support both development mode and PyInstaller bundle.
- Modified script path loading to accommodate PyInstaller bundle structure.
- Adjusted download interceptor path to work with PyInstaller bundle.
2026-02-19 15:34:10 +01:00
d799339d93 cleanup 2026-02-19 15:16:05 +01:00
a20f703554 Enhance Windows installer configuration and add license file
- Updated the WiX configuration in build_windows.py to include required properties for the WixUI_InstallDir dialog.
- Changed the UI reference from WixUI_Minimal to WixUI_InstallDir for improved installation experience.
- Added custom branding images for the installer UI using WixVariable for background and banner images.
- Introduced a new license.rtf file containing the MIT License for the WebDrop Bridge project.
- Updated binary resources for background and banner images.
2026-02-19 15:09:08 +01:00
302ec15e15 Refactor code structure for improved readability and maintainability 2026-02-19 12:18:08 +01:00
aeed311f53 Add WixUIExtension for enhanced installation UI in WindowsBuilder
- Included WixUIExtension in candle and light commands to enable UI features.
- Added references for minimal and error progress UI dialogs in the WiX XML configuration.
2026-02-19 09:12:20 +01:00
f9cfb9f558 Add support for 64-bit components and create desktop shortcut in WiX installer
- Mark components as 64-bit by adding Win64="yes" attribute in harvested files.
- Update WiX configuration to use ProgramFiles64Folder for installation.
- Add a new component for creating a desktop shortcut for the WebDrop Bridge application.
2026-02-19 09:01:43 +01:00
37b772166c Fix WindowsBuilder executable path and enhance MSI linking error reporting
- Updated the path to the built executable to reflect changes in the output structure.
- Added calculation and display of total distribution size after build.
- Enhanced error reporting for the MSI linking process by capturing and printing stdout and stderr output.
- Updated WiX source file to include UI namespace for better compatibility.
- Removed unnecessary blank line in test_msi.py for cleaner code.
2026-02-18 18:20:57 +01:00
2b12ee2aef Add test script for MSI creation using WindowsBuilder
This commit introduces a new test script, `test_msi.py`, which automates the process of creating an MSI installer. The script utilizes the `WindowsBuilder` class to generate the installer and checks for its successful creation, providing feedback on the result and the file size.
2026-02-18 15:14:21 +01:00
6213bbfa0a feat: Update product version to 0.5.0 in WebDropBridge.wxs 2026-02-18 14:00:38 +01:00
aad2e59c1c refactor: Enhance Unicode handling in build scripts and rename sync_version function 2026-02-18 13:54:17 +01:00
ff804790e6 feat: Update application version to 0.5.0 and refactor version syncing script 2026-02-18 13:48:32 +01:00
27 changed files with 12665 additions and 132 deletions

View file

@ -2,7 +2,7 @@
# Application # Application
APP_NAME=WebDrop Bridge APP_NAME=WebDrop Bridge
APP_VERSION=0.1.0 APP_VERSION=0.5.0
# Web App # Web App
WEBAPP_URL=file:///./webapp/index.html WEBAPP_URL=file:///./webapp/index.html
@ -10,7 +10,8 @@ WEBAPP_URL=file:///./webapp/index.html
# Logging # Logging
LOG_LEVEL=DEBUG LOG_LEVEL=DEBUG
LOG_FILE=logs/webdrop_bridge.log # LOG_FILE defaults to AppData/Roaming/webdrop_bridge/logs/webdrop_bridge.log if not set
# LOG_FILE=logs/webdrop_bridge.log
ENABLE_LOGGING=true ENABLE_LOGGING=true
# Security - Path Whitelist # Security - Path Whitelist

View file

@ -18,10 +18,10 @@ Create a `config.json` file with the following structure:
```json ```json
{ {
"app_name": "WebDrop Bridge", "app_name": "WebDrop Bridge",
"webapp_url": "https://wps.agravity.io/", "webapp_url": "https://dev.agravity.io/",
"url_mappings": [ "url_mappings": [
{ {
"url_prefix": "https://wpsagravitystg.file.core.windows.net/wpsagravitysync/", "url_prefix": "https://devagravitystg.file.core.windows.net/devagravitysync/",
"local_path": "Z:" "local_path": "Z:"
} }
], ],
@ -45,7 +45,7 @@ Create a `config.json` file with the following structure:
### Core Settings ### Core Settings
- **`webapp_url`** (string): URL of the web application to load - **`webapp_url`** (string): URL of the web application to load
- Example: `"https://wps.agravity.io/"` - Example: `"https://dev.agravity.io/"`
- Supports `http://`, `https://`, or `file:///` URLs - Supports `http://`, `https://`, or `file:///` URLs
- **`url_mappings`** (array): Azure Blob Storage URL to local path mappings - **`url_mappings`** (array): Azure Blob Storage URL to local path mappings
@ -55,7 +55,7 @@ Create a `config.json` file with the following structure:
- Example: - Example:
```json ```json
{ {
"url_prefix": "https://wpsagravitystg.file.core.windows.net/wpsagravitysync/", "url_prefix": "https://dev.file.core.windows.net/devagravitysync/",
"local_path": "Z:" "local_path": "Z:"
} }
``` ```
@ -68,7 +68,7 @@ Create a `config.json` file with the following structure:
When the web application provides a drag URL like: When the web application provides a drag URL like:
``` ```
https://wpsagravitystg.file.core.windows.net/wpsagravitysync/aN5PysnXIuRECzcRbvHkjL7g0/Hintergrund_Agravity.png https://devagravitystg.file.core.windows.net/devagravitysync/aN5PysnXIuRECzcRbvHkjL7g0/Hintergrund_Agravity.png
``` ```
It will be converted to: It will be converted to:
@ -84,7 +84,7 @@ Z:\aN5PysnXIuRECzcRbvHkjL7g0\Hintergrund_Agravity.png
- **`allowed_urls`** (array): Allowed URL patterns for web content - **`allowed_urls`** (array): Allowed URL patterns for web content
- Empty array = no restriction - Empty array = no restriction
- Example: `["wps.agravity.io", "*.example.com"]` - Example: `["dev.agravity.io", "*.example.com"]`
### Update Settings ### Update Settings
@ -103,6 +103,13 @@ Z:\aN5PysnXIuRECzcRbvHkjL7g0\Hintergrund_Agravity.png
- Options: `"DEBUG"`, `"INFO"`, `"WARNING"`, `"ERROR"`, `"CRITICAL"` - Options: `"DEBUG"`, `"INFO"`, `"WARNING"`, `"ERROR"`, `"CRITICAL"`
- Default: `"INFO"` - Default: `"INFO"`
- **`log_file`** (string, optional): Path to log file
- If `null` or not specified: Logs to `%APPDATA%\webdrop_bridge\logs\webdrop_bridge.log` (Windows) or `~/.local/share/webdrop_bridge/logs/webdrop_bridge.log` (macOS/Linux)
- If relative path: Resolved relative to the app data directory (same as above location)
- If absolute path: Used as-is
- Default: `null` (uses AppData directory for permissions compatibility)
- **Important**: Logs are always stored in the user's AppData directory to ensure the app can write logs in both development and installed scenarios
- **`enable_logging`** (boolean): Whether to write logs to file - **`enable_logging`** (boolean): Whether to write logs to file
- Default: `true` - Default: `true`
@ -148,7 +155,7 @@ If no JSON config exists, WebDrop Bridge will load from `.env`:
```env ```env
APP_NAME=WebDrop Bridge APP_NAME=WebDrop Bridge
WEBAPP_URL=https://wps.agravity.io/ WEBAPP_URL=https://dev.agravity.io/
ALLOWED_ROOTS=Z:/ ALLOWED_ROOTS=Z:/
LOG_LEVEL=INFO LOG_LEVEL=INFO
WINDOW_WIDTH=1024 WINDOW_WIDTH=1024
@ -175,14 +182,14 @@ WINDOW_HEIGHT=768
## Example Configurations ## Example Configurations
### Production (Agravity WPS) ### Production (Agravity DEV)
```json ```json
{ {
"webapp_url": "https://wps.agravity.io/", "webapp_url": "https://dev.agravity.io/",
"url_mappings": [ "url_mappings": [
{ {
"url_prefix": "https://wpsagravitystg.file.core.windows.net/wpsagravitysync/", "url_prefix": "https://devagravitystg.file.core.windows.net/devagravitysync/",
"local_path": "Z:" "local_path": "Z:"
} }
], ],

View file

@ -1220,6 +1220,14 @@ Phase 4 Complete - Professional Features & Auto-Update system fully implemented
- ✅ Phase 4.3: Advanced Configuration & Settings UI (43 tests) - ✅ Phase 4.3: Advanced Configuration & Settings UI (43 tests)
- ✅ Total Phase 4: 139 tests passing, 90%+ coverage - ✅ Total Phase 4: 139 tests passing, 90%+ coverage
**MSI Update Support (Feb 20, 2026):**
- ✅ Added `<MajorUpgrade />` element to WiX configuration (build/WebDropBridge.wxs)
- ✅ Configured `Schedule="afterInstallInitialize"` for safe upgrade flow
- ✅ Implemented EXE version information setting in build script (build/scripts/build_windows.py)
- ✅ Added pefile dependency for version injection
- Impact: MSI installer now properly detects and applies version updates
- Status: Ready for Phase 5 release candidate builds
**Application Status:** **Application Status:**
- Version: 1.0.0 (released Jan 28, 2026) - Version: 1.0.0 (released Jan 28, 2026)
- Phase 1-3: Complete (core features, testing, build system) - Phase 1-3: Complete (core features, testing, build system)

File diff suppressed because one or more lines are too long

View file

@ -1,38 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"> <Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"
<Product Id="*" Name="WebDrop Bridge" Language="1033" Version="0.1.0" xmlns:ui="http://schemas.microsoft.com/wix/2010/ui">
<Product Id="*" Name="WebDrop Bridge" Language="1033" Version="0.5.0"
Manufacturer="HIM-Tools" Manufacturer="HIM-Tools"
UpgradeCode="12345678-1234-1234-1234-123456789012"> UpgradeCode="12345678-1234-1234-1234-123456789012">
<Package InstallerVersion="200" Compressed="yes" InstallScope="perMachine" /> <Package InstallerVersion="200" Compressed="yes" InstallScope="perMachine" Platform="x64" />
<Media Id="1" Cabinet="WebDropBridge.cab" EmbedCab="yes" /> <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" />
<Feature Id="ProductFeature" Title="WebDrop Bridge" Level="1"> <Feature Id="ProductFeature" Title="WebDrop Bridge" Level="1">
<ComponentRef Id="MainExecutable" /> <ComponentGroupRef Id="AppFiles" />
<ComponentRef Id="ProgramMenuShortcut" /> <ComponentRef Id="ProgramMenuShortcut" />
<ComponentRef Id="DesktopShortcut" />
</Feature> </Feature>
<Directory Id="TARGETDIR" Name="SourceDir"> <Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="ProgramFilesFolder"> <Directory Id="ProgramFiles64Folder">
<Directory Id="INSTALLFOLDER" Name="WebDrop Bridge" /> <Directory Id="INSTALLFOLDER" Name="WebDrop Bridge" />
</Directory> </Directory>
<Directory Id="ProgramMenuFolder"> <Directory Id="ProgramMenuFolder">
<Directory Id="ApplicationProgramsFolder" Name="WebDrop Bridge"/> <Directory Id="ApplicationProgramsFolder" Name="WebDrop Bridge"/>
</Directory> </Directory>
<Directory Id="DesktopFolder" />
</Directory> </Directory>
<DirectoryRef Id="INSTALLFOLDER">
<Component Id="MainExecutable" Guid="*">
<File Id="WebDropBridgeExe" Source="$(var.DistDir)\WebDropBridge.exe" KeyPath="yes"/>
</Component>
</DirectoryRef>
<DirectoryRef Id="ApplicationProgramsFolder"> <DirectoryRef Id="ApplicationProgramsFolder">
<Component Id="ProgramMenuShortcut" Guid="*"> <Component Id="ProgramMenuShortcut" Guid="*">
<Shortcut Id="ApplicationStartMenuShortcut" <Shortcut Id="ApplicationStartMenuShortcut"
Name="WebDrop Bridge" Name="WebDrop Bridge"
Description="Web Drag-and-Drop Bridge" Description="Web Drag-and-Drop Bridge"
Target="[INSTALLFOLDER]WebDropBridge.exe" Target="[INSTALLFOLDER]WebDropBridge.exe"
Icon="AppIcon.ico"
IconIndex="0"
WorkingDirectory="INSTALLFOLDER" /> WorkingDirectory="INSTALLFOLDER" />
<RemoveFolder Id="ApplicationProgramsFolderRemove" <RemoveFolder Id="ApplicationProgramsFolderRemove"
On="uninstall" /> On="uninstall" />
@ -44,5 +58,23 @@
KeyPath="yes" /> KeyPath="yes" />
</Component> </Component>
</DirectoryRef> </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> </Product>
</Wix> </Wix>

File diff suppressed because one or more lines are too long

11833
build/WebDropBridge_Files.wxs Normal file

File diff suppressed because it is too large Load diff

View file

@ -20,22 +20,29 @@ Usage:
""" """
import sys import sys
import subprocess
import os import os
from typing import Optional
# Fix Unicode output on Windows BEFORE any other imports
if sys.platform == "win32":
os.environ["PYTHONIOENCODING"] = "utf-8"
import io
# Reconfigure stdout/stderr for UTF-8 output
sys.stdout = io.TextIOWrapper(
sys.stdout.buffer, encoding="utf-8", errors="replace"
)
sys.stderr = io.TextIOWrapper(
sys.stderr.buffer, encoding="utf-8", errors="replace"
)
import subprocess
import shutil import shutil
import argparse import argparse
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
# Import shared version utilities # Import shared version utilities
from version_utils import get_current_version from sync_version import get_current_version, do_sync_version
# Fix Unicode output on Windows
if sys.platform == "win32":
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8")
class WindowsBuilder: class WindowsBuilder:
@ -116,8 +123,7 @@ class WindowsBuilder:
result = subprocess.run( result = subprocess.run(
cmd, cmd,
cwd=str(self.project_root), cwd=str(self.project_root),
encoding="utf-8", text=True,
errors="replace",
env=env env=env
) )
@ -125,14 +131,22 @@ class WindowsBuilder:
print("❌ PyInstaller build failed") print("❌ PyInstaller build failed")
return False return False
exe_path = self.dist_dir / "WebDropBridge.exe" # Check if executable exists (now in WebDropBridge/WebDropBridge.exe due to COLLECT)
exe_path = self.dist_dir / "WebDropBridge" / "WebDropBridge.exe"
if not exe_path.exists(): if not exe_path.exists():
print(f"❌ Executable not found at {exe_path}") print(f"❌ Executable not found at {exe_path}")
return False return False
print("✅ Executable built successfully") print("✅ Executable built successfully")
print(f"📦 Output: {exe_path}") print(f"📦 Output: {exe_path}")
print(f" Size: {exe_path.stat().st_size / 1024 / 1024:.1f} MB")
# Calculate total dist size
total_size = sum(f.stat().st_size for f in self.dist_dir.glob("WebDropBridge/**/*") if f.is_file())
if total_size > 0:
print(f" Total size: {total_size / 1024 / 1024:.1f} MB")
# Set executable version information (required for MSI updates)
self.set_exe_version(exe_path)
# Generate SHA256 checksum # Generate SHA256 checksum
self.generate_checksum(exe_path) self.generate_checksum(exe_path)
@ -158,6 +172,52 @@ class WindowsBuilder:
print(f" File: {checksum_file}") print(f" File: {checksum_file}")
print(f" Hash: {checksum}") print(f" Hash: {checksum}")
def set_exe_version(self, exe_path: Path) -> None:
"""Set executable file version for Windows.
This is important for MSI updates: Windows Installer compares file versions
to determine if files should be updated during a major upgrade.
Args:
exe_path: Path to the executable file
"""
print("\n🏷️ Setting executable version information...")
try:
import pefile
except ImportError:
print("⚠️ pefile not installed - skipping EXE version update")
print(" Note: Install with: pip install pefile")
print(" EXE version info will be blank (MSI updates may not work correctly)")
return
try:
pe = pefile.PE(str(exe_path))
# Parse version into 4-part format (Major, Minor, Build, Revision)
version_parts = self.version.split(".")
while len(version_parts) < 4:
version_parts.append("0")
file_version = tuple(int(v) for v in version_parts[:4])
# Set version resource if it exists
if hasattr(pe, "VS_FIXEDFILEINFO"):
pe.VS_FIXEDFILEINFO[0].FileVersionMS = (file_version[0] << 16) | file_version[1]
pe.VS_FIXEDFILEINFO[0].FileVersionLS = (file_version[2] << 16) | file_version[3]
pe.VS_FIXEDFILEINFO[0].ProductVersionMS = (file_version[0] << 16) | file_version[1]
pe.VS_FIXEDFILEINFO[0].ProductVersionLS = (file_version[2] << 16) | file_version[3]
# Write modified PE back to file
pe.write(filename=str(exe_path))
print(f"✅ Version set to {self.version}")
else:
print("⚠️ No version resource found in EXE")
except Exception as e:
print(f"⚠️ Could not set EXE version: {e}")
print(" MSI updates may not work correctly without file version info")
def create_msi(self) -> bool: def create_msi(self) -> bool:
"""Create MSI installer using WiX Toolset. """Create MSI installer using WiX Toolset.
@ -185,49 +245,96 @@ class WindowsBuilder:
print(" Or use: choco install wixtoolset") print(" Or use: choco install wixtoolset")
return False return False
# Create WiX source file # Create base WiX source file
if not self._create_wix_source(): if not self._create_wix_source():
return False return False
# Compile and link # Harvest application files using Heat
print(f" Harvesting application files...")
dist_folder = self.dist_dir / "WebDropBridge"
if not dist_folder.exists():
print(f"❌ Distribution folder not found: {dist_folder}")
return False
harvest_file = self.build_dir / "WebDropBridge_Files.wxs"
# Use Heat to harvest all files
heat_cmd = [
str(heat_exe),
"dir",
str(dist_folder),
"-cg", "AppFiles",
"-dr", "INSTALLFOLDER",
"-sfrag",
"-srd",
"-gg",
"-o", str(harvest_file),
]
result = subprocess.run(heat_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if result.returncode != 0:
print("⚠️ Heat harvest warnings (may be non-critical)")
if result.stderr:
print(result.stderr[:200]) # Show first 200 chars of errors
else:
print(f" ✓ Harvested files")
# Post-process harvested file to mark components as 64-bit
if harvest_file.exists():
content = harvest_file.read_text()
# Add Win64="yes" to all Component tags
content = content.replace(
'<Component ',
'<Component Win64="yes" '
)
harvest_file.write_text(content)
print(f" ✓ Marked components as 64-bit")
# Compile both WiX files
wix_obj = self.build_dir / "WebDropBridge.wixobj" wix_obj = self.build_dir / "WebDropBridge.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 / f"WebDropBridge-{self.version}-Setup.msi"
# Run candle (compiler) - pass preprocessor variables # Run candle compiler - make sure to use correct source directory
candle_cmd = [ candle_cmd = [
str(candle_exe), str(candle_exe),
"-ext", "WixUIExtension",
f"-dDistDir={self.dist_dir}", f"-dDistDir={self.dist_dir}",
"-o", f"-dSourceDir={self.dist_dir}\\WebDropBridge", # Set SourceDir for Heat-generated files
str(wix_obj), 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.wxs"),
] ]
if harvest_file.exists():
candle_cmd.append(str(harvest_file))
print(f" Compiling WiX source...") print(f" Compiling WiX source...")
result = subprocess.run( result = subprocess.run(candle_cmd, text=True, cwd=str(self.build_dir))
candle_cmd,
encoding="utf-8",
errors="replace"
)
if result.returncode != 0: if result.returncode != 0:
print("❌ WiX compilation failed") print("❌ WiX compilation failed")
return False return False
# Run light (linker) # Link MSI - include both obj files if harvest was successful
light_cmd = [ light_cmd = [
str(light_exe), str(light_exe),
"-o", "-ext", "WixUIExtension",
str(msi_output), "-b", str(self.dist_dir / "WebDropBridge"), # Base path for source files
"-o", str(msi_output),
str(wix_obj), str(wix_obj),
] ]
if wix_files_obj.exists():
light_cmd.append(str(wix_files_obj))
print(f" Linking MSI installer...") print(f" Linking MSI installer...")
result = subprocess.run( result = subprocess.run(light_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
light_cmd,
encoding="utf-8",
errors="replace"
)
if result.returncode != 0: if result.returncode != 0:
print("❌ MSI linking failed") print("❌ MSI linking failed")
if result.stdout:
print(f" Output: {result.stdout[:500]}")
if result.stderr:
print(f" Error: {result.stderr[:500]}")
return False return False
if not msi_output.exists(): if not msi_output.exists():
@ -241,42 +348,60 @@ class WindowsBuilder:
return True return True
def _create_wix_source(self) -> bool: def _create_wix_source(self) -> bool:
"""Create WiX source file for MSI generation.""" """Create WiX source file for MSI generation.
Creates per-machine installation (Program Files).
Installation requires admin rights, but the app does not.
"""
wix_content = f'''<?xml version="1.0" encoding="UTF-8"?> wix_content = f'''<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"> <Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui">
<Product Id="*" Name="WebDrop Bridge" Language="1033" Version="{self.version}" <Product Id="*" Name="WebDrop Bridge" Language="1033" Version="{self.version}"
Manufacturer="HIM-Tools" Manufacturer="HIM-Tools"
UpgradeCode="12345678-1234-1234-1234-123456789012"> UpgradeCode="12345678-1234-1234-1234-123456789012">
<Package InstallerVersion="200" Compressed="yes" InstallScope="perMachine" /> <Package InstallerVersion="200" Compressed="yes" InstallScope="perMachine" Platform="x64" />
<Media Id="1" Cabinet="WebDropBridge.cab" EmbedCab="yes" /> <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" />
<Feature Id="ProductFeature" Title="WebDrop Bridge" Level="1"> <Feature Id="ProductFeature" Title="WebDrop Bridge" Level="1">
<ComponentRef Id="MainExecutable" /> <ComponentGroupRef Id="AppFiles" />
<ComponentRef Id="ProgramMenuShortcut" /> <ComponentRef Id="ProgramMenuShortcut" />
<ComponentRef Id="DesktopShortcut" />
</Feature> </Feature>
<Directory Id="TARGETDIR" Name="SourceDir"> <Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="ProgramFilesFolder"> <Directory Id="ProgramFiles64Folder">
<Directory Id="INSTALLFOLDER" Name="WebDrop Bridge" /> <Directory Id="INSTALLFOLDER" Name="WebDrop Bridge" />
</Directory> </Directory>
<Directory Id="ProgramMenuFolder"> <Directory Id="ProgramMenuFolder">
<Directory Id="ApplicationProgramsFolder" Name="WebDrop Bridge"/> <Directory Id="ApplicationProgramsFolder" Name="WebDrop Bridge"/>
</Directory> </Directory>
<Directory Id="DesktopFolder" />
</Directory> </Directory>
<DirectoryRef Id="INSTALLFOLDER">
<Component Id="MainExecutable" Guid="*">
<File Id="WebDropBridgeExe" Source="$(var.DistDir)\\WebDropBridge.exe" KeyPath="yes"/>
</Component>
</DirectoryRef>
<DirectoryRef Id="ApplicationProgramsFolder"> <DirectoryRef Id="ApplicationProgramsFolder">
<Component Id="ProgramMenuShortcut" Guid="*"> <Component Id="ProgramMenuShortcut" Guid="*">
<Shortcut Id="ApplicationStartMenuShortcut" <Shortcut Id="ApplicationStartMenuShortcut"
Name="WebDrop Bridge" Name="WebDrop Bridge"
Description="Web Drag-and-Drop Bridge" Description="Web Drag-and-Drop Bridge"
Target="[INSTALLFOLDER]WebDropBridge.exe" Target="[INSTALLFOLDER]WebDropBridge.exe"
Icon="AppIcon.ico"
IconIndex="0"
WorkingDirectory="INSTALLFOLDER" /> WorkingDirectory="INSTALLFOLDER" />
<RemoveFolder Id="ApplicationProgramsFolderRemove" <RemoveFolder Id="ApplicationProgramsFolderRemove"
On="uninstall" /> On="uninstall" />
@ -288,6 +413,24 @@ class WindowsBuilder:
KeyPath="yes" /> KeyPath="yes" />
</Component> </Component>
</DirectoryRef> </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> </Product>
</Wix> </Wix>
''' '''
@ -297,6 +440,69 @@ class WindowsBuilder:
print(f" Created WiX source: {wix_file}") print(f" Created WiX source: {wix_file}")
return True return True
def _generate_file_elements(self, folder: Path, parent_dir_ref: str, parent_rel_path: str, indent: int = 8, file_counter: Optional[dict] = None) -> str:
"""Generate WiX File elements for all files in a folder.
Args:
folder: Root folder to scan
parent_dir_ref: Parent WiX DirectoryRef ID
parent_rel_path: Relative path for component structure
indent: Indentation level
file_counter: Dictionary to track file IDs for uniqueness
Returns:
WiX XML string with all File elements
"""
if file_counter is None:
file_counter = {}
elements = []
indent_str = " " * indent
try:
# Get all files in current folder
for item in sorted(folder.iterdir()):
if item.is_file():
# Create unique File element ID using hash of full path
import hashlib
path_hash = hashlib.md5(str(item).encode()).hexdigest()[:8]
file_id = f"File_{path_hash}"
file_path = str(item)
elements.append(f'{indent_str}<File Id="{file_id}" Source="{file_path}" />')
elif item.is_dir() and item.name != "__pycache__":
# Recursively add files from subdirectories
sub_elements = self._generate_file_elements(
item, parent_dir_ref,
f"{parent_rel_path}/{item.name}",
indent,
file_counter
)
if sub_elements:
elements.append(sub_elements)
except PermissionError:
print(f" ⚠️ Permission denied accessing {folder}")
return "\n".join(elements)
def _sanitize_id(self, filename: str) -> str:
"""Sanitize filename to be a valid WiX identifier.
Args:
filename: Filename to sanitize
Returns:
Sanitized identifier
"""
# Remove extension and invalid characters
safe_name = filename.rsplit(".", 1)[0] if "." in filename else filename
# Replace invalid characters with underscores
safe_name = "".join(c if c.isalnum() or c == "_" else "_" for c in safe_name)
# Ensure it starts with a letter or underscore
if safe_name and not (safe_name[0].isalpha() or safe_name[0] == "_"):
safe_name = f"_{safe_name}"
# Limit length to avoid WiX ID limits
return safe_name[:50] if len(safe_name) > 50 else safe_name
def sign_executable(self, cert_path: str, password: str) -> bool: def sign_executable(self, cert_path: str, password: str) -> bool:
"""Sign executable with certificate (optional). """Sign executable with certificate (optional).
@ -330,8 +536,7 @@ class WindowsBuilder:
result = subprocess.run( result = subprocess.run(
cmd, cmd,
encoding="utf-8", text=True
errors="replace"
) )
if result.returncode != 0: if result.returncode != 0:
print("❌ Code signing failed") print("❌ Code signing failed")
@ -402,7 +607,7 @@ def main() -> int:
args = parser.parse_args() args = parser.parse_args()
print("🔄 Syncing version...") print("🔄 Syncing version...")
sync_version() do_sync_version()
try: try:
builder = WindowsBuilder(env_file=args.env_file) builder = WindowsBuilder(env_file=args.env_file)

View file

@ -25,10 +25,17 @@ param(
$ErrorActionPreference = "Stop" $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 to read version from .env or .env.example
function Get-VersionFromEnv { function Get-VersionFromEnv {
# PSScriptRoot is build/scripts, go up to project root with ../../ # Use already resolved project root
$projectRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..")
# Try .env first (runtime config), then .env.example (template) # Try .env first (runtime config), then .env.example (template)
$envFile = Join-Path $projectRoot ".env" $envFile = Join-Path $projectRoot ".env"

View file

@ -23,9 +23,8 @@ from pathlib import Path
# Enable UTF-8 output on Windows # Enable UTF-8 output on Windows
if sys.platform == "win32": if sys.platform == "win32":
import io import os
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") os.environ["PYTHONIOENCODING"] = "utf-8"
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8")
# Import shared version utilities # Import shared version utilities
sys.path.insert(0, str(Path(__file__).parent.parent / "build" / "scripts")) sys.path.insert(0, str(Path(__file__).parent.parent / "build" / "scripts"))
@ -131,34 +130,26 @@ def update_changelog(version: str) -> None:
print(f"✓ Added version header to CHANGELOG.md for {version}") print(f"✓ Added version header to CHANGELOG.md for {version}")
def main() -> int: def do_sync_version(version: str | None = None) -> int:
"""Sync version across project. """Sync version across project.
Updates __init__.py (source of truth) and changelog. Updates __init__.py (source of truth) and changelog.
Config and pyproject.toml automatically read from __init__.py. Config and pyproject.toml automatically read from __init__.py.
Args:
version: Version to set (if None, reads from __init__.py)
Returns: Returns:
0 on success, 1 on error 0 on success, 1 on error
""" """
parser = argparse.ArgumentParser(
description="Sync version from __init__.py to dependent files"
)
parser.add_argument(
"--version",
type=str,
help="Version to set (if not provided, reads from __init__.py)",
)
args = parser.parse_args()
try: try:
if args.version: if version:
if not re.match(r"^\d+\.\d+\.\d+", args.version): if not re.match(r"^\d+\.\d+\.\d+", version):
print( print(
"❌ Invalid version format. Use semantic versioning" "❌ Invalid version format. Use semantic versioning"
" (e.g., 1.2.3)" " (e.g., 1.2.3)"
) )
return 1 return 1
version = args.version
update_init_version(version) update_init_version(version)
else: else:
version = get_current_version_from_init() version = get_current_version_from_init()
@ -175,5 +166,26 @@ def main() -> int:
return 1 return 1
def sync_version() -> int:
"""Sync version across project (command-line interface).
Parses command-line arguments and calls do_sync_version().
This function is only called when the script is run directly.
Returns:
0 on success, 1 on error
"""
parser = argparse.ArgumentParser(
description="Sync version from __init__.py to dependent files"
)
parser.add_argument(
"--version",
type=str,
help="Version to set (if not provided, reads from __init__.py)",
)
args = parser.parse_args()
return do_sync_version(args.version)
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(sync_version())

1
build/test.txt Normal file
View file

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

View file

@ -1,9 +1,9 @@
{ {
"app_name": "WebDrop Bridge", "app_name": "WebDrop Bridge",
"webapp_url": "https://wps.agravity.io/", "webapp_url": "https://dev.agravity.io/",
"url_mappings": [ "url_mappings": [
{ {
"url_prefix": "https://wpsagravitystg.file.core.windows.net/wpsagravitysync/", "url_prefix": "https://devagravitystg.file.core.windows.net/devagravitysync/",
"local_path": "Z:" "local_path": "Z:"
} }
], ],
@ -15,7 +15,7 @@
"auto_check_updates": true, "auto_check_updates": true,
"update_check_interval_hours": 24, "update_check_interval_hours": 24,
"log_level": "INFO", "log_level": "INFO",
"log_file": "logs/webdrop_bridge.log", "log_file": null,
"window_width": 1024, "window_width": 1024,
"window_height": 768, "window_height": 768,
"enable_logging": true "enable_logging": true

View file

@ -14,6 +14,7 @@ isort>=5.12.0
# Building # Building
pyinstaller>=6.0.0 pyinstaller>=6.0.0
pefile>=2023.2.7
# Documentation # Documentation
sphinx>=7.0.0 sphinx>=7.0.0

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 356 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

BIN
resources/icons/banner.bmp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

18
resources/license.rtf Normal file
View file

@ -0,0 +1,18 @@
{\rtf1\ansi\ansicpg1252\cocoartf2\cuc
{\fonttbl\f0\fswiss Helvetica;}
{\colortbl;\red255\green255\blue255;}
{\*\expandedcolortbl;;}
\margl1440\margr1440\vieww11900\viewh8605\viewkind0
\pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfocus1
\f0\fs20 \cf0 WebDrop Bridge - License Agreement\
\
\b MIT License\b0\
\
Copyright (c) 2026 HIM-Tools\
\
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\
\
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\
\
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.}

View file

@ -49,7 +49,7 @@ class Config:
log_file: Optional log file path log_file: Optional log file path
allowed_roots: List of whitelisted root directories for file access allowed_roots: List of whitelisted root directories for file access
allowed_urls: List of whitelisted URL domains/patterns (empty = no restriction) allowed_urls: List of whitelisted URL domains/patterns (empty = no restriction)
webapp_url: URL to load in embedded web application (default: https://wps.agravity.io/) webapp_url: URL to load in embedded web application (default: https://dev.agravity.io/)
url_mappings: List of Azure URL to local path mappings url_mappings: List of Azure URL to local path mappings
check_file_exists: Whether to validate that files exist before drag check_file_exists: Whether to validate that files exist before drag
auto_check_updates: Whether to automatically check for updates auto_check_updates: Whether to automatically check for updates
@ -126,8 +126,15 @@ class Config:
# Get log file path # Get log file path
log_file = None log_file = None
if data.get("enable_logging", True): if data.get("enable_logging", True):
log_file_str = data.get("log_file", "logs/webdrop_bridge.log") log_file_str = data.get("log_file", None)
log_file = Path(log_file_str).resolve() if log_file_str:
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
else:
# Use default log path in app data
log_file = Config.get_default_log_path()
app_name = data.get("app_name", "WebDrop Bridge") app_name = data.get("app_name", "WebDrop Bridge")
window_title = data.get("window_title", f"{app_name} v{__version__}") window_title = data.get("window_title", f"{app_name} v{__version__}")
@ -139,7 +146,7 @@ class Config:
log_file=log_file, log_file=log_file,
allowed_roots=allowed_roots, allowed_roots=allowed_roots,
allowed_urls=data.get("allowed_urls", []), allowed_urls=data.get("allowed_urls", []),
webapp_url=data.get("webapp_url", "https://wps.agravity.io/"), webapp_url=data.get("webapp_url", "https://dev.agravity.io/"),
url_mappings=mappings, url_mappings=mappings,
check_file_exists=data.get("check_file_exists", True), check_file_exists=data.get("check_file_exists", True),
auto_check_updates=data.get("auto_check_updates", True), auto_check_updates=data.get("auto_check_updates", True),
@ -178,10 +185,10 @@ class Config:
else: else:
app_version = os.getenv("APP_VERSION") app_version = os.getenv("APP_VERSION")
log_level = os.getenv("LOG_LEVEL", "INFO").upper() log_level = os.getenv("LOG_LEVEL", "INFO").upper()
log_file_str = os.getenv("LOG_FILE", "logs/webdrop_bridge.log") log_file_str = os.getenv("LOG_FILE", None)
allowed_roots_str = os.getenv("ALLOWED_ROOTS", "Z:/,C:/Users/Public") allowed_roots_str = os.getenv("ALLOWED_ROOTS", "Z:/,C:/Users/Public")
allowed_urls_str = os.getenv("ALLOWED_URLS", "") allowed_urls_str = os.getenv("ALLOWED_URLS", "")
webapp_url = os.getenv("WEBAPP_URL", "https://wps.agravity.io/") webapp_url = os.getenv("WEBAPP_URL", "https://dev.agravity.io/")
window_width = int(os.getenv("WINDOW_WIDTH", "1024")) window_width = int(os.getenv("WINDOW_WIDTH", "1024"))
window_height = int(os.getenv("WINDOW_HEIGHT", "768")) window_height = int(os.getenv("WINDOW_HEIGHT", "768"))
# Window title defaults to app_name + version if not specified # Window title defaults to app_name + version if not specified
@ -227,7 +234,14 @@ class Config:
# Create log file path if logging enabled # Create log file path if logging enabled
log_file = None log_file = None
if enable_logging: if enable_logging:
log_file = Path(log_file_str).resolve() if log_file_str:
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
else:
# Use default log path in app data
log_file = Config.get_default_log_path()
# Validate webapp URL is not empty # Validate webapp URL is not empty
if not webapp_url: if not webapp_url:
@ -284,7 +298,9 @@ class Config:
"""Save configuration to JSON file. """Save configuration to JSON file.
Args: Args:
config_path: Path to save configuration config_path: Path to save configuration to
Creates parent directories if they don't exist.
""" """
data = { data = {
"app_name": self.app_name, "app_name": self.app_name,
@ -318,7 +334,7 @@ class Config:
"""Get the default configuration file path. """Get the default configuration file path.
Returns: Returns:
Path to default config file Path to default config file in user's AppData/Roaming
""" """
import platform import platform
if platform.system() == "Windows": if platform.system() == "Windows":
@ -327,6 +343,32 @@ class Config:
base = Path.home() / ".config" base = Path.home() / ".config"
return base / "webdrop_bridge" / "config.json" return base / "webdrop_bridge" / "config.json"
@staticmethod
def get_default_log_dir() -> Path:
"""Get the default directory for log files.
Always uses user's AppData directory to ensure permissions work
correctly in both development and installed scenarios.
Returns:
Path to default logs directory in user's AppData/Roaming
"""
import platform
if platform.system() == "Windows":
base = Path.home() / "AppData" / "Roaming"
else:
base = Path.home() / ".local" / "share"
return base / "webdrop_bridge" / "logs"
@staticmethod
def get_default_log_path() -> 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"
def __repr__(self) -> str: def __repr__(self) -> str:
"""Return developer-friendly representation.""" """Return developer-friendly representation."""
return ( return (

View file

@ -32,7 +32,7 @@ class URLConverter:
Example: Example:
>>> converter.convert_url_to_path( >>> converter.convert_url_to_path(
... "https://wpsagravitystg.file.core.windows.net/wpsagravitysync/aN5PysnXIuRECzcRbvHkjL7g0/file.png" ... "https://devagravitystg.file.core.windows.net/devagravitysync/aN5PysnXIuRECzcRbvHkjL7g0/file.png"
... ) ... )
Path("Z:/aN5PysnXIuRECzcRbvHkjL7g0/file.png") Path("Z:/aN5PysnXIuRECzcRbvHkjL7g0/file.png")
""" """

View file

@ -4,6 +4,7 @@ import asyncio
import json import json
import logging import logging
import re import re
import sys
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@ -280,7 +281,14 @@ class MainWindow(QMainWindow):
) )
# Set window icon # Set window icon
# Support both development mode and PyInstaller bundle
if hasattr(sys, '_MEIPASS'):
# Running as PyInstaller bundle
icon_path = Path(sys._MEIPASS) / "resources" / "icons" / "app.ico" # type: ignore
else:
# Running in development mode
icon_path = Path(__file__).parent.parent.parent.parent / "resources" / "icons" / "app.ico" icon_path = Path(__file__).parent.parent.parent.parent / "resources" / "icons" / "app.ico"
if icon_path.exists(): if icon_path.exists():
self.setWindowIcon(QIcon(str(icon_path))) self.setWindowIcon(QIcon(str(icon_path)))
logger.debug(f"Window icon set from {icon_path}") logger.debug(f"Window icon set from {icon_path}")
@ -440,21 +448,72 @@ class MainWindow(QMainWindow):
config_code = self._generate_config_injection_script() config_code = self._generate_config_injection_script()
# Load bridge script from file # Load bridge script from file
# Using intercept script - prevents browser drag, hands off to Qt # Try multiple paths to support dev mode, PyInstaller bundle, and MSI installation
script_path = Path(__file__).parent / "bridge_script_intercept.js" # 1. Development mode: __file__.parent / script.js
# 2. PyInstaller bundle: sys._MEIPASS / webdrop_bridge / ui / script.js
# 3. MSI installation: Same directory as executable
script_path = None
download_interceptor_path = None
# List of paths to try in order of preference
search_paths = []
# 1. Development mode
search_paths.append(Path(__file__).parent / "bridge_script_intercept.js")
# 2. PyInstaller bundle (via sys._MEIPASS)
if hasattr(sys, '_MEIPASS'):
search_paths.append(Path(sys._MEIPASS) / "webdrop_bridge" / "ui" / "bridge_script_intercept.js") # type: ignore
# 3. Installed executable's directory (handles MSI installation where all files are packaged together)
exe_dir = Path(sys.executable).parent
search_paths.append(exe_dir / "webdrop_bridge" / "ui" / "bridge_script_intercept.js")
# Find the bridge script
for path in search_paths:
if path.exists():
script_path = path
logger.debug(f"Found bridge script at: {script_path}")
break
if script_path is None:
# Log all attempted paths for debugging
logger.error("Bridge script NOT found at any expected location:")
for i, path in enumerate(search_paths, 1):
logger.error(f" [{i}] {path} (exists: {path.exists()})")
logger.error(f"sys._MEIPASS: {getattr(sys, '_MEIPASS', 'NOT SET')}")
logger.error(f"sys.executable: {sys.executable}")
logger.error(f"__file__: {__file__}")
try: try:
if script_path is None:
raise FileNotFoundError("bridge_script_intercept.js not found in any expected location")
with open(script_path, 'r', encoding='utf-8') as f: with open(script_path, 'r', encoding='utf-8') as f:
bridge_code = f.read() bridge_code = f.read()
# Load download interceptor # Load download interceptor using similar search path logic
download_interceptor_path = Path(__file__).parent / "download_interceptor.js" download_search_paths = []
download_search_paths.append(Path(__file__).parent / "download_interceptor.js")
if hasattr(sys, '_MEIPASS'):
download_search_paths.append(Path(sys._MEIPASS) / "webdrop_bridge" / "ui" / "download_interceptor.js") # type: ignore
download_search_paths.append(exe_dir / "webdrop_bridge" / "ui" / "download_interceptor.js")
download_interceptor_code = "" download_interceptor_code = ""
for path in download_search_paths:
if path.exists():
download_interceptor_path = path
break
if download_interceptor_path:
try: try:
with open(download_interceptor_path, 'r', encoding='utf-8') as f: with open(download_interceptor_path, 'r', encoding='utf-8') as f:
download_interceptor_code = f.read() download_interceptor_code = f.read()
logger.debug(f"Loaded download interceptor from {download_interceptor_path}") logger.debug(f"Loaded download interceptor from {download_interceptor_path}")
except (OSError, IOError) as e: except (OSError, IOError) as e:
logger.warning(f"Download interceptor not found: {e}") logger.warning(f"Download interceptor exists but failed to load: {e}")
else:
logger.debug("Download interceptor not found (optional)")
# Combine: qwebchannel.js + config + bridge script + download interceptor # Combine: qwebchannel.js + config + bridge script + download interceptor
combined_code = qwebchannel_code + "\n\n" + config_code + "\n\n" + bridge_code combined_code = qwebchannel_code + "\n\n" + config_code + "\n\n" + bridge_code
@ -473,9 +532,13 @@ class MainWindow(QMainWindow):
script.setSourceCode(combined_code) script.setSourceCode(combined_code)
self.web_view.page().scripts().insert(script) self.web_view.page().scripts().insert(script)
logger.debug(f"Installed bridge script from {script_path}") logger.debug(f"✅ Successfully installed bridge script")
logger.debug(f" Script size: {len(combined_code)} chars")
logger.debug(f" Loaded from: {script_path}")
except (OSError, IOError) as e: except (OSError, IOError) as e:
logger.warning(f"Failed to load bridge script: {e}") logger.error(f"❌ Failed to load bridge script: {e}")
logger.error(f" This will break drag-and-drop functionality!")
# Don't re-raise - allow app to start (will show error in logs)
def _generate_config_injection_script(self) -> str: def _generate_config_injection_script(self) -> str:
"""Generate JavaScript code that injects configuration. """Generate JavaScript code that injects configuration.
@ -1095,10 +1158,21 @@ class MainWindow(QMainWindow):
f"<b>{self.config.app_name}</b><br>" f"<b>{self.config.app_name}</b><br>"
f"Version: {self.config.app_version}<br>" f"Version: {self.config.app_version}<br>"
f"<br>" f"<br>"
f"A professional Qt-based desktop application that converts " f"Bridges web-based drag-and-drop workflows with native file operations "
f"web-based drag-and-drop text paths into native file operations.<br>" f"for professional desktop applications.<br>"
f"<br>" f"<br>"
f"<small>© 2026 WebDrop Bridge Contributors</small>" f"<b>Product of:</b><br>"
f"<b>Hörl Information Management GmbH</b><br>"
f"Silberburgstraße 126<br>"
f"70176 Stuttgart, Germany<br>"
f"<br>"
f"<small>"
f"<b>Email:</b> <a href='mailto:info@hoerl-im.de'>info@hoerl-im.de</a><br>"
f"<b>Phone:</b> +49 (0) 711 933 42 52 0<br>"
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>"
) )
QMessageBox.about(self, f"About {self.config.app_name}", about_text) QMessageBox.about(self, f"About {self.config.app_name}", about_text)

View file

@ -1,5 +1,6 @@
"""Settings dialog for configuration management.""" """Settings dialog for configuration management."""
import logging
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
@ -15,6 +16,8 @@ from PySide6.QtWidgets import (
QListWidgetItem, QListWidgetItem,
QPushButton, QPushButton,
QSpinBox, QSpinBox,
QTableWidget,
QTableWidgetItem,
QTabWidget, QTabWidget,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
@ -22,6 +25,9 @@ from PySide6.QtWidgets import (
from webdrop_bridge.config import Config, ConfigurationError from webdrop_bridge.config import Config, ConfigurationError
from webdrop_bridge.core.config_manager import ConfigExporter, ConfigProfile, ConfigValidator from webdrop_bridge.core.config_manager import ConfigExporter, ConfigProfile, ConfigValidator
from webdrop_bridge.utils.logging import reconfigure_logging
logger = logging.getLogger(__name__)
class SettingsDialog(QDialog): class SettingsDialog(QDialog):
@ -58,6 +64,7 @@ class SettingsDialog(QDialog):
self.tabs = QTabWidget() self.tabs = QTabWidget()
# Add tabs # 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_paths_tab(), "Paths")
self.tabs.addTab(self._create_urls_tab(), "URLs") self.tabs.addTab(self._create_urls_tab(), "URLs")
self.tabs.addTab(self._create_logging_tab(), "Logging") self.tabs.addTab(self._create_logging_tab(), "Logging")
@ -76,6 +83,200 @@ class SettingsDialog(QDialog):
self.setLayout(layout) 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.
"""
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 = [
URLMapping(
url_prefix=m["url_prefix"],
local_path=m["local_path"]
)
for m in config_data["url_mappings"]
]
# Update the config object with new values
old_log_level = self.config.log_level
self.config.log_level = config_data["log_level"]
self.config.log_file = Path(config_data["log_file"]) if config_data["log_file"] else None
self.config.allowed_roots = [Path(r).resolve() for r in config_data["allowed_roots"]]
self.config.allowed_urls = config_data["allowed_urls"]
self.config.webapp_url = config_data["webapp_url"]
self.config.url_mappings = url_mappings
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()
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}")
# Call parent accept to close dialog
super().accept()
except ConfigurationError as e:
logger.error(f"Configuration error: {e}")
self._show_error(f"Configuration Error:\n\n{e}")
except Exception as e:
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
widget = QWidget()
layout = QVBoxLayout()
# Webapp URL configuration
layout.addWidget(QLabel("Web Application URL:"))
url_layout = QHBoxLayout()
self.webapp_url_input = QLineEdit()
self.webapp_url_input.setText(self.config.webapp_url)
self.webapp_url_input.setPlaceholderText("e.g., http://localhost:8080 or file:///./webapp/index.html")
url_layout.addWidget(self.webapp_url_input)
open_btn = QPushButton("Open")
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):"))
# 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.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)
self.url_mappings_table.setItem(row, 0, QTableWidgetItem(mapping.url_prefix))
self.url_mappings_table.setItem(row, 1, QTableWidgetItem(mapping.local_path))
layout.addWidget(self.url_mappings_table)
# Buttons for URL mapping management
button_layout = QHBoxLayout()
add_mapping_btn = QPushButton("Add Mapping")
add_mapping_btn.clicked.connect(self._add_url_mapping)
button_layout.addWidget(add_mapping_btn)
edit_mapping_btn = QPushButton("Edit Selected")
edit_mapping_btn.clicked.connect(self._edit_url_mapping)
button_layout.addWidget(edit_mapping_btn)
remove_mapping_btn = QPushButton("Remove Selected")
remove_mapping_btn.clicked.connect(self._remove_url_mapping)
button_layout.addWidget(remove_mapping_btn)
layout.addLayout(button_layout)
layout.addStretch()
widget.setLayout(layout)
return widget
def _open_webapp_url(self) -> None:
"""Open the webapp URL in the default browser."""
import webbrowser
url = self.webapp_url_input.text().strip()
if url:
# Handle file:// URLs
try:
webbrowser.open(url)
except Exception as e:
logger.error(f"Failed to open URL: {e}")
self._show_error(f"Failed to open URL:\n\n{e}")
def _add_url_mapping(self) -> None:
"""Add new URL mapping."""
from PySide6.QtWidgets import QInputDialog
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/)"
)
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)"
)
if ok2 and local_path:
row = self.url_mappings_table.rowCount()
self.url_mappings_table.insertRow(row)
self.url_mappings_table.setItem(row, 0, QTableWidgetItem(url_prefix))
self.url_mappings_table.setItem(row, 1, QTableWidgetItem(local_path))
def _edit_url_mapping(self) -> None:
"""Edit selected URL mapping."""
from PySide6.QtWidgets import QInputDialog
current_row = self.url_mappings_table.currentRow()
if current_row < 0:
self._show_error("Please select a mapping to edit")
return
url_prefix = self.url_mappings_table.item(current_row, 0).text()
local_path = self.url_mappings_table.item(current_row, 1).text()
new_url_prefix, ok1 = QInputDialog.getText(
self,
"Edit URL Mapping",
"Enter Azure Blob Storage URL prefix:",
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
)
if ok2 and new_local_path:
self.url_mappings_table.setItem(current_row, 0, QTableWidgetItem(new_url_prefix))
self.url_mappings_table.setItem(current_row, 1, QTableWidgetItem(new_local_path))
def _remove_url_mapping(self) -> None:
"""Remove selected URL mapping."""
current_row = self.url_mappings_table.currentRow()
if current_row >= 0:
self.url_mappings_table.removeRow(current_row)
def _create_paths_tab(self) -> QWidget: def _create_paths_tab(self) -> QWidget:
"""Create paths configuration tab.""" """Create paths configuration tab."""
widget = QWidget() widget = QWidget()
@ -414,7 +615,14 @@ class SettingsDialog(QDialog):
"log_file": self.log_file_input.text() or None, "log_file": self.log_file_input.text() or None,
"allowed_roots": [self.paths_list.item(i).text() for i in range(self.paths_list.count())], "allowed_roots": [self.paths_list.item(i).text() for i in range(self.paths_list.count())],
"allowed_urls": [self.urls_list.item(i).text() for i in range(self.urls_list.count())], "allowed_urls": [self.urls_list.item(i).text() for i in range(self.urls_list.count())],
"webapp_url": self.config.webapp_url, "webapp_url": self.webapp_url_input.text().strip(),
"url_mappings": [
{
"url_prefix": self.url_mappings_table.item(i, 0).text(),
"local_path": self.url_mappings_table.item(i, 1).text()
}
for i in range(self.url_mappings_table.rowCount())
],
"window_width": self.width_spin.value(), "window_width": self.width_spin.value(),
"window_height": self.height_spin.value(), "window_height": self.height_spin.value(),
"enable_logging": self.config.enable_logging, "enable_logging": self.config.enable_logging,

View file

@ -154,6 +154,66 @@ def setup_logging(
return logger return logger
def reconfigure_logging(
logger_name: str = "webdrop_bridge",
level: str = "INFO",
log_file: Optional[Path] = None,
) -> None:
"""Reconfigure existing logger at runtime (e.g., from settings dialog).
Updates the log level and log file for a running logger.
Useful when user changes logging settings in the UI.
Args:
logger_name: Name of logger to reconfigure
level: New logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
log_file: Optional path to log file
Raises:
KeyError: If level is not a valid logging level
"""
try:
numeric_level = getattr(logging, level.upper())
except AttributeError as e:
raise KeyError(f"Invalid logging level: {level}") from e
logger = logging.getLogger(logger_name)
logger.setLevel(numeric_level)
# Update level on all existing handlers
for handler in logger.handlers:
handler.setLevel(numeric_level)
# If log file changed, remove old file handler and add new one
if log_file:
# Remove old file handlers
for handler in logger.handlers[:]:
if isinstance(handler, logging.handlers.RotatingFileHandler):
handler.close()
logger.removeHandler(handler)
try:
# Create parent directories if needed
log_file.parent.mkdir(parents=True, exist_ok=True)
# Create new file handler
file_handler = logging.handlers.RotatingFileHandler(
log_file,
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=5,
encoding="utf-8",
)
file_handler.setLevel(numeric_level)
# Use same formatter as existing handlers
if logger.handlers:
file_handler.setFormatter(logger.handlers[0].formatter)
logger.addHandler(file_handler)
except (OSError, IOError) as e:
raise ValueError(f"Cannot write to log file {log_file}: {e}") from e
def get_logger(name: str = __name__) -> logging.Logger: def get_logger(name: str = __name__) -> logging.Logger:
"""Get a logger instance for a module. """Get a logger instance for a module.

23
test_msi.py Normal file
View file

@ -0,0 +1,23 @@
#!/usr/bin/env python
"""Test MSI creation."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / "build" / "scripts"))
from build_windows import WindowsBuilder
if __name__ == "__main__":
builder = WindowsBuilder()
print("Creating MSI installer...")
result = builder.create_msi()
print(f"MSI Creation Result: {result}")
# Check if MSI was created
msi_path = builder.dist_dir / f"WebDropBridge-{builder.version}-Setup.msi"
if msi_path.exists():
print(f"\n✅ MSI created successfully: {msi_path}")
print(f" Size: {msi_path.stat().st_size / 1024 / 1024:.1f} MB")
else:
print(f"\n❌ MSI not found: {msi_path}")

View file

@ -19,7 +19,7 @@ def test_config(tmp_path):
log_file=None, log_file=None,
allowed_roots=[tmp_path], allowed_roots=[tmp_path],
allowed_urls=[], allowed_urls=[],
webapp_url="https://wps.agravity.io/", webapp_url="https://dev.agravity.io/",
url_mappings=[], url_mappings=[],
check_file_exists=True, check_file_exists=True,
) )
@ -135,7 +135,7 @@ class TestDragInterceptorAzureURL:
webapp_url="https://test.com/", webapp_url="https://test.com/",
url_mappings=[ url_mappings=[
URLMapping( URLMapping(
url_prefix="https://wpsagravitystg.file.core.windows.net/wpsagravitysync/", url_prefix="https://devagravitystg.file.core.windows.net/devagravitysync/",
local_path=str(tmp_path) local_path=str(tmp_path)
) )
], ],
@ -144,7 +144,7 @@ class TestDragInterceptorAzureURL:
interceptor = DragInterceptor(config) interceptor = DragInterceptor(config)
# Azure URL # Azure URL
azure_url = "https://wpsagravitystg.file.core.windows.net/wpsagravitysync/test.png" azure_url = "https://devagravitystg.file.core.windows.net/devagravitysync/test.png"
# Mock the drag operation # Mock the drag operation
with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag: with patch("webdrop_bridge.core.drag_interceptor.QDrag") as mock_drag:

View file

@ -18,10 +18,10 @@ def test_config():
log_file=None, log_file=None,
allowed_roots=[], allowed_roots=[],
allowed_urls=[], allowed_urls=[],
webapp_url="https://wps.agravity.io/", webapp_url="https://dev.agravity.io/",
url_mappings=[ url_mappings=[
URLMapping( URLMapping(
url_prefix="https://wpsagravitystg.file.core.windows.net/wpsagravitysync/", url_prefix="https://devagravitystg.file.core.windows.net/devagravitysync/",
local_path="Z:" local_path="Z:"
), ),
URLMapping( URLMapping(
@ -40,7 +40,7 @@ def converter(test_config):
def test_convert_simple_url(converter): def test_convert_simple_url(converter):
"""Test converting a simple Azure URL to local path.""" """Test converting a simple Azure URL to local path."""
url = "https://wpsagravitystg.file.core.windows.net/wpsagravitysync/test/file.png" url = "https://devagravitystg.file.core.windows.net/devagravitysync/test/file.png"
result = converter.convert_url_to_path(url) result = converter.convert_url_to_path(url)
assert result is not None assert result is not None
@ -49,7 +49,7 @@ def test_convert_simple_url(converter):
def test_convert_url_with_special_characters(converter): def test_convert_url_with_special_characters(converter):
"""Test URL with special characters (URL encoded).""" """Test URL with special characters (URL encoded)."""
url = "https://wpsagravitystg.file.core.windows.net/wpsagravitysync/folder/file%20with%20spaces.png" url = "https://devagravitystg.file.core.windows.net/devagravitysync/folder/file%20with%20spaces.png"
result = converter.convert_url_to_path(url) result = converter.convert_url_to_path(url)
assert result is not None assert result is not None
@ -58,7 +58,7 @@ def test_convert_url_with_special_characters(converter):
def test_convert_url_with_subdirectories(converter): def test_convert_url_with_subdirectories(converter):
"""Test URL with deep directory structure.""" """Test URL with deep directory structure."""
url = "https://wpsagravitystg.file.core.windows.net/wpsagravitysync/aN5PysnXIuRECzcRbvHkjL7g0/subfolder/file.png" url = "https://devagravitystg.file.core.windows.net/devagravitysync/aN5PysnXIuRECzcRbvHkjL7g0/subfolder/file.png"
result = converter.convert_url_to_path(url) result = converter.convert_url_to_path(url)
assert result is not None assert result is not None
@ -88,7 +88,7 @@ def test_convert_none_url(converter):
def test_is_azure_url_positive(converter): def test_is_azure_url_positive(converter):
"""Test recognizing valid Azure URLs.""" """Test recognizing valid Azure URLs."""
url = "https://wpsagravitystg.file.core.windows.net/wpsagravitysync/file.png" url = "https://devagravitystg.file.core.windows.net/devagravitysync/file.png"
assert converter.is_azure_url(url) is True assert converter.is_azure_url(url) is True
@ -101,7 +101,7 @@ def test_is_azure_url_negative(converter):
def test_multiple_mappings(converter): def test_multiple_mappings(converter):
"""Test that correct mapping is used for URL.""" """Test that correct mapping is used for URL."""
url1 = "https://wpsagravitystg.file.core.windows.net/wpsagravitysync/file.png" url1 = "https://devagravitystg.file.core.windows.net/devagravitysync/file.png"
url2 = "https://other.blob.core.windows.net/container/file.png" url2 = "https://other.blob.core.windows.net/container/file.png"
result1 = converter.convert_url_to_path(url1) result1 = converter.convert_url_to_path(url1)
@ -133,7 +133,7 @@ def test_url_mapping_adds_trailing_slash():
def test_convert_url_example_from_docs(converter): def test_convert_url_example_from_docs(converter):
"""Test the exact example from documentation.""" """Test the exact example from documentation."""
url = "https://wpsagravitystg.file.core.windows.net/wpsagravitysync/aN5PysnXIuRECzcRbvHkjL7g0/Hintergrund_Agravity.png" url = "https://devagravitystg.file.core.windows.net/devagravitysync/aN5PysnXIuRECzcRbvHkjL7g0/Hintergrund_Agravity.png"
result = converter.convert_url_to_path(url) result = converter.convert_url_to_path(url)
assert result is not None assert result is not None

View file

@ -175,13 +175,13 @@
<div class="drag-item" draggable="true" id="dragItem3"> <div class="drag-item" draggable="true" id="dragItem3">
<div class="icon">☁️</div> <div class="icon">☁️</div>
<h3>Azure Blob Storage Image</h3> <h3>Azure Blob Storage Image</h3>
<p id="path3">https://wpsagravitystg.file.core.windows.net/wpsagravitysync/aN5PysnXIuRECzcRbvHkjL7g0/Hintergrund_Agravity.png</p> <p id="path3">https://devagravitystg.file.core.windows.net/devagravitysync/aN5PysnXIuRECzcRbvHkjL7g0/Hintergrund_Agravity.png</p>
</div> </div>
<div class="drag-item" draggable="true" id="dragItem4"> <div class="drag-item" draggable="true" id="dragItem4">
<div class="icon">☁️</div> <div class="icon">☁️</div>
<h3>Azure Blob Storage Document</h3> <h3>Azure Blob Storage Document</h3>
<p id="path4">https://wpsagravitystg.file.core.windows.net/wpsagravitysync/test/document.pdf</p> <p id="path4">https://devagravitystg.file.core.windows.net/devagravitysync/test/document.pdf</p>
</div> </div>
</div> </div>