Compare commits
18 commits
dffc925bb6
...
7873d0a060
| Author | SHA1 | Date | |
|---|---|---|---|
| 7873d0a060 | |||
| 6ba40ce25d | |||
| f34421bb18 | |||
| bf7c7b5e5f | |||
| a8aa54fa5e | |||
| b3fd61aed2 | |||
| 8f3f859e5b | |||
| 0c276b9022 | |||
| d799339d93 | |||
| a20f703554 | |||
| 302ec15e15 | |||
| aeed311f53 | |||
| f9cfb9f558 | |||
| 37b772166c | |||
| 2b12ee2aef | |||
| 6213bbfa0a | |||
| aad2e59c1c | |||
| ff804790e6 |
27 changed files with 12665 additions and 132 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
|
|
|
||||||
1
build/WebDropBridge_Files.wixobj
Normal file
1
build/WebDropBridge_Files.wixobj
Normal file
File diff suppressed because one or more lines are too long
11833
build/WebDropBridge_Files.wxs
Normal file
11833
build/WebDropBridge_Files.wxs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
1
build/test.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
WebDropBridge.wxs
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 |
Binary file not shown.
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 356 KiB |
BIN
resources/icons/background.bmp
Normal file
BIN
resources/icons/background.bmp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 451 KiB |
BIN
resources/icons/banner.bmp
Normal file
BIN
resources/icons/banner.bmp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
18
resources/license.rtf
Normal file
18
resources/license.rtf
Normal 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.}
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
23
test_msi.py
Normal 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}")
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue