Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs/widgets/(Widget)-Update-Check.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ This widget checks for available updates using Windows Update, Winget, and Scoop
| `tooltip` | boolean | `true` | Whether to show the tooltip on hover. |
| `interval` | integer | `1440` | Check interval in minutes (30 to 10080). |
| `exclude` | list | `[]` | List of updates to exclude (matched against name). |
| `show_popup_menu` | boolean | `false` | Whether to show a popup menu on left click. This allows the user to choose between opening Windows Update in Settings or executing a command (with a UAC elevation prompt) to directly update Windows Defender Virus' security intelligence updates. |
| `popup_menu_padding` | integer | `8` | Padding (in pixels) around the menu items if `show_popup_menu: true`. (Valid range: 0 - 80) |

### Winget Update Options

Expand Down Expand Up @@ -92,6 +94,14 @@ update_check:
.update-check-widget .widget-container.paired-right {}
.update-check-widget .label {}
.update-check-widget .icon {}
/* Styles for the new optional Windows Update popup menu */
.widget-container.windows.menu-popup {}
.widget-container.windows.menu-popup .menu-item {}
.widget-container.windows.menu-popup .menu-item:hover {}
.widget-container.windows.menu-popup .menu-item .icon {}
.widget-container.windows.menu-popup .menu-item .label {}
.widget-container.windows.menu-popup .menu-item:hover .icon {}
.widget-container.windows.menu-popup .menu-item:hover .label {}
```

### State Classes
Expand Down Expand Up @@ -168,4 +178,36 @@ Example:
font-weight: 600;
font-size: 14px;
}
/* Styles for the new optional Windows Update popup menu */
.widget-container.windows.menu-popup {
background-color: var(--surface0);
border-radius: 4px;
}

.widget-container.windows.menu-popup .menu-item {
background-color: var(--surface1);
border-radius: 4px;
padding: 4px;
}

.widget-container.windows.menu-popup .menu-item:hover {
background-color: var(--surface2);
}

.widget-container.windows.menu-popup .menu-item .icon {
color: var(--text);
font-size: 16px;
min-width: 20px;
}

.widget-container.windows.menu-popup .menu-item .label {
color: var(--text);
font-size: 13px;
font-weight: 400;
}

.widget-container.windows.menu-popup .menu-item:hover .icon,
.widget-container.windows.menu-popup .menu-item:hover .label {
color: var(--text);
}
```
2 changes: 2 additions & 0 deletions src/core/validation/widgets/yasb/update_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ class UpdateConfig(CustomBaseModel):

class WindowsUpdateConfig(UpdateConfig):
interval: int = Field(default=1440, ge=30, le=10080)
show_popup_menu: bool = False
popup_menu_padding: int = Field(default=8, ge=0, le=80)


class WingetUpdateConfig(UpdateConfig):
Expand Down
228 changes: 225 additions & 3 deletions src/core/widgets/yasb/update_check.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import logging
import os
import re
from ctypes import windll

from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QFrame, QHBoxLayout, QLabel
from PyQt6.QtGui import QCursor, QPixmap
from PyQt6.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout

from core.utils.tooltip import set_tooltip
from core.utils.utilities import add_shadow, refresh_widget_style
from core.utils.utilities import PopupWidget, add_shadow, is_valid_qobject, refresh_widget_style
from core.utils.widgets.animation_manager import AnimationManager
from core.utils.widgets.update_check.service import UpdateCheckService
from core.validation.widgets.yasb.update_check import UpdateCheckWidgetConfig
from core.widgets.base import BaseWidget

logger = logging.getLogger("UpdateCheckWidget")

# Sources and their config attribute names
_SOURCES = ("winget", "scoop", "windows")

Expand All @@ -24,12 +31,37 @@ def __init__(self, config: UpdateCheckWidgetConfig):
self._label_widgets: dict[str, list[QLabel]] = {}
self._counts: dict[str, int] = {}

# Create a variable for the popup
self._popup = None

# Variables in the WindowsUpdateConfig that will be populated via "for source in _SOURCES" below
self._show_popup_menu = False
self._popup_menu_padding = 0 # Default value will be set via the WindowsUpdateConfig

# These are the two hard-coded menu items to show in the popup - just the metadata - the execution commands happen below
self._popup_menu_items = [
{"icon": "\ue62a", "launch": "windows_update", "name": "Open Windows Update"},
{"icon": "\udb84\udfb6", "launch": "update_virus_defs", "name": "Update Virus Definitions"},
]

# Set up defaults for all variables that are used for the popup but for which we do not yet have configuration / validation schema
self._container_shadow = None
self._blur = False
self._popup_image_icon_size = 16
self._animation = {"enabled": True, "type": "fadeInOut", "duration": 200}
self._alignment = "center"
self._direction = "down"
self._popup_offset = {"top": 8, "left": 0}

for source in _SOURCES:
cfg = getattr(self.config, f"{source}_update", None)
if cfg and cfg.enabled:
container, widgets = self._create_container(source, cfg.label)
self._containers[source] = container
self._label_widgets[source] = widgets
if source == "windows":
self._show_popup_menu = cfg.show_popup_menu
self._popup_menu_padding = cfg.popup_menu_padding
else:
self._containers[source] = None
self._label_widgets[source] = []
Expand Down Expand Up @@ -171,8 +203,198 @@ def _make_mouse_handler(self, source: str):

def handler(event):
if event.button() == Qt.MouseButton.LeftButton:
self._service.handle_left_click(source)
if source == "windows":
if self._show_popup_menu == True:
self._toggle_popup()
else:
self._service.handle_left_click("windows")
else:
self._service.handle_left_click(source)
elif event.button() == Qt.MouseButton.RightButton:
self._service.handle_right_click(source)

return handler

def _toggle_popup(self):
"""Toggle the menu popup visibility."""
try:
if self._popup and is_valid_qobject(self._popup) and self._popup.isVisible():
self._popup.hide_animated()
else:
self._show_popup()
except RuntimeError:
# Popup was deleted, create a new one
self._show_popup()

def _show_popup(self):
"""Create and show the popup menu."""
# Close existing popup if any
try:
if self._popup and is_valid_qobject(self._popup):
self._popup.hide()
except RuntimeError:
pass

# Create new popup
self._popup = PopupWidget(
parent=self, blur=self._blur, round_corners=True, round_corners_type="normal", border_color="None"
)
self._popup.setProperty("class", "widget-container windows menu-popup")

# Prevent click-through to windows behind the popup
self._popup.setAttribute(Qt.WidgetAttribute.WA_NoMouseReplay)

# Create popup layout directly on the PopupWidget (like media.py does)
popup_layout = QVBoxLayout(self._popup)
popup_layout.setSpacing(0)
popup_layout.setContentsMargins(
self._popup_menu_padding,
self._popup_menu_padding,
self._popup_menu_padding,
self._popup_menu_padding,
)

# Add menu items directly to the popup layout
if isinstance(self._popup_menu_items, list):
item = self.create_menu_item(
item_data=self._popup_menu_items[0],
parent=self,
icon_size=self._popup_image_icon_size,
animation=self._animation,
bottom_padding=self._popup_menu_padding,
)
popup_layout.addWidget(item)

item = self.create_menu_item(
item_data=self._popup_menu_items[1],
parent=self,
icon_size=self._popup_image_icon_size,
animation=self._animation,
bottom_padding=0,
)
popup_layout.addWidget(item)

# Adjust popup size to content
self._popup.adjustSize()

# Force Qt to apply the stylesheet to the popup and its children (guard against None)
popup_style = self._popup.style()
if popup_style is not None:
popup_style.unpolish(self._popup)
popup_style.polish(self._popup)

popup_content = getattr(self._popup, "_popup_content", None)
if popup_content is not None:
content_style = popup_content.style()
if content_style is not None:
content_style.unpolish(popup_content)
content_style.polish(popup_content)

# Position and show popup
self._popup.setPosition(
alignment=self._alignment,
direction=self._direction,
offset_left=self._popup_offset["left"],
offset_top=self._popup_offset["top"],
)
self._popup.show()

def create_menu_item(self, item_data, parent, icon_size, animation, bottom_padding):
if "icon" in item_data and "launch" in item_data:
# Create menu item
item = MenuItemWidget(
parent=parent,
icon=item_data["icon"],
label=item_data.get("name", ""),
launch=item_data["launch"],
icon_size=icon_size,
animation=animation,
bottom_padding=bottom_padding,
)
return item

def execute_code(self, data):
"""Execute the command associated with a menu item."""
try:
try:
if data == "windows_update":
self._service.handle_left_click("windows")
elif data == "update_virus_defs":
# Execute the Windows Defender command to update signatures
exePath = r"C:\Program Files\Windows Defender\MpCmdRun.exe"
parameters = r"-SignatureUpdate"
windll.shell32.ShellExecuteW(
None,
"runas",
exePath,
parameters,
None,
1,
)
# Run the right-click command too, to tell the service to clear and refresh
self._service.handle_right_click("windows")

except Exception as e:
logger.error(f"Error executing popup menu choice: {str(e)}")

# Close popup after executing command
try:
if self._popup and is_valid_qobject(self._popup) and self._popup.isVisible():
self._popup.hide_animated()
except RuntimeError:
pass
except Exception as e:
logger.error(f'Exception occurred: {str(e)} "{data}"')


class MenuItemWidget(QFrame):
"""A single menu item in the popup."""

def __init__(self, parent, icon, label, launch, icon_size, animation, bottom_padding):
super().__init__()
self.parent_widget = parent
self._launch = launch
self._animation = animation
self._bottom_padding = bottom_padding

self.setProperty("class", "menu-item")
self.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))

self.setStyleSheet(f"QFrame .menu-item {{ margin-bottom: {self._bottom_padding}px; }}")

# Create layout
layout = QHBoxLayout(self)
layout.setSpacing(8)
layout.setContentsMargins(3, 3, 3, 3)

# Create icon label
self._icon_label = QLabel()
self._icon_label.setProperty("class", "icon")
self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)

if os.path.isfile(icon):
pixmap = QPixmap(icon).scaled(
icon_size,
icon_size,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
self._icon_label.setPixmap(pixmap)
else:
self._icon_label.setText(icon)

layout.addWidget(self._icon_label)

# Create text label
self._text_label = QLabel(label)
self._text_label.setProperty("class", "label")
layout.addWidget(self._text_label, stretch=1)

def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
event.accept() # Accept the event to prevent propagation
if self._animation["enabled"]:
AnimationManager.animate(self, self._animation["type"], self._animation["duration"])
self.parent_widget.execute_code(self._launch)
else:
super().mousePressEvent(event)
36 changes: 35 additions & 1 deletion src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -506,4 +506,38 @@ For more information about configuration options, please visit the Wiki https://
font-weight: 900;
color: rgb(255, 255, 255);
margin-top: -20px;
}
}

/* Styles for the new optional Windows Update popup menu */

.widget-container.windows.menu-popup {
background-color: var(--surface0);
border-radius: 4px;
}

.widget-container.windows.menu-popup .menu-item {
background-color: var(--surface1);
border-radius: 4px;
padding: 4px;
}

.widget-container.windows.menu-popup .menu-item:hover {
background-color: var(--surface2);
}

.widget-container.windows.menu-popup .menu-item .icon {
color: var(--text);
font-size: 16px;
min-width: 20px;
}

.widget-container.windows.menu-popup .menu-item .label {
color: var(--text);
font-size: 13px;
font-weight: 400;
}

.widget-container.windows.menu-popup .menu-item:hover .icon,
.widget-container.windows.menu-popup .menu-item:hover .label {
color: var(--text);
}