diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21cca286..8fbc0695 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,10 +68,24 @@ jobs: python .github/scripts/update_version_info.py "$VERSION" # Update .iss files (Inno Setup installer scripts) - sed -i "s/#define MyAppVersion \".*\"/#define MyAppVersion \"$VERSION\"/" switchcraft_modern.iss - sed -i "s/#define MyAppVersionNumeric \".*\"/#define MyAppVersionNumeric \"$VERSION\"/" switchcraft_modern.iss - sed -i "s/#define MyAppVersion \".*\"/#define MyAppVersion \"$VERSION\"/" switchcraft_legacy.iss - sed -i "s/#define MyAppVersionNumeric \".*\"/#define MyAppVersionNumeric \"$VERSION\"/" switchcraft_legacy.iss + # Extract numeric version only (remove .dev0, +build, etc.) for VersionInfoVersion + BASE_VERSION=$(echo "$VERSION" | sed -E 's/([0-9]+\.[0-9]+\.[0-9]+).*/\1/') + # VersionInfoVersion requires 4 numeric components (Major.Minor.Patch.Build) + VERSION_INFO="${BASE_VERSION}.0" + # For MyAppVersion: use full version for dev (with commit ID), but remove commit ID for beta/stable + if [[ "${{ github.event.inputs.release_type }}" == "development" ]]; then + # Dev release: keep full version with commit ID (e.g., "2026.1.2.dev0+9d07a00") + APP_VERSION="$VERSION" + else + # Beta/Stable: remove commit ID if present (e.g., "2026.1.2b1" or "2026.1.2") + APP_VERSION=$(echo "$VERSION" | sed -E 's/\+[a-zA-Z0-9.-]+$//') + fi + sed -i "s/#define MyAppVersion \".*\"/#define MyAppVersion \"$APP_VERSION\"/" switchcraft_modern.iss + sed -i "s/#define MyAppVersionNumeric \".*\"/#define MyAppVersionNumeric \"$BASE_VERSION\"/" switchcraft_modern.iss + sed -i "s/#define MyAppVersionInfo \".*\"/#define MyAppVersionInfo \"$VERSION_INFO\"/" switchcraft_modern.iss + sed -i "s/#define MyAppVersion \".*\"/#define MyAppVersion \"$APP_VERSION\"/" switchcraft_legacy.iss + sed -i "s/#define MyAppVersionNumeric \".*\"/#define MyAppVersionNumeric \"$BASE_VERSION\"/" switchcraft_legacy.iss + sed -i "s/#define MyAppVersionInfo \".*\"/#define MyAppVersionInfo \"$VERSION_INFO\"/" switchcraft_legacy.iss - name: Generate Changelog id: changelog @@ -171,12 +185,19 @@ jobs: sed -i "s/__version__ = \".*\"/__version__ = \"$DEV_VERSION\"/" src/switchcraft/__init__.py python .github/scripts/update_version_info.py "$DEV_VERSION" - # Update .iss files (Inno Setup installer scripts) - keep base version without .dev0 suffix for installer - BASE_VERSION=$(echo $DEV_VERSION | sed 's/\.dev0.*$//') - sed -i "s/#define MyAppVersion \".*\"/#define MyAppVersion \"$BASE_VERSION\"/" switchcraft_modern.iss + # Update .iss files (Inno Setup installer scripts) + # Extract numeric version only (remove .dev0, +build, etc.) for VersionInfoVersion + BASE_VERSION=$(echo "$DEV_VERSION" | sed -E 's/([0-9]+\.[0-9]+\.[0-9]+).*/\1/') + # VersionInfoVersion requires 4 numeric components (Major.Minor.Patch.Build) + VERSION_INFO="${BASE_VERSION}.0" + # For dev releases: MyAppVersion should include commit ID (full DEV_VERSION) + # MyAppVersionNumeric should be numeric only (BASE_VERSION) + sed -i "s/#define MyAppVersion \".*\"/#define MyAppVersion \"$DEV_VERSION\"/" switchcraft_modern.iss sed -i "s/#define MyAppVersionNumeric \".*\"/#define MyAppVersionNumeric \"$BASE_VERSION\"/" switchcraft_modern.iss - sed -i "s/#define MyAppVersion \".*\"/#define MyAppVersion \"$BASE_VERSION\"/" switchcraft_legacy.iss + sed -i "s/#define MyAppVersionInfo \".*\"/#define MyAppVersionInfo \"$VERSION_INFO\"/" switchcraft_modern.iss + sed -i "s/#define MyAppVersion \".*\"/#define MyAppVersion \"$DEV_VERSION\"/" switchcraft_legacy.iss sed -i "s/#define MyAppVersionNumeric \".*\"/#define MyAppVersionNumeric \"$BASE_VERSION\"/" switchcraft_legacy.iss + sed -i "s/#define MyAppVersionInfo \".*\"/#define MyAppVersionInfo \"$VERSION_INFO\"/" switchcraft_legacy.iss # Update fallback versions in build scripts and version generator # Update build_release.ps1 fallback diff --git a/scripts/build_release.ps1 b/scripts/build_release.ps1 index 000f4a0f..6b65e35e 100644 --- a/scripts/build_release.ps1 +++ b/scripts/build_release.ps1 @@ -154,15 +154,23 @@ Write-Host "Project Root: $RepoRoot" -ForegroundColor Gray $PyProjectFile = Join-Path $RepoRoot "pyproject.toml" # Fallback version if extraction fails (can be overridden via env variable) $AppVersion = if ($env:SWITCHCRAFT_VERSION) { $env:SWITCHCRAFT_VERSION } else { "2026.1.2" } -$AppVersionNumeric = $AppVersion -replace '-.*', '' # Remove suffixes like -dev for numeric parsing +# Extract numeric version only (remove .dev0, +build, -dev, etc.) for VersionInfoVersion +# Pattern: extract MAJOR.MINOR.PATCH from any version format +$AppVersionNumeric = if ($AppVersion -match '^(\d+\.\d+\.\d+)') { $Matches[1] } else { $AppVersion -replace '[^0-9.].*', '' } +# VersionInfoVersion requires 4 numeric components (Major.Minor.Patch.Build) +$AppVersionInfo = "$AppVersionNumeric.0" if (Test-Path $PyProjectFile) { try { $VersionLine = Get-Content -Path $PyProjectFile | Select-String "version = " | Select-Object -First 1 if ($VersionLine -match 'version = "(.*)"') { $AppVersion = $Matches[1] - $AppVersionNumeric = $AppVersion -replace '-.*', '' # Remove suffixes like -dev for numeric parsing - Write-Host "Detected Version: $AppVersion (Numeric base: $AppVersionNumeric)" -ForegroundColor Cyan + # Extract numeric version only (remove .dev0, +build, -dev, etc.) for VersionInfoVersion + # Pattern: extract MAJOR.MINOR.PATCH from any version format + $AppVersionNumeric = if ($AppVersion -match '^(\d+\.\d+\.\d+)') { $Matches[1] } else { $AppVersion -replace '[^0-9.].*', '' } + # VersionInfoVersion requires 4 numeric components (Major.Minor.Patch.Build) + $AppVersionInfo = "$AppVersionNumeric.0" + Write-Host "Detected Version: $AppVersion (Numeric base: $AppVersionNumeric, Info: $AppVersionInfo)" -ForegroundColor Cyan } else { Write-Warning "Could not parse version from pyproject.toml, using fallback: $AppVersion" } @@ -364,12 +372,18 @@ if ($Installer -and $IsWinBuild) { if (Test-Path "switchcraft_modern.iss") { Write-Host "Compiling Modern Installer..." - & $IsccPath "/DMyAppVersion=$AppVersion" "/DMyAppVersionNumeric=$AppVersionNumeric" "switchcraft_modern.iss" | Out-Null + & $IsccPath "/DMyAppVersion=$AppVersion" "/DMyAppVersionNumeric=$AppVersionNumeric" "/DMyAppVersionInfo=$AppVersionInfo" "switchcraft_modern.iss" | Out-Null # Cleanup temporary SwitchCraft.exe used for bundling if (Test-Path $ModernExe) { Remove-Item $ModernExe -Force } - Write-Host "Installer Created: SwitchCraft-Setup.exe" -ForegroundColor Green + # Get full path to created installer (OutputDir is "dist" in switchcraft_modern.iss) + $InstallerPath = Join-Path (Resolve-Path "dist") "SwitchCraft-Setup.exe" + if (Test-Path $InstallerPath) { + Write-Host "Installer Created: $InstallerPath" -ForegroundColor Green + } else { + Write-Host "Installer Created: SwitchCraft-Setup.exe (in dist)" -ForegroundColor Green + } } } else { @@ -409,8 +423,14 @@ if ($Legacy -and $IsWinBuild) { Write-Host "`nBuilding Legacy Installer..." -ForegroundColor Cyan $IsccPath = Get-InnoSetupPath if ($IsccPath -and (Test-Path "switchcraft_legacy.iss")) { - & $IsccPath "/DMyAppVersion=$AppVersion" "/DMyAppVersionNumeric=$AppVersionNumeric" "switchcraft_legacy.iss" | Out-Null - Write-Host "Installer Created: SwitchCraft-Legacy-Setup.exe" -ForegroundColor Green + & $IsccPath "/DMyAppVersion=$AppVersion" "/DMyAppVersionNumeric=$AppVersionNumeric" "/DMyAppVersionInfo=$AppVersionInfo" "switchcraft_legacy.iss" | Out-Null + # Get full path to created installer (OutputDir is "dist" in switchcraft_legacy.iss) + $LegacyInstallerPath = Join-Path (Resolve-Path "dist") "SwitchCraft-Legacy-Setup.exe" + if (Test-Path $LegacyInstallerPath) { + Write-Host "Installer Created: $LegacyInstallerPath" -ForegroundColor Green + } else { + Write-Host "Installer Created: SwitchCraft-Legacy-Setup.exe (in dist)" -ForegroundColor Green + } } } } diff --git a/src/switchcraft/assets/lang/de.json b/src/switchcraft/assets/lang/de.json index 6681691c..a2d0c462 100644 --- a/src/switchcraft/assets/lang/de.json +++ b/src/switchcraft/assets/lang/de.json @@ -432,6 +432,7 @@ "lbl_active_cert": "Aktives Zertifikat:", "cert_not_configured": "Nicht konfiguriert", "cert_not_found": "Keine Code-Signing-Zertifikate gefunden.", + "cert_gpo_detected": "GPO-konfiguriertes Zertifikat erkannt.", "cert_auto_detected": "Zertifikat automatisch erkannt", "cert_auto_detected_multi": "Mehrere Zertifikate gefunden, verwende erstes", "cert_detect_failed": "Zertifikatserkennung fehlgeschlagen", @@ -482,6 +483,8 @@ "copied_to_clipboard": "In Zwischenablage kopiert!", "not_assigned": "Nicht zugewiesen.", "no_description": "Keine Beschreibung.", + "no_groups_found": "Keine Gruppen gefunden.", + "group_deselected": "Gruppe abgewählt", "all_users_devices": "Alle Benutzer/Geräte", "logs_no_logs_found": "Keine Log-Dateien zum Exportieren gefunden.", "logs_export_failed": "Log-Export fehlgeschlagen.", diff --git a/src/switchcraft/assets/lang/en.json b/src/switchcraft/assets/lang/en.json index 65dfeb72..e5898465 100644 --- a/src/switchcraft/assets/lang/en.json +++ b/src/switchcraft/assets/lang/en.json @@ -409,6 +409,7 @@ "lbl_active_cert": "Active Certificate:", "cert_not_configured": "Not Configured", "cert_not_found": "No code signing certificates found.", + "cert_gpo_detected": "GPO-configured certificate detected.", "cert_auto_detected": "Certificate auto-detected", "cert_auto_detected_multi": "Multiple certs found, using first", "cert_detect_failed": "Certificate detection failed", @@ -459,6 +460,8 @@ "copied_to_clipboard": "Copied to clipboard!", "not_assigned": "Not assigned.", "no_description": "No description.", + "no_groups_found": "No groups found.", + "group_deselected": "Group deselected", "all_users_devices": "All Users/Devices", "logs_no_logs_found": "No log files found to export.", "logs_export_failed": "Log export failed.", diff --git a/src/switchcraft/gui_modern/app.py b/src/switchcraft/gui_modern/app.py index 4caf1673..1e6b5328 100644 --- a/src/switchcraft/gui_modern/app.py +++ b/src/switchcraft/gui_modern/app.py @@ -710,7 +710,14 @@ def update_ui(): logging.getLogger(__name__).warning(f"Failed to update wizard UI: {ex}") if self.page: - self.page.run_task(update_ui) + # Wrap sync function in async wrapper for run_task + import inspect + if inspect.iscoroutinefunction(update_ui): + self.page.run_task(update_ui) + else: + async def async_update_ui(): + update_ui() + self.page.run_task(async_update_ui) threading.Thread(target=_base_install, daemon=True).start() @@ -1745,7 +1752,13 @@ def skip_demo(e): self.page.open(dlg) # Show dialog on UI thread - self.page.run_task(show_demo_dialog) + import inspect + if inspect.iscoroutinefunction(show_demo_dialog): + self.page.run_task(show_demo_dialog) + else: + async def async_show_demo_dialog(): + show_demo_dialog() + self.page.run_task(async_show_demo_dialog) def _start_demo_analysis(self): """ @@ -1797,7 +1810,10 @@ def download_and_analyze(): if 'analyzer' in self._view_cache: analyzer_view = self._view_cache['analyzer'] if hasattr(analyzer_view, 'start_analysis'): - self.page.run_task(lambda: analyzer_view.start_analysis(tmp.name)) + # Wrap lambda in async wrapper for run_task + async def async_start_analysis(): + analyzer_view.start_analysis(tmp.name) + self.page.run_task(async_start_analysis) except Exception as e: logger.error(f"Demo failed: {e}") @@ -1818,7 +1834,14 @@ def open_download(e): ) self.page.open(dlg) - self.page.run_task(show_error) + # Wrap sync function in async wrapper for run_task + import inspect + if inspect.iscoroutinefunction(show_error): + self.page.run_task(show_error) + else: + async def async_show_error(): + show_error() + self.page.run_task(async_show_error) threading.Thread(target=download_and_analyze, daemon=True).start() diff --git a/src/switchcraft/gui_modern/utils/view_utils.py b/src/switchcraft/gui_modern/utils/view_utils.py index 934bd32a..f3292eee 100644 --- a/src/switchcraft/gui_modern/utils/view_utils.py +++ b/src/switchcraft/gui_modern/utils/view_utils.py @@ -54,6 +54,50 @@ def _open_path(self, path): except Exception as e2: self._show_snack(f"Failed to open path: {path}", "RED") logger.error(f"Failed to open path: {e2}") + def _run_task_safe(self, func): + """ + Safely run a function via page.run_task, wrapping sync functions in async wrappers. + + This helper ensures that both sync and async functions can be passed to run_task + without causing TypeError: handler must be a coroutine function. + + Parameters: + func: A callable (sync or async function) + + Returns: + bool: True if task was scheduled, False otherwise + """ + import inspect + + try: + page = getattr(self, "app_page", None) + if not page: + try: + page = self.page + except (RuntimeError, AttributeError): + return False + if not page or not hasattr(page, 'run_task'): + return False + + # Check if function is already async + if inspect.iscoroutinefunction(func): + page.run_task(func) + return True + else: + # Wrap sync function in async wrapper + async def async_wrapper(): + func() + page.run_task(async_wrapper) + return True + except Exception as ex: + logger.debug(f"Failed to run task safely: {ex}") + # Fallback: try direct call + try: + func() + return True + except Exception: + return False + def _close_dialog(self, dialog=None): """Close a dialog on the page.""" try: diff --git a/src/switchcraft/gui_modern/views/dashboard_view.py b/src/switchcraft/gui_modern/views/dashboard_view.py index cd22d0d6..6427514c 100644 --- a/src/switchcraft/gui_modern/views/dashboard_view.py +++ b/src/switchcraft/gui_modern/views/dashboard_view.py @@ -64,8 +64,9 @@ def __init__(self, page: ft.Page): height=280 ) ], spacing=20, wrap=True) - ], spacing=15), - padding=20 # Consistent padding with other views + ], spacing=15, expand=True), + padding=20, # Consistent padding with other views + expand=True ) ] @@ -156,17 +157,18 @@ def _refresh_ui(self): ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=4) ) - chart_content = ft.Column([ - ft.Text(i18n.get("chart_activity_title") or "Activity (Last 5 Days)", weight=ft.FontWeight.BOLD, size=18), - ft.Container(height=20), - ft.Row(bars, alignment=ft.MainAxisAlignment.SPACE_EVENLY, vertical_alignment=ft.CrossAxisAlignment.END), - ]) - - # Ensure chart container has content - if not self.chart_container.content: - self.chart_container.content = chart_content + # Get the existing chart container's content Column + chart_col = self.chart_container.content + if isinstance(chart_col, ft.Column) and len(chart_col.controls) >= 3: + # Update the bars row (index 2) + chart_col.controls[2] = ft.Row(bars, alignment=ft.MainAxisAlignment.SPACE_EVENLY, vertical_alignment=ft.CrossAxisAlignment.END) else: - # Update existing content + # Rebuild entire chart content if structure is wrong + chart_content = ft.Column([ + ft.Text(i18n.get("chart_activity_title") or "Activity (Last 5 Days)", weight=ft.FontWeight.BOLD, size=18), + ft.Container(height=20), + ft.Row(bars, alignment=ft.MainAxisAlignment.SPACE_EVENLY, vertical_alignment=ft.CrossAxisAlignment.END), + ]) self.chart_container.content = chart_content # Recent @@ -201,26 +203,49 @@ def _refresh_ui(self): ) ) - recent_content = ft.Column([ - ft.Text(i18n.get("recent_actions") or "Recent Actions", weight=ft.FontWeight.BOLD, size=18), - ft.Container(height=10), - ft.Column(recent_list, spacing=8, scroll=ft.ScrollMode.AUTO, expand=True) - ], expand=True) - - # Ensure recent container has content - if not self.recent_container.content: - self.recent_container.content = recent_content + # Get the existing recent container's content Column + recent_col = self.recent_container.content + if isinstance(recent_col, ft.Column) and len(recent_col.controls) >= 3: + # Update the list column (index 2) + recent_col.controls[2] = ft.Column(recent_list, spacing=8, scroll=ft.ScrollMode.AUTO, expand=True) else: - # Update existing content + # Rebuild entire recent content if structure is wrong + recent_content = ft.Column([ + ft.Text(i18n.get("recent_actions") or "Recent Actions", weight=ft.FontWeight.BOLD, size=18), + ft.Container(height=10), + ft.Column(recent_list, spacing=8, scroll=ft.ScrollMode.AUTO, expand=True) + ], expand=True) self.recent_container.content = recent_content # Force update of all containers - try: - self.chart_container.update() - self.recent_container.update() - self.update() - except Exception: - pass + # Use run_task to ensure updates happen on UI thread + if hasattr(self, 'app_page') and hasattr(self.app_page, 'run_task'): + def update_ui(): + try: + self.chart_container.update() + self.recent_container.update() + self.update() + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"Failed to update dashboard UI: {e}") + + # Wrap in async if needed + import inspect + if inspect.iscoroutinefunction(update_ui): + self.app_page.run_task(update_ui) + else: + async def async_update(): + update_ui() + self.app_page.run_task(async_update) + else: + # Fallback: direct update + try: + self.chart_container.update() + self.recent_container.update() + self.update() + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"Failed to update dashboard UI: {e}") def _stat_card(self, label, value, icon, color): diff --git a/src/switchcraft/gui_modern/views/group_manager_view.py b/src/switchcraft/gui_modern/views/group_manager_view.py index a06809f9..241594d0 100644 --- a/src/switchcraft/gui_modern/views/group_manager_view.py +++ b/src/switchcraft/gui_modern/views/group_manager_view.py @@ -91,22 +91,10 @@ def _init_ui(self): self.delete_btn ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN) - # Datatable - self.dt = ft.DataTable( - columns=[ - ft.DataColumn(ft.Text(i18n.get("col_name") or "Name")), - ft.DataColumn(ft.Text(i18n.get("col_description") or "Description")), - ft.DataColumn(ft.Text(i18n.get("col_id") or "ID")), - ft.DataColumn(ft.Text(i18n.get("col_type") or "Type")), - ], - rows=[], - border=ft.Border.all(1, "GREY_400"), - vertical_lines=ft.border.BorderSide(1, "GREY_400"), - horizontal_lines=ft.border.BorderSide(1, "GREY_400"), - heading_row_color="BLACK12", - ) + # Groups List (replacing DataTable with clickable ListView) + self.groups_list = ft.ListView(expand=True, spacing=5) - self.list_container = ft.Column([self.dt], scroll=ft.ScrollMode.AUTO, expand=True) + self.list_container = ft.Column([self.groups_list], scroll=ft.ScrollMode.AUTO, expand=True) # Main Layout self.controls = [ @@ -148,10 +136,7 @@ def update_table(): except (RuntimeError, AttributeError): # Control not added to page yet (common in tests) logger.debug("Cannot update table: control not added to page") - if hasattr(self.app_page, 'run_task'): - self.app_page.run_task(update_table) - else: - update_table() + self._run_task_safe(update_table) except requests.exceptions.HTTPError as e: # Handle specific permission error (403) logger.error(f"Permission denied loading groups: {e}") @@ -166,10 +151,7 @@ def show_error(): self._show_snack(error_msg, "RED") except (RuntimeError, AttributeError): pass # Control not added to page (common in tests) - if hasattr(self.app_page, 'run_task'): - self.app_page.run_task(show_error) - else: - show_error() + self._run_task_safe(show_error) except requests.exceptions.ConnectionError as e: # Handle authentication failure logger.error(f"Authentication failed: {e}") @@ -180,10 +162,7 @@ def show_error(): self._show_snack(error_msg, "RED") except (RuntimeError, AttributeError): pass # Control not added to page (common in tests) - if hasattr(self.app_page, 'run_task'): - self.app_page.run_task(show_error) - else: - show_error() + self._run_task_safe(show_error) except Exception as e: error_str = str(e).lower() # Detect permission issues from error message @@ -200,10 +179,7 @@ def show_error(): self._show_snack(error_msg, "RED") except (RuntimeError, AttributeError): pass # Control not added to page (common in tests) - if hasattr(self.app_page, 'run_task'): - self.app_page.run_task(show_error) - else: - show_error() + self._run_task_safe(show_error) except BaseException as be: # Catch all exceptions including KeyboardInterrupt to prevent unhandled thread exceptions logger.exception("Unexpected error in group loading background thread") @@ -214,10 +190,7 @@ def update_ui(): self.update() except (RuntimeError, AttributeError): pass - if hasattr(self.app_page, 'run_task'): - self.app_page.run_task(update_ui) - else: - update_ui() + self._run_task_safe(update_ui) else: # Only update UI if no exception occurred - marshal to main thread def update_ui(): @@ -226,33 +199,54 @@ def update_ui(): self.update() except (RuntimeError, AttributeError): pass - if hasattr(self.app_page, 'run_task'): - self.app_page.run_task(update_ui) - else: - update_ui() + self._run_task_safe(update_ui) threading.Thread(target=_bg, daemon=True).start() def _update_table(self): try: - self.dt.rows.clear() - for g in self.filtered_groups: - self.dt.rows.append( - ft.DataRow( - cells=[ - ft.DataCell(ft.Text(g.get('displayName', ''))), - ft.DataCell(ft.Text(g.get('description', ''))), - ft.DataCell(ft.Text(g.get('id', ''))), - ft.DataCell(ft.Text(", ".join(g.get('groupTypes', [])) or "Security")), - ], - on_select_change=lambda e, grp=g: self._on_select(e.control.selected, grp), - selected=self.selected_group == g - ) - ) + self.groups_list.controls.clear() + + if not self.filtered_groups: + self.groups_list.controls.append( + ft.Container( + content=ft.Text(i18n.get("no_groups_found") or "No groups found.", italic=True, color="GREY"), + padding=20, + alignment=ft.Alignment(0, 0) + ) + ) + else: + for g in self.filtered_groups: + is_selected = self.selected_group == g or (self.selected_group and self.selected_group.get('id') == g.get('id')) + + # Create clickable tile for each group + tile = ft.Container( + content=ft.ListTile( + leading=ft.Icon( + ft.Icons.CHECK_CIRCLE if is_selected else ft.Icons.RADIO_BUTTON_UNCHECKED, + color="BLUE" if is_selected else "GREY" + ), + title=ft.Text(g.get('displayName', ''), weight=ft.FontWeight.BOLD if is_selected else None), + subtitle=ft.Column([ + ft.Text(g.get('description', '') or i18n.get("no_description") or "No description", size=12, color="GREY_600"), + ft.Text(f"ID: {g.get('id', '')}", size=10, color="GREY_500"), + ft.Text(f"Type: {', '.join(g.get('groupTypes', [])) or 'Security'}", size=10, color="GREY_500"), + ], spacing=2, tight=True), + trailing=ft.Icon(ft.Icons.CHEVRON_RIGHT, color="GREY_400") if is_selected else None, + ), + bgcolor="BLUE_50" if is_selected else None, + border=ft.Border.all(2, "BLUE" if is_selected else "GREY_300"), + border_radius=5, + padding=5, + on_click=lambda e, grp=g: self._on_group_click(grp), + data=g # Store group data in container + ) + self.groups_list.controls.append(tile) + self.update() except (RuntimeError, AttributeError): # Control not added to page yet (common in tests) - logger.debug("Cannot update table: control not added to page") + logger.debug("Cannot update groups list: control not added to page") def _on_search(self, e): query = self.search_field.value.lower() @@ -265,16 +259,26 @@ def _on_search(self, e): ] self._update_table() - def _on_select(self, selected, group): - if selected: - self.selected_group = group - else: + def _on_group_click(self, group): + """Handle click on a group tile to select/deselect it.""" + # Toggle selection: if same group clicked, deselect; otherwise select new group + if self.selected_group and self.selected_group.get('id') == group.get('id'): self.selected_group = None + else: + self.selected_group = group + + # Update UI to reflect selection + self._update_table() # Enable delete only if toggle on and item selected self.delete_btn.disabled = not (self.delete_toggle.value and self.selected_group) self.members_btn.disabled = not self.selected_group - self.update() + + # Show feedback + if self.selected_group: + self._show_snack(f"Selected: {self.selected_group.get('displayName', '')}", "BLUE") + else: + self._show_snack(i18n.get("group_deselected") or "Group deselected", "GREY") def _toggle_delete_mode(self, e): self.delete_btn.disabled = not (self.delete_toggle.value and self.selected_group) @@ -483,7 +487,7 @@ def _bg(): results_list.controls.append(ft.Text(error_tmpl.format(error=ex), color="RED")) # Marshal UI update to main thread if hasattr(self.app_page, 'run_task'): - self.app_page.run_task(add_dlg.update) + self._run_task_safe(add_dlg.update) else: add_dlg.update() diff --git a/src/switchcraft/gui_modern/views/intune_store_view.py b/src/switchcraft/gui_modern/views/intune_store_view.py index fe5b5033..47379da5 100644 --- a/src/switchcraft/gui_modern/views/intune_store_view.py +++ b/src/switchcraft/gui_modern/views/intune_store_view.py @@ -211,9 +211,19 @@ def _update_ui(): self._show_error(f"Error updating UI: {ex}") # Use run_task as primary method to marshal UI updates to the page event loop + # run_task requires async functions, so wrap sync function if needed + import inspect + is_async = inspect.iscoroutinefunction(_update_ui) + if hasattr(self.app_page, 'run_task'): try: - self.app_page.run_task(_update_ui) + if is_async: + self.app_page.run_task(_update_ui) + else: + # Wrap sync function in async wrapper + async def async_wrapper(): + _update_ui() + self.app_page.run_task(async_wrapper) except Exception as ex: logger.exception(f"Error in run_task for UI update: {ex}") # Fallback to direct call if run_task fails @@ -302,9 +312,13 @@ def _handle_app_click(self, app): try: logger.info(f"App clicked: {app.get('displayName', 'Unknown')}") # Use run_task to ensure UI updates happen on the correct thread + # run_task requires async functions, so wrap sync function if needed if hasattr(self.app_page, 'run_task'): try: - self.app_page.run_task(lambda: self._show_details(app)) + # Create async wrapper for sync function + async def async_show_details(): + self._show_details(app) + self.app_page.run_task(async_show_details) except Exception: # Fallback if run_task fails self._show_details(app) @@ -367,7 +381,10 @@ def _load_image_async(): img = ft.Image(src=logo_url, width=64, height=64, fit=ft.ImageFit.CONTAIN, error_content=ft.Icon(ft.Icons.APPS, size=64)) # Replace icon with image in title row if hasattr(self, 'app_page') and hasattr(self.app_page, 'run_task'): - self.app_page.run_task(lambda: self._replace_title_icon(title_row_container, img)) + # Create async wrapper for sync function + async def async_replace_icon(): + self._replace_title_icon(title_row_container, img) + self.app_page.run_task(async_replace_icon) else: self._replace_title_icon(title_row_container, img) except Exception as ex: @@ -587,9 +604,12 @@ def _deploy_bg(): # Refresh details # Use run_task if available to ensure thread safety when calling show_details if hasattr(self.app_page, 'run_task'): - self.app_page.run_task(lambda: self._show_details(app)) + # Create async wrapper for sync function + async def async_show_details(): + self._show_details(app) + self.app_page.run_task(async_show_details) else: - self._show_details(app) + self._show_details(app) except Exception as ex: self._show_snack(f"Assignment failed: {ex}", "RED") diff --git a/src/switchcraft/gui_modern/views/intune_view.py b/src/switchcraft/gui_modern/views/intune_view.py index 8aa6741f..c30f131e 100644 --- a/src/switchcraft/gui_modern/views/intune_view.py +++ b/src/switchcraft/gui_modern/views/intune_view.py @@ -190,7 +190,7 @@ def update_fail_creds(): self.up_status.value = "Missing Credentials (check Settings)" self.up_status.color = "RED" self.update() - self.app_page.run_task(update_fail_creds) + self._run_task_safe(update_fail_creds) return auth_token = self.intune_service.authenticate(self.tenant_id, self.client_id, self.client_secret) @@ -201,7 +201,7 @@ def update_success(): self.up_status.color = "GREEN" self.btn_upload.disabled = False self.update() - self.app_page.run_task(update_success) + self._run_task_safe(update_success) except Exception as ex: # Capture error message for use in nested function @@ -211,7 +211,7 @@ def update_error(): self.up_status.value = f"Connection Failed: {error_msg}" self.up_status.color = "RED" self.update() - self.app_page.run_task(update_error) + self._run_task_safe(update_error) threading.Thread(target=_bg, daemon=True).start() @@ -280,10 +280,7 @@ def update_results(): self.update() # Use run_task if available, otherwise update directly - try: - self.app_page.run_task(update_results) - except Exception: - update_results() + self._run_task_safe(update_results) except Exception as ex: logger.exception(f"Error searching apps: {ex}") @@ -296,10 +293,7 @@ def update_error(): self.supersede_copy_btn.visible = False self.update() - try: - self.app_page.run_task(update_error) - except Exception: - update_error() + self._run_task_safe(update_error) threading.Thread(target=_bg, daemon=True).start() @@ -507,15 +501,8 @@ def update_ui(): self.update() self._show_snack((i18n.get("metadata_copied_from") or "Metadata copied from {name}").format(name=full_app.get("displayName", "")), "GREEN") - # Use page.update() directly instead of run_task to avoid coroutine requirement - if hasattr(self.app_page, 'run_task'): - try: - self.app_page.run_task(update_ui) - except TypeError: - # If run_task requires async, use update directly - update_ui() - else: - update_ui() + # Use safe run_task helper to handle sync/async properly + self._run_task_safe(update_ui) except Exception as ex: logger.exception(f"Error copying metadata: {ex}") @@ -524,13 +511,7 @@ def update_error(): self.supersede_status_text.value = f"{i18n.get('copy_failed') or 'Copy Failed'}: {error_msg}" self.supersede_status_text.color = "RED" self.update() - if hasattr(self.app_page, 'run_task'): - try: - self.app_page.run_task(update_error) - except TypeError: - update_error() - else: - update_error() + self._run_task_safe(update_error) threading.Thread(target=_bg, daemon=True).start() def _log(self, msg): @@ -545,13 +526,7 @@ def _log(self, msg): def _update_ui(): self.log_view.controls.append(ft.Text(msg, font_family="Consolas", size=12, color="GREEN_400")) self.update() - if hasattr(self.app_page, 'run_task'): - try: - self.app_page.run_task(_update_ui) - except TypeError: - _update_ui() - else: - _update_ui() + self._run_task_safe(_update_ui) def _run_creation(self, e): # ... logic mainly same as before ... @@ -630,13 +605,7 @@ def open_folder(e): ) def show_dialog(): self.app_page.open(dlg) - if hasattr(self.app_page, 'run_task'): - try: - self.app_page.run_task(show_dialog) - except TypeError: - show_dialog() - else: - show_dialog() + self._run_task_safe(show_dialog) except Exception as ex: self._log(f"ERROR: {ex}") @@ -673,7 +642,7 @@ def update_progress(pct, msg): def _u(): self.up_status.value = f"{int(pct*100)}% - {msg}" self.update() - self.app_page.run_task(_u) + self._run_task_safe(_u) # 1. Upload new_app_id = self.intune_service.upload_win32_app( @@ -688,7 +657,7 @@ def _u(): def update_sup(): self.up_status.value = "Adding Supersedence..." self.update() - self.app_page.run_task(update_sup) + self._run_task_safe(update_sup) self.intune_service.add_supersedence(self.token, new_app_id, child_supersede, uninstall_prev=uninstall_prev) @@ -698,7 +667,7 @@ def update_done(): self.btn_upload.disabled = False self.update() self._show_success_dialog(new_app_id) - self.app_page.run_task(update_done) + self._run_task_safe(update_done) except Exception as ex: logger.error(f"Upload failed: {ex}") @@ -708,7 +677,7 @@ def update_fail(): self.up_status.color = "RED" self.btn_upload.disabled = False self.update() - self.app_page.run_task(update_fail) + self._run_task_safe(update_fail) threading.Thread(target=_bg, daemon=True).start() diff --git a/src/switchcraft/gui_modern/views/settings_view.py b/src/switchcraft/gui_modern/views/settings_view.py index 20deaa14..c0f1dbe1 100644 --- a/src/switchcraft/gui_modern/views/settings_view.py +++ b/src/switchcraft/gui_modern/views/settings_view.py @@ -998,6 +998,18 @@ def _export_logs(self, e): if exported: self._show_snack(i18n.get("logs_exported") or f"Logs exported to {path}", "GREEN") + # Open the folder containing the exported file + try: + folder_path = Path(path).parent + if os.name == 'nt': # Windows + import subprocess + subprocess.Popen(['explorer', str(folder_path)]) + else: + # Use ViewMixin's _open_path method for cross-platform support + self._open_path(str(folder_path)) + except Exception as ex: + logger.debug(f"Failed to open folder: {ex}") + # Don't show error to user, folder opening is a nice-to-have feature else: self._show_snack(i18n.get("logs_export_failed") or "Log export failed.", "RED") @@ -1477,7 +1489,14 @@ async def _ui_update(): import asyncio try: loop = asyncio.get_running_loop() - asyncio.create_task(_ui_update()) + task = asyncio.create_task(_ui_update()) + # Add exception handler to catch and log exceptions from the task + def handle_task_exception(task): + try: + task.result() + except Exception as task_ex: + logger.exception(f"Exception in async UI update task: {task_ex}") + task.add_done_callback(handle_task_exception) except RuntimeError: asyncio.run(_ui_update()) except Exception as e: @@ -1498,12 +1517,40 @@ def _download_and_install_github(self, addon_id): resp.raise_for_status() assets = resp.json().get("assets", []) - asset = next((a for a in assets if a["name"] == f"{addon_id}.zip"), None) + + # Naming convention: switchcraft_{addon_id}.zip OR {addon_id}.zip + # Try both naming patterns (matching AddonService.install_from_github logic) + candidates = [f"switchcraft_{addon_id}.zip", f"{addon_id}.zip"] + + asset = None + for candidate in candidates: + # Try exact match first + asset = next((a for a in assets if a["name"] == candidate), None) + if asset: + break + + # Fallback: try case-insensitive match + asset = next((a for a in assets if a["name"].lower() == candidate.lower()), None) + if asset: + break + + # Fallback: try prefix-based match (e.g., "ai" matches "switchcraft_ai.zip") + if not asset: + asset = next((a for a in assets if a["name"].startswith(f"switchcraft_{addon_id}") and a["name"].endswith(".zip")), None) + + # Last fallback: try any match with addon_id prefix + if not asset: + asset = next((a for a in assets if a["name"].startswith(addon_id) and a["name"].endswith(".zip")), None) if not asset: - raise Exception(f"Addon {addon_id}.zip not found in latest release") + # List available assets for debugging + available_assets = [a["name"] for a in assets] + logger.warning(f"Addon {addon_id} not found in latest release. Searched for: {candidates}. Available assets: {available_assets}") + raise Exception(f"Addon {addon_id} not found in latest release. Searched for: {', '.join(candidates)}. Available: {', '.join(available_assets[:10])}") download_url = asset["browser_download_url"] + asset_name = asset.get("name", f"{addon_id}.zip") + logger.info(f"Found {asset_name} in release, downloading from: {download_url}") with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp: d_resp = requests.get(download_url, timeout=30) @@ -1544,10 +1591,34 @@ def _download_addon_from_github(self, addon_id): assets = release.get("assets", []) # Look for the addon ZIP in assets - asset = next((a for a in assets if a["name"] == f"{addon_id}.zip"), None) + # Naming convention: switchcraft_{addon_id}.zip OR {addon_id}.zip + # Try both naming patterns (matching AddonService.install_from_github logic) + candidates = [f"switchcraft_{addon_id}.zip", f"{addon_id}.zip"] + + asset = None + for candidate in candidates: + # Try exact match first + asset = next((a for a in assets if a["name"] == candidate), None) + if asset: + break + + # Fallback: try case-insensitive match + asset = next((a for a in assets if a["name"].lower() == candidate.lower()), None) + if asset: + break + + # Fallback: try prefix-based match (e.g., "ai" matches "switchcraft_ai.zip") + if not asset: + asset = next((a for a in assets if a["name"].startswith(f"switchcraft_{addon_id}") and a["name"].endswith(".zip")), None) + + # Last fallback: try any match with addon_id prefix + if not asset: + asset = next((a for a in assets if a["name"].startswith(addon_id) and a["name"].endswith(".zip")), None) + if asset: download_url = asset["browser_download_url"] - logger.info(f"Found {addon_id}.zip in release, downloading from: {download_url}") + asset_name = asset.get("name", f"{addon_id}.zip") + logger.info(f"Found {asset_name} in release, downloading from: {download_url}") # Download to temp location import tempfile @@ -1571,8 +1642,10 @@ def _download_addon_from_github(self, addon_id): return # If not found in latest release, show error - logger.warning(f"Addon {addon_id}.zip not found in GitHub releases") - self._show_snack(f"Addon {addon_id} not found. Please download manually from GitHub.", "RED") + available_assets = [a["name"] for a in assets] if assets else [] + candidates = [f"switchcraft_{addon_id}.zip", f"{addon_id}.zip"] + logger.warning(f"Addon {addon_id} not found in GitHub releases. Searched for: {candidates}. Available assets: {available_assets}") + self._show_snack(f"Addon {addon_id} not found. Searched for: {', '.join(candidates)}. Available: {', '.join(available_assets[:10]) if available_assets else 'none'}", "RED") except Exception as ex: logger.exception(f"Failed to download addon from GitHub: {ex}") self._show_snack(f"Failed to download addon: {str(ex)}", "RED") @@ -1667,19 +1740,62 @@ def _on_signing_toggle(self, value): """Handle Code Signing toggle.""" SwitchCraftConfig.set_user_preference("SignScripts", value) if value: - # Auto-detect on enable if no cert configured - if not SwitchCraftConfig.get_value("CodeSigningCertThumbprint") and not SwitchCraftConfig.get_value("CodeSigningCertPath"): - self._auto_detect_signing_cert(None) + # Always run auto-detect when enabling code signing + # This ensures GPO-configured certificates are detected automatically + # If a cert is already configured, auto-detect will update it if a better match is found + self._auto_detect_signing_cert(None) def _auto_detect_signing_cert(self, e): - """Auto-detect code signing certificates from Windows Certificate Store.""" + """Auto-detect code signing certificates from Windows Certificate Store. + + Checks in order: + 1. GPO/Policy configured certificate (CodeSigningCertThumbprint from Policy) + 2. CurrentUser\My certificate store (user certificates) + 3. LocalMachine\My certificate store (GPO-deployed certificates) + """ import subprocess + import json + + # First, check if GPO/Policy has configured a certificate + # SwitchCraftConfig.get_value() already checks Policy paths first + gpo_thumb = SwitchCraftConfig.get_value("CodeSigningCertThumbprint", "") + gpo_cert_path = SwitchCraftConfig.get_value("CodeSigningCertPath", "") + + # If GPO has configured a certificate, verify it exists and use it + if gpo_thumb or gpo_cert_path: + # Verify the certificate exists in the store + try: + if gpo_thumb: + # Check if thumbprint exists in certificate stores + verify_cmd = [ + "powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", + f"$cert = Get-ChildItem -Recurse Cert:\\ -CodeSigningCert | Where-Object {{ $_.Thumbprint -eq '{gpo_thumb}' }} | Select-Object -First 1; " + f"if ($cert) {{ Write-Output 'FOUND' }} else {{ Write-Output 'NOT_FOUND' }}" + ] + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + verify_proc = subprocess.run(verify_cmd, capture_output=True, text=True, startupinfo=startupinfo, timeout=5) + + if verify_proc.returncode == 0 and "FOUND" in verify_proc.stdout: + # GPO certificate exists, use it + # Don't overwrite Policy settings, just display them + self.cert_status_text.value = f"GPO: {gpo_thumb[:8]}..." if gpo_thumb else f"GPO: {gpo_cert_path}" + self.cert_status_text.color = "GREEN" + self.update() + self._show_snack(i18n.get("cert_gpo_detected") or "GPO-configured certificate detected.", "GREEN") + return + except Exception as ex: + logger.debug(f"GPO cert verification failed: {ex}") + # Continue with auto-detection if verification fails try: - # Added Timeout and simplified command + # Search in order: CurrentUser\My, then LocalMachine\My (for GPO-deployed certs) cmd = [ "powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", - "Get-ChildItem Cert:\\CurrentUser\\My -CodeSigningCert | Select-Object Subject, Thumbprint | ConvertTo-Json -Depth 1" + "$certs = @(); " + "$certs += Get-ChildItem Cert:\\CurrentUser\\My -CodeSigningCert -ErrorAction SilentlyContinue | Select-Object Subject, Thumbprint; " + "$certs += Get-ChildItem Cert:\\LocalMachine\\My -CodeSigningCert -ErrorAction SilentlyContinue | Select-Object Subject, Thumbprint; " + "$certs | ConvertTo-Json -Depth 1" ] startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW @@ -1689,10 +1805,11 @@ def _auto_detect_signing_cert(self, e): output = proc.stdout.strip() if proc.returncode != 0 or not output: logger.warning(f"Cert detect returned empty or error: {proc.stderr}") - self._show_snack(i18n.get("cert_not_found") or "No code signing certificates found.", "ORANGE") + # If GPO cert was found but verification failed, don't show error + if not (gpo_thumb or gpo_cert_path): + self._show_snack(i18n.get("cert_not_found") or "No code signing certificates found.", "ORANGE") return - import json try: data = json.loads(output) except json.JSONDecodeError: @@ -1703,23 +1820,30 @@ def _auto_detect_signing_cert(self, e): data = [data] if len(data) == 0: - self._show_snack(i18n.get("cert_not_found") or "No code signing certificates found.", "ORANGE") + # If GPO cert was found but not in store, don't show error + if not (gpo_thumb or gpo_cert_path): + self._show_snack(i18n.get("cert_not_found") or "No code signing certificates found.", "ORANGE") elif len(data) == 1: cert = data[0] thumb = cert.get("Thumbprint", "") subj = cert.get("Subject", "").split(",")[0] - SwitchCraftConfig.set_user_preference("CodeSigningCertThumbprint", thumb) - SwitchCraftConfig.set_user_preference("CodeSigningCertPath", "") + # Only save to user preferences if not set by GPO + if not gpo_thumb: + SwitchCraftConfig.set_user_preference("CodeSigningCertThumbprint", thumb) + SwitchCraftConfig.set_user_preference("CodeSigningCertPath", "") self.cert_status_text.value = f"{subj} ({thumb[:8]}...)" self.cert_status_text.color = "GREEN" self.update() self._show_snack(f"{i18n.get('cert_auto_detected') or 'Certificate auto-detected'}: {subj}", "GREEN") else: - # Multiple certs - just use the first one for now (could show a picker) + # Multiple certs - prefer CurrentUser over LocalMachine, use first one + # Sort: CurrentUser first, then LocalMachine cert = data[0] thumb = cert.get("Thumbprint", "") subj = cert.get("Subject", "").split(",")[0] - SwitchCraftConfig.set_user_preference("CodeSigningCertThumbprint", thumb) + # Only save to user preferences if not set by GPO + if not gpo_thumb: + SwitchCraftConfig.set_user_preference("CodeSigningCertThumbprint", thumb) self.cert_status_text.value = f"{subj} ({thumb[:8]}...)" self.cert_status_text.color = "GREEN" self.update() @@ -1727,7 +1851,9 @@ def _auto_detect_signing_cert(self, e): except Exception as ex: logger.error(f"Cert auto-detect failed: {ex}") - self._show_snack(f"{i18n.get('cert_detect_failed') or 'Cert detection failed'}: {ex}", "RED") + # If GPO cert exists, don't show error + if not (gpo_thumb or gpo_cert_path): + self._show_snack(f"{i18n.get('cert_detect_failed') or 'Cert detection failed'}: {ex}", "RED") def _browse_signing_cert(self, e): """Browse for a .pfx certificate file.""" diff --git a/src/switchcraft/gui_modern/views/winget_view.py b/src/switchcraft/gui_modern/views/winget_view.py index 2c34df3c..3c4085fa 100644 --- a/src/switchcraft/gui_modern/views/winget_view.py +++ b/src/switchcraft/gui_modern/views/winget_view.py @@ -371,11 +371,17 @@ def _fetch(): try: logger.info(f"Fetching package details for: {short_info['Id']}") full = self.winget.get_package_details(short_info['Id']) + logger.debug(f"Raw package details received: {list(full.keys()) if full else 'empty'}") merged = {**short_info, **full} self.current_pkg = merged logger.info(f"Package details fetched, showing UI for: {merged.get('Name', 'Unknown')}") logger.info(f"Merged package data keys: {list(merged.keys())}") + # Validate that we got some data + if not full: + logger.warning(f"get_package_details returned empty dict for {short_info['Id']}") + raise Exception(f"No details found for package: {short_info['Id']}") + # Update UI using run_task to marshal back to main thread def _show_ui(): try: @@ -454,20 +460,37 @@ def _run_ui_update(self, ui_func): Parameters: ui_func (callable): Function that performs UI updates. Must be callable with no arguments. """ - if hasattr(self.app_page, 'run_task'): + import inspect + import asyncio + + # Check if function is async + is_async = inspect.iscoroutinefunction(ui_func) + + if hasattr(self, 'app_page') and hasattr(self.app_page, 'run_task'): try: - self.app_page.run_task(ui_func) + if is_async: + # Function is already async, can use run_task directly + self.app_page.run_task(ui_func) + logger.debug("UI update scheduled via run_task (async)") + else: + # Wrap sync function in async wrapper for run_task + async def async_wrapper(): + ui_func() + self.app_page.run_task(async_wrapper) + logger.debug("UI update scheduled via run_task (sync wrapped)") except Exception as ex: logger.exception(f"Failed to run UI update via run_task: {ex}") # Fallback: try direct call (not recommended but better than nothing) try: ui_func() - except Exception: - pass + logger.debug("UI update executed directly (fallback)") + except Exception as ex2: + logger.exception(f"Failed to run UI update directly: {ex2}") else: # Fallback if run_task is not available try: ui_func() + logger.debug("UI update executed directly (no run_task available)") except Exception as ex: logger.exception(f"Failed to run UI update: {ex}") @@ -767,7 +790,10 @@ def _replace(): logger.debug(f"Failed to replace header icon: {ex}") if hasattr(self, 'app_page') and hasattr(self.app_page, 'run_task'): - self.app_page.run_task(_replace) + # Wrap sync function in async wrapper for run_task + async def async_replace(): + _replace() + self.app_page.run_task(async_replace) else: _replace() except Exception as ex: diff --git a/src/switchcraft/utils/app_updater.py b/src/switchcraft/utils/app_updater.py index f50d0626..12283d36 100644 --- a/src/switchcraft/utils/app_updater.py +++ b/src/switchcraft/utils/app_updater.py @@ -72,24 +72,35 @@ def _resolve_best_update(self, candidates): is_newer = False if source == "dev": # For dev, check if SHA is different - # Current version format might be: 2026.1.1-dev-abcdef OR just dev-abcdef (legacy) + # Current version format might be: + # - PEP 440: 2026.1.2.dev0+9d07a00 + # - Legacy: 2026.1.1-dev-abcdef OR just dev-abcdef if "dev" in self.current_version.lower(): - # Extract last part as SHA - parts = self.current_version.split("-") - # If format is YYYY.M.P-dev-SHA - if len(parts) >= 3 and len(parts[-1]) >= 7: - curr_sha = parts[-1] - # If format is dev-SHA - elif len(parts) == 2 and parts[0] == "dev": - curr_sha = parts[1] - else: - curr_sha = "" # Unknown format + curr_sha = "" + # Try PEP 440 format first: YYYY.M.P.dev0+SHA + if "+" in self.current_version: + # Extract SHA after the + sign + parts = self.current_version.split("+") + if len(parts) >= 2: + curr_sha = parts[-1] + # Fallback to legacy format: YYYY.M.P-dev-SHA or dev-SHA + if not curr_sha: + parts = self.current_version.split("-") + # If format is YYYY.M.P-dev-SHA + if len(parts) >= 3 and len(parts[-1]) >= 7: + curr_sha = parts[-1] + # If format is dev-SHA + elif len(parts) == 2 and parts[0].lower() == "dev": + curr_sha = parts[1] # Remote 'ver' from _get_latest_dev_commit is 'dev-{sha}' - new_sha = ver.split("-")[-1] + new_sha = ver.split("-")[-1] if "-" in ver else "" if curr_sha and new_sha and curr_sha != new_sha: is_newer = True + elif not curr_sha: + # If we can't extract current SHA, assume update available + is_newer = True else: # If currently on Stable/Beta and switching to Dev channel is_newer = True diff --git a/src/switchcraft_winget/utils/winget.py b/src/switchcraft_winget/utils/winget.py index 40b26835..1d8f3dca 100644 --- a/src/switchcraft_winget/utils/winget.py +++ b/src/switchcraft_winget/utils/winget.py @@ -127,8 +127,11 @@ def _search_via_powershell(self, query: str) -> List[Dict[str, str]]: List[Dict[str, str]]: A list of result dictionaries with keys 'Name', 'Id', 'Version', and 'Source'. Returns an empty list when no results are found or on error. """ try: - ps_script = f"Find-WinGetPackage -Query '{query}' | Select-Object Name, Id, Version, Source | ConvertTo-Json -Depth 1" - cmd = ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script] + # Ensure module is available (but don't fail if it's not - we have fallbacks) + self._ensure_winget_module() + + ps_script = f"Import-Module Microsoft.WinGet.Client -ErrorAction SilentlyContinue; Find-WinGetPackage -Query '{query}' | Select-Object Name, Id, Version, Source | ConvertTo-Json -Depth 1" + cmd = ["powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", ps_script] startupinfo = self._get_startup_info() # Hide CMD window on Windows @@ -177,20 +180,32 @@ def _search_via_powershell(self, query: str) -> List[Dict[str, str]]: def get_package_details(self, package_id: str) -> Dict[str, str]: """ - Get detailed package information using 'winget show' command. + Get detailed package information using PowerShell Get-WinGetPackage cmdlet (primary) or 'winget show' (fallback). This provides full manifest details including Description, License, Homepage, etc. """ + # Try PowerShell first (preferred method) + try: + details = self._get_package_details_via_powershell(package_id) + if details: + return details + except Exception as e: + logger.debug(f"PowerShell Get-WinGetPackage failed for {package_id}, falling back to CLI: {e}") + + # Fallback to CLI try: # Use 'winget show' which provides full manifest details - cmd = ["winget", "show", "--id", package_id, "--source", "winget", "--accept-source-agreements", "--accept-package-agreements"] + # Add --disable-interactivity to prevent any interactive prompts + cmd = ["winget", "show", "--id", package_id, "--source", "winget", + "--accept-source-agreements", "--accept-package-agreements", + "--disable-interactivity"] startupinfo = self._get_startup_info() # Additional flags to hide window on Windows kwargs = {} - if startupinfo: - kwargs['startupinfo'] = startupinfo import sys if sys.platform == "win32": + if startupinfo: + kwargs['startupinfo'] = startupinfo # Use CREATE_NO_WINDOW flag to prevent console window if hasattr(subprocess, 'CREATE_NO_WINDOW'): kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW @@ -314,11 +329,19 @@ def _parse_winget_show_output(self, output: str) -> Dict[str, str]: return details def install_package(self, package_id: str, scope: str = "machine") -> bool: - """Install a package via Winget CLI.""" + """Install a package via PowerShell Install-WinGetPackage (primary) or Winget CLI (fallback).""" if scope not in ("machine", "user"): logger.error(f"Invalid scope: {scope}") return False + # Try PowerShell first (preferred method) + try: + if self._install_via_powershell(package_id, scope): + return True + except Exception as e: + logger.debug(f"PowerShell Install-WinGetPackage failed for {package_id}, falling back to CLI: {e}") + + # Fallback to CLI cmd = [ "winget", "install", "--id", package_id, @@ -347,7 +370,16 @@ def install_package(self, package_id: str, scope: str = "machine") -> bool: return False def download_package(self, package_id: str, dest_dir: Path) -> Optional[Path]: - """Download a package installer to dest_dir. Returns path to installer if found.""" + """Download a package installer to dest_dir using PowerShell (primary) or Winget CLI (fallback). Returns path to installer if found.""" + # Try PowerShell first (preferred method) + try: + result = self._download_via_powershell(package_id, dest_dir) + if result: + return result + except Exception as e: + logger.debug(f"PowerShell download failed for {package_id}, falling back to CLI: {e}") + + # Fallback to CLI cmd = ["winget", "download", "--id", package_id, "--dir", str(dest_dir), "--accept-source-agreements", "--accept-package-agreements"] try: startupinfo = self._get_startup_info() @@ -501,6 +533,174 @@ def _search_via_cli(self, query: str) -> List[Dict[str, str]]: logger.debug(f"Winget CLI fallback failed: {e}") return [] + def _ensure_winget_module(self) -> bool: + """ + Ensure Microsoft.WinGet.Client module is available. + Returns True if module is available or successfully installed, False otherwise. + """ + try: + ps_script = """ + if (-not (Get-Module -ListAvailable -Name Microsoft.WinGet.Client)) { + try { + Install-Module -Name Microsoft.WinGet.Client -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop + Write-Output "INSTALLED" + } catch { + Write-Output "FAILED: $_" + exit 1 + } + } else { + Write-Output "AVAILABLE" + } + """ + cmd = ["powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", ps_script] + startupinfo = self._get_startup_info() + kwargs = {} + if startupinfo: + kwargs['startupinfo'] = startupinfo + import sys + if sys.platform == "win32": + if hasattr(subprocess, 'CREATE_NO_WINDOW'): + kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW + else: + kwargs['creationflags'] = 0x08000000 + proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="ignore", timeout=60, **kwargs) + if proc.returncode == 0 and ("AVAILABLE" in proc.stdout or "INSTALLED" in proc.stdout): + return True + logger.warning(f"WinGet module check failed: {proc.stderr}") + return False + except Exception as e: + logger.debug(f"WinGet module check exception: {e}") + return False + + def _get_package_details_via_powershell(self, package_id: str) -> Dict[str, str]: + """Get package details using PowerShell Get-WinGetPackage cmdlet.""" + try: + # Ensure module is available + if not self._ensure_winget_module(): + return {} + + ps_script = f""" + Import-Module Microsoft.WinGet.Client -ErrorAction SilentlyContinue + $pkg = Get-WinGetPackage -Id '{package_id}' -ErrorAction SilentlyContinue + if ($pkg) {{ + $pkg | Select-Object Name, Id, Version, Publisher, Description, Homepage, License, LicenseUrl, PrivacyUrl, Copyright, ReleaseNotes, Tags | ConvertTo-Json -Depth 2 + }} + """ + cmd = ["powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", ps_script] + startupinfo = self._get_startup_info() + kwargs = {} + if startupinfo: + kwargs['startupinfo'] = startupinfo + import sys + if sys.platform == "win32": + if hasattr(subprocess, 'CREATE_NO_WINDOW'): + kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW + else: + kwargs['creationflags'] = 0x08000000 + proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="ignore", timeout=30, **kwargs) + + if proc.returncode != 0 or not proc.stdout.strip(): + return {} + + try: + data = json.loads(proc.stdout.strip()) + # Convert PowerShell object to our format + details = { + "Name": data.get("Name", ""), + "Id": data.get("Id", package_id), + "Version": str(data.get("Version", "")), + "Publisher": data.get("Publisher", ""), + "Description": data.get("Description", ""), + "Homepage": data.get("Homepage", ""), + "License": data.get("License", ""), + "LicenseUrl": data.get("LicenseUrl", ""), + "PrivacyUrl": data.get("PrivacyUrl", ""), + "Copyright": data.get("Copyright", ""), + "ReleaseNotes": data.get("ReleaseNotes", ""), + "Tags": ", ".join(data.get("Tags", [])) if isinstance(data.get("Tags"), list) else str(data.get("Tags", "")) + } + return {k: v for k, v in details.items() if v} # Remove empty values + except json.JSONDecodeError: + return {} + except Exception as e: + logger.debug(f"PowerShell Get-WinGetPackage error: {e}") + return {} + + def _install_via_powershell(self, package_id: str, scope: str) -> bool: + """Install a package using PowerShell Install-WinGetPackage cmdlet.""" + try: + # Ensure module is available + if not self._ensure_winget_module(): + return False + + scope_param = "Machine" if scope == "machine" else "User" + ps_script = f""" + Import-Module Microsoft.WinGet.Client -ErrorAction SilentlyContinue + $result = Install-WinGetPackage -Id '{package_id}' -Scope {scope_param} -AcceptPackageAgreements -AcceptSourceAgreements -ErrorAction Stop + if ($result -and $result.ExitCode -eq 0) {{ + Write-Output "SUCCESS" + }} else {{ + Write-Output "FAILED" + exit 1 + }} + """ + cmd = ["powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", ps_script] + startupinfo = self._get_startup_info() + kwargs = {} + if startupinfo: + kwargs['startupinfo'] = startupinfo + import sys + if sys.platform == "win32": + if hasattr(subprocess, 'CREATE_NO_WINDOW'): + kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW + else: + kwargs['creationflags'] = 0x08000000 + proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="ignore", timeout=300, **kwargs) + return proc.returncode == 0 and "SUCCESS" in proc.stdout + except Exception as e: + logger.debug(f"PowerShell Install-WinGetPackage error: {e}") + return False + + def _download_via_powershell(self, package_id: str, dest_dir: Path) -> Optional[Path]: + """Download a package using PowerShell (via Get-WinGetPackage and manual download).""" + try: + # PowerShell module doesn't have a direct download cmdlet, so we fall back to CLI + # But we can use it to verify the package exists first + if not self._ensure_winget_module(): + return None + + ps_script = f""" + Import-Module Microsoft.WinGet.Client -ErrorAction SilentlyContinue + $pkg = Get-WinGetPackage -Id '{package_id}' -ErrorAction SilentlyContinue + if ($pkg) {{ + Write-Output "EXISTS" + }} else {{ + Write-Output "NOT_FOUND" + exit 1 + }} + """ + cmd = ["powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", ps_script] + startupinfo = self._get_startup_info() + kwargs = {} + if startupinfo: + kwargs['startupinfo'] = startupinfo + import sys + if sys.platform == "win32": + if hasattr(subprocess, 'CREATE_NO_WINDOW'): + kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW + else: + kwargs['creationflags'] = 0x08000000 + proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="ignore", timeout=30, **kwargs) + + # If package exists, use CLI to download (PowerShell module doesn't have download cmdlet) + if proc.returncode == 0 and "EXISTS" in proc.stdout: + # Fall through to CLI download + return None + return None + except Exception as e: + logger.debug(f"PowerShell download check error: {e}") + return None + def _get_startup_info(self): """Create STARTUPINFO to hide console window on Windows.""" if hasattr(subprocess, 'STARTUPINFO'): diff --git a/switchcraft_legacy.iss b/switchcraft_legacy.iss index 76404f02..85713fc3 100644 --- a/switchcraft_legacy.iss +++ b/switchcraft_legacy.iss @@ -9,7 +9,10 @@ #define MyAppVersion "2026.1.2-dev-9d07a00" #endif #ifndef MyAppVersionNumeric - #define MyAppVersionNumeric "2026.1.2-dev-9d07a00" + #define MyAppVersionNumeric "2026.1.2" +#endif +#ifndef MyAppVersionInfo + #define MyAppVersionInfo "2026.1.2.0" #endif #define MyAppPublisher "FaserF" #define MyAppURL "https://github.com/FaserF/SwitchCraft" @@ -26,7 +29,7 @@ AppPublisher={#MyAppPublisher} AppPublisherURL={#MyAppURL} AppSupportURL={#MyAppURL}/issues AppUpdatesURL={#MyAppURL}/releases -VersionInfoVersion={#MyAppVersionNumeric}.0 +VersionInfoVersion={#MyAppVersionInfo} VersionInfoCompany={#MyAppPublisher} VersionInfoDescription={#MyAppDescription} VersionInfoProductName={#MyAppName} diff --git a/switchcraft_modern.iss b/switchcraft_modern.iss index f202e0d3..b48c23b5 100644 --- a/switchcraft_modern.iss +++ b/switchcraft_modern.iss @@ -9,7 +9,10 @@ #define MyAppVersion "2026.1.2-dev-9d07a00" #endif #ifndef MyAppVersionNumeric - #define MyAppVersionNumeric "2026.1.2-dev-9d07a00" + #define MyAppVersionNumeric "2026.1.2" +#endif +#ifndef MyAppVersionInfo + #define MyAppVersionInfo "2026.1.2.0" #endif #define MyAppPublisher "FaserF" #define MyAppURL "https://github.com/FaserF/SwitchCraft" @@ -26,7 +29,7 @@ AppPublisher={#MyAppPublisher} AppPublisherURL={#MyAppURL} AppSupportURL={#MyAppURL}/issues AppUpdatesURL={#MyAppURL}/releases -VersionInfoVersion={#MyAppVersionNumeric}.0 +VersionInfoVersion={#MyAppVersionInfo} VersionInfoCompany={#MyAppPublisher} VersionInfoDescription={#MyAppDescription} VersionInfoProductName={#MyAppName}