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
|
# Application
|
||||||
APP_NAME=WebDrop Bridge
|
APP_NAME=WebDrop Bridge
|
||||||
APP_VERSION=0.6.5
|
APP_VERSION=0.6.0
|
||||||
|
|
||||||
# Web App
|
# Web App
|
||||||
WEBAPP_URL=file:///./webapp/index.html
|
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
|
## [0.6.0] - 2026-02-20
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,8 +1,7 @@
|
||||||
<?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"
|
||||||
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui"
|
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.0"
|
||||||
<Product Id="*" Name="WebDrop Bridge" Language="1033" Version="0.6.5"
|
|
||||||
Manufacturer="HIM-Tools"
|
Manufacturer="HIM-Tools"
|
||||||
UpgradeCode="12345678-1234-1234-1234-123456789012">
|
UpgradeCode="12345678-1234-1234-1234-123456789012">
|
||||||
|
|
||||||
|
|
@ -24,13 +23,6 @@
|
||||||
<UIRef Id="WixUI_InstallDir" />
|
<UIRef Id="WixUI_InstallDir" />
|
||||||
<UIRef Id="WixUI_ErrorProgressText" />
|
<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">
|
<Feature Id="ProductFeature" Title="WebDrop Bridge" Level="1">
|
||||||
<ComponentGroupRef Id="AppFiles" />
|
<ComponentGroupRef Id="AppFiles" />
|
||||||
<ComponentRef Id="ProgramMenuShortcut" />
|
<ComponentRef Id="ProgramMenuShortcut" />
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
|
|
@ -27,10 +27,13 @@ from typing import Optional
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
os.environ["PYTHONIOENCODING"] = "utf-8"
|
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||||
import io
|
import io
|
||||||
|
|
||||||
# Reconfigure stdout/stderr for UTF-8 output
|
# Reconfigure stdout/stderr for UTF-8 output
|
||||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
sys.stdout = io.TextIOWrapper(
|
||||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
sys.stdout.buffer, encoding="utf-8", errors="replace"
|
||||||
|
)
|
||||||
|
sys.stderr = io.TextIOWrapper(
|
||||||
|
sys.stderr.buffer, encoding="utf-8", errors="replace"
|
||||||
|
)
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import shutil
|
import shutil
|
||||||
|
|
@ -117,7 +120,12 @@ class WindowsBuilder:
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env["WEBDROP_ENV_FILE"] = str(self.env_file)
|
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:
|
if result.returncode != 0:
|
||||||
print("❌ PyInstaller build failed")
|
print("❌ PyInstaller build failed")
|
||||||
|
|
@ -133,9 +141,7 @@ class WindowsBuilder:
|
||||||
print(f"📦 Output: {exe_path}")
|
print(f"📦 Output: {exe_path}")
|
||||||
|
|
||||||
# Calculate total dist size
|
# Calculate total dist size
|
||||||
total_size = sum(
|
total_size = sum(f.stat().st_size for f in self.dist_dir.glob("WebDropBridge/**/*") if f.is_file())
|
||||||
f.stat().st_size for f in self.dist_dir.glob("WebDropBridge/**/*") if f.is_file()
|
|
||||||
)
|
|
||||||
if total_size > 0:
|
if total_size > 0:
|
||||||
print(f" Total size: {total_size / 1024 / 1024:.1f} MB")
|
print(f" Total size: {total_size / 1024 / 1024:.1f} MB")
|
||||||
|
|
||||||
|
|
@ -257,15 +263,12 @@ class WindowsBuilder:
|
||||||
str(heat_exe),
|
str(heat_exe),
|
||||||
"dir",
|
"dir",
|
||||||
str(dist_folder),
|
str(dist_folder),
|
||||||
"-cg",
|
"-cg", "AppFiles",
|
||||||
"AppFiles",
|
"-dr", "INSTALLFOLDER",
|
||||||
"-dr",
|
|
||||||
"INSTALLFOLDER",
|
|
||||||
"-sfrag",
|
"-sfrag",
|
||||||
"-srd",
|
"-srd",
|
||||||
"-gg",
|
"-gg",
|
||||||
"-o",
|
"-o", str(harvest_file),
|
||||||
str(harvest_file),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
result = subprocess.run(heat_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
result = subprocess.run(heat_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
|
@ -280,7 +283,10 @@ class WindowsBuilder:
|
||||||
if harvest_file.exists():
|
if harvest_file.exists():
|
||||||
content = harvest_file.read_text()
|
content = harvest_file.read_text()
|
||||||
# Add Win64="yes" to all Component tags
|
# 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)
|
harvest_file.write_text(content)
|
||||||
print(f" ✓ Marked components as 64-bit")
|
print(f" ✓ Marked components as 64-bit")
|
||||||
|
|
||||||
|
|
@ -292,15 +298,11 @@ class WindowsBuilder:
|
||||||
# Run candle compiler - make sure to use correct source directory
|
# Run candle compiler - make sure to use correct source directory
|
||||||
candle_cmd = [
|
candle_cmd = [
|
||||||
str(candle_exe),
|
str(candle_exe),
|
||||||
"-ext",
|
"-ext", "WixUIExtension",
|
||||||
"WixUIExtension",
|
|
||||||
"-ext",
|
|
||||||
"WixUtilExtension",
|
|
||||||
f"-dDistDir={self.dist_dir}",
|
f"-dDistDir={self.dist_dir}",
|
||||||
f"-dSourceDir={self.dist_dir}\\WebDropBridge", # Set SourceDir for Heat-generated files
|
f"-dSourceDir={self.dist_dir}\\WebDropBridge", # Set SourceDir for Heat-generated files
|
||||||
f"-dResourcesDir={self.project_root}\\resources", # Set ResourcesDir for branding assets
|
f"-dResourcesDir={self.project_root}\\resources", # Set ResourcesDir for branding assets
|
||||||
"-o",
|
"-o", str(self.build_dir) + "\\",
|
||||||
str(self.build_dir) + "\\",
|
|
||||||
str(self.build_dir / "WebDropBridge.wxs"),
|
str(self.build_dir / "WebDropBridge.wxs"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -316,14 +318,9 @@ class WindowsBuilder:
|
||||||
# Link MSI - include both obj files if harvest was successful
|
# Link MSI - include both obj files if harvest was successful
|
||||||
light_cmd = [
|
light_cmd = [
|
||||||
str(light_exe),
|
str(light_exe),
|
||||||
"-ext",
|
"-ext", "WixUIExtension",
|
||||||
"WixUIExtension",
|
"-b", str(self.dist_dir / "WebDropBridge"), # Base path for source files
|
||||||
"-ext",
|
"-o", str(msi_output),
|
||||||
"WixUtilExtension",
|
|
||||||
"-b",
|
|
||||||
str(self.dist_dir / "WebDropBridge"), # Base path for source files
|
|
||||||
"-o",
|
|
||||||
str(msi_output),
|
|
||||||
str(wix_obj),
|
str(wix_obj),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -331,9 +328,7 @@ class WindowsBuilder:
|
||||||
light_cmd.append(str(wix_files_obj))
|
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, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print("❌ MSI linking failed")
|
print("❌ MSI linking failed")
|
||||||
if result.stdout:
|
if result.stdout:
|
||||||
|
|
@ -358,10 +353,9 @@ class WindowsBuilder:
|
||||||
Creates per-machine installation (Program Files).
|
Creates per-machine installation (Program Files).
|
||||||
Installation requires admin rights, but the app does not.
|
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"
|
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui">
|
||||||
xmlns:util="http://schemas.microsoft.com/wix/UtilExtension">
|
|
||||||
<Product Id="*" Name="WebDrop Bridge" Language="1033" Version="{self.version}"
|
<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">
|
||||||
|
|
@ -384,13 +378,6 @@ class WindowsBuilder:
|
||||||
<UIRef Id="WixUI_InstallDir" />
|
<UIRef Id="WixUI_InstallDir" />
|
||||||
<UIRef Id="WixUI_ErrorProgressText" />
|
<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">
|
<Feature Id="ProductFeature" Title="WebDrop Bridge" Level="1">
|
||||||
<ComponentGroupRef Id="AppFiles" />
|
<ComponentGroupRef Id="AppFiles" />
|
||||||
<ComponentRef Id="ProgramMenuShortcut" />
|
<ComponentRef Id="ProgramMenuShortcut" />
|
||||||
|
|
@ -446,21 +433,14 @@ class WindowsBuilder:
|
||||||
</DirectoryRef>
|
</DirectoryRef>
|
||||||
</Product>
|
</Product>
|
||||||
</Wix>
|
</Wix>
|
||||||
"""
|
'''
|
||||||
|
|
||||||
wix_file = self.build_dir / "WebDropBridge.wxs"
|
wix_file = self.build_dir / "WebDropBridge.wxs"
|
||||||
wix_file.write_text(wix_content)
|
wix_file.write_text(wix_content)
|
||||||
print(f" Created WiX source: {wix_file}")
|
print(f" Created WiX source: {wix_file}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _generate_file_elements(
|
def _generate_file_elements(self, folder: Path, parent_dir_ref: str, parent_rel_path: str, indent: int = 8, file_counter: Optional[dict] = None) -> str:
|
||||||
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.
|
"""Generate WiX File elements for all files in a folder.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -485,7 +465,6 @@ class WindowsBuilder:
|
||||||
if item.is_file():
|
if item.is_file():
|
||||||
# Create unique File element ID using hash of full path
|
# Create unique File element ID using hash of full path
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
path_hash = hashlib.md5(str(item).encode()).hexdigest()[:8]
|
path_hash = hashlib.md5(str(item).encode()).hexdigest()[:8]
|
||||||
file_id = f"File_{path_hash}"
|
file_id = f"File_{path_hash}"
|
||||||
file_path = str(item)
|
file_path = str(item)
|
||||||
|
|
@ -493,7 +472,10 @@ class WindowsBuilder:
|
||||||
elif item.is_dir() and item.name != "__pycache__":
|
elif item.is_dir() and item.name != "__pycache__":
|
||||||
# Recursively add files from subdirectories
|
# Recursively add files from subdirectories
|
||||||
sub_elements = self._generate_file_elements(
|
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:
|
if sub_elements:
|
||||||
elements.append(sub_elements)
|
elements.append(sub_elements)
|
||||||
|
|
@ -552,7 +534,10 @@ class WindowsBuilder:
|
||||||
str(exe_path),
|
str(exe_path),
|
||||||
]
|
]
|
||||||
|
|
||||||
result = subprocess.run(cmd, text=True)
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print("❌ Code signing failed")
|
print("❌ Code signing failed")
|
||||||
return False
|
return False
|
||||||
|
|
@ -599,7 +584,9 @@ class WindowsBuilder:
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
"""Build Windows MSI installer."""
|
"""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(
|
parser.add_argument(
|
||||||
"--msi",
|
"--msi",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,5 @@
|
||||||
"log_file": null,
|
"log_file": null,
|
||||||
"window_width": 1024,
|
"window_width": 1024,
|
||||||
"window_height": 768,
|
"window_height": 768,
|
||||||
"enable_logging": true,
|
"enable_logging": true
|
||||||
"enable_checkout": false
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""WebDrop Bridge - Qt-based desktop application for intelligent drag-and-drop file handling."""
|
"""WebDrop Bridge - Qt-based desktop application for intelligent drag-and-drop file handling."""
|
||||||
|
|
||||||
__version__ = "0.6.5"
|
__version__ = "0.6.0"
|
||||||
__author__ = "WebDrop Team"
|
__author__ = "WebDrop Team"
|
||||||
__license__ = "MIT"
|
__license__ = "MIT"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,6 @@ class Config:
|
||||||
window_height: Initial window height in pixels
|
window_height: Initial window height in pixels
|
||||||
window_title: Main window title (default: "{app_name} v{app_version}")
|
window_title: Main window title (default: "{app_name} v{app_version}")
|
||||||
enable_logging: Whether to write logs to file
|
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:
|
Raises:
|
||||||
ConfigurationError: If configuration values are invalid
|
ConfigurationError: If configuration values are invalid
|
||||||
|
|
@ -80,7 +78,6 @@ class Config:
|
||||||
window_height: int = 768
|
window_height: int = 768
|
||||||
window_title: str = ""
|
window_title: str = ""
|
||||||
enable_logging: bool = True
|
enable_logging: bool = True
|
||||||
enable_checkout: bool = False
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_file(cls, config_path: Path) -> "Config":
|
def from_file(cls, config_path: Path) -> "Config":
|
||||||
|
|
@ -109,7 +106,10 @@ class Config:
|
||||||
|
|
||||||
# Parse URL mappings
|
# Parse URL mappings
|
||||||
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", [])
|
for m in data.get("url_mappings", [])
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -143,7 +143,6 @@ class Config:
|
||||||
# If the stored title matches the pattern "{app_name} v{version}", regenerate it
|
# 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.
|
# with the current version. This ensures the title updates automatically on upgrades.
|
||||||
import re
|
import re
|
||||||
|
|
||||||
version_pattern = re.compile(rf"^{re.escape(app_name)}\s+v[\d.]+$")
|
version_pattern = re.compile(rf"^{re.escape(app_name)}\s+v[\d.]+$")
|
||||||
if stored_window_title and version_pattern.match(stored_window_title):
|
if stored_window_title and version_pattern.match(stored_window_title):
|
||||||
# Detected a default-pattern title with old version, regenerate
|
# Detected a default-pattern title with old version, regenerate
|
||||||
|
|
@ -171,7 +170,6 @@ class Config:
|
||||||
window_height=data.get("window_height", 768),
|
window_height=data.get("window_height", 768),
|
||||||
window_title=window_title,
|
window_title=window_title,
|
||||||
enable_logging=data.get("enable_logging", True),
|
enable_logging=data.get("enable_logging", True),
|
||||||
enable_checkout=data.get("enable_checkout", False),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -197,7 +195,6 @@ class Config:
|
||||||
app_name = os.getenv("APP_NAME", "WebDrop Bridge")
|
app_name = os.getenv("APP_NAME", "WebDrop Bridge")
|
||||||
# Version always comes from __init__.py for consistency
|
# Version always comes from __init__.py for consistency
|
||||||
from webdrop_bridge import __version__
|
from webdrop_bridge import __version__
|
||||||
|
|
||||||
app_version = __version__
|
app_version = __version__
|
||||||
|
|
||||||
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
|
|
@ -211,13 +208,13 @@ class Config:
|
||||||
default_title = f"{app_name} v{app_version}"
|
default_title = f"{app_name} v{app_version}"
|
||||||
window_title = os.getenv("WINDOW_TITLE", default_title)
|
window_title = os.getenv("WINDOW_TITLE", default_title)
|
||||||
enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true"
|
enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true"
|
||||||
enable_checkout = os.getenv("ENABLE_CHECKOUT", "false").lower() == "true"
|
|
||||||
|
|
||||||
# Validate log level
|
# Validate log level
|
||||||
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
||||||
if log_level not in valid_levels:
|
if log_level not in valid_levels:
|
||||||
raise ConfigurationError(
|
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
|
# Validate and parse allowed roots
|
||||||
|
|
@ -228,7 +225,9 @@ class Config:
|
||||||
if not root_path.exists():
|
if not root_path.exists():
|
||||||
logger.warning(f"Allowed root does not exist: {p.strip()}")
|
logger.warning(f"Allowed root does not exist: {p.strip()}")
|
||||||
elif not root_path.is_dir():
|
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:
|
else:
|
||||||
allowed_roots.append(root_path)
|
allowed_roots.append(root_path)
|
||||||
except ConfigurationError:
|
except ConfigurationError:
|
||||||
|
|
@ -241,7 +240,8 @@ class Config:
|
||||||
# Validate window dimensions
|
# Validate window dimensions
|
||||||
if window_width <= 0 or window_height <= 0:
|
if window_width <= 0 or window_height <= 0:
|
||||||
raise ConfigurationError(
|
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
|
# Create log file path if logging enabled
|
||||||
|
|
@ -261,11 +261,10 @@ class Config:
|
||||||
raise ConfigurationError("WEBAPP_URL cannot be empty")
|
raise ConfigurationError("WEBAPP_URL cannot be empty")
|
||||||
|
|
||||||
# Parse allowed URLs (empty string = no restriction)
|
# Parse allowed URLs (empty string = no restriction)
|
||||||
allowed_urls = (
|
allowed_urls = [
|
||||||
[url.strip() for url in allowed_urls_str.split(",") if url.strip()]
|
url.strip() for url in allowed_urls_str.split(",")
|
||||||
if allowed_urls_str
|
if url.strip()
|
||||||
else []
|
] if allowed_urls_str else []
|
||||||
)
|
|
||||||
|
|
||||||
# Parse URL mappings (Azure Blob Storage → Local Paths)
|
# Parse URL mappings (Azure Blob Storage → Local Paths)
|
||||||
# Format: url_prefix1=local_path1;url_prefix2=local_path2
|
# Format: url_prefix1=local_path1;url_prefix2=local_path2
|
||||||
|
|
@ -283,7 +282,10 @@ class Config:
|
||||||
)
|
)
|
||||||
url_prefix, local_path_str = mapping.split("=", 1)
|
url_prefix, local_path_str = mapping.split("=", 1)
|
||||||
url_mappings.append(
|
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:
|
except (ValueError, OSError) as e:
|
||||||
raise ConfigurationError(
|
raise ConfigurationError(
|
||||||
|
|
@ -303,7 +305,6 @@ class Config:
|
||||||
window_height=window_height,
|
window_height=window_height,
|
||||||
window_title=window_title,
|
window_title=window_title,
|
||||||
enable_logging=enable_logging,
|
enable_logging=enable_logging,
|
||||||
enable_checkout=enable_checkout,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_file(self, config_path: Path) -> None:
|
def to_file(self, config_path: Path) -> None:
|
||||||
|
|
@ -318,7 +319,11 @@ class Config:
|
||||||
"app_name": self.app_name,
|
"app_name": self.app_name,
|
||||||
"webapp_url": self.webapp_url,
|
"webapp_url": self.webapp_url,
|
||||||
"url_mappings": [
|
"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_roots": [str(p) for p in self.allowed_roots],
|
||||||
"allowed_urls": self.allowed_urls,
|
"allowed_urls": self.allowed_urls,
|
||||||
|
|
@ -331,7 +336,6 @@ class Config:
|
||||||
"window_height": self.window_height,
|
"window_height": self.window_height,
|
||||||
"window_title": self.window_title,
|
"window_title": self.window_title,
|
||||||
"enable_logging": self.enable_logging,
|
"enable_logging": self.enable_logging,
|
||||||
"enable_checkout": self.enable_checkout,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
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
|
Path to default config file in user's AppData/Roaming
|
||||||
"""
|
"""
|
||||||
import platform
|
import platform
|
||||||
|
|
||||||
if platform.system() == "Windows":
|
if platform.system() == "Windows":
|
||||||
base = Path.home() / "AppData" / "Roaming"
|
base = Path.home() / "AppData" / "Roaming"
|
||||||
else:
|
else:
|
||||||
|
|
@ -364,7 +367,6 @@ class Config:
|
||||||
Path to default logs directory in user's AppData/Roaming
|
Path to default logs directory in user's AppData/Roaming
|
||||||
"""
|
"""
|
||||||
import platform
|
import platform
|
||||||
|
|
||||||
if platform.system() == "Windows":
|
if platform.system() == "Windows":
|
||||||
base = Path.home() / "AppData" / "Roaming"
|
base = Path.home() / "AppData" / "Roaming"
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -53,9 +53,7 @@ class ConfigValidator:
|
||||||
# Check type
|
# Check type
|
||||||
expected_type = rules.get("type")
|
expected_type = rules.get("type")
|
||||||
if expected_type and not isinstance(value, expected_type):
|
if expected_type and not isinstance(value, expected_type):
|
||||||
errors.append(
|
errors.append(f"{field}: expected {expected_type.__name__}, got {type(value).__name__}")
|
||||||
f"{field}: expected {expected_type.__name__}, got {type(value).__name__}"
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check allowed values
|
# Check allowed values
|
||||||
|
|
@ -106,7 +104,7 @@ class ConfigProfile:
|
||||||
|
|
||||||
PROFILES_DIR = Path.home() / ".webdrop-bridge" / "profiles"
|
PROFILES_DIR = Path.home() / ".webdrop-bridge" / "profiles"
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self):
|
||||||
"""Initialize profile manager."""
|
"""Initialize profile manager."""
|
||||||
self.PROFILES_DIR.mkdir(parents=True, exist_ok=True)
|
self.PROFILES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,9 @@ class UpdateManager:
|
||||||
self.current_version = current_version
|
self.current_version = current_version
|
||||||
self.forgejo_url = "https://git.him-tools.de"
|
self.forgejo_url = "https://git.him-tools.de"
|
||||||
self.repo = "HIM-public/webdrop-bridge"
|
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
|
# Cache management
|
||||||
self.cache_dir = config_dir or Path.home() / ".webdrop-bridge"
|
self.cache_dir = config_dir or Path.home() / ".webdrop-bridge"
|
||||||
|
|
@ -145,44 +147,43 @@ class UpdateManager:
|
||||||
"""
|
"""
|
||||||
logger.debug(f"check_for_updates() called, current version: {self.current_version}")
|
logger.debug(f"check_for_updates() called, current version: {self.current_version}")
|
||||||
|
|
||||||
# Only use cache when a pending update was already found (avoids
|
# Try cache first
|
||||||
# showing the update dialog on every start). "No update" is never
|
logger.debug("Checking cache...")
|
||||||
# cached so that a freshly published release is visible immediately.
|
|
||||||
logger.debug("Checking cache for pending update...")
|
|
||||||
cached = self._load_cache()
|
cached = self._load_cache()
|
||||||
if cached:
|
if cached:
|
||||||
|
logger.debug("Found cached release")
|
||||||
release_data = cached.get("release")
|
release_data = cached.get("release")
|
||||||
if release_data:
|
if release_data:
|
||||||
version = release_data["tag_name"].lstrip("v")
|
version = release_data["tag_name"].lstrip("v")
|
||||||
logger.debug(f"Cached pending update version: {version}")
|
if not self._is_newer_version(version):
|
||||||
if self._is_newer_version(version):
|
logger.info("No newer version available (cached)")
|
||||||
logger.info(f"Returning cached pending update: {version}")
|
return None
|
||||||
return Release(**release_data)
|
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)
|
|
||||||
|
|
||||||
# Always fetch fresh from API so new releases are seen immediately
|
# Fetch from API
|
||||||
logger.debug("Fetching from API...")
|
logger.debug("Fetching from API...")
|
||||||
try:
|
try:
|
||||||
logger.info(f"Checking for updates from {self.api_endpoint}")
|
logger.info(f"Checking for updates from {self.api_endpoint}")
|
||||||
|
|
||||||
|
# Run in thread pool with aggressive timeout
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
response = await asyncio.wait_for(
|
response = await asyncio.wait_for(
|
||||||
loop.run_in_executor(None, self._fetch_release),
|
loop.run_in_executor(
|
||||||
timeout=8,
|
None, self._fetch_release
|
||||||
|
),
|
||||||
|
timeout=8 # Timeout after network call also has timeout
|
||||||
)
|
)
|
||||||
|
|
||||||
if not response:
|
if not response:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Check if newer version
|
||||||
version = response["tag_name"].lstrip("v")
|
version = response["tag_name"].lstrip("v")
|
||||||
if not self._is_newer_version(version):
|
if not self._is_newer_version(version):
|
||||||
logger.info(f"Latest version {version} is not newer than {self.current_version}")
|
logger.info(f"Latest version {version} is not newer than {self.current_version}")
|
||||||
|
self._save_cache(response)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Cache the found update so repeated starts don't hammer the API
|
|
||||||
logger.info(f"New version available: {version}")
|
logger.info(f"New version available: {version}")
|
||||||
release = Release(**response)
|
release = Release(**response)
|
||||||
self._save_cache(response)
|
self._save_cache(response)
|
||||||
|
|
@ -230,15 +231,17 @@ class UpdateManager:
|
||||||
except socket.timeout as e:
|
except socket.timeout as e:
|
||||||
logger.error(f"Socket timeout (5s) connecting to {self.api_endpoint}")
|
logger.error(f"Socket timeout (5s) connecting to {self.api_endpoint}")
|
||||||
return None
|
return None
|
||||||
|
except TimeoutError as e:
|
||||||
|
logger.error(f"Timeout error: {e}")
|
||||||
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to fetch release: {type(e).__name__}: {e}")
|
logger.error(f"Failed to fetch release: {type(e).__name__}: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
logger.debug(traceback.format_exc())
|
logger.debug(traceback.format_exc())
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def download_update(
|
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]:
|
) -> Optional[Path]:
|
||||||
"""Download installer from release assets.
|
"""Download installer from release assets.
|
||||||
|
|
||||||
|
|
@ -279,9 +282,8 @@ class UpdateManager:
|
||||||
self._download_file,
|
self._download_file,
|
||||||
installer_asset["browser_download_url"],
|
installer_asset["browser_download_url"],
|
||||||
output_file,
|
output_file,
|
||||||
progress_callback,
|
|
||||||
),
|
),
|
||||||
timeout=300,
|
timeout=300
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
|
|
@ -300,13 +302,12 @@ class UpdateManager:
|
||||||
output_file.unlink()
|
output_file.unlink()
|
||||||
return None
|
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).
|
"""Download file from URL (blocking).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url: URL to download from
|
url: URL to download from
|
||||||
output_path: Path to save file
|
output_path: Path to save file
|
||||||
progress_callback: Optional callable(bytes_downloaded, total_bytes)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if successful, False otherwise
|
True if successful, False otherwise
|
||||||
|
|
@ -314,28 +315,17 @@ class UpdateManager:
|
||||||
try:
|
try:
|
||||||
logger.debug(f"Downloading from {url}")
|
logger.debug(f"Downloading from {url}")
|
||||||
with urlopen(url, timeout=300) as response: # 5 min timeout
|
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:
|
with open(output_path, "wb") as f:
|
||||||
while True:
|
f.write(response.read())
|
||||||
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
|
|
||||||
logger.debug(f"Downloaded {output_path.stat().st_size} bytes")
|
logger.debug(f"Downloaded {output_path.stat().st_size} bytes")
|
||||||
return True
|
return True
|
||||||
except URLError as e:
|
except URLError as e:
|
||||||
logger.error(f"Download failed: {e}")
|
logger.error(f"Download failed: {e}")
|
||||||
return False
|
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.
|
"""Verify file checksum against release checksum file.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -345,12 +335,10 @@ class UpdateManager:
|
||||||
Returns:
|
Returns:
|
||||||
True if checksum matches, False otherwise
|
True if checksum matches, False otherwise
|
||||||
"""
|
"""
|
||||||
# Find .sha256 file matching the installer name (e.g. Setup.msi.sha256)
|
# Find .sha256 file in release assets
|
||||||
# Fall back to any .sha256 only if no specific match exists
|
|
||||||
installer_name = file_path.name
|
|
||||||
checksum_asset = None
|
checksum_asset = None
|
||||||
for asset in release.assets:
|
for asset in release.assets:
|
||||||
if asset["name"] == f"{installer_name}.sha256":
|
if asset["name"].endswith(".sha256"):
|
||||||
checksum_asset = asset
|
checksum_asset = asset
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
@ -369,7 +357,7 @@ class UpdateManager:
|
||||||
self._download_checksum,
|
self._download_checksum,
|
||||||
checksum_asset["browser_download_url"],
|
checksum_asset["browser_download_url"],
|
||||||
),
|
),
|
||||||
timeout=30,
|
timeout=30
|
||||||
)
|
)
|
||||||
|
|
||||||
if not checksum_content:
|
if not checksum_content:
|
||||||
|
|
@ -389,7 +377,9 @@ class UpdateManager:
|
||||||
logger.info("Checksum verification passed")
|
logger.info("Checksum verification passed")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.error(f"Checksum mismatch: {file_checksum} != {expected_checksum}")
|
logger.error(
|
||||||
|
f"Checksum mismatch: {file_checksum} != {expected_checksum}"
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
|
|
@ -436,11 +426,8 @@ class UpdateManager:
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
if platform.system() == "Windows":
|
if platform.system() == "Windows":
|
||||||
# Windows: MSI files must be launched via msiexec
|
# Windows: Run MSI installer
|
||||||
logger.info(f"Launching installer: {installer_path}")
|
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
|
return True
|
||||||
elif platform.system() == "Darwin":
|
elif platform.system() == "Darwin":
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,12 @@ from PySide6.QtCore import (
|
||||||
Signal,
|
Signal,
|
||||||
Slot,
|
Slot,
|
||||||
)
|
)
|
||||||
from PySide6.QtGui import QDesktopServices, QIcon, QMouseEvent
|
from PySide6.QtGui import QIcon, QMouseEvent
|
||||||
from PySide6.QtWebChannel import QWebChannel
|
from PySide6.QtWebChannel import QWebChannel
|
||||||
from PySide6.QtWebEngineCore import QWebEngineDownloadRequest, QWebEngineScript
|
from PySide6.QtWebEngineCore import QWebEngineDownloadRequest, QWebEngineScript
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QLabel,
|
QLabel,
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
QMessageBox,
|
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QSpacerItem,
|
QSpacerItem,
|
||||||
QStatusBar,
|
QStatusBar,
|
||||||
|
|
@ -206,7 +205,7 @@ class _DragBridge(QObject):
|
||||||
Exposed to JavaScript as 'bridge' object.
|
Exposed to JavaScript as 'bridge' object.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, window: "MainWindow", parent: Optional[QObject] = None):
|
def __init__(self, window: 'MainWindow', parent: Optional[QObject] = None):
|
||||||
"""Initialize the drag bridge.
|
"""Initialize the drag bridge.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -270,7 +269,6 @@ class MainWindow(QMainWindow):
|
||||||
self._background_threads = [] # Keep references to background threads
|
self._background_threads = [] # Keep references to background threads
|
||||||
self._background_workers = {} # Keep references to background workers
|
self._background_workers = {} # Keep references to background workers
|
||||||
self.checking_dialog = None # Track the checking dialog
|
self.checking_dialog = None # Track the checking dialog
|
||||||
self.downloading_dialog = None # Track the download dialog
|
|
||||||
self._is_manual_check = False # Track if this is a manual check (for UI feedback)
|
self._is_manual_check = False # Track if this is a manual check (for UI feedback)
|
||||||
|
|
||||||
# Set window properties
|
# Set window properties
|
||||||
|
|
@ -284,14 +282,12 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
# Set window icon
|
# Set window icon
|
||||||
# Support both development mode and PyInstaller bundle
|
# Support both development mode and PyInstaller bundle
|
||||||
if hasattr(sys, "_MEIPASS"):
|
if hasattr(sys, '_MEIPASS'):
|
||||||
# Running as PyInstaller bundle
|
# Running as PyInstaller bundle
|
||||||
icon_path = Path(sys._MEIPASS) / "resources" / "icons" / "app.ico" # type: ignore
|
icon_path = Path(sys._MEIPASS) / "resources" / "icons" / "app.ico" # type: ignore
|
||||||
else:
|
else:
|
||||||
# Running in development mode
|
# Running in development mode
|
||||||
icon_path = (
|
icon_path = Path(__file__).parent.parent.parent.parent / "resources" / "icons" / "app.ico"
|
||||||
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)))
|
||||||
|
|
@ -408,7 +404,7 @@ class MainWindow(QMainWindow):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Load local file
|
# Load local file
|
||||||
html_content = file_path.read_text(encoding="utf-8")
|
html_content = file_path.read_text(encoding='utf-8')
|
||||||
|
|
||||||
# Inject WebChannel bridge JavaScript
|
# Inject WebChannel bridge JavaScript
|
||||||
injected_html = self._inject_drag_bridge(html_content)
|
injected_html = self._inject_drag_bridge(html_content)
|
||||||
|
|
@ -442,7 +438,7 @@ class MainWindow(QMainWindow):
|
||||||
qwebchannel_code = ""
|
qwebchannel_code = ""
|
||||||
qwebchannel_file = QFile(":/qtwebchannel/qwebchannel.js")
|
qwebchannel_file = QFile(":/qtwebchannel/qwebchannel.js")
|
||||||
if qwebchannel_file.open(QIODevice.OpenModeFlag.ReadOnly | QIODevice.OpenModeFlag.Text):
|
if qwebchannel_file.open(QIODevice.OpenModeFlag.ReadOnly | QIODevice.OpenModeFlag.Text):
|
||||||
qwebchannel_code = bytes(qwebchannel_file.readAll()).decode("utf-8") # type: ignore
|
qwebchannel_code = bytes(qwebchannel_file.readAll()).decode('utf-8') # type: ignore
|
||||||
qwebchannel_file.close()
|
qwebchannel_file.close()
|
||||||
logger.debug("Loaded qwebchannel.js inline to avoid CSP issues")
|
logger.debug("Loaded qwebchannel.js inline to avoid CSP issues")
|
||||||
else:
|
else:
|
||||||
|
|
@ -466,7 +462,7 @@ class MainWindow(QMainWindow):
|
||||||
search_paths.append(Path(__file__).parent / "bridge_script_intercept.js")
|
search_paths.append(Path(__file__).parent / "bridge_script_intercept.js")
|
||||||
|
|
||||||
# 2. PyInstaller bundle (via sys._MEIPASS)
|
# 2. PyInstaller bundle (via sys._MEIPASS)
|
||||||
if hasattr(sys, "_MEIPASS"):
|
if hasattr(sys, '_MEIPASS'):
|
||||||
search_paths.append(Path(sys._MEIPASS) / "webdrop_bridge" / "ui" / "bridge_script_intercept.js") # type: ignore
|
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)
|
# 3. Installed executable's directory (handles MSI installation where all files are packaged together)
|
||||||
|
|
@ -491,21 +487,17 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if script_path is None:
|
if script_path is None:
|
||||||
raise FileNotFoundError(
|
raise FileNotFoundError("bridge_script_intercept.js not found in any expected location")
|
||||||
"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 using similar search path logic
|
# Load download interceptor using similar search path logic
|
||||||
download_search_paths = []
|
download_search_paths = []
|
||||||
download_search_paths.append(Path(__file__).parent / "download_interceptor.js")
|
download_search_paths.append(Path(__file__).parent / "download_interceptor.js")
|
||||||
if hasattr(sys, "_MEIPASS"):
|
if hasattr(sys, '_MEIPASS'):
|
||||||
download_search_paths.append(Path(sys._MEIPASS) / "webdrop_bridge" / "ui" / "download_interceptor.js") # type: ignore
|
download_search_paths.append(Path(sys._MEIPASS) / "webdrop_bridge" / "ui" / "download_interceptor.js") # type: ignore
|
||||||
download_search_paths.append(
|
download_search_paths.append(exe_dir / "webdrop_bridge" / "ui" / "download_interceptor.js")
|
||||||
exe_dir / "webdrop_bridge" / "ui" / "download_interceptor.js"
|
|
||||||
)
|
|
||||||
|
|
||||||
download_interceptor_code = ""
|
download_interceptor_code = ""
|
||||||
for path in download_search_paths:
|
for path in download_search_paths:
|
||||||
|
|
@ -515,7 +507,7 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
if download_interceptor_path:
|
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:
|
||||||
|
|
@ -529,13 +521,11 @@ class MainWindow(QMainWindow):
|
||||||
if download_interceptor_code:
|
if download_interceptor_code:
|
||||||
combined_code += "\n\n" + download_interceptor_code
|
combined_code += "\n\n" + download_interceptor_code
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(f"Combined script size: {len(combined_code)} chars "
|
||||||
f"Combined script size: {len(combined_code)} chars "
|
|
||||||
f"(qwebchannel: {len(qwebchannel_code)}, "
|
f"(qwebchannel: {len(qwebchannel_code)}, "
|
||||||
f"config: {len(config_code)}, "
|
f"config: {len(config_code)}, "
|
||||||
f"bridge: {len(bridge_code)}, "
|
f"bridge: {len(bridge_code)}, "
|
||||||
f"interceptor: {len(download_interceptor_code)})"
|
f"interceptor: {len(download_interceptor_code)})")
|
||||||
)
|
|
||||||
logger.debug(f"URL mappings in config: {len(self.config.url_mappings)}")
|
logger.debug(f"URL mappings in config: {len(self.config.url_mappings)}")
|
||||||
for i, mapping in enumerate(self.config.url_mappings):
|
for i, mapping in enumerate(self.config.url_mappings):
|
||||||
logger.debug(f" Mapping {i+1}: {mapping.url_prefix} → {mapping.local_path}")
|
logger.debug(f" Mapping {i+1}: {mapping.url_prefix} → {mapping.local_path}")
|
||||||
|
|
@ -563,7 +553,10 @@ class MainWindow(QMainWindow):
|
||||||
# Convert URL mappings to format expected by bridge script
|
# Convert URL mappings to format expected by bridge script
|
||||||
mappings = []
|
mappings = []
|
||||||
for mapping in self.config.url_mappings:
|
for mapping in self.config.url_mappings:
|
||||||
mappings.append({"url_prefix": mapping.url_prefix, "local_path": mapping.local_path})
|
mappings.append({
|
||||||
|
"url_prefix": mapping.url_prefix,
|
||||||
|
"local_path": mapping.local_path
|
||||||
|
})
|
||||||
|
|
||||||
logger.debug(f"Generating config injection with {len(mappings)} URL mappings")
|
logger.debug(f"Generating config injection with {len(mappings)} URL mappings")
|
||||||
for i, m in enumerate(mappings):
|
for i, m in enumerate(mappings):
|
||||||
|
|
@ -617,9 +610,8 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
def _apply_stylesheet(self) -> None:
|
def _apply_stylesheet(self) -> None:
|
||||||
"""Apply application stylesheet if available."""
|
"""Apply application stylesheet if available."""
|
||||||
stylesheet_path = (
|
stylesheet_path = Path(__file__).parent.parent.parent.parent / \
|
||||||
Path(__file__).parent.parent.parent.parent / "resources" / "stylesheets" / "default.qss"
|
"resources" / "stylesheets" / "default.qss"
|
||||||
)
|
|
||||||
|
|
||||||
if stylesheet_path.exists():
|
if stylesheet_path.exists():
|
||||||
try:
|
try:
|
||||||
|
|
@ -639,8 +631,8 @@ class MainWindow(QMainWindow):
|
||||||
"""
|
"""
|
||||||
logger.info(f"Drag started: {source} -> {local_path}")
|
logger.info(f"Drag started: {source} -> {local_path}")
|
||||||
|
|
||||||
# Ask user if they want to check out the asset (only when enabled in config)
|
# Ask user if they want to check out the asset
|
||||||
if source.startswith("http") and self.config.enable_checkout:
|
if source.startswith('http'):
|
||||||
self._prompt_checkout(source, local_path)
|
self._prompt_checkout(source, local_path)
|
||||||
|
|
||||||
def _prompt_checkout(self, azure_url: str, local_path: str) -> None:
|
def _prompt_checkout(self, azure_url: str, local_path: str) -> None:
|
||||||
|
|
@ -658,7 +650,7 @@ class MainWindow(QMainWindow):
|
||||||
filename = Path(local_path).name
|
filename = Path(local_path).name
|
||||||
|
|
||||||
# Extract asset ID
|
# Extract asset ID
|
||||||
match = re.search(r"/([^/]+)/[^/]+$", azure_url)
|
match = re.search(r'/([^/]+)/[^/]+$', azure_url)
|
||||||
if not match:
|
if not match:
|
||||||
logger.warning(f"Could not extract asset ID from URL: {azure_url}")
|
logger.warning(f"Could not extract asset ID from URL: {azure_url}")
|
||||||
return
|
return
|
||||||
|
|
@ -717,21 +709,13 @@ class MainWindow(QMainWindow):
|
||||||
# After a short delay, read the result from window variable
|
# After a short delay, read the result from window variable
|
||||||
def check_result():
|
def check_result():
|
||||||
read_code = f"window['{callback_id}']"
|
read_code = f"window['{callback_id}']"
|
||||||
self.web_view.page().runJavaScript(
|
self.web_view.page().runJavaScript(read_code, lambda result: self._handle_checkout_status(result, azure_url, filename, callback_id))
|
||||||
read_code,
|
|
||||||
lambda result: self._handle_checkout_status(
|
|
||||||
result, azure_url, filename, callback_id
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wait 500ms for async fetch to complete
|
# Wait 500ms for async fetch to complete
|
||||||
from PySide6.QtCore import QTimer
|
from PySide6.QtCore import QTimer
|
||||||
|
|
||||||
QTimer.singleShot(500, check_result)
|
QTimer.singleShot(500, check_result)
|
||||||
|
|
||||||
def _handle_checkout_status(
|
def _handle_checkout_status(self, result, azure_url: str, filename: str, callback_id: str) -> None:
|
||||||
self, result, azure_url: str, filename: str, callback_id: str
|
|
||||||
) -> None:
|
|
||||||
"""Handle the result of checkout status check.
|
"""Handle the result of checkout status check.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -754,25 +738,22 @@ class MainWindow(QMainWindow):
|
||||||
# Parse JSON string
|
# Parse JSON string
|
||||||
try:
|
try:
|
||||||
import json
|
import json
|
||||||
|
|
||||||
parsed_result = json.loads(result)
|
parsed_result = json.loads(result)
|
||||||
except (json.JSONDecodeError, ValueError) as e:
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
logger.warning(f"Failed to parse checkout status result: {e}")
|
logger.warning(f"Failed to parse checkout status result: {e}")
|
||||||
self._show_checkout_dialog(azure_url, filename)
|
self._show_checkout_dialog(azure_url, filename)
|
||||||
return
|
return
|
||||||
|
|
||||||
if parsed_result.get("error"):
|
if parsed_result.get('error'):
|
||||||
logger.warning(f"Could not check checkout status: {parsed_result}")
|
logger.warning(f"Could not check checkout status: {parsed_result}")
|
||||||
self._show_checkout_dialog(azure_url, filename)
|
self._show_checkout_dialog(azure_url, filename)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if already checked out
|
# Check if already checked out
|
||||||
has_checkout = parsed_result.get("hasCheckout", False)
|
has_checkout = parsed_result.get('hasCheckout', False)
|
||||||
if has_checkout:
|
if has_checkout:
|
||||||
checkout_info = parsed_result.get("checkout", {})
|
checkout_info = parsed_result.get('checkout', {})
|
||||||
logger.info(
|
logger.info(f"Asset {filename} is already checked out: {checkout_info}, skipping dialog")
|
||||||
f"Asset {filename} is already checked out: {checkout_info}, skipping dialog"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Not checked out, show confirmation dialog
|
# Not checked out, show confirmation dialog
|
||||||
|
|
@ -793,7 +774,7 @@ class MainWindow(QMainWindow):
|
||||||
"Checkout Asset",
|
"Checkout Asset",
|
||||||
f"Do you want to check out this asset?\n\n{filename}",
|
f"Do you want to check out this asset?\n\n{filename}",
|
||||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
QMessageBox.StandardButton.Yes,
|
QMessageBox.StandardButton.Yes
|
||||||
)
|
)
|
||||||
|
|
||||||
if reply == QMessageBox.StandardButton.Yes:
|
if reply == QMessageBox.StandardButton.Yes:
|
||||||
|
|
@ -815,7 +796,7 @@ class MainWindow(QMainWindow):
|
||||||
try:
|
try:
|
||||||
# Extract asset ID from URL (middle segment between domain and filename)
|
# Extract asset ID from URL (middle segment between domain and filename)
|
||||||
# Format: https://domain/container/ASSET_ID/filename
|
# Format: https://domain/container/ASSET_ID/filename
|
||||||
match = re.search(r"/([^/]+)/[^/]+$", azure_url)
|
match = re.search(r'/([^/]+)/[^/]+$', azure_url)
|
||||||
if not match:
|
if not match:
|
||||||
logger.warning(f"Could not extract asset ID from URL: {azure_url}")
|
logger.warning(f"Could not extract asset ID from URL: {azure_url}")
|
||||||
return
|
return
|
||||||
|
|
@ -869,11 +850,11 @@ class MainWindow(QMainWindow):
|
||||||
def on_result(result):
|
def on_result(result):
|
||||||
"""Callback when JavaScript completes."""
|
"""Callback when JavaScript completes."""
|
||||||
if result and isinstance(result, dict):
|
if result and isinstance(result, dict):
|
||||||
if result.get("success"):
|
if result.get('success'):
|
||||||
logger.info(f"✅ Checkout successful for asset {asset_id}")
|
logger.info(f"✅ Checkout successful for asset {asset_id}")
|
||||||
else:
|
else:
|
||||||
status = result.get("status", "unknown")
|
status = result.get('status', 'unknown')
|
||||||
error = result.get("error", "unknown error")
|
error = result.get('error', 'unknown error')
|
||||||
logger.warning(f"Checkout API returned status {status}: {error}")
|
logger.warning(f"Checkout API returned status {status}: {error}")
|
||||||
else:
|
else:
|
||||||
logger.debug(f"Checkout API call completed (result: {result})")
|
logger.debug(f"Checkout API call completed (result: {result})")
|
||||||
|
|
@ -892,12 +873,7 @@ class MainWindow(QMainWindow):
|
||||||
error: Error message
|
error: Error message
|
||||||
"""
|
"""
|
||||||
logger.warning(f"Drag failed for {source}: {error}")
|
logger.warning(f"Drag failed for {source}: {error}")
|
||||||
# Show error dialog to user
|
# Can be extended with user notification or status bar message
|
||||||
QMessageBox.warning(
|
|
||||||
self,
|
|
||||||
"Drag-and-Drop Error",
|
|
||||||
f"Could not complete the drag-and-drop operation.\n\nError: {error}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _on_download_requested(self, download: QWebEngineDownloadRequest) -> None:
|
def _on_download_requested(self, download: QWebEngineDownloadRequest) -> None:
|
||||||
"""Handle download requests from the embedded web view.
|
"""Handle download requests from the embedded web view.
|
||||||
|
|
@ -943,7 +919,9 @@ class MainWindow(QMainWindow):
|
||||||
logger.info(f"Download started: {filename}")
|
logger.info(f"Download started: {filename}")
|
||||||
|
|
||||||
# Update status bar (temporarily)
|
# Update status bar (temporarily)
|
||||||
self.status_bar.showMessage(f"📥 Download: {filename}", 3000)
|
self.status_bar.showMessage(
|
||||||
|
f"📥 Download: {filename}", 3000
|
||||||
|
)
|
||||||
|
|
||||||
# Connect to state changed for progress tracking
|
# Connect to state changed for progress tracking
|
||||||
download.stateChanged.connect(
|
download.stateChanged.connect(
|
||||||
|
|
@ -975,13 +953,19 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
if state == QWebEngineDownloadRequest.DownloadState.DownloadCompleted:
|
if state == QWebEngineDownloadRequest.DownloadState.DownloadCompleted:
|
||||||
logger.info(f"Download completed: {file_path.name}")
|
logger.info(f"Download completed: {file_path.name}")
|
||||||
self.status_bar.showMessage(f"Download completed: {file_path.name}", 5000)
|
self.status_bar.showMessage(
|
||||||
|
f"Download completed: {file_path.name}", 5000
|
||||||
|
)
|
||||||
elif state == QWebEngineDownloadRequest.DownloadState.DownloadCancelled:
|
elif state == QWebEngineDownloadRequest.DownloadState.DownloadCancelled:
|
||||||
logger.info(f"Download cancelled: {file_path.name}")
|
logger.info(f"Download cancelled: {file_path.name}")
|
||||||
self.status_bar.showMessage(f"⚠️ Download abgebrochen: {file_path.name}", 3000)
|
self.status_bar.showMessage(
|
||||||
|
f"⚠️ Download abgebrochen: {file_path.name}", 3000
|
||||||
|
)
|
||||||
elif state == QWebEngineDownloadRequest.DownloadState.DownloadInterrupted:
|
elif state == QWebEngineDownloadRequest.DownloadState.DownloadInterrupted:
|
||||||
logger.warning(f"Download interrupted: {file_path.name}")
|
logger.warning(f"Download interrupted: {file_path.name}")
|
||||||
self.status_bar.showMessage(f"❌ Download fehlgeschlagen: {file_path.name}", 5000)
|
self.status_bar.showMessage(
|
||||||
|
f"❌ Download fehlgeschlagen: {file_path.name}", 5000
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in download finished handler: {e}", exc_info=True)
|
logger.error(f"Error in download finished handler: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
@ -1065,8 +1049,8 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
# Execute JS to check if our script is loaded
|
# Execute JS to check if our script is loaded
|
||||||
self.web_view.page().runJavaScript(
|
self.web_view.page().runJavaScript(
|
||||||
"typeof window.__webdrop_intercept_injected !== 'undefined' && window.__webdrop_intercept_injected === true",
|
"typeof window.__webdrop_bridge_injected !== 'undefined' && window.__webdrop_bridge_injected === true",
|
||||||
check_script,
|
check_script
|
||||||
)
|
)
|
||||||
|
|
||||||
def _create_navigation_toolbar(self) -> None:
|
def _create_navigation_toolbar(self) -> None:
|
||||||
|
|
@ -1081,25 +1065,29 @@ class MainWindow(QMainWindow):
|
||||||
self.addToolBar(Qt.ToolBarArea.TopToolBarArea, toolbar)
|
self.addToolBar(Qt.ToolBarArea.TopToolBarArea, toolbar)
|
||||||
|
|
||||||
# Back button
|
# Back button
|
||||||
back_action = self.web_view.pageAction(self.web_view.page().WebAction.Back)
|
back_action = self.web_view.pageAction(
|
||||||
|
self.web_view.page().WebAction.Back
|
||||||
|
)
|
||||||
toolbar.addAction(back_action)
|
toolbar.addAction(back_action)
|
||||||
|
|
||||||
# Forward button
|
# Forward button
|
||||||
forward_action = self.web_view.pageAction(self.web_view.page().WebAction.Forward)
|
forward_action = self.web_view.pageAction(
|
||||||
|
self.web_view.page().WebAction.Forward
|
||||||
|
)
|
||||||
toolbar.addAction(forward_action)
|
toolbar.addAction(forward_action)
|
||||||
|
|
||||||
# Separator
|
# Separator
|
||||||
toolbar.addSeparator()
|
toolbar.addSeparator()
|
||||||
|
|
||||||
# Home button
|
# Home button
|
||||||
home_action = toolbar.addAction(
|
home_action = toolbar.addAction(self.style().standardIcon(self.style().StandardPixmap.SP_DirHomeIcon), "")
|
||||||
self.style().standardIcon(self.style().StandardPixmap.SP_DirHomeIcon), ""
|
|
||||||
)
|
|
||||||
home_action.setToolTip("Home")
|
home_action.setToolTip("Home")
|
||||||
home_action.triggered.connect(self._navigate_home)
|
home_action.triggered.connect(self._navigate_home)
|
||||||
|
|
||||||
# Refresh button
|
# Refresh button
|
||||||
refresh_action = self.web_view.pageAction(self.web_view.page().WebAction.Reload)
|
refresh_action = self.web_view.pageAction(
|
||||||
|
self.web_view.page().WebAction.Reload
|
||||||
|
)
|
||||||
toolbar.addAction(refresh_action)
|
toolbar.addAction(refresh_action)
|
||||||
|
|
||||||
# Add stretch spacer to push help buttons to the right
|
# Add stretch spacer to push help buttons to the right
|
||||||
|
|
@ -1122,38 +1110,6 @@ class MainWindow(QMainWindow):
|
||||||
check_updates_action.setToolTip("Check for Updates")
|
check_updates_action.setToolTip("Check for Updates")
|
||||||
check_updates_action.triggered.connect(self._on_manual_check_for_updates)
|
check_updates_action.triggered.connect(self._on_manual_check_for_updates)
|
||||||
|
|
||||||
# Log file button on the right
|
|
||||||
log_action = toolbar.addAction("📋")
|
|
||||||
log_action.setToolTip("Open Log File")
|
|
||||||
log_action.triggered.connect(self._open_log_file)
|
|
||||||
|
|
||||||
def _open_log_file(self) -> None:
|
|
||||||
"""Open the application log file in the system default text editor.
|
|
||||||
|
|
||||||
Resolves the log file path from config, falls back to the default
|
|
||||||
AppData location, and opens it with QDesktopServices. Shows an
|
|
||||||
informational message if the file does not exist yet.
|
|
||||||
"""
|
|
||||||
log_file: Optional[Path] = None
|
|
||||||
|
|
||||||
if self.config.log_file:
|
|
||||||
log_file = Path(self.config.log_file)
|
|
||||||
else:
|
|
||||||
# Default location: <AppData/Roaming>/webdrop_bridge/logs/webdrop_bridge.log
|
|
||||||
app_data = Path(
|
|
||||||
QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
|
|
||||||
)
|
|
||||||
log_file = app_data / "logs" / "webdrop_bridge.log"
|
|
||||||
|
|
||||||
if log_file.exists():
|
|
||||||
QDesktopServices.openUrl(QUrl.fromLocalFile(str(log_file)))
|
|
||||||
else:
|
|
||||||
QMessageBox.information(
|
|
||||||
self,
|
|
||||||
"Log File Not Found",
|
|
||||||
f"No log file found at:\n{log_file}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _create_status_bar(self) -> None:
|
def _create_status_bar(self) -> None:
|
||||||
"""Create status bar with update status indicator."""
|
"""Create status bar with update status indicator."""
|
||||||
self.status_bar = self.statusBar()
|
self.status_bar = self.statusBar()
|
||||||
|
|
@ -1206,7 +1162,7 @@ class MainWindow(QMainWindow):
|
||||||
f"for professional desktop applications.<br>"
|
f"for professional desktop applications.<br>"
|
||||||
f"<br>"
|
f"<br>"
|
||||||
f"<b>Product of:</b><br>"
|
f"<b>Product of:</b><br>"
|
||||||
f"<b>hörl Information Management GmbH</b><br>"
|
f"<b>Hörl Information Management GmbH</b><br>"
|
||||||
f"Silberburgstraße 126<br>"
|
f"Silberburgstraße 126<br>"
|
||||||
f"70176 Stuttgart, Germany<br>"
|
f"70176 Stuttgart, Germany<br>"
|
||||||
f"<br>"
|
f"<br>"
|
||||||
|
|
@ -1216,7 +1172,7 @@ class MainWindow(QMainWindow):
|
||||||
f"<b>Web:</b> <a href='https://www.hoerl-im.de/'>https://www.hoerl-im.de/</a><br>"
|
f"<b>Web:</b> <a href='https://www.hoerl-im.de/'>https://www.hoerl-im.de/</a><br>"
|
||||||
f"</small>"
|
f"</small>"
|
||||||
f"<br>"
|
f"<br>"
|
||||||
f"<small>© 2026 hörl Information Management GmbH. All rights reserved.</small>"
|
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)
|
||||||
|
|
@ -1255,7 +1211,7 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
# Properly delete WebEnginePage before the profile is released
|
# Properly delete WebEnginePage before the profile is released
|
||||||
# This ensures cookies and session data are saved correctly
|
# This ensures cookies and session data are saved correctly
|
||||||
if hasattr(self, "web_view") and self.web_view:
|
if hasattr(self, 'web_view') and self.web_view:
|
||||||
page = self.web_view.page()
|
page = self.web_view.page()
|
||||||
if page:
|
if page:
|
||||||
# Disconnect signals to prevent callbacks during shutdown
|
# Disconnect signals to prevent callbacks during shutdown
|
||||||
|
|
@ -1284,7 +1240,10 @@ class MainWindow(QMainWindow):
|
||||||
try:
|
try:
|
||||||
# Create update manager
|
# Create update manager
|
||||||
cache_dir = Path.home() / ".webdrop-bridge"
|
cache_dir = Path.home() / ".webdrop-bridge"
|
||||||
manager = UpdateManager(current_version=self.config.app_version, config_dir=cache_dir)
|
manager = UpdateManager(
|
||||||
|
current_version=self.config.app_version,
|
||||||
|
config_dir=cache_dir
|
||||||
|
)
|
||||||
|
|
||||||
# Run async check in background
|
# Run async check in background
|
||||||
self._run_async_check(manager)
|
self._run_async_check(manager)
|
||||||
|
|
@ -1308,7 +1267,7 @@ class MainWindow(QMainWindow):
|
||||||
# IMPORTANT: Keep references to prevent garbage collection
|
# IMPORTANT: Keep references to prevent garbage collection
|
||||||
# Store in a list to keep worker alive during thread execution
|
# Store in a list to keep worker alive during thread execution
|
||||||
self._background_threads.append(thread)
|
self._background_threads.append(thread)
|
||||||
self._background_workers = getattr(self, "_background_workers", {})
|
self._background_workers = getattr(self, '_background_workers', {})
|
||||||
self._background_workers[id(thread)] = worker
|
self._background_workers[id(thread)] = worker
|
||||||
|
|
||||||
logger.debug(f"Created worker and thread, thread id: {id(thread)}")
|
logger.debug(f"Created worker and thread, thread id: {id(thread)}")
|
||||||
|
|
@ -1325,19 +1284,18 @@ class MainWindow(QMainWindow):
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.warning("Update check taking too long (30s timeout)")
|
logger.warning("Update check taking too long (30s timeout)")
|
||||||
if hasattr(self, "checking_dialog") and self.checking_dialog:
|
if hasattr(self, 'checking_dialog') and self.checking_dialog:
|
||||||
self.checking_dialog.close()
|
self.checking_dialog.close()
|
||||||
self.set_update_status("Check timed out - no server response", emoji="⏱️")
|
self.set_update_status("Check timed out - no server response", emoji="⏱️")
|
||||||
|
|
||||||
# Show error dialog
|
# Show error dialog
|
||||||
from PySide6.QtWidgets import QMessageBox
|
from PySide6.QtWidgets import QMessageBox
|
||||||
|
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self,
|
self,
|
||||||
"Update Check Timeout",
|
"Update Check Timeout",
|
||||||
"The server did not respond within 30 seconds.\n\n"
|
"The server did not respond within 30 seconds.\n\n"
|
||||||
"This may be due to a network issue or server unavailability.\n\n"
|
"This may be due to a network issue or server unavailability.\n\n"
|
||||||
"Please check your connection and try again.",
|
"Please check your connection and try again."
|
||||||
)
|
)
|
||||||
|
|
||||||
safety_timer = QTimer()
|
safety_timer = QTimer()
|
||||||
|
|
@ -1398,11 +1356,10 @@ class MainWindow(QMainWindow):
|
||||||
# If this is a manual check and we get the "Ready" status, it means no updates
|
# If this is a manual check and we get the "Ready" status, it means no updates
|
||||||
if self._is_manual_check and status == "Ready":
|
if self._is_manual_check and status == "Ready":
|
||||||
# Close checking dialog first, then show result
|
# Close checking dialog first, then show result
|
||||||
if hasattr(self, "checking_dialog") and self.checking_dialog:
|
if hasattr(self, 'checking_dialog') and self.checking_dialog:
|
||||||
self.checking_dialog.close()
|
self.checking_dialog.close()
|
||||||
|
|
||||||
from webdrop_bridge.ui.update_manager_ui import NoUpdateDialog
|
from webdrop_bridge.ui.update_manager_ui import NoUpdateDialog
|
||||||
|
|
||||||
dialog = NoUpdateDialog(parent=self)
|
dialog = NoUpdateDialog(parent=self)
|
||||||
self._is_manual_check = False
|
self._is_manual_check = False
|
||||||
dialog.exec()
|
dialog.exec()
|
||||||
|
|
@ -1418,15 +1375,14 @@ class MainWindow(QMainWindow):
|
||||||
self._is_manual_check = False
|
self._is_manual_check = False
|
||||||
|
|
||||||
# Close checking dialog first, then show error
|
# Close checking dialog first, then show error
|
||||||
if hasattr(self, "checking_dialog") and self.checking_dialog:
|
if hasattr(self, 'checking_dialog') and self.checking_dialog:
|
||||||
self.checking_dialog.close()
|
self.checking_dialog.close()
|
||||||
|
|
||||||
from PySide6.QtWidgets import QMessageBox
|
from PySide6.QtWidgets import QMessageBox
|
||||||
|
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self,
|
self,
|
||||||
"Update Check Failed",
|
"Update Check Failed",
|
||||||
f"Could not check for updates:\n\n{error_message}\n\nPlease try again later.",
|
f"Could not check for updates:\n\n{error_message}\n\nPlease try again later."
|
||||||
)
|
)
|
||||||
|
|
||||||
def _on_update_available(self, release) -> None:
|
def _on_update_available(self, release) -> None:
|
||||||
|
|
@ -1435,23 +1391,22 @@ class MainWindow(QMainWindow):
|
||||||
Args:
|
Args:
|
||||||
release: Release object with update info
|
release: Release object with update info
|
||||||
"""
|
"""
|
||||||
# Close checking dialog if open (manual check case)
|
|
||||||
if hasattr(self, "checking_dialog") and self.checking_dialog:
|
|
||||||
self.checking_dialog.close()
|
|
||||||
self.checking_dialog = None
|
|
||||||
self._is_manual_check = False
|
|
||||||
|
|
||||||
# Update status to show update available
|
# Update status to show update available
|
||||||
self.set_update_status(f"Update available: v{release.version}", emoji="✅")
|
self.set_update_status(f"Update available: v{release.version}", emoji="✅")
|
||||||
|
|
||||||
# Show update available dialog
|
# Show update available dialog
|
||||||
from webdrop_bridge.ui.update_manager_ui import UpdateAvailableDialog
|
from webdrop_bridge.ui.update_manager_ui import UpdateAvailableDialog
|
||||||
|
|
||||||
dialog = UpdateAvailableDialog(version=release.version, changelog=release.body, parent=self)
|
dialog = UpdateAvailableDialog(
|
||||||
|
version=release.version,
|
||||||
|
changelog=release.body,
|
||||||
|
parent=self
|
||||||
|
)
|
||||||
|
|
||||||
# Connect dialog signals
|
# Connect dialog signals
|
||||||
dialog.update_now.connect(lambda: self._on_user_update_now(release))
|
dialog.update_now.connect(lambda: self._on_user_update_now(release))
|
||||||
dialog.update_later.connect(lambda: self._on_user_update_later())
|
dialog.update_later.connect(lambda: self._on_user_update_later())
|
||||||
|
dialog.skip_version.connect(lambda: self._on_user_skip_version(release.version))
|
||||||
|
|
||||||
# Show dialog (modal)
|
# Show dialog (modal)
|
||||||
dialog.exec()
|
dialog.exec()
|
||||||
|
|
@ -1472,6 +1427,21 @@ class MainWindow(QMainWindow):
|
||||||
logger.info("User deferred update")
|
logger.info("User deferred update")
|
||||||
self.set_update_status("Update deferred", emoji="")
|
self.set_update_status("Update deferred", emoji="")
|
||||||
|
|
||||||
|
def _on_user_skip_version(self, version: str) -> None:
|
||||||
|
"""Handle user clicking 'Skip Version' button.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
version: Version to skip
|
||||||
|
"""
|
||||||
|
logger.info(f"User skipped version {version}")
|
||||||
|
|
||||||
|
# Store skipped version in preferences
|
||||||
|
skipped_file = Path.home() / ".webdrop-bridge" / "skipped_version.txt"
|
||||||
|
skipped_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
skipped_file.write_text(version)
|
||||||
|
|
||||||
|
self.set_update_status(f"Skipped v{version}", emoji="")
|
||||||
|
|
||||||
def _start_update_download(self, release) -> None:
|
def _start_update_download(self, release) -> None:
|
||||||
"""Start downloading the update in background thread.
|
"""Start downloading the update in background thread.
|
||||||
|
|
||||||
|
|
@ -1481,12 +1451,6 @@ class MainWindow(QMainWindow):
|
||||||
logger.info(f"Starting download for v{release.version}")
|
logger.info(f"Starting download for v{release.version}")
|
||||||
self.set_update_status(f"Downloading v{release.version}", emoji="⬇️")
|
self.set_update_status(f"Downloading v{release.version}", emoji="⬇️")
|
||||||
|
|
||||||
# Show download progress dialog
|
|
||||||
from webdrop_bridge.ui.update_manager_ui import DownloadingDialog
|
|
||||||
|
|
||||||
self.downloading_dialog = DownloadingDialog(self)
|
|
||||||
self.downloading_dialog.show()
|
|
||||||
|
|
||||||
# Run download in background thread to avoid blocking UI
|
# Run download in background thread to avoid blocking UI
|
||||||
self._perform_update_async(release)
|
self._perform_update_async(release)
|
||||||
|
|
||||||
|
|
@ -1503,7 +1467,8 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
# Create update manager
|
# Create update manager
|
||||||
manager = UpdateManager(
|
manager = UpdateManager(
|
||||||
current_version=self.config.app_version, config_dir=Path.home() / ".webdrop-bridge"
|
current_version=self.config.app_version,
|
||||||
|
config_dir=Path.home() / ".webdrop-bridge"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create and start background thread
|
# Create and start background thread
|
||||||
|
|
@ -1519,7 +1484,6 @@ class MainWindow(QMainWindow):
|
||||||
# Connect signals
|
# Connect signals
|
||||||
worker.download_complete.connect(self._on_download_complete)
|
worker.download_complete.connect(self._on_download_complete)
|
||||||
worker.download_failed.connect(self._on_download_failed)
|
worker.download_failed.connect(self._on_download_failed)
|
||||||
worker.download_progress.connect(self._on_download_progress)
|
|
||||||
worker.update_status.connect(self._on_update_status)
|
worker.update_status.connect(self._on_update_status)
|
||||||
worker.finished.connect(thread.quit)
|
worker.finished.connect(thread.quit)
|
||||||
worker.finished.connect(worker.deleteLater)
|
worker.finished.connect(worker.deleteLater)
|
||||||
|
|
@ -1588,16 +1552,14 @@ class MainWindow(QMainWindow):
|
||||||
"""
|
"""
|
||||||
from webdrop_bridge.ui.update_manager_ui import InstallDialog
|
from webdrop_bridge.ui.update_manager_ui import InstallDialog
|
||||||
|
|
||||||
if hasattr(self, "downloading_dialog") and self.downloading_dialog:
|
|
||||||
self.downloading_dialog.close()
|
|
||||||
self.downloading_dialog = None
|
|
||||||
|
|
||||||
logger.info(f"Download complete: {installer_path}")
|
logger.info(f"Download complete: {installer_path}")
|
||||||
self.set_update_status("Ready to install", emoji="✅")
|
self.set_update_status("Ready to install", emoji="✅")
|
||||||
|
|
||||||
# Show install confirmation dialog
|
# Show install confirmation dialog
|
||||||
install_dialog = InstallDialog(parent=self)
|
install_dialog = InstallDialog(parent=self)
|
||||||
install_dialog.install_now.connect(lambda: self._do_install(installer_path))
|
install_dialog.install_now.connect(
|
||||||
|
lambda: self._do_install(installer_path)
|
||||||
|
)
|
||||||
install_dialog.exec()
|
install_dialog.exec()
|
||||||
|
|
||||||
def _on_download_failed(self, error: str) -> None:
|
def _on_download_failed(self, error: str) -> None:
|
||||||
|
|
@ -1606,31 +1568,16 @@ class MainWindow(QMainWindow):
|
||||||
Args:
|
Args:
|
||||||
error: Error message
|
error: Error message
|
||||||
"""
|
"""
|
||||||
if hasattr(self, "downloading_dialog") and self.downloading_dialog:
|
|
||||||
self.downloading_dialog.close()
|
|
||||||
self.downloading_dialog = None
|
|
||||||
|
|
||||||
logger.error(f"Download failed: {error}")
|
logger.error(f"Download failed: {error}")
|
||||||
self.set_update_status(error, emoji="❌")
|
self.set_update_status(error, emoji="❌")
|
||||||
|
|
||||||
from PySide6.QtWidgets import QMessageBox
|
from PySide6.QtWidgets import QMessageBox
|
||||||
|
|
||||||
QMessageBox.critical(
|
QMessageBox.critical(
|
||||||
self,
|
self,
|
||||||
"Download Failed",
|
"Download Failed",
|
||||||
f"Could not download the update:\n\n{error}\n\nPlease try again later.",
|
f"Could not download the update:\n\n{error}\n\nPlease try again later."
|
||||||
)
|
)
|
||||||
|
|
||||||
def _on_download_progress(self, downloaded: int, total: int) -> None:
|
|
||||||
"""Forward download progress to the downloading dialog.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
downloaded: Bytes downloaded so far
|
|
||||||
total: Total bytes (0 if unknown)
|
|
||||||
"""
|
|
||||||
if hasattr(self, "downloading_dialog") and self.downloading_dialog:
|
|
||||||
self.downloading_dialog.set_progress(downloaded, total)
|
|
||||||
|
|
||||||
def _do_install(self, installer_path: Path) -> None:
|
def _do_install(self, installer_path: Path) -> None:
|
||||||
"""Execute the installer.
|
"""Execute the installer.
|
||||||
|
|
||||||
|
|
@ -1642,7 +1589,8 @@ class MainWindow(QMainWindow):
|
||||||
from webdrop_bridge.core.updater import UpdateManager
|
from webdrop_bridge.core.updater import UpdateManager
|
||||||
|
|
||||||
manager = UpdateManager(
|
manager = UpdateManager(
|
||||||
current_version=self.config.app_version, config_dir=Path.home() / ".webdrop-bridge"
|
current_version=self.config.app_version,
|
||||||
|
config_dir=Path.home() / ".webdrop-bridge"
|
||||||
)
|
)
|
||||||
|
|
||||||
if manager.install_update(installer_path):
|
if manager.install_update(installer_path):
|
||||||
|
|
@ -1691,7 +1639,10 @@ class UpdateCheckWorker(QObject):
|
||||||
# Check for updates with short timeout (network call has its own timeout)
|
# Check for updates with short timeout (network call has its own timeout)
|
||||||
logger.debug("Starting update check with 10-second timeout")
|
logger.debug("Starting update check with 10-second timeout")
|
||||||
release = loop.run_until_complete(
|
release = loop.run_until_complete(
|
||||||
asyncio.wait_for(self.manager.check_for_updates(), timeout=10)
|
asyncio.wait_for(
|
||||||
|
self.manager.check_for_updates(),
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
)
|
)
|
||||||
logger.debug(f"Update check completed, release={release}")
|
logger.debug(f"Update check completed, release={release}")
|
||||||
|
|
||||||
|
|
@ -1729,7 +1680,6 @@ class UpdateDownloadWorker(QObject):
|
||||||
# Define signals at class level
|
# Define signals at class level
|
||||||
download_complete = Signal(Path) # Emits installer_path
|
download_complete = Signal(Path) # Emits installer_path
|
||||||
download_failed = Signal(str) # Emits error message
|
download_failed = Signal(str) # Emits error message
|
||||||
download_progress = Signal(int, int) # Emits (bytes_downloaded, total_bytes)
|
|
||||||
update_status = Signal(str, str) # Emits (status_text, emoji)
|
update_status = Signal(str, str) # Emits (status_text, emoji)
|
||||||
finished = Signal()
|
finished = Signal()
|
||||||
|
|
||||||
|
|
@ -1762,13 +1712,8 @@ class UpdateDownloadWorker(QObject):
|
||||||
logger.info("Starting download with 5-minute timeout")
|
logger.info("Starting download with 5-minute timeout")
|
||||||
installer_path = loop.run_until_complete(
|
installer_path = loop.run_until_complete(
|
||||||
asyncio.wait_for(
|
asyncio.wait_for(
|
||||||
self.manager.download_update(
|
self.manager.download_update(self.release),
|
||||||
self.release,
|
timeout=300
|
||||||
progress_callback=lambda cur, tot: self.download_progress.emit(
|
|
||||||
cur, tot
|
|
||||||
),
|
|
||||||
),
|
|
||||||
timeout=300,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1785,7 +1730,8 @@ class UpdateDownloadWorker(QObject):
|
||||||
logger.info("Starting checksum verification")
|
logger.info("Starting checksum verification")
|
||||||
checksum_ok = loop.run_until_complete(
|
checksum_ok = loop.run_until_complete(
|
||||||
asyncio.wait_for(
|
asyncio.wait_for(
|
||||||
self.manager.verify_checksum(installer_path, self.release), timeout=30
|
self.manager.verify_checksum(installer_path, self.release),
|
||||||
|
timeout=30
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1801,9 +1747,7 @@ class UpdateDownloadWorker(QObject):
|
||||||
except asyncio.TimeoutError as e:
|
except asyncio.TimeoutError as e:
|
||||||
logger.error(f"Download/verification timed out: {e}")
|
logger.error(f"Download/verification timed out: {e}")
|
||||||
self.update_status.emit("Operation timed out", "⏱️")
|
self.update_status.emit("Operation timed out", "⏱️")
|
||||||
self.download_failed.emit(
|
self.download_failed.emit("Download or verification timed out (no response from server)")
|
||||||
"Download or verification timed out (no response from server)"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error during download: {e}")
|
logger.error(f"Error during download: {e}")
|
||||||
self.download_failed.emit(f"Download error: {str(e)[:50]}")
|
self.download_failed.emit(f"Download error: {str(e)[:50]}")
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,10 @@
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QComboBox,
|
|
||||||
QDialog,
|
QDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
|
|
@ -42,7 +41,7 @@ class SettingsDialog(QDialog):
|
||||||
- Profiles: Save/load/delete configuration profiles
|
- 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.
|
"""Initialize the settings dialog.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -99,16 +98,17 @@ class SettingsDialog(QDialog):
|
||||||
from webdrop_bridge.config import URLMapping
|
from webdrop_bridge.config import URLMapping
|
||||||
|
|
||||||
url_mappings = [
|
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"]
|
for m in config_data["url_mappings"]
|
||||||
]
|
]
|
||||||
|
|
||||||
# Update the config object with new values
|
# Update the config object with new values
|
||||||
old_log_level = self.config.log_level
|
old_log_level = self.config.log_level
|
||||||
self.config.log_level = config_data["log_level"]
|
self.config.log_level = config_data["log_level"]
|
||||||
self.config.log_file = (
|
self.config.log_file = Path(config_data["log_file"]) if config_data["log_file"] else None
|
||||||
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_roots = [Path(r).resolve() for r in config_data["allowed_roots"]]
|
||||||
self.config.allowed_urls = config_data["allowed_urls"]
|
self.config.allowed_urls = config_data["allowed_urls"]
|
||||||
self.config.webapp_url = config_data["webapp_url"]
|
self.config.webapp_url = config_data["webapp_url"]
|
||||||
|
|
@ -130,7 +130,7 @@ class SettingsDialog(QDialog):
|
||||||
reconfigure_logging(
|
reconfigure_logging(
|
||||||
logger_name="webdrop_bridge",
|
logger_name="webdrop_bridge",
|
||||||
level=self.config.log_level,
|
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}")
|
logger.info(f"✅ Log level updated to {self.config.log_level}")
|
||||||
|
|
||||||
|
|
@ -157,9 +157,7 @@ class SettingsDialog(QDialog):
|
||||||
|
|
||||||
self.webapp_url_input = QLineEdit()
|
self.webapp_url_input = QLineEdit()
|
||||||
self.webapp_url_input.setText(self.config.webapp_url)
|
self.webapp_url_input.setText(self.config.webapp_url)
|
||||||
self.webapp_url_input.setPlaceholderText(
|
self.webapp_url_input.setPlaceholderText("e.g., http://localhost:8080 or file:///./webapp/index.html")
|
||||||
"e.g., http://localhost:8080 or file:///./webapp/index.html"
|
|
||||||
)
|
|
||||||
url_layout.addWidget(self.webapp_url_input)
|
url_layout.addWidget(self.webapp_url_input)
|
||||||
|
|
||||||
open_btn = QPushButton("Open")
|
open_btn = QPushButton("Open")
|
||||||
|
|
@ -210,7 +208,6 @@ class SettingsDialog(QDialog):
|
||||||
def _open_webapp_url(self) -> None:
|
def _open_webapp_url(self) -> None:
|
||||||
"""Open the webapp URL in the default browser."""
|
"""Open the webapp URL in the default browser."""
|
||||||
import webbrowser
|
import webbrowser
|
||||||
|
|
||||||
url = self.webapp_url_input.text().strip()
|
url = self.webapp_url_input.text().strip()
|
||||||
if url:
|
if url:
|
||||||
# Handle file:// URLs
|
# Handle file:// URLs
|
||||||
|
|
@ -227,14 +224,14 @@ class SettingsDialog(QDialog):
|
||||||
url_prefix, ok1 = QInputDialog.getText(
|
url_prefix, ok1 = QInputDialog.getText(
|
||||||
self,
|
self,
|
||||||
"Add URL Mapping",
|
"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:
|
if ok1 and url_prefix:
|
||||||
local_path, ok2 = QInputDialog.getText(
|
local_path, ok2 = QInputDialog.getText(
|
||||||
self,
|
self,
|
||||||
"Add URL Mapping",
|
"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:
|
if ok2 and local_path:
|
||||||
|
|
@ -252,16 +249,22 @@ class SettingsDialog(QDialog):
|
||||||
self._show_error("Please select a mapping to edit")
|
self._show_error("Please select a mapping to edit")
|
||||||
return
|
return
|
||||||
|
|
||||||
url_prefix = self.url_mappings_table.item(current_row, 0).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() # type: ignore
|
local_path = self.url_mappings_table.item(current_row, 1).text()
|
||||||
|
|
||||||
new_url_prefix, ok1 = QInputDialog.getText(
|
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:
|
if ok1 and new_url_prefix:
|
||||||
new_local_path, ok2 = QInputDialog.getText(
|
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:
|
if ok2 and new_local_path:
|
||||||
|
|
@ -342,7 +345,6 @@ class SettingsDialog(QDialog):
|
||||||
# Log level selection
|
# Log level selection
|
||||||
layout.addWidget(QLabel("Log Level:"))
|
layout.addWidget(QLabel("Log Level:"))
|
||||||
from PySide6.QtWidgets import QComboBox
|
from PySide6.QtWidgets import QComboBox
|
||||||
|
|
||||||
self.log_level_combo: QComboBox = self._create_log_level_widget()
|
self.log_level_combo: QComboBox = self._create_log_level_widget()
|
||||||
layout.addWidget(self.log_level_combo)
|
layout.addWidget(self.log_level_combo)
|
||||||
|
|
||||||
|
|
@ -441,8 +443,10 @@ class SettingsDialog(QDialog):
|
||||||
widget.setLayout(layout)
|
widget.setLayout(layout)
|
||||||
return widget
|
return widget
|
||||||
|
|
||||||
def _create_log_level_widget(self) -> QComboBox:
|
def _create_log_level_widget(self):
|
||||||
"""Create log level selection widget."""
|
"""Create log level selection widget."""
|
||||||
|
from PySide6.QtWidgets import QComboBox
|
||||||
|
|
||||||
combo = QComboBox()
|
combo = QComboBox()
|
||||||
levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||||
combo.addItems(levels)
|
combo.addItems(levels)
|
||||||
|
|
@ -465,7 +469,9 @@ class SettingsDialog(QDialog):
|
||||||
from PySide6.QtWidgets import QInputDialog
|
from PySide6.QtWidgets import QInputDialog
|
||||||
|
|
||||||
url, ok = QInputDialog.getText(
|
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:
|
if ok and url:
|
||||||
self.urls_list.addItem(url)
|
self.urls_list.addItem(url)
|
||||||
|
|
@ -478,7 +484,10 @@ class SettingsDialog(QDialog):
|
||||||
def _browse_log_file(self) -> None:
|
def _browse_log_file(self) -> None:
|
||||||
"""Browse for log file location."""
|
"""Browse for log file location."""
|
||||||
file_path, _ = QFileDialog.getSaveFileName(
|
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:
|
if file_path:
|
||||||
self.log_file_input.setText(file_path)
|
self.log_file_input.setText(file_path)
|
||||||
|
|
@ -494,7 +503,9 @@ class SettingsDialog(QDialog):
|
||||||
from PySide6.QtWidgets import QInputDialog
|
from PySide6.QtWidgets import QInputDialog
|
||||||
|
|
||||||
profile_name, ok = QInputDialog.getText(
|
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:
|
if ok and profile_name:
|
||||||
|
|
@ -535,7 +546,10 @@ class SettingsDialog(QDialog):
|
||||||
def _export_config(self) -> None:
|
def _export_config(self) -> None:
|
||||||
"""Export configuration to file."""
|
"""Export configuration to file."""
|
||||||
file_path, _ = QFileDialog.getSaveFileName(
|
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:
|
if file_path:
|
||||||
|
|
@ -547,7 +561,10 @@ class SettingsDialog(QDialog):
|
||||||
def _import_config(self) -> None:
|
def _import_config(self) -> None:
|
||||||
"""Import configuration from file."""
|
"""Import configuration from file."""
|
||||||
file_path, _ = QFileDialog.getOpenFileName(
|
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:
|
if file_path:
|
||||||
|
|
@ -557,7 +574,7 @@ class SettingsDialog(QDialog):
|
||||||
except ConfigurationError as e:
|
except ConfigurationError as e:
|
||||||
self._show_error(f"Failed to import configuration: {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.
|
"""Apply imported configuration data to UI.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -582,7 +599,7 @@ class SettingsDialog(QDialog):
|
||||||
self.width_spin.setValue(config_data.get("window_width", 800))
|
self.width_spin.setValue(config_data.get("window_width", 800))
|
||||||
self.height_spin.setValue(config_data.get("window_height", 600))
|
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.
|
"""Get updated configuration data from dialog.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -591,26 +608,20 @@ class SettingsDialog(QDialog):
|
||||||
Raises:
|
Raises:
|
||||||
ConfigurationError: If configuration is invalid
|
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 = {
|
config_data = {
|
||||||
"app_name": self.config.app_name,
|
"app_name": self.config.app_name,
|
||||||
"app_version": self.config.app_version,
|
"app_version": self.config.app_version,
|
||||||
"log_level": self.log_level_combo.currentText(),
|
"log_level": self.log_level_combo.currentText(),
|
||||||
"log_file": self.log_file_input.text() or None,
|
"log_file": self.log_file_input.text() or None,
|
||||||
"allowed_roots": [
|
"allowed_roots": [self.paths_list.item(i).text() for i in range(self.paths_list.count())],
|
||||||
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.webapp_url_input.text().strip(),
|
"webapp_url": self.webapp_url_input.text().strip(),
|
||||||
"url_mappings": [
|
"url_mappings": [
|
||||||
{
|
{
|
||||||
"url_prefix": self.url_mappings_table.item(i, 0).text() if self.url_mappings_table.item(i, 0) else "", # type: ignore
|
"url_prefix": self.url_mappings_table.item(i, 0).text(),
|
||||||
"local_path": self.url_mappings_table.item(i, 1).text() if self.url_mappings_table.item(i, 1) else "", # type: ignore
|
"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_width": self.width_spin.value(),
|
||||||
"window_height": self.height_spin.value(),
|
"window_height": self.height_spin.value(),
|
||||||
|
|
@ -629,5 +640,4 @@ class SettingsDialog(QDialog):
|
||||||
message: Error message
|
message: Error message
|
||||||
"""
|
"""
|
||||||
from PySide6.QtWidgets import QMessageBox
|
from PySide6.QtWidgets import QMessageBox
|
||||||
|
|
||||||
QMessageBox.critical(self, "Error", message)
|
QMessageBox.critical(self, "Error", message)
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ class UpdateAvailableDialog(QDialog):
|
||||||
# Signals
|
# Signals
|
||||||
update_now = Signal()
|
update_now = Signal()
|
||||||
update_later = Signal()
|
update_later = Signal()
|
||||||
|
skip_version = Signal()
|
||||||
|
|
||||||
def __init__(self, version: str, changelog: str, parent=None):
|
def __init__(self, version: str, changelog: str, parent=None):
|
||||||
"""Initialize update available dialog.
|
"""Initialize update available dialog.
|
||||||
|
|
@ -121,6 +122,10 @@ class UpdateAvailableDialog(QDialog):
|
||||||
self.update_later_btn.clicked.connect(self._on_update_later)
|
self.update_later_btn.clicked.connect(self._on_update_later)
|
||||||
button_layout.addWidget(self.update_later_btn)
|
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)
|
layout.addLayout(button_layout)
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
|
@ -134,6 +139,11 @@ class UpdateAvailableDialog(QDialog):
|
||||||
self.update_later.emit()
|
self.update_later.emit()
|
||||||
self.reject()
|
self.reject()
|
||||||
|
|
||||||
|
def _on_skip(self):
|
||||||
|
"""Handle skip version button click."""
|
||||||
|
self.skip_version.emit()
|
||||||
|
self.reject()
|
||||||
|
|
||||||
|
|
||||||
class DownloadingDialog(QDialog):
|
class DownloadingDialog(QDialog):
|
||||||
"""Dialog shown while downloading the update.
|
"""Dialog shown while downloading the update.
|
||||||
|
|
@ -354,7 +364,9 @@ class ErrorDialog(QDialog):
|
||||||
layout.addWidget(self.error_text)
|
layout.addWidget(self.error_text)
|
||||||
|
|
||||||
# Info message
|
# 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.setWordWrap(True)
|
||||||
info.setStyleSheet("color: gray; font-size: 11px;")
|
info.setStyleSheet("color: gray; font-size: 11px;")
|
||||||
layout.addWidget(info)
|
layout.addWidget(info)
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,13 @@ class TestUpdateAvailableDialog:
|
||||||
with qtbot.waitSignal(dialog.update_later):
|
with qtbot.waitSignal(dialog.update_later):
|
||||||
dialog.update_later_btn.click()
|
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:
|
class TestDownloadingDialog:
|
||||||
"""Tests for DownloadingDialog."""
|
"""Tests for DownloadingDialog."""
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,9 @@ class TestCheckForUpdates:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch.object(UpdateManager, "_fetch_release")
|
@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."""
|
"""Test no update available."""
|
||||||
mock_fetch.return_value = {
|
mock_fetch.return_value = {
|
||||||
"tag_name": "v0.0.1",
|
"tag_name": "v0.0.1",
|
||||||
|
|
@ -182,7 +184,9 @@ class TestCheckForUpdates:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch.object(UpdateManager, "_fetch_release")
|
@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."""
|
"""Test cache is used on subsequent calls."""
|
||||||
mock_fetch.return_value = sample_release
|
mock_fetch.return_value = sample_release
|
||||||
|
|
||||||
|
|
@ -203,7 +207,9 @@ class TestDownloading:
|
||||||
"""Test update downloading."""
|
"""Test update downloading."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Test successful update download."""
|
||||||
# Create release with .msi asset
|
# Create release with .msi asset
|
||||||
release_data = {
|
release_data = {
|
||||||
|
|
@ -231,7 +237,9 @@ class TestDownloading:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch.object(UpdateManager, "_download_file")
|
@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."""
|
"""Test download fails when no installer in release."""
|
||||||
release_data = {
|
release_data = {
|
||||||
"tag_name": "v0.0.2",
|
"tag_name": "v0.0.2",
|
||||||
|
|
@ -262,8 +270,8 @@ class TestChecksumVerification:
|
||||||
self, mock_download_checksum, update_manager, sample_release, tmp_path
|
self, mock_download_checksum, update_manager, sample_release, tmp_path
|
||||||
):
|
):
|
||||||
"""Test successful checksum verification."""
|
"""Test successful checksum verification."""
|
||||||
# File must match the asset name so the .sha256 lookup succeeds
|
# Create test file
|
||||||
test_file = tmp_path / "WebDropBridge.exe"
|
test_file = tmp_path / "test.exe"
|
||||||
test_file.write_bytes(b"test content")
|
test_file.write_bytes(b"test content")
|
||||||
|
|
||||||
# Calculate actual checksum
|
# Calculate actual checksum
|
||||||
|
|
@ -283,8 +291,7 @@ class TestChecksumVerification:
|
||||||
self, mock_download_checksum, update_manager, sample_release, tmp_path
|
self, mock_download_checksum, update_manager, sample_release, tmp_path
|
||||||
):
|
):
|
||||||
"""Test checksum verification fails on mismatch."""
|
"""Test checksum verification fails on mismatch."""
|
||||||
# File must match the asset name so the .sha256 lookup succeeds
|
test_file = tmp_path / "test.exe"
|
||||||
test_file = tmp_path / "WebDropBridge.exe"
|
|
||||||
test_file.write_bytes(b"test content")
|
test_file.write_bytes(b"test content")
|
||||||
|
|
||||||
# Return wrong checksum
|
# Return wrong checksum
|
||||||
|
|
@ -296,7 +303,9 @@ class TestChecksumVerification:
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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 verification skipped when no checksum file in release."""
|
||||||
test_file = tmp_path / "test.exe"
|
test_file = tmp_path / "test.exe"
|
||||||
test_file.write_bytes(b"test content")
|
test_file.write_bytes(b"test content")
|
||||||
|
|
@ -327,7 +336,9 @@ class TestInstallation:
|
||||||
|
|
||||||
@patch("subprocess.Popen")
|
@patch("subprocess.Popen")
|
||||||
@patch("platform.system")
|
@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."""
|
"""Test installation on Windows."""
|
||||||
mock_platform.return_value = "Windows"
|
mock_platform.return_value = "Windows"
|
||||||
installer = tmp_path / "WebDropBridge.msi"
|
installer = tmp_path / "WebDropBridge.msi"
|
||||||
|
|
@ -340,7 +351,9 @@ class TestInstallation:
|
||||||
|
|
||||||
@patch("subprocess.Popen")
|
@patch("subprocess.Popen")
|
||||||
@patch("platform.system")
|
@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."""
|
"""Test installation on macOS."""
|
||||||
mock_platform.return_value = "Darwin"
|
mock_platform.return_value = "Darwin"
|
||||||
installer = tmp_path / "WebDropBridge.dmg"
|
installer = tmp_path / "WebDropBridge.dmg"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue