Compare commits
No commits in common. "9609a12ae71f947d60af03864bf6a7dcbd710605" and "03991fdea542f063314841515ed5e53d8d229a65" have entirely different histories.
9609a12ae7
...
03991fdea5
17 changed files with 3640 additions and 3729 deletions
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
# Application
|
||||
APP_NAME=WebDrop Bridge
|
||||
APP_VERSION=0.6.5
|
||||
APP_VERSION=0.6.0
|
||||
|
||||
# Web App
|
||||
WEBAPP_URL=file:///./webapp/index.html
|
||||
|
|
|
|||
40
CHANGELOG.md
40
CHANGELOG.md
|
|
@ -1,43 +1,3 @@
|
|||
## [0.6.5] - 2026-02-25
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
## [0.6.4] - 2026-02-25
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
## [0.6.3] - 2026-02-25
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
## [0.6.2] - 2026-02-25
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
## [0.6.1] - 2026-02-25
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
## [0.6.0] - 2026-02-20
|
||||
|
||||
### Added
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,8 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"
|
||||
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui"
|
||||
xmlns:util="http://schemas.microsoft.com/wix/UtilExtension">
|
||||
<Product Id="*" Name="WebDrop Bridge" Language="1033" Version="0.6.5"
|
||||
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui">
|
||||
<Product Id="*" Name="WebDrop Bridge" Language="1033" Version="0.6.0"
|
||||
Manufacturer="HIM-Tools"
|
||||
UpgradeCode="12345678-1234-1234-1234-123456789012">
|
||||
|
||||
|
|
@ -24,13 +23,6 @@
|
|||
<UIRef Id="WixUI_InstallDir" />
|
||||
<UIRef Id="WixUI_ErrorProgressText" />
|
||||
|
||||
<!-- Close running application before installation -->
|
||||
<util:CloseApplication
|
||||
Target="WebDropBridge.exe"
|
||||
CloseMessage="yes"
|
||||
RebootPrompt="no"
|
||||
ElevatedCloseMessage="no" />
|
||||
|
||||
<Feature Id="ProductFeature" Title="WebDrop Bridge" Level="1">
|
||||
<ComponentGroupRef Id="AppFiles" />
|
||||
<ComponentRef Id="ProgramMenuShortcut" />
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
|
|
@ -10,7 +10,7 @@ Requirements:
|
|||
|
||||
Usage:
|
||||
python build_windows.py [--msi] [--code-sign] [--env-file PATH]
|
||||
|
||||
|
||||
Options:
|
||||
--msi Create MSI installer (requires WiX Toolset)
|
||||
--code-sign Sign executable (requires certificate)
|
||||
|
|
@ -27,10 +27,13 @@ from typing import Optional
|
|||
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")
|
||||
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
|
||||
|
|
@ -47,7 +50,7 @@ class WindowsBuilder:
|
|||
|
||||
def __init__(self, env_file: Path | None = None):
|
||||
"""Initialize builder paths.
|
||||
|
||||
|
||||
Args:
|
||||
env_file: Path to .env file to bundle. If None, uses project root .env.
|
||||
If that doesn't exist, raises error.
|
||||
|
|
@ -58,20 +61,20 @@ class WindowsBuilder:
|
|||
self.temp_dir = self.build_dir / "temp" / "windows"
|
||||
self.spec_file = self.build_dir / "webdrop_bridge.spec"
|
||||
self.version = get_current_version()
|
||||
|
||||
|
||||
# Validate and set env file
|
||||
if env_file is None:
|
||||
env_file = self.project_root / ".env"
|
||||
else:
|
||||
env_file = Path(env_file).resolve()
|
||||
|
||||
|
||||
if not env_file.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Configuration file not found: {env_file}\n"
|
||||
f"Please provide a .env file using --env-file parameter\n"
|
||||
f"or ensure .env exists in project root"
|
||||
)
|
||||
|
||||
|
||||
self.env_file = env_file
|
||||
print(f"📋 Using configuration: {self.env_file}")
|
||||
|
||||
|
|
@ -112,12 +115,17 @@ class WindowsBuilder:
|
|||
]
|
||||
|
||||
print(f" Command: {' '.join(cmd)}")
|
||||
|
||||
|
||||
# Set environment variable for spec file to use
|
||||
env = os.environ.copy()
|
||||
env["WEBDROP_ENV_FILE"] = str(self.env_file)
|
||||
|
||||
result = subprocess.run(cmd, cwd=str(self.project_root), text=True, env=env)
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(self.project_root),
|
||||
text=True,
|
||||
env=env
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print("❌ PyInstaller build failed")
|
||||
|
|
@ -131,11 +139,9 @@ class WindowsBuilder:
|
|||
|
||||
print("✅ Executable built successfully")
|
||||
print(f"📦 Output: {exe_path}")
|
||||
|
||||
|
||||
# Calculate total dist size
|
||||
total_size = sum(
|
||||
f.stat().st_size for f in self.dist_dir.glob("WebDropBridge/**/*") if f.is_file()
|
||||
)
|
||||
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")
|
||||
|
||||
|
|
@ -168,15 +174,15 @@ class WindowsBuilder:
|
|||
|
||||
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:
|
||||
|
|
@ -184,30 +190,30 @@ class WindowsBuilder:
|
|||
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")
|
||||
|
|
@ -249,25 +255,22 @@ class WindowsBuilder:
|
|||
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",
|
||||
"-cg", "AppFiles",
|
||||
"-dr", "INSTALLFOLDER",
|
||||
"-sfrag",
|
||||
"-srd",
|
||||
"-gg",
|
||||
"-o",
|
||||
str(harvest_file),
|
||||
"-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)")
|
||||
|
|
@ -275,12 +278,15 @@ class WindowsBuilder:
|
|||
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" ')
|
||||
content = content.replace(
|
||||
'<Component ',
|
||||
'<Component Win64="yes" '
|
||||
)
|
||||
harvest_file.write_text(content)
|
||||
print(f" ✓ Marked components as 64-bit")
|
||||
|
||||
|
|
@ -292,18 +298,14 @@ class WindowsBuilder:
|
|||
# Run candle compiler - make sure to use correct source directory
|
||||
candle_cmd = [
|
||||
str(candle_exe),
|
||||
"-ext",
|
||||
"WixUIExtension",
|
||||
"-ext",
|
||||
"WixUtilExtension",
|
||||
"-ext", "WixUIExtension",
|
||||
f"-dDistDir={self.dist_dir}",
|
||||
f"-dSourceDir={self.dist_dir}\\WebDropBridge", # Set SourceDir for Heat-generated files
|
||||
f"-dResourcesDir={self.project_root}\\resources", # Set ResourcesDir for branding assets
|
||||
"-o",
|
||||
str(self.build_dir) + "\\",
|
||||
"-o", str(self.build_dir) + "\\",
|
||||
str(self.build_dir / "WebDropBridge.wxs"),
|
||||
]
|
||||
|
||||
|
||||
if harvest_file.exists():
|
||||
candle_cmd.append(str(harvest_file))
|
||||
|
||||
|
|
@ -316,24 +318,17 @@ class WindowsBuilder:
|
|||
# Link MSI - include both obj files if harvest was successful
|
||||
light_cmd = [
|
||||
str(light_exe),
|
||||
"-ext",
|
||||
"WixUIExtension",
|
||||
"-ext",
|
||||
"WixUtilExtension",
|
||||
"-b",
|
||||
str(self.dist_dir / "WebDropBridge"), # Base path for source files
|
||||
"-o",
|
||||
str(msi_output),
|
||||
"-ext", "WixUIExtension",
|
||||
"-b", str(self.dist_dir / "WebDropBridge"), # Base path for source files
|
||||
"-o", str(msi_output),
|
||||
str(wix_obj),
|
||||
]
|
||||
|
||||
|
||||
if wix_files_obj.exists():
|
||||
light_cmd.append(str(wix_files_obj))
|
||||
|
||||
print(f" Linking MSI installer...")
|
||||
result = subprocess.run(
|
||||
light_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
result = subprocess.run(light_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if result.returncode != 0:
|
||||
print("❌ MSI linking failed")
|
||||
if result.stdout:
|
||||
|
|
@ -354,14 +349,13 @@ class WindowsBuilder:
|
|||
|
||||
def _create_wix_source(self) -> bool:
|
||||
"""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"
|
||||
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui"
|
||||
xmlns:util="http://schemas.microsoft.com/wix/UtilExtension">
|
||||
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui">
|
||||
<Product Id="*" Name="WebDrop Bridge" Language="1033" Version="{self.version}"
|
||||
Manufacturer="HIM-Tools"
|
||||
UpgradeCode="12345678-1234-1234-1234-123456789012">
|
||||
|
|
@ -384,13 +378,6 @@ class WindowsBuilder:
|
|||
<UIRef Id="WixUI_InstallDir" />
|
||||
<UIRef Id="WixUI_ErrorProgressText" />
|
||||
|
||||
<!-- Close running application before installation -->
|
||||
<util:CloseApplication
|
||||
Target="WebDropBridge.exe"
|
||||
CloseMessage="yes"
|
||||
RebootPrompt="no"
|
||||
ElevatedCloseMessage="no" />
|
||||
|
||||
<Feature Id="ProductFeature" Title="WebDrop Bridge" Level="1">
|
||||
<ComponentGroupRef Id="AppFiles" />
|
||||
<ComponentRef Id="ProgramMenuShortcut" />
|
||||
|
|
@ -446,46 +433,38 @@ class WindowsBuilder:
|
|||
</DirectoryRef>
|
||||
</Product>
|
||||
</Wix>
|
||||
"""
|
||||
'''
|
||||
|
||||
wix_file = self.build_dir / "WebDropBridge.wxs"
|
||||
wix_file.write_text(wix_content)
|
||||
print(f" Created WiX source: {wix_file}")
|
||||
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:
|
||||
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)
|
||||
|
|
@ -493,21 +472,24 @@ class WindowsBuilder:
|
|||
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
|
||||
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
|
||||
"""
|
||||
|
|
@ -552,7 +534,10 @@ class WindowsBuilder:
|
|||
str(exe_path),
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, text=True)
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print("❌ Code signing failed")
|
||||
return False
|
||||
|
|
@ -599,7 +584,9 @@ class WindowsBuilder:
|
|||
|
||||
def main() -> int:
|
||||
"""Build Windows MSI installer."""
|
||||
parser = argparse.ArgumentParser(description="Build WebDrop Bridge Windows installer")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build WebDrop Bridge Windows installer"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--msi",
|
||||
action="store_true",
|
||||
|
|
@ -616,9 +603,9 @@ def main() -> int:
|
|||
default=None,
|
||||
help="Path to .env file to bundle (default: project root .env)",
|
||||
)
|
||||
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
print("🔄 Syncing version...")
|
||||
do_sync_version()
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,5 @@
|
|||
"log_file": null,
|
||||
"window_width": 1024,
|
||||
"window_height": 768,
|
||||
"enable_logging": true,
|
||||
"enable_checkout": false
|
||||
"enable_logging": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""WebDrop Bridge - Qt-based desktop application for intelligent drag-and-drop file handling."""
|
||||
|
||||
__version__ = "0.6.5"
|
||||
__version__ = "0.6.0"
|
||||
__author__ = "WebDrop Team"
|
||||
__license__ = "MIT"
|
||||
|
||||
|
|
|
|||
|
|
@ -58,8 +58,6 @@ class Config:
|
|||
window_height: Initial window height in pixels
|
||||
window_title: Main window title (default: "{app_name} v{app_version}")
|
||||
enable_logging: Whether to write logs to file
|
||||
enable_checkout: Whether to check asset checkout status and show checkout dialog
|
||||
on drag. Disabled by default as checkout support is optional.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If configuration values are invalid
|
||||
|
|
@ -80,7 +78,6 @@ class Config:
|
|||
window_height: int = 768
|
||||
window_title: str = ""
|
||||
enable_logging: bool = True
|
||||
enable_checkout: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, config_path: Path) -> "Config":
|
||||
|
|
@ -109,7 +106,10 @@ class Config:
|
|||
|
||||
# Parse URL mappings
|
||||
mappings = [
|
||||
URLMapping(url_prefix=m["url_prefix"], local_path=m["local_path"])
|
||||
URLMapping(
|
||||
url_prefix=m["url_prefix"],
|
||||
local_path=m["local_path"]
|
||||
)
|
||||
for m in data.get("url_mappings", [])
|
||||
]
|
||||
|
||||
|
|
@ -138,12 +138,11 @@ class Config:
|
|||
|
||||
app_name = data.get("app_name", "WebDrop Bridge")
|
||||
stored_window_title = data.get("window_title", "")
|
||||
|
||||
|
||||
# Regenerate default window titles on version upgrade
|
||||
# If the stored title matches the pattern "{app_name} v{version}", regenerate it
|
||||
# with the current version. This ensures the title updates automatically on upgrades.
|
||||
import re
|
||||
|
||||
version_pattern = re.compile(rf"^{re.escape(app_name)}\s+v[\d.]+$")
|
||||
if stored_window_title and version_pattern.match(stored_window_title):
|
||||
# Detected a default-pattern title with old version, regenerate
|
||||
|
|
@ -171,7 +170,6 @@ class Config:
|
|||
window_height=data.get("window_height", 768),
|
||||
window_title=window_title,
|
||||
enable_logging=data.get("enable_logging", True),
|
||||
enable_checkout=data.get("enable_checkout", False),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
|
@ -197,9 +195,8 @@ class Config:
|
|||
app_name = os.getenv("APP_NAME", "WebDrop Bridge")
|
||||
# Version always comes from __init__.py for consistency
|
||||
from webdrop_bridge import __version__
|
||||
|
||||
app_version = __version__
|
||||
|
||||
|
||||
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
log_file_str = os.getenv("LOG_FILE", None)
|
||||
allowed_roots_str = os.getenv("ALLOWED_ROOTS", "Z:/,C:/Users/Public")
|
||||
|
|
@ -211,13 +208,13 @@ class Config:
|
|||
default_title = f"{app_name} v{app_version}"
|
||||
window_title = os.getenv("WINDOW_TITLE", default_title)
|
||||
enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true"
|
||||
enable_checkout = os.getenv("ENABLE_CHECKOUT", "false").lower() == "true"
|
||||
|
||||
# Validate log level
|
||||
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
||||
if log_level not in valid_levels:
|
||||
raise ConfigurationError(
|
||||
f"Invalid LOG_LEVEL: {log_level}. " f"Must be one of: {', '.join(valid_levels)}"
|
||||
f"Invalid LOG_LEVEL: {log_level}. "
|
||||
f"Must be one of: {', '.join(valid_levels)}"
|
||||
)
|
||||
|
||||
# Validate and parse allowed roots
|
||||
|
|
@ -228,7 +225,9 @@ class Config:
|
|||
if not root_path.exists():
|
||||
logger.warning(f"Allowed root does not exist: {p.strip()}")
|
||||
elif not root_path.is_dir():
|
||||
raise ConfigurationError(f"Allowed root '{p.strip()}' is not a directory")
|
||||
raise ConfigurationError(
|
||||
f"Allowed root '{p.strip()}' is not a directory"
|
||||
)
|
||||
else:
|
||||
allowed_roots.append(root_path)
|
||||
except ConfigurationError:
|
||||
|
|
@ -241,7 +240,8 @@ class Config:
|
|||
# Validate window dimensions
|
||||
if window_width <= 0 or window_height <= 0:
|
||||
raise ConfigurationError(
|
||||
f"Window dimensions must be positive: " f"{window_width}x{window_height}"
|
||||
f"Window dimensions must be positive: "
|
||||
f"{window_width}x{window_height}"
|
||||
)
|
||||
|
||||
# Create log file path if logging enabled
|
||||
|
|
@ -261,11 +261,10 @@ class Config:
|
|||
raise ConfigurationError("WEBAPP_URL cannot be empty")
|
||||
|
||||
# Parse allowed URLs (empty string = no restriction)
|
||||
allowed_urls = (
|
||||
[url.strip() for url in allowed_urls_str.split(",") if url.strip()]
|
||||
if allowed_urls_str
|
||||
else []
|
||||
)
|
||||
allowed_urls = [
|
||||
url.strip() for url in allowed_urls_str.split(",")
|
||||
if url.strip()
|
||||
] if allowed_urls_str else []
|
||||
|
||||
# Parse URL mappings (Azure Blob Storage → Local Paths)
|
||||
# Format: url_prefix1=local_path1;url_prefix2=local_path2
|
||||
|
|
@ -283,7 +282,10 @@ class Config:
|
|||
)
|
||||
url_prefix, local_path_str = mapping.split("=", 1)
|
||||
url_mappings.append(
|
||||
URLMapping(url_prefix=url_prefix.strip(), local_path=local_path_str.strip())
|
||||
URLMapping(
|
||||
url_prefix=url_prefix.strip(),
|
||||
local_path=local_path_str.strip()
|
||||
)
|
||||
)
|
||||
except (ValueError, OSError) as e:
|
||||
raise ConfigurationError(
|
||||
|
|
@ -303,7 +305,6 @@ class Config:
|
|||
window_height=window_height,
|
||||
window_title=window_title,
|
||||
enable_logging=enable_logging,
|
||||
enable_checkout=enable_checkout,
|
||||
)
|
||||
|
||||
def to_file(self, config_path: Path) -> None:
|
||||
|
|
@ -311,14 +312,18 @@ class Config:
|
|||
|
||||
Args:
|
||||
config_path: Path to save configuration to
|
||||
|
||||
|
||||
Creates parent directories if they don't exist.
|
||||
"""
|
||||
data = {
|
||||
"app_name": self.app_name,
|
||||
"webapp_url": self.webapp_url,
|
||||
"url_mappings": [
|
||||
{"url_prefix": m.url_prefix, "local_path": m.local_path} for m in self.url_mappings
|
||||
{
|
||||
"url_prefix": m.url_prefix,
|
||||
"local_path": m.local_path
|
||||
}
|
||||
for m in self.url_mappings
|
||||
],
|
||||
"allowed_roots": [str(p) for p in self.allowed_roots],
|
||||
"allowed_urls": self.allowed_urls,
|
||||
|
|
@ -331,7 +336,6 @@ class Config:
|
|||
"window_height": self.window_height,
|
||||
"window_title": self.window_title,
|
||||
"enable_logging": self.enable_logging,
|
||||
"enable_checkout": self.enable_checkout,
|
||||
}
|
||||
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -346,7 +350,6 @@ class Config:
|
|||
Path to default config file in user's AppData/Roaming
|
||||
"""
|
||||
import platform
|
||||
|
||||
if platform.system() == "Windows":
|
||||
base = Path.home() / "AppData" / "Roaming"
|
||||
else:
|
||||
|
|
@ -356,7 +359,7 @@ class Config:
|
|||
@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.
|
||||
|
||||
|
|
@ -364,7 +367,6 @@ class Config:
|
|||
Path to default logs directory in user's AppData/Roaming
|
||||
"""
|
||||
import platform
|
||||
|
||||
if platform.system() == "Windows":
|
||||
base = Path.home() / "AppData" / "Roaming"
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
class ConfigValidator:
|
||||
"""Validates configuration values against schema.
|
||||
|
||||
|
||||
Provides detailed error messages for invalid configurations.
|
||||
"""
|
||||
|
||||
|
|
@ -33,10 +33,10 @@ class ConfigValidator:
|
|||
@staticmethod
|
||||
def validate(config_dict: Dict[str, Any]) -> List[str]:
|
||||
"""Validate configuration dictionary.
|
||||
|
||||
|
||||
Args:
|
||||
config_dict: Configuration dictionary to validate
|
||||
|
||||
|
||||
Returns:
|
||||
List of validation error messages (empty if valid)
|
||||
"""
|
||||
|
|
@ -53,9 +53,7 @@ class ConfigValidator:
|
|||
# Check type
|
||||
expected_type = rules.get("type")
|
||||
if expected_type and not isinstance(value, expected_type):
|
||||
errors.append(
|
||||
f"{field}: expected {expected_type.__name__}, got {type(value).__name__}"
|
||||
)
|
||||
errors.append(f"{field}: expected {expected_type.__name__}, got {type(value).__name__}")
|
||||
continue
|
||||
|
||||
# Check allowed values
|
||||
|
|
@ -86,10 +84,10 @@ class ConfigValidator:
|
|||
@staticmethod
|
||||
def validate_or_raise(config_dict: Dict[str, Any]) -> None:
|
||||
"""Validate configuration and raise error if invalid.
|
||||
|
||||
|
||||
Args:
|
||||
config_dict: Configuration dictionary to validate
|
||||
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If configuration is invalid
|
||||
"""
|
||||
|
|
@ -100,26 +98,26 @@ class ConfigValidator:
|
|||
|
||||
class ConfigProfile:
|
||||
"""Manages named configuration profiles.
|
||||
|
||||
|
||||
Profiles are stored in ~/.webdrop-bridge/profiles/ directory as JSON files.
|
||||
"""
|
||||
|
||||
PROFILES_DIR = Path.home() / ".webdrop-bridge" / "profiles"
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self):
|
||||
"""Initialize profile manager."""
|
||||
self.PROFILES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def save_profile(self, profile_name: str, config: Config) -> Path:
|
||||
"""Save configuration as a named profile.
|
||||
|
||||
|
||||
Args:
|
||||
profile_name: Name of the profile (e.g., "work", "personal")
|
||||
config: Config object to save
|
||||
|
||||
|
||||
Returns:
|
||||
Path to the saved profile file
|
||||
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If profile name is invalid
|
||||
"""
|
||||
|
|
@ -150,13 +148,13 @@ class ConfigProfile:
|
|||
|
||||
def load_profile(self, profile_name: str) -> Dict[str, Any]:
|
||||
"""Load configuration from a named profile.
|
||||
|
||||
|
||||
Args:
|
||||
profile_name: Name of the profile to load
|
||||
|
||||
|
||||
Returns:
|
||||
Configuration dictionary
|
||||
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If profile not found or invalid
|
||||
"""
|
||||
|
|
@ -175,7 +173,7 @@ class ConfigProfile:
|
|||
|
||||
def list_profiles(self) -> List[str]:
|
||||
"""List all available profiles.
|
||||
|
||||
|
||||
Returns:
|
||||
List of profile names (without .json extension)
|
||||
"""
|
||||
|
|
@ -186,10 +184,10 @@ class ConfigProfile:
|
|||
|
||||
def delete_profile(self, profile_name: str) -> None:
|
||||
"""Delete a profile.
|
||||
|
||||
|
||||
Args:
|
||||
profile_name: Name of the profile to delete
|
||||
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If profile not found
|
||||
"""
|
||||
|
|
@ -211,11 +209,11 @@ class ConfigExporter:
|
|||
@staticmethod
|
||||
def export_to_json(config: Config, output_path: Path) -> None:
|
||||
"""Export configuration to JSON file.
|
||||
|
||||
|
||||
Args:
|
||||
config: Config object to export
|
||||
output_path: Path to write JSON file
|
||||
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If export fails
|
||||
"""
|
||||
|
|
@ -242,13 +240,13 @@ class ConfigExporter:
|
|||
@staticmethod
|
||||
def import_from_json(input_path: Path) -> Dict[str, Any]:
|
||||
"""Import configuration from JSON file.
|
||||
|
||||
|
||||
Args:
|
||||
input_path: Path to JSON file to import
|
||||
|
||||
|
||||
Returns:
|
||||
Configuration dictionary
|
||||
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If import fails or validation fails
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -44,8 +44,10 @@ class UpdateManager:
|
|||
self.current_version = current_version
|
||||
self.forgejo_url = "https://git.him-tools.de"
|
||||
self.repo = "HIM-public/webdrop-bridge"
|
||||
self.api_endpoint = f"{self.forgejo_url}/api/v1/repos/{self.repo}/releases/latest"
|
||||
|
||||
self.api_endpoint = (
|
||||
f"{self.forgejo_url}/api/v1/repos/{self.repo}/releases/latest"
|
||||
)
|
||||
|
||||
# Cache management
|
||||
self.cache_dir = config_dir or Path.home() / ".webdrop-bridge"
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -66,7 +68,7 @@ class UpdateManager:
|
|||
"""
|
||||
# Remove 'v' prefix if present
|
||||
version_str = version_str.lstrip("v")
|
||||
|
||||
|
||||
try:
|
||||
parts = version_str.split(".")
|
||||
if len(parts) != 3:
|
||||
|
|
@ -144,45 +146,44 @@ class UpdateManager:
|
|||
Release object if newer version available, None otherwise
|
||||
"""
|
||||
logger.debug(f"check_for_updates() called, current version: {self.current_version}")
|
||||
|
||||
# Only use cache when a pending update was already found (avoids
|
||||
# showing the update dialog on every start). "No update" is never
|
||||
# cached so that a freshly published release is visible immediately.
|
||||
logger.debug("Checking cache for pending update...")
|
||||
|
||||
# Try cache first
|
||||
logger.debug("Checking cache...")
|
||||
cached = self._load_cache()
|
||||
if cached:
|
||||
logger.debug("Found cached release")
|
||||
release_data = cached.get("release")
|
||||
if release_data:
|
||||
version = release_data["tag_name"].lstrip("v")
|
||||
logger.debug(f"Cached pending update version: {version}")
|
||||
if self._is_newer_version(version):
|
||||
logger.info(f"Returning cached pending update: {version}")
|
||||
return Release(**release_data)
|
||||
else:
|
||||
# Current version is >= cached release (e.g. already updated)
|
||||
logger.debug("Cached release is no longer newer — discarding cache")
|
||||
self.cache_file.unlink(missing_ok=True)
|
||||
if not self._is_newer_version(version):
|
||||
logger.info("No newer version available (cached)")
|
||||
return None
|
||||
return Release(**release_data)
|
||||
|
||||
# Always fetch fresh from API so new releases are seen immediately
|
||||
# Fetch from API
|
||||
logger.debug("Fetching from API...")
|
||||
try:
|
||||
logger.info(f"Checking for updates from {self.api_endpoint}")
|
||||
|
||||
|
||||
# Run in thread pool with aggressive timeout
|
||||
loop = asyncio.get_event_loop()
|
||||
response = await asyncio.wait_for(
|
||||
loop.run_in_executor(None, self._fetch_release),
|
||||
timeout=8,
|
||||
loop.run_in_executor(
|
||||
None, self._fetch_release
|
||||
),
|
||||
timeout=8 # Timeout after network call also has timeout
|
||||
)
|
||||
|
||||
|
||||
if not response:
|
||||
return None
|
||||
|
||||
# Check if newer version
|
||||
version = response["tag_name"].lstrip("v")
|
||||
if not self._is_newer_version(version):
|
||||
logger.info(f"Latest version {version} is not newer than {self.current_version}")
|
||||
self._save_cache(response)
|
||||
return None
|
||||
|
||||
# Cache the found update so repeated starts don't hammer the API
|
||||
logger.info(f"New version available: {version}")
|
||||
release = Release(**response)
|
||||
self._save_cache(response)
|
||||
|
|
@ -203,11 +204,11 @@ class UpdateManager:
|
|||
"""
|
||||
try:
|
||||
logger.debug(f"Fetching release from {self.api_endpoint}")
|
||||
|
||||
|
||||
# Set socket timeout to prevent hanging
|
||||
old_timeout = socket.getdefaulttimeout()
|
||||
socket.setdefaulttimeout(5)
|
||||
|
||||
|
||||
try:
|
||||
logger.debug("Opening URL connection...")
|
||||
with urlopen(self.api_endpoint, timeout=5) as response:
|
||||
|
|
@ -226,19 +227,21 @@ class UpdateManager:
|
|||
}
|
||||
finally:
|
||||
socket.setdefaulttimeout(old_timeout)
|
||||
|
||||
|
||||
except socket.timeout as e:
|
||||
logger.error(f"Socket timeout (5s) connecting to {self.api_endpoint}")
|
||||
return None
|
||||
except TimeoutError as e:
|
||||
logger.error(f"Timeout error: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch release: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
|
||||
logger.debug(traceback.format_exc())
|
||||
return None
|
||||
|
||||
async def download_update(
|
||||
self, release: Release, output_dir: Optional[Path] = None, progress_callback=None
|
||||
self, release: Release, output_dir: Optional[Path] = None
|
||||
) -> Optional[Path]:
|
||||
"""Download installer from release assets.
|
||||
|
||||
|
|
@ -270,7 +273,7 @@ class UpdateManager:
|
|||
|
||||
try:
|
||||
logger.info(f"Downloading {installer_asset['name']}")
|
||||
|
||||
|
||||
# Run in thread pool with 5-minute timeout for large files
|
||||
loop = asyncio.get_event_loop()
|
||||
success = await asyncio.wait_for(
|
||||
|
|
@ -279,11 +282,10 @@ class UpdateManager:
|
|||
self._download_file,
|
||||
installer_asset["browser_download_url"],
|
||||
output_file,
|
||||
progress_callback,
|
||||
),
|
||||
timeout=300,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
|
||||
if success:
|
||||
logger.info(f"Downloaded to {output_file}")
|
||||
return output_file
|
||||
|
|
@ -300,13 +302,12 @@ class UpdateManager:
|
|||
output_file.unlink()
|
||||
return None
|
||||
|
||||
def _download_file(self, url: str, output_path: Path, progress_callback=None) -> bool:
|
||||
def _download_file(self, url: str, output_path: Path) -> bool:
|
||||
"""Download file from URL (blocking).
|
||||
|
||||
Args:
|
||||
url: URL to download from
|
||||
output_path: Path to save file
|
||||
progress_callback: Optional callable(bytes_downloaded, total_bytes)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
|
|
@ -314,28 +315,17 @@ class UpdateManager:
|
|||
try:
|
||||
logger.debug(f"Downloading from {url}")
|
||||
with urlopen(url, timeout=300) as response: # 5 min timeout
|
||||
total = int(response.headers.get("Content-Length", 0))
|
||||
downloaded = 0
|
||||
chunk_size = 65536 # 64 KB chunks
|
||||
with open(output_path, "wb") as f:
|
||||
while True:
|
||||
chunk = response.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if progress_callback:
|
||||
try:
|
||||
progress_callback(downloaded, total)
|
||||
except Exception:
|
||||
pass # Never let progress errors abort the download
|
||||
f.write(response.read())
|
||||
logger.debug(f"Downloaded {output_path.stat().st_size} bytes")
|
||||
return True
|
||||
except URLError as e:
|
||||
logger.error(f"Download failed: {e}")
|
||||
return False
|
||||
|
||||
async def verify_checksum(self, file_path: Path, release: Release) -> bool:
|
||||
async def verify_checksum(
|
||||
self, file_path: Path, release: Release
|
||||
) -> bool:
|
||||
"""Verify file checksum against release checksum file.
|
||||
|
||||
Args:
|
||||
|
|
@ -345,12 +335,10 @@ class UpdateManager:
|
|||
Returns:
|
||||
True if checksum matches, False otherwise
|
||||
"""
|
||||
# Find .sha256 file matching the installer name (e.g. Setup.msi.sha256)
|
||||
# Fall back to any .sha256 only if no specific match exists
|
||||
installer_name = file_path.name
|
||||
# Find .sha256 file in release assets
|
||||
checksum_asset = None
|
||||
for asset in release.assets:
|
||||
if asset["name"] == f"{installer_name}.sha256":
|
||||
if asset["name"].endswith(".sha256"):
|
||||
checksum_asset = asset
|
||||
break
|
||||
|
||||
|
|
@ -360,7 +348,7 @@ class UpdateManager:
|
|||
|
||||
try:
|
||||
logger.info("Verifying checksum...")
|
||||
|
||||
|
||||
# Download checksum file with 30 second timeout
|
||||
loop = asyncio.get_event_loop()
|
||||
checksum_content = await asyncio.wait_for(
|
||||
|
|
@ -369,7 +357,7 @@ class UpdateManager:
|
|||
self._download_checksum,
|
||||
checksum_asset["browser_download_url"],
|
||||
),
|
||||
timeout=30,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if not checksum_content:
|
||||
|
|
@ -389,7 +377,9 @@ class UpdateManager:
|
|||
logger.info("Checksum verification passed")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Checksum mismatch: {file_checksum} != {expected_checksum}")
|
||||
logger.error(
|
||||
f"Checksum mismatch: {file_checksum} != {expected_checksum}"
|
||||
)
|
||||
return False
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
|
|
@ -436,12 +426,9 @@ class UpdateManager:
|
|||
import subprocess
|
||||
|
||||
if platform.system() == "Windows":
|
||||
# Windows: MSI files must be launched via msiexec
|
||||
# Windows: Run MSI installer
|
||||
logger.info(f"Launching installer: {installer_path}")
|
||||
if str(installer_path).lower().endswith(".msi"):
|
||||
subprocess.Popen(["msiexec.exe", "/i", str(installer_path)])
|
||||
else:
|
||||
subprocess.Popen([str(installer_path)])
|
||||
subprocess.Popen([str(installer_path)])
|
||||
return True
|
||||
elif platform.system() == "Darwin":
|
||||
# macOS: Mount DMG and run installer
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2,11 +2,10 @@
|
|||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFileDialog,
|
||||
|
|
@ -33,7 +32,7 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
class SettingsDialog(QDialog):
|
||||
"""Dialog for managing application settings and configuration.
|
||||
|
||||
|
||||
Provides tabs for:
|
||||
- Paths: Manage allowed root directories
|
||||
- URLs: Manage allowed web URLs
|
||||
|
|
@ -42,9 +41,9 @@ class SettingsDialog(QDialog):
|
|||
- Profiles: Save/load/delete configuration profiles
|
||||
"""
|
||||
|
||||
def __init__(self, config: Config, parent: Optional[QWidget] = None):
|
||||
def __init__(self, config: Config, parent=None):
|
||||
"""Initialize the settings dialog.
|
||||
|
||||
|
||||
Args:
|
||||
config: Current application configuration
|
||||
parent: Parent widget
|
||||
|
|
@ -54,16 +53,16 @@ class SettingsDialog(QDialog):
|
|||
self.profile_manager = ConfigProfile()
|
||||
self.setWindowTitle("Settings")
|
||||
self.setGeometry(100, 100, 600, 500)
|
||||
|
||||
|
||||
self.setup_ui()
|
||||
|
||||
def setup_ui(self) -> None:
|
||||
"""Set up the dialog UI with tabs."""
|
||||
layout = QVBoxLayout()
|
||||
|
||||
|
||||
# Create tab widget
|
||||
self.tabs = QTabWidget()
|
||||
|
||||
|
||||
# Add tabs
|
||||
self.tabs.addTab(self._create_web_source_tab(), "Web Source")
|
||||
self.tabs.addTab(self._create_paths_tab(), "Paths")
|
||||
|
|
@ -71,9 +70,9 @@ class SettingsDialog(QDialog):
|
|||
self.tabs.addTab(self._create_logging_tab(), "Logging")
|
||||
self.tabs.addTab(self._create_window_tab(), "Window")
|
||||
self.tabs.addTab(self._create_profiles_tab(), "Profiles")
|
||||
|
||||
|
||||
layout.addWidget(self.tabs)
|
||||
|
||||
|
||||
# Add buttons
|
||||
button_box = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||
|
|
@ -81,12 +80,12 @@ class SettingsDialog(QDialog):
|
|||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
layout.addWidget(button_box)
|
||||
|
||||
|
||||
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.
|
||||
|
|
@ -94,49 +93,50 @@ class SettingsDialog(QDialog):
|
|||
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"])
|
||||
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.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,
|
||||
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}")
|
||||
|
|
@ -147,70 +147,67 @@ class SettingsDialog(QDialog):
|
|||
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"
|
||||
)
|
||||
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
|
||||
|
|
@ -219,55 +216,61 @@ class SettingsDialog(QDialog):
|
|||
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/)",
|
||||
"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)",
|
||||
"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() # type: ignore
|
||||
local_path = self.url_mappings_table.item(current_row, 1).text() # type: ignore
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
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()
|
||||
|
|
@ -278,29 +281,29 @@ class SettingsDialog(QDialog):
|
|||
"""Create paths configuration tab."""
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout()
|
||||
|
||||
|
||||
layout.addWidget(QLabel("Allowed root directories for file access:"))
|
||||
|
||||
|
||||
# List widget for paths
|
||||
self.paths_list = QListWidget()
|
||||
for path in self.config.allowed_roots:
|
||||
self.paths_list.addItem(str(path))
|
||||
layout.addWidget(self.paths_list)
|
||||
|
||||
|
||||
# Buttons for path management
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
|
||||
add_path_btn = QPushButton("Add Path")
|
||||
add_path_btn.clicked.connect(self._add_path)
|
||||
button_layout.addWidget(add_path_btn)
|
||||
|
||||
|
||||
remove_path_btn = QPushButton("Remove Selected")
|
||||
remove_path_btn.clicked.connect(self._remove_path)
|
||||
button_layout.addWidget(remove_path_btn)
|
||||
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
layout.addStretch()
|
||||
|
||||
|
||||
widget.setLayout(layout)
|
||||
return widget
|
||||
|
||||
|
|
@ -308,29 +311,29 @@ class SettingsDialog(QDialog):
|
|||
"""Create URLs configuration tab."""
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout()
|
||||
|
||||
|
||||
layout.addWidget(QLabel("Allowed web URLs (supports wildcards like http://*.example.com):"))
|
||||
|
||||
|
||||
# List widget for URLs
|
||||
self.urls_list = QListWidget()
|
||||
for url in self.config.allowed_urls:
|
||||
self.urls_list.addItem(url)
|
||||
layout.addWidget(self.urls_list)
|
||||
|
||||
|
||||
# Buttons for URL management
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
|
||||
add_url_btn = QPushButton("Add URL")
|
||||
add_url_btn.clicked.connect(self._add_url)
|
||||
button_layout.addWidget(add_url_btn)
|
||||
|
||||
|
||||
remove_url_btn = QPushButton("Remove Selected")
|
||||
remove_url_btn.clicked.connect(self._remove_url)
|
||||
button_layout.addWidget(remove_url_btn)
|
||||
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
layout.addStretch()
|
||||
|
||||
|
||||
widget.setLayout(layout)
|
||||
return widget
|
||||
|
||||
|
|
@ -338,28 +341,27 @@ class SettingsDialog(QDialog):
|
|||
"""Create logging configuration tab."""
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout()
|
||||
|
||||
|
||||
# Log level selection
|
||||
layout.addWidget(QLabel("Log Level:"))
|
||||
from PySide6.QtWidgets import QComboBox
|
||||
|
||||
self.log_level_combo: QComboBox = self._create_log_level_widget()
|
||||
layout.addWidget(self.log_level_combo)
|
||||
|
||||
|
||||
# Log file path
|
||||
layout.addWidget(QLabel("Log File (optional):"))
|
||||
log_file_layout = QHBoxLayout()
|
||||
|
||||
|
||||
self.log_file_input = QLineEdit()
|
||||
self.log_file_input.setText(str(self.config.log_file) if self.config.log_file else "")
|
||||
log_file_layout.addWidget(self.log_file_input)
|
||||
|
||||
|
||||
browse_btn = QPushButton("Browse...")
|
||||
browse_btn.clicked.connect(self._browse_log_file)
|
||||
log_file_layout.addWidget(browse_btn)
|
||||
|
||||
|
||||
layout.addLayout(log_file_layout)
|
||||
|
||||
|
||||
layout.addStretch()
|
||||
widget.setLayout(layout)
|
||||
return widget
|
||||
|
|
@ -368,7 +370,7 @@ class SettingsDialog(QDialog):
|
|||
"""Create window settings tab."""
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout()
|
||||
|
||||
|
||||
# Window width
|
||||
width_layout = QHBoxLayout()
|
||||
width_layout.addWidget(QLabel("Window Width:"))
|
||||
|
|
@ -379,7 +381,7 @@ class SettingsDialog(QDialog):
|
|||
width_layout.addWidget(self.width_spin)
|
||||
width_layout.addStretch()
|
||||
layout.addLayout(width_layout)
|
||||
|
||||
|
||||
# Window height
|
||||
height_layout = QHBoxLayout()
|
||||
height_layout.addWidget(QLabel("Window Height:"))
|
||||
|
|
@ -390,7 +392,7 @@ class SettingsDialog(QDialog):
|
|||
height_layout.addWidget(self.height_spin)
|
||||
height_layout.addStretch()
|
||||
layout.addLayout(height_layout)
|
||||
|
||||
|
||||
layout.addStretch()
|
||||
widget.setLayout(layout)
|
||||
return widget
|
||||
|
|
@ -399,50 +401,52 @@ class SettingsDialog(QDialog):
|
|||
"""Create profiles management tab."""
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout()
|
||||
|
||||
|
||||
layout.addWidget(QLabel("Saved Configuration Profiles:"))
|
||||
|
||||
|
||||
# List of profiles
|
||||
self.profiles_list = QListWidget()
|
||||
self._refresh_profiles_list()
|
||||
layout.addWidget(self.profiles_list)
|
||||
|
||||
|
||||
# Profile management buttons
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
|
||||
save_profile_btn = QPushButton("Save as Profile")
|
||||
save_profile_btn.clicked.connect(self._save_profile)
|
||||
button_layout.addWidget(save_profile_btn)
|
||||
|
||||
|
||||
load_profile_btn = QPushButton("Load Profile")
|
||||
load_profile_btn.clicked.connect(self._load_profile)
|
||||
button_layout.addWidget(load_profile_btn)
|
||||
|
||||
|
||||
delete_profile_btn = QPushButton("Delete Profile")
|
||||
delete_profile_btn.clicked.connect(self._delete_profile)
|
||||
button_layout.addWidget(delete_profile_btn)
|
||||
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
|
||||
# Export/Import buttons
|
||||
export_layout = QHBoxLayout()
|
||||
|
||||
|
||||
export_btn = QPushButton("Export Configuration")
|
||||
export_btn.clicked.connect(self._export_config)
|
||||
export_layout.addWidget(export_btn)
|
||||
|
||||
|
||||
import_btn = QPushButton("Import Configuration")
|
||||
import_btn.clicked.connect(self._import_config)
|
||||
export_layout.addWidget(import_btn)
|
||||
|
||||
|
||||
layout.addLayout(export_layout)
|
||||
layout.addStretch()
|
||||
|
||||
|
||||
widget.setLayout(layout)
|
||||
return widget
|
||||
|
||||
def _create_log_level_widget(self) -> QComboBox:
|
||||
def _create_log_level_widget(self):
|
||||
"""Create log level selection widget."""
|
||||
from PySide6.QtWidgets import QComboBox
|
||||
|
||||
combo = QComboBox()
|
||||
levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
combo.addItems(levels)
|
||||
|
|
@ -463,9 +467,11 @@ class SettingsDialog(QDialog):
|
|||
def _add_url(self) -> None:
|
||||
"""Add a new allowed URL."""
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
|
||||
|
||||
url, ok = QInputDialog.getText(
|
||||
self, "Add URL", "Enter URL pattern (e.g., http://example.com or http://*.example.com):"
|
||||
self,
|
||||
"Add URL",
|
||||
"Enter URL pattern (e.g., http://example.com or http://*.example.com):"
|
||||
)
|
||||
if ok and url:
|
||||
self.urls_list.addItem(url)
|
||||
|
|
@ -478,7 +484,10 @@ class SettingsDialog(QDialog):
|
|||
def _browse_log_file(self) -> None:
|
||||
"""Browse for log file location."""
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Select Log File", str(Path.home()), "Log Files (*.log);;All Files (*)"
|
||||
self,
|
||||
"Select Log File",
|
||||
str(Path.home()),
|
||||
"Log Files (*.log);;All Files (*)"
|
||||
)
|
||||
if file_path:
|
||||
self.log_file_input.setText(file_path)
|
||||
|
|
@ -492,11 +501,13 @@ class SettingsDialog(QDialog):
|
|||
def _save_profile(self) -> None:
|
||||
"""Save current configuration as a profile."""
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
|
||||
|
||||
profile_name, ok = QInputDialog.getText(
|
||||
self, "Save Profile", "Enter profile name (e.g., work, personal):"
|
||||
self,
|
||||
"Save Profile",
|
||||
"Enter profile name (e.g., work, personal):"
|
||||
)
|
||||
|
||||
|
||||
if ok and profile_name:
|
||||
try:
|
||||
self.profile_manager.save_profile(profile_name, self.config)
|
||||
|
|
@ -510,7 +521,7 @@ class SettingsDialog(QDialog):
|
|||
if not current_item:
|
||||
self._show_error("Please select a profile to load")
|
||||
return
|
||||
|
||||
|
||||
profile_name = current_item.text()
|
||||
try:
|
||||
config_data = self.profile_manager.load_profile(profile_name)
|
||||
|
|
@ -524,7 +535,7 @@ class SettingsDialog(QDialog):
|
|||
if not current_item:
|
||||
self._show_error("Please select a profile to delete")
|
||||
return
|
||||
|
||||
|
||||
profile_name = current_item.text()
|
||||
try:
|
||||
self.profile_manager.delete_profile(profile_name)
|
||||
|
|
@ -535,9 +546,12 @@ class SettingsDialog(QDialog):
|
|||
def _export_config(self) -> None:
|
||||
"""Export configuration to file."""
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Export Configuration", str(Path.home()), "JSON Files (*.json);;All Files (*)"
|
||||
self,
|
||||
"Export Configuration",
|
||||
str(Path.home()),
|
||||
"JSON Files (*.json);;All Files (*)"
|
||||
)
|
||||
|
||||
|
||||
if file_path:
|
||||
try:
|
||||
ConfigExporter.export_to_json(self.config, Path(file_path))
|
||||
|
|
@ -547,9 +561,12 @@ class SettingsDialog(QDialog):
|
|||
def _import_config(self) -> None:
|
||||
"""Import configuration from file."""
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Import Configuration", str(Path.home()), "JSON Files (*.json);;All Files (*)"
|
||||
self,
|
||||
"Import Configuration",
|
||||
str(Path.home()),
|
||||
"JSON Files (*.json);;All Files (*)"
|
||||
)
|
||||
|
||||
|
||||
if file_path:
|
||||
try:
|
||||
config_data = ConfigExporter.import_from_json(Path(file_path))
|
||||
|
|
@ -557,9 +574,9 @@ class SettingsDialog(QDialog):
|
|||
except ConfigurationError as e:
|
||||
self._show_error(f"Failed to import configuration: {e}")
|
||||
|
||||
def _apply_config_data(self, config_data: Dict[str, Any]) -> None:
|
||||
def _apply_config_data(self, config_data: dict) -> None:
|
||||
"""Apply imported configuration data to UI.
|
||||
|
||||
|
||||
Args:
|
||||
config_data: Configuration dictionary
|
||||
"""
|
||||
|
|
@ -567,67 +584,60 @@ class SettingsDialog(QDialog):
|
|||
self.paths_list.clear()
|
||||
for path in config_data.get("allowed_roots", []):
|
||||
self.paths_list.addItem(str(path))
|
||||
|
||||
|
||||
# Apply URLs
|
||||
self.urls_list.clear()
|
||||
for url in config_data.get("allowed_urls", []):
|
||||
self.urls_list.addItem(url)
|
||||
|
||||
|
||||
# Apply logging settings
|
||||
self.log_level_combo.setCurrentText(config_data.get("log_level", "INFO"))
|
||||
log_file = config_data.get("log_file")
|
||||
self.log_file_input.setText(str(log_file) if log_file else "")
|
||||
|
||||
|
||||
# Apply window settings
|
||||
self.width_spin.setValue(config_data.get("window_width", 800))
|
||||
self.height_spin.setValue(config_data.get("window_height", 600))
|
||||
|
||||
def get_config_data(self) -> Dict[str, Any]:
|
||||
def get_config_data(self) -> dict:
|
||||
"""Get updated configuration data from dialog.
|
||||
|
||||
|
||||
Returns:
|
||||
Configuration dictionary
|
||||
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If configuration is invalid
|
||||
"""
|
||||
if self.url_mappings_table:
|
||||
url_mappings_table_count = self.url_mappings_table.rowCount() or 0
|
||||
else:
|
||||
url_mappings_table_count = 0
|
||||
config_data = {
|
||||
"app_name": self.config.app_name,
|
||||
"app_version": self.config.app_version,
|
||||
"log_level": self.log_level_combo.currentText(),
|
||||
"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())],
|
||||
"webapp_url": self.webapp_url_input.text().strip(),
|
||||
"url_mappings": [
|
||||
{
|
||||
"url_prefix": self.url_mappings_table.item(i, 0).text() if self.url_mappings_table.item(i, 0) else "", # type: ignore
|
||||
"local_path": self.url_mappings_table.item(i, 1).text() if self.url_mappings_table.item(i, 1) else "", # type: ignore
|
||||
"url_prefix": self.url_mappings_table.item(i, 0).text(),
|
||||
"local_path": self.url_mappings_table.item(i, 1).text()
|
||||
}
|
||||
for i in range(url_mappings_table_count)
|
||||
for i in range(self.url_mappings_table.rowCount())
|
||||
],
|
||||
"window_width": self.width_spin.value(),
|
||||
"window_height": self.height_spin.value(),
|
||||
"enable_logging": self.config.enable_logging,
|
||||
}
|
||||
|
||||
|
||||
# Validate
|
||||
ConfigValidator.validate_or_raise(config_data)
|
||||
|
||||
|
||||
return config_data
|
||||
|
||||
def _show_error(self, message: str) -> None:
|
||||
"""Show error message to user.
|
||||
|
||||
|
||||
Args:
|
||||
message: Error message
|
||||
"""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
QMessageBox.critical(self, "Error", message)
|
||||
|
|
|
|||
|
|
@ -30,13 +30,13 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
class CheckingDialog(QDialog):
|
||||
"""Dialog shown while checking for updates.
|
||||
|
||||
|
||||
Shows an animated progress indicator and times out after 10 seconds.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
"""Initialize checking dialog.
|
||||
|
||||
|
||||
Args:
|
||||
parent: Parent widget
|
||||
"""
|
||||
|
|
@ -45,43 +45,44 @@ class CheckingDialog(QDialog):
|
|||
self.setModal(True)
|
||||
self.setMinimumWidth(300)
|
||||
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
|
||||
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
|
||||
# Status label
|
||||
self.label = QLabel("Checking for updates...")
|
||||
layout.addWidget(self.label)
|
||||
|
||||
|
||||
# Animated progress bar
|
||||
self.progress = QProgressBar()
|
||||
self.progress.setMaximum(0) # Makes it animated
|
||||
layout.addWidget(self.progress)
|
||||
|
||||
|
||||
# Timeout info
|
||||
info_label = QLabel("This may take up to 10 seconds")
|
||||
info_label.setStyleSheet("color: gray; font-size: 11px;")
|
||||
layout.addWidget(info_label)
|
||||
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
|
||||
class UpdateAvailableDialog(QDialog):
|
||||
"""Dialog shown when an update is available.
|
||||
|
||||
|
||||
Displays:
|
||||
- Current version
|
||||
- Available version
|
||||
- Changelog/release notes
|
||||
- Buttons: Update Now, Update Later, Skip This Version
|
||||
"""
|
||||
|
||||
|
||||
# Signals
|
||||
update_now = Signal()
|
||||
update_later = Signal()
|
||||
skip_version = Signal()
|
||||
|
||||
def __init__(self, version: str, changelog: str, parent=None):
|
||||
"""Initialize update available dialog.
|
||||
|
||||
|
||||
Args:
|
||||
version: New version string (e.g., "0.0.2")
|
||||
changelog: Release notes text
|
||||
|
|
@ -92,63 +93,72 @@ class UpdateAvailableDialog(QDialog):
|
|||
self.setModal(True)
|
||||
self.setMinimumWidth(400)
|
||||
self.setMinimumHeight(300)
|
||||
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
|
||||
# Header
|
||||
header = QLabel(f"WebDrop Bridge v{version} is available")
|
||||
header.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||
layout.addWidget(header)
|
||||
|
||||
|
||||
# Changelog
|
||||
changelog_label = QLabel("Release Notes:")
|
||||
changelog_label.setStyleSheet("font-weight: bold; margin-top: 10px;")
|
||||
layout.addWidget(changelog_label)
|
||||
|
||||
|
||||
self.changelog = QTextEdit()
|
||||
self.changelog.setText(changelog)
|
||||
self.changelog.setReadOnly(True)
|
||||
layout.addWidget(self.changelog)
|
||||
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
|
||||
self.update_now_btn = QPushButton("Update Now")
|
||||
self.update_now_btn.clicked.connect(self._on_update_now)
|
||||
button_layout.addWidget(self.update_now_btn)
|
||||
|
||||
|
||||
self.update_later_btn = QPushButton("Later")
|
||||
self.update_later_btn.clicked.connect(self._on_update_later)
|
||||
button_layout.addWidget(self.update_later_btn)
|
||||
|
||||
|
||||
self.skip_btn = QPushButton("Skip Version")
|
||||
self.skip_btn.clicked.connect(self._on_skip)
|
||||
button_layout.addWidget(self.skip_btn)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
self.setLayout(layout)
|
||||
|
||||
|
||||
def _on_update_now(self):
|
||||
"""Handle update now button click."""
|
||||
self.update_now.emit()
|
||||
self.accept()
|
||||
|
||||
|
||||
def _on_update_later(self):
|
||||
"""Handle update later button click."""
|
||||
self.update_later.emit()
|
||||
self.reject()
|
||||
|
||||
def _on_skip(self):
|
||||
"""Handle skip version button click."""
|
||||
self.skip_version.emit()
|
||||
self.reject()
|
||||
|
||||
|
||||
class DownloadingDialog(QDialog):
|
||||
"""Dialog shown while downloading the update.
|
||||
|
||||
|
||||
Displays:
|
||||
- Download progress bar
|
||||
- Current file being downloaded
|
||||
- Cancel button
|
||||
"""
|
||||
|
||||
|
||||
cancel_download = Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
"""Initialize downloading dialog.
|
||||
|
||||
|
||||
Args:
|
||||
parent: Parent widget
|
||||
"""
|
||||
|
|
@ -157,40 +167,40 @@ class DownloadingDialog(QDialog):
|
|||
self.setModal(True)
|
||||
self.setMinimumWidth(350)
|
||||
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
|
||||
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
|
||||
# Header
|
||||
header = QLabel("Downloading update...")
|
||||
header.setStyleSheet("font-weight: bold;")
|
||||
layout.addWidget(header)
|
||||
|
||||
|
||||
# File label
|
||||
self.file_label = QLabel("Preparing download")
|
||||
layout.addWidget(self.file_label)
|
||||
|
||||
|
||||
# Progress bar
|
||||
self.progress = QProgressBar()
|
||||
self.progress.setMinimum(0)
|
||||
self.progress.setMaximum(100)
|
||||
self.progress.setValue(0)
|
||||
layout.addWidget(self.progress)
|
||||
|
||||
|
||||
# Size info
|
||||
self.size_label = QLabel("0 MB / 0 MB")
|
||||
self.size_label.setStyleSheet("color: gray; font-size: 11px;")
|
||||
layout.addWidget(self.size_label)
|
||||
|
||||
|
||||
# Cancel button
|
||||
self.cancel_btn = QPushButton("Cancel")
|
||||
self.cancel_btn.clicked.connect(self._on_cancel)
|
||||
layout.addWidget(self.cancel_btn)
|
||||
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
|
||||
def set_progress(self, current: int, total: int):
|
||||
"""Update progress bar.
|
||||
|
||||
|
||||
Args:
|
||||
current: Current bytes downloaded
|
||||
total: Total bytes to download
|
||||
|
|
@ -198,20 +208,20 @@ class DownloadingDialog(QDialog):
|
|||
if total > 0:
|
||||
percentage = int((current / total) * 100)
|
||||
self.progress.setValue(percentage)
|
||||
|
||||
|
||||
# Format size display
|
||||
current_mb = current / (1024 * 1024)
|
||||
total_mb = total / (1024 * 1024)
|
||||
self.size_label.setText(f"{current_mb:.1f} MB / {total_mb:.1f} MB")
|
||||
|
||||
|
||||
def set_filename(self, filename: str):
|
||||
"""Set the filename being downloaded.
|
||||
|
||||
|
||||
Args:
|
||||
filename: Name of file being downloaded
|
||||
"""
|
||||
self.file_label.setText(f"Downloading: {filename}")
|
||||
|
||||
|
||||
def _on_cancel(self):
|
||||
"""Handle cancel button click."""
|
||||
self.cancel_download.emit()
|
||||
|
|
@ -220,18 +230,18 @@ class DownloadingDialog(QDialog):
|
|||
|
||||
class InstallDialog(QDialog):
|
||||
"""Dialog shown before installing update and restarting.
|
||||
|
||||
|
||||
Displays:
|
||||
- Installation confirmation message
|
||||
- Warning about unsaved changes
|
||||
- Buttons: Install Now, Cancel
|
||||
"""
|
||||
|
||||
|
||||
install_now = Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
"""Initialize install dialog.
|
||||
|
||||
|
||||
Args:
|
||||
parent: Parent widget
|
||||
"""
|
||||
|
|
@ -239,18 +249,18 @@ class InstallDialog(QDialog):
|
|||
self.setWindowTitle("Install Update")
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(350)
|
||||
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
|
||||
# Header
|
||||
header = QLabel("Ready to Install")
|
||||
header.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||
layout.addWidget(header)
|
||||
|
||||
|
||||
# Message
|
||||
message = QLabel("The update is ready to install. The application will restart.")
|
||||
layout.addWidget(message)
|
||||
|
||||
|
||||
# Warning
|
||||
warning = QLabel(
|
||||
"⚠️ Please save any unsaved work before continuing.\n"
|
||||
|
|
@ -259,22 +269,22 @@ class InstallDialog(QDialog):
|
|||
warning.setStyleSheet("background-color: #fff3cd; padding: 10px; border-radius: 4px;")
|
||||
warning.setWordWrap(True)
|
||||
layout.addWidget(warning)
|
||||
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
|
||||
self.install_btn = QPushButton("Install Now")
|
||||
self.install_btn.setStyleSheet("background-color: #28a745; color: white;")
|
||||
self.install_btn.clicked.connect(self._on_install)
|
||||
button_layout.addWidget(self.install_btn)
|
||||
|
||||
|
||||
self.cancel_btn = QPushButton("Cancel")
|
||||
self.cancel_btn.clicked.connect(self.reject)
|
||||
button_layout.addWidget(self.cancel_btn)
|
||||
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
self.setLayout(layout)
|
||||
|
||||
|
||||
def _on_install(self):
|
||||
"""Handle install now button click."""
|
||||
self.install_now.emit()
|
||||
|
|
@ -283,13 +293,13 @@ class InstallDialog(QDialog):
|
|||
|
||||
class NoUpdateDialog(QDialog):
|
||||
"""Dialog shown when no updates are available.
|
||||
|
||||
|
||||
Simple confirmation that the application is up to date.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
"""Initialize no update dialog.
|
||||
|
||||
|
||||
Args:
|
||||
parent: Parent widget
|
||||
"""
|
||||
|
|
@ -297,39 +307,39 @@ class NoUpdateDialog(QDialog):
|
|||
self.setWindowTitle("No Updates Available")
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(300)
|
||||
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
|
||||
# Message
|
||||
message = QLabel("✓ You're using the latest version")
|
||||
message.setStyleSheet("font-weight: bold; font-size: 12px; color: #28a745;")
|
||||
layout.addWidget(message)
|
||||
|
||||
|
||||
info = QLabel("WebDrop Bridge is up to date.")
|
||||
layout.addWidget(info)
|
||||
|
||||
|
||||
# Close button
|
||||
close_btn = QPushButton("OK")
|
||||
close_btn.clicked.connect(self.accept)
|
||||
layout.addWidget(close_btn)
|
||||
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
|
||||
class ErrorDialog(QDialog):
|
||||
"""Dialog shown when update check or installation fails.
|
||||
|
||||
|
||||
Displays:
|
||||
- Error message
|
||||
- Buttons: Retry, Manual Download, Cancel
|
||||
"""
|
||||
|
||||
|
||||
retry = Signal()
|
||||
manual_download = Signal()
|
||||
|
||||
def __init__(self, error_message: str, parent=None):
|
||||
"""Initialize error dialog.
|
||||
|
||||
|
||||
Args:
|
||||
error_message: Description of the error
|
||||
parent: Parent widget
|
||||
|
|
@ -338,50 +348,52 @@ class ErrorDialog(QDialog):
|
|||
self.setWindowTitle("Update Failed")
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(350)
|
||||
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
|
||||
# Header
|
||||
header = QLabel("⚠️ Update Failed")
|
||||
header.setStyleSheet("font-weight: bold; font-size: 14px; color: #dc3545;")
|
||||
layout.addWidget(header)
|
||||
|
||||
|
||||
# Error message
|
||||
self.error_text = QTextEdit()
|
||||
self.error_text.setText(error_message)
|
||||
self.error_text.setReadOnly(True)
|
||||
self.error_text.setMaximumHeight(100)
|
||||
layout.addWidget(self.error_text)
|
||||
|
||||
|
||||
# Info message
|
||||
info = QLabel("Please try again or visit the website to download the update manually.")
|
||||
info = QLabel(
|
||||
"Please try again or visit the website to download the update manually."
|
||||
)
|
||||
info.setWordWrap(True)
|
||||
info.setStyleSheet("color: gray; font-size: 11px;")
|
||||
layout.addWidget(info)
|
||||
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
|
||||
self.retry_btn = QPushButton("Retry")
|
||||
self.retry_btn.clicked.connect(self._on_retry)
|
||||
button_layout.addWidget(self.retry_btn)
|
||||
|
||||
|
||||
self.manual_btn = QPushButton("Download Manually")
|
||||
self.manual_btn.clicked.connect(self._on_manual)
|
||||
button_layout.addWidget(self.manual_btn)
|
||||
|
||||
|
||||
self.cancel_btn = QPushButton("Cancel")
|
||||
self.cancel_btn.clicked.connect(self.reject)
|
||||
button_layout.addWidget(self.cancel_btn)
|
||||
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
self.setLayout(layout)
|
||||
|
||||
|
||||
def _on_retry(self):
|
||||
"""Handle retry button click."""
|
||||
self.retry.emit()
|
||||
self.accept()
|
||||
|
||||
|
||||
def _on_manual(self):
|
||||
"""Handle manual download button click."""
|
||||
self.manual_download.emit()
|
||||
|
|
|
|||
|
|
@ -76,17 +76,24 @@ class TestUpdateAvailableDialog:
|
|||
def test_signals_emitted_update_now(self, qapp, qtbot):
|
||||
"""Test update now signal is emitted."""
|
||||
dialog = UpdateAvailableDialog("0.0.2", "Changes")
|
||||
|
||||
|
||||
with qtbot.waitSignal(dialog.update_now):
|
||||
dialog.update_now_btn.click()
|
||||
|
||||
def test_signals_emitted_update_later(self, qapp, qtbot):
|
||||
"""Test update later signal is emitted."""
|
||||
dialog = UpdateAvailableDialog("0.0.2", "Changes")
|
||||
|
||||
|
||||
with qtbot.waitSignal(dialog.update_later):
|
||||
dialog.update_later_btn.click()
|
||||
|
||||
def test_signals_emitted_skip(self, qapp, qtbot):
|
||||
"""Test skip version signal is emitted."""
|
||||
dialog = UpdateAvailableDialog("0.0.2", "Changes")
|
||||
|
||||
with qtbot.waitSignal(dialog.skip_version):
|
||||
dialog.skip_btn.click()
|
||||
|
||||
|
||||
class TestDownloadingDialog:
|
||||
"""Tests for DownloadingDialog."""
|
||||
|
|
@ -127,7 +134,7 @@ class TestDownloadingDialog:
|
|||
def test_cancel_signal(self, qapp, qtbot):
|
||||
"""Test cancel signal is emitted."""
|
||||
dialog = DownloadingDialog()
|
||||
|
||||
|
||||
with qtbot.waitSignal(dialog.cancel_download):
|
||||
dialog.cancel_btn.click()
|
||||
|
||||
|
|
@ -149,7 +156,7 @@ class TestInstallDialog:
|
|||
def test_install_signal(self, qapp, qtbot):
|
||||
"""Test install signal is emitted."""
|
||||
dialog = InstallDialog()
|
||||
|
||||
|
||||
with qtbot.waitSignal(dialog.install_now):
|
||||
dialog.install_btn.click()
|
||||
|
||||
|
|
@ -204,13 +211,13 @@ class TestErrorDialog:
|
|||
def test_retry_signal(self, qapp, qtbot):
|
||||
"""Test retry signal is emitted."""
|
||||
dialog = ErrorDialog("Error")
|
||||
|
||||
|
||||
with qtbot.waitSignal(dialog.retry):
|
||||
dialog.retry_btn.click()
|
||||
|
||||
def test_manual_download_signal(self, qapp, qtbot):
|
||||
"""Test manual download signal is emitted."""
|
||||
dialog = ErrorDialog("Error")
|
||||
|
||||
|
||||
with qtbot.waitSignal(dialog.manual_download):
|
||||
dialog.manual_btn.click()
|
||||
|
|
|
|||
|
|
@ -166,7 +166,9 @@ class TestCheckForUpdates:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
@patch.object(UpdateManager, "_fetch_release")
|
||||
async def test_check_for_updates_no_update(self, mock_fetch, update_manager):
|
||||
async def test_check_for_updates_no_update(
|
||||
self, mock_fetch, update_manager
|
||||
):
|
||||
"""Test no update available."""
|
||||
mock_fetch.return_value = {
|
||||
"tag_name": "v0.0.1",
|
||||
|
|
@ -182,7 +184,9 @@ class TestCheckForUpdates:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
@patch.object(UpdateManager, "_fetch_release")
|
||||
async def test_check_for_updates_uses_cache(self, mock_fetch, update_manager, sample_release):
|
||||
async def test_check_for_updates_uses_cache(
|
||||
self, mock_fetch, update_manager, sample_release
|
||||
):
|
||||
"""Test cache is used on subsequent calls."""
|
||||
mock_fetch.return_value = sample_release
|
||||
|
||||
|
|
@ -203,7 +207,9 @@ class TestDownloading:
|
|||
"""Test update downloading."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_update_success(self, update_manager, tmp_path):
|
||||
async def test_download_update_success(
|
||||
self, update_manager, tmp_path
|
||||
):
|
||||
"""Test successful update download."""
|
||||
# Create release with .msi asset
|
||||
release_data = {
|
||||
|
|
@ -231,7 +237,9 @@ class TestDownloading:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
@patch.object(UpdateManager, "_download_file")
|
||||
async def test_download_update_no_installer(self, mock_download, update_manager):
|
||||
async def test_download_update_no_installer(
|
||||
self, mock_download, update_manager
|
||||
):
|
||||
"""Test download fails when no installer in release."""
|
||||
release_data = {
|
||||
"tag_name": "v0.0.2",
|
||||
|
|
@ -262,8 +270,8 @@ class TestChecksumVerification:
|
|||
self, mock_download_checksum, update_manager, sample_release, tmp_path
|
||||
):
|
||||
"""Test successful checksum verification."""
|
||||
# File must match the asset name so the .sha256 lookup succeeds
|
||||
test_file = tmp_path / "WebDropBridge.exe"
|
||||
# Create test file
|
||||
test_file = tmp_path / "test.exe"
|
||||
test_file.write_bytes(b"test content")
|
||||
|
||||
# Calculate actual checksum
|
||||
|
|
@ -283,8 +291,7 @@ class TestChecksumVerification:
|
|||
self, mock_download_checksum, update_manager, sample_release, tmp_path
|
||||
):
|
||||
"""Test checksum verification fails on mismatch."""
|
||||
# File must match the asset name so the .sha256 lookup succeeds
|
||||
test_file = tmp_path / "WebDropBridge.exe"
|
||||
test_file = tmp_path / "test.exe"
|
||||
test_file.write_bytes(b"test content")
|
||||
|
||||
# Return wrong checksum
|
||||
|
|
@ -296,7 +303,9 @@ class TestChecksumVerification:
|
|||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_checksum_no_checksum_file(self, update_manager, tmp_path):
|
||||
async def test_verify_checksum_no_checksum_file(
|
||||
self, update_manager, tmp_path
|
||||
):
|
||||
"""Test verification skipped when no checksum file in release."""
|
||||
test_file = tmp_path / "test.exe"
|
||||
test_file.write_bytes(b"test content")
|
||||
|
|
@ -327,7 +336,9 @@ class TestInstallation:
|
|||
|
||||
@patch("subprocess.Popen")
|
||||
@patch("platform.system")
|
||||
def test_install_update_windows(self, mock_platform, mock_popen, update_manager, tmp_path):
|
||||
def test_install_update_windows(
|
||||
self, mock_platform, mock_popen, update_manager, tmp_path
|
||||
):
|
||||
"""Test installation on Windows."""
|
||||
mock_platform.return_value = "Windows"
|
||||
installer = tmp_path / "WebDropBridge.msi"
|
||||
|
|
@ -340,7 +351,9 @@ class TestInstallation:
|
|||
|
||||
@patch("subprocess.Popen")
|
||||
@patch("platform.system")
|
||||
def test_install_update_macos(self, mock_platform, mock_popen, update_manager, tmp_path):
|
||||
def test_install_update_macos(
|
||||
self, mock_platform, mock_popen, update_manager, tmp_path
|
||||
):
|
||||
"""Test installation on macOS."""
|
||||
mock_platform.return_value = "Darwin"
|
||||
installer = tmp_path / "WebDropBridge.dmg"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue