diff --git a/gui/main_ui.py b/gui/main_ui.py index d447668..3c71bb7 100755 --- a/gui/main_ui.py +++ b/gui/main_ui.py @@ -775,11 +775,13 @@ class CustomWindow(QMainWindow): if clear: self._set_status_font_size(16) self.status_label.setText('') + self.disable_marquee() return + text = str(text) full_text = 'Status: ' + text metrics = self.status_label.fontMetrics() - available_width = self.status_label.width() - 10 + available_width = getattr(self, 'text_width', self.status_label.width() - 10) if metrics.horizontalAdvance(full_text) > available_width: self.enable_marquee(text) @@ -809,10 +811,12 @@ class CustomWindow(QMainWindow): self.navigator.navigate("menu") def enable_marquee(self, text): - self.marquee_text = text + " " + self.marquee_text = str(text) + self.marquee_gap = " " self.marquee_position = 0 self.marquee_enabled = True - self.marquee_timer.start(500) + self.update_marquee() + self.marquee_timer.start(180) def disable_marquee(self): self.marquee_enabled = False @@ -823,31 +827,33 @@ class CustomWindow(QMainWindow): return metrics = self.status_label.fontMetrics() - text_width = metrics.horizontalAdvance(self.marquee_text) + prefix = 'Status: ' + content_width = max( + 80, + getattr(self, 'text_width', self.status_label.width() - 10) + - metrics.horizontalAdvance(prefix) + ) - self.marquee_position += self.scroll_speed - if self.marquee_position >= text_width: + marquee_unit = self.marquee_text + getattr(self, 'marquee_gap', " ") + if not marquee_unit.strip(): + self.disable_marquee() + return + + if self.marquee_position >= len(marquee_unit): self.marquee_position = 0 - looped_text = self.marquee_text * 2 + looped_text = marquee_unit + marquee_unit + visible_text = '' + for char in looped_text[self.marquee_position:]: + next_text = visible_text + char + if metrics.horizontalAdvance(next_text) > content_width: + break + visible_text = next_text - chars_that_fit = metrics.horizontalAdvance( - looped_text[:self.text_width]) - - visible_text = looped_text[self.marquee_position: - self.marquee_position + chars_that_fit] - - while metrics.horizontalAdvance(visible_text) < self.text_width: - visible_text += self.marquee_text - - while metrics.horizontalAdvance(visible_text) > self.text_width: - visible_text = visible_text[:-1] - - display_text = 'Status: ' + ' ' * \ - (self.text_start_x - metrics.horizontalAdvance('Status: ')) - display_text += visible_text + display_text = prefix + visible_text self.status_label.setText(display_text) + self.marquee_position += max(1, int(self.scroll_speed / 7)) def set_scroll_speed(self, speed): self.scroll_speed = speed diff --git a/gui/v2/actions/operation_results.py b/gui/v2/actions/operation_results.py new file mode 100755 index 0000000..c9d88dc --- /dev/null +++ b/gui/v2/actions/operation_results.py @@ -0,0 +1,95 @@ +from core.models.Result import ResultError + + +def result_is_valid(result): + if hasattr(result, 'valid'): + return bool(result.valid) + if isinstance(result, dict): + return bool(result.get('valid')) + return False + + +def result_data(result): + if hasattr(result, 'data'): + return result.data + if isinstance(result, dict): + return result.get('data') or result.get('billing_code') + return None + + +def result_message(result, default='Operation failed.'): + if hasattr(result, 'message') and result.message: + return str(result.message) + if isinstance(result, dict): + return str(result.get('message') or result.get('error_code') or default) + return default + + +def result_error_value(result, default=ResultError.UNKNOWN.value): + error_type = None + if hasattr(result, 'error_type'): + error_type = result.error_type + elif isinstance(result, dict): + error_type = result.get('error_type') or result.get('error_code') + + if error_type is None: + return default + if hasattr(error_type, 'value'): + return str(error_type.value) + return str(error_type) + + +def operation_failure_payload(operation, result=None, message=None, exception=None, context=None): + if exception is not None: + error_value = ResultError.UNKNOWN.value + detail = str(exception) or type(exception).__name__ + else: + error_value = result_error_value(result) + detail = message or result_message(result) + + title = _operation_title(operation, error_value) + return { + 'operation': operation, + 'title': title, + 'message': detail, + 'error_type': error_value, + 'severity': _severity_for(error_value), + 'actions': _actions_for(error_value), + 'context': context or {}, + } + + +def _operation_title(operation, error_value): + if error_value == ResultError.CONNECTION.value: + return 'Connection Problem' + if operation == 'use_ticket' and error_value == ResultError.SUBSCRIPTION.value: + return 'Ticket Expired' + if operation == 'use_ticket': + return 'Ticket Use Failed' + if operation == 'enable_profile': + return 'Profile Enable Failed' + if operation == 'disable_profile': + return 'Profile Disable Failed' + return 'Operation Failed' + + +def _severity_for(error_value): + if error_value in { + ResultError.CONNECTION.value, + ResultError.SUBSCRIPTION.value, + ResultError.MISSING_FILE.value, + ResultError.MISSING_DATA.value, + }: + return 'warning' + return 'error' + + +def _actions_for(error_value): + if error_value == ResultError.CONNECTION.value: + return [ + {'key': 'retry', 'label': 'Try Again', 'role': 'primary'}, + {'key': 'cancel', 'label': 'Cancel', 'role': 'secondary'}, + ] + return [ + {'key': 'dismiss', 'label': 'OK', 'role': 'primary'}, + ] diff --git a/gui/v2/ui/builders/top_section.py b/gui/v2/ui/builders/top_section.py index 0aa54a0..edd88cd 100755 --- a/gui/v2/ui/builders/top_section.py +++ b/gui/v2/ui/builders/top_section.py @@ -26,5 +26,5 @@ def create_top_section(parent, css_path): 'marquee_timer': marquee_timer, 'text_start_x': 15, 'text_end_x': 420, - 'text_width': 405, + 'text_width': 500, } diff --git a/gui/v2/ui/pages/menu_page.py b/gui/v2/ui/pages/menu_page.py index fd508d5..a15bd95 100755 --- a/gui/v2/ui/pages/menu_page.py +++ b/gui/v2/ui/pages/menu_page.py @@ -29,6 +29,7 @@ from gui.v2.ui.pages.location_verification_page import LocationVerificationPage from gui.v2.ui.styles.styles import SCROLLBAR_CYAN_QSS from gui.v2.ui.popups.confirmation_popup import ConfirmationPopup from gui.v2.ui.popups.endpoint_verification_popup import EndpointVerificationPopup +from gui.v2.ui.popups.operation_result_popup import OperationResultPopup from gui.v2.ui.popups.ticket_data_loss_popup import TicketDataLossPopup from gui.v2.workers.worker import Worker from gui.v2.workers.worker_thread import WorkerThread @@ -1258,10 +1259,50 @@ class MenuPage(Page): self.worker.update_signal.connect(self.update_gui_main_thread) self.worker.change_page.connect(self.change_app_page) self.worker.ticket_data_loss.connect(self.show_ticket_data_loss_popup) + self.worker.operation_failed.connect(self.handle_operation_failure) thread = threading.Thread(target=self.worker.run) thread.start() + def handle_operation_failure(self, payload): + if not isinstance(payload, dict): + payload = { + 'title': 'Operation Failed', + 'message': str(payload), + 'actions': [{'key': 'dismiss', 'label': 'OK', 'role': 'primary'}], + 'severity': 'error', + 'context': {}, + } + + message = str(payload.get('message') or 'The operation could not be completed.') + self.update_status.update_status(message) + self.boton_just.setEnabled(True) + self.boton_just_session.setEnabled(True) + self.disconnect_button.setEnabled(True) + self.disconnect_system_wide_button.setEnabled(True) + + self.popup = OperationResultPopup( + self, + title=payload.get('title', 'Operation Failed'), + message=message, + actions=payload.get('actions'), + severity=payload.get('severity', 'error'), + ) + self.popup.action_selected.connect( + lambda action, current_payload=payload: self.handle_operation_popup_action(action, current_payload)) + self.popup.show() + + def handle_operation_popup_action(self, action, payload): + if action != 'retry': + return + context = payload.get('context') or {} + profile_data = context.get('profile_data') + if not isinstance(profile_data, dict) or 'id' not in profile_data: + self.update_status.update_status('Retry is unavailable for this operation.') + return + self.update_status.update_status('Retrying...') + self.enabling_profile(dict(profile_data)) + def show_ticket_data_loss_popup(self, ticket_number, billing_code): self.custom_window.navigator.navigate("menu") self.update_status.update_status('Critical: invalid billing code from ticket.') diff --git a/gui/v2/ui/popups/operation_result_popup.py b/gui/v2/ui/popups/operation_result_popup.py new file mode 100755 index 0000000..9a005c2 --- /dev/null +++ b/gui/v2/ui/popups/operation_result_popup.py @@ -0,0 +1,223 @@ +from PyQt6.QtCore import Qt, QTimer, pyqtSignal +from PyQt6.QtGui import QFont, QGuiApplication +from PyQt6.QtWidgets import ( + QDialog, + QFrame, + QHBoxLayout, + QLabel, + QPushButton, + QScrollArea, + QVBoxLayout, + QWidget, +) + +from gui.v2.ui.styles.styles import ( + POPUP_BG_QSS, + POPUP_CANCEL_BUTTON_QSS, +) + + +class OperationResultPopup(QDialog): + action_selected = pyqtSignal(str) + + def __init__(self, parent=None, title="", message="", actions=None, severity="error"): + super().__init__(parent) + self.parent_window = parent + self.title_text = title or "Operation Failed" + self.message = message or "The operation could not be completed." + self.actions = actions or [{'key': 'dismiss', 'label': 'OK', 'role': 'primary'}] + self.severity = severity + self._completed = False + self._use_parent_local_geometry = QGuiApplication.platformName().lower().startswith("wayland") + self.initUI() + + def initUI(self): + self.setFixedSize(560, 300) + if self._use_parent_local_geometry: + if self.parent_window is not None: + self.setParent(self.parent_window) + self.setWindowFlags(Qt.WindowType.Widget) + else: + self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + + bg_widget = QWidget(self) + bg_widget.setStyleSheet(POPUP_BG_QSS) + outer.addWidget(bg_widget) + + content_layout = QVBoxLayout(bg_widget) + content_layout.setContentsMargins(22, 14, 22, 18) + content_layout.setSpacing(12) + + top_row = QHBoxLayout() + top_row.setContentsMargins(0, 0, 0, 0) + top_row.setSpacing(10) + + title_label = QLabel(self.title_text) + title_label.setFont(QFont("Arial", 18, QFont.Weight.Bold)) + title_label.setStyleSheet(f"color: {self._title_color()}; background: transparent; border: none;") + title_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + title_label.setMinimumWidth(0) + top_row.addWidget(title_label, 1) + + close_button = QPushButton("X") + close_button.setFixedSize(36, 36) + close_button.setFont(QFont("Arial", 14, QFont.Weight.Bold)) + close_button.setStyleSheet(""" + QPushButton { + background-color: transparent; + color: #444444; + font-size: 18px; + font-weight: 900; + border: none; + } + QPushButton:hover { + color: #d62828; + } + """) + close_button.clicked.connect(lambda: self._choose('cancel')) + top_row.addWidget(close_button, 0, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop) + + content_layout.addLayout(top_row) + + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setFrameShape(QFrame.Shape.NoFrame) + scroll_area.setStyleSheet("background: transparent; border: none;") + content_layout.addWidget(scroll_area, 1) + + scroll_content = QWidget() + scroll_content.setStyleSheet("background: transparent; border: none;") + scroll_layout = QVBoxLayout(scroll_content) + scroll_layout.setContentsMargins(0, 0, 0, 0) + + message_label = QLabel(self.message) + message_label.setFont(QFont("Arial", 12)) + message_label.setStyleSheet("color: #333333; background: transparent; border: none;") + message_label.setWordWrap(True) + message_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + message_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop) + scroll_layout.addWidget(message_label) + scroll_layout.addStretch() + scroll_area.setWidget(scroll_content) + + button_row = QHBoxLayout() + button_row.addStretch() + for action in self.actions: + button = QPushButton(action.get('label', 'OK')) + button.setFixedSize(max(110, min(180, 22 + len(action.get('label', 'OK')) * 8)), 42) + button.setFont(QFont("Arial", 11, QFont.Weight.Bold)) + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.setStyleSheet(self._button_style(action.get('role', 'primary'))) + button.clicked.connect(lambda checked=False, key=action.get('key', 'dismiss'): self._choose(key)) + button_row.addWidget(button) + content_layout.addLayout(button_row) + + if not self._use_parent_local_geometry: + self.setWindowModality(Qt.WindowModality.ApplicationModal) + + def _title_color(self): + if self.severity == 'warning': + return '#d97706' + return '#d62828' + + def _button_style(self, role): + if role == 'secondary': + return POPUP_CANCEL_BUTTON_QSS + if self.severity == 'warning': + return """ + QPushButton { + background-color: #f59e0b; + border: none; + color: white; + border-radius: 5px; + font-weight: bold; + } + QPushButton:hover { + background-color: #d97706; + } + """ + return """ + QPushButton { + background-color: #d62828; + border: none; + color: white; + border-radius: 5px; + font-weight: bold; + } + QPushButton:hover { + background-color: #b91c1c; + } + """ + + def _choose(self, action): + if self._completed: + return + self._completed = True + self.action_selected.emit(action) + self.accept() + + def closeEvent(self, event): + if not self._completed: + self._completed = True + self.action_selected.emit('cancel') + event.accept() + + def show(self): + self._center_on_parent() + super().show() + + def showEvent(self, event): + super().showEvent(event) + self._center_on_parent() + QTimer.singleShot(0, self._center_on_parent) + + def _center_on_parent(self): + parent = self.parent_window + if parent is None: + return + try: + if not hasattr(parent, 'mapToGlobal') or not hasattr(parent, 'rect'): + return + parent_local_rect = parent.rect() + if parent_local_rect.isEmpty(): + return + my_size = self.size() + if my_size.isEmpty() or my_size.width() <= 0 or my_size.height() <= 0: + my_size = self.sizeHint() + if self._use_parent_local_geometry: + target_x = parent_local_rect.x() + (parent_local_rect.width() - my_size.width()) // 2 + target_y = parent_local_rect.y() + (parent_local_rect.height() - my_size.height()) // 2 + target_x = max(parent_local_rect.left(), min(target_x, parent_local_rect.right() - my_size.width() + 1)) + target_y = max(parent_local_rect.top(), min(target_y, parent_local_rect.bottom() - my_size.height() + 1)) + else: + parent_center = parent.mapToGlobal(parent_local_rect.center()) + target_x = parent_center.x() - my_size.width() // 2 + target_y = parent_center.y() - my_size.height() // 2 + screen = None + if hasattr(parent, 'screen'): + try: + screen = parent.screen() + except Exception: + screen = None + if screen is not None: + avail = screen.availableGeometry() + target_x = max(avail.left(), min(target_x, avail.right() - my_size.width())) + target_y = max(avail.top(), min(target_y, avail.bottom() - my_size.height())) + self.setGeometry(target_x, target_y, my_size.width(), my_size.height()) + if self._use_parent_local_geometry: + self.raise_() + self.setFocus(Qt.FocusReason.PopupFocusReason) + except Exception: + pass + + def mousePressEvent(self, event): + self.oldPos = event.globalPosition().toPoint() + + def mouseMoveEvent(self, event): + delta = event.globalPosition().toPoint() - self.oldPos + self.move(self.x() + delta.x(), self.y() + delta.y()) + self.oldPos = event.globalPosition().toPoint() diff --git a/gui/v2/workers/worker.py b/gui/v2/workers/worker.py index 264f352..64c85f0 100755 --- a/gui/v2/workers/worker.py +++ b/gui/v2/workers/worker.py @@ -10,7 +10,7 @@ from core.controllers.tickets.UseTicketController import ( ) from core.models.session.SessionProfile import SessionProfile from core.models.system.SystemProfile import SystemProfile -from core.models.Result import Result, ResultError +from core.models.Result import ResultError from core.errors.exceptions import SudoScript, MissingPreReqs, FirewallError from core.Errors import ( CommandNotFoundError, @@ -25,6 +25,11 @@ from core.Errors import ( ) from gui.v2.actions.database_health import GuiStorageDatabaseError +from gui.v2.actions.operation_results import ( + operation_failure_payload, + result_data, + result_is_valid, +) from gui.v2.infrastructure.screen_size import get_max_screensize from gui.v2.infrastructure.setup_observers import ( application_version_observer, @@ -38,6 +43,7 @@ class Worker(QObject): update_signal = pyqtSignal(str, bool, int, int, str) change_page = pyqtSignal(str, bool) ticket_data_loss = pyqtSignal(str, str) + operation_failed = pyqtSignal(object) def __init__(self, profile_data): self.profile_data = profile_data @@ -82,18 +88,20 @@ class Worker(QObject): self.profile_data['billing_code'] = ticket_billing_code if 'billing_code' in self.profile_data: - subscription = SubscriptionController.get( - self.profile_data['billing_code'], connection_observer=connection_observer) + try: + subscription = SubscriptionController.get( + self.profile_data['billing_code'], connection_observer=connection_observer) + except Exception as e: + if self._consumed_ticket is not None: + self._emit_subscription_lookup_failure(exception=e) + return + subscription = None if subscription is not None: ProfileController.attach_subscription( self.profile, subscription) else: if self._consumed_ticket is not None: - self._ticket_error_emitted = True - self.ticket_data_loss.emit( - self._consumed_ticket, - str(self.profile_data.get('billing_code', '')), - ) + self._emit_subscription_lookup_failure() return self.change_page.emit('The billing code is invalid.', True) return @@ -113,8 +121,11 @@ class Worker(QObject): self.update_signal.emit( "ENDPOINT_VERIFICATION_ERROR", False, self.profile_data['id'], None, None) except (InvalidSubscriptionError, MissingSubscriptionError) as e: - self.change_page.emit( - f"Subscription missing or invalid for profile {self.profile_data['id']}", True) + if self._consumed_ticket is not None: + self._emit_subscription_lookup_failure(exception=e) + else: + self.change_page.emit( + f"Subscription missing or invalid for profile {self.profile_data['id']}", True) except ProfileActivationError: self.update_signal.emit( "The profile could not be enabled", False, None, None, None) @@ -196,11 +207,18 @@ class Worker(QObject): try: print("using a random ticket from GUI...") which_ticket, error_msg = do_we_use_a_random_ticket(ticket_observer) - except Exception: + except Exception as e: + self._emit_ticket_failure(None, exception=e) return None if error_msg: - self._ticket_error_emitted = True - self.change_page.emit(f'Ticket use failed: {error_msg}', True) + self._emit_ticket_failure( + None, + result={ + 'valid': False, + 'error_type': ResultError.TICKET, + 'message': f'Ticket use failed: {error_msg}', + }, + ) return None if which_ticket is None or which_ticket == 'error': return None @@ -211,16 +229,68 @@ class Worker(QObject): location_id = getattr(location, 'id', None) if location_id is None: return None - location_id = str(location_id).strip() - if not location_id: + if isinstance(location_id, str): + location_id = location_id.strip() + if location_id == '': return None - return location_id + try: + return int(location_id) + except (TypeError, ValueError): + return None + + def _retry_profile_data(self, which_ticket=None): + profile_data = dict(self.profile_data) + profile_data.pop('billing_code', None) + if which_ticket is not None: + profile_data['use_ticket'] = which_ticket + return profile_data + + def _emit_ticket_failure(self, which_ticket, result=None, message=None, exception=None): + self._ticket_error_emitted = True + context = { + 'profile_data': self._retry_profile_data(which_ticket), + } + if which_ticket is not None: + context['ticket'] = str(which_ticket) + self.operation_failed.emit( + operation_failure_payload( + operation='use_ticket', + result=result, + message=message, + exception=exception, + context=context, + ) + ) + + def _emit_subscription_lookup_failure(self, exception=None): + message = ( + "Ticket was accepted, but the subscription details could not be " + "retrieved. Check your connection and try again." + ) + if exception is not None: + error_text = str(exception) + if error_text: + message = f"{message} {error_text}" + self._emit_ticket_failure( + self._consumed_ticket, + result={ + 'valid': False, + 'error_type': ResultError.CONNECTION, + 'message': message, + }, + ) def _consume_ticket(self, which_ticket): which_location = self._ticket_location_id() if which_location is None: - self._ticket_error_emitted = True - self.change_page.emit('Could not determine profile location for ticket use.', True) + self._emit_ticket_failure( + which_ticket, + result={ + 'valid': False, + 'error_type': ResultError.MISSING_DATA, + 'message': 'Could not determine profile location for ticket use.', + }, + ) return None try: @@ -232,25 +302,25 @@ class Worker(QObject): profile=self.profile ) except Exception as e: - self._ticket_error_emitted = True - self.change_page.emit(f'Ticket use failed: {e}', True) + self._emit_ticket_failure(which_ticket, exception=e) return None - if not isinstance(outcome, dict): - self._ticket_error_emitted = True - self.change_page.emit('Ticket use failed: invalid_response', True) - return None - - billing_code = outcome.get('billing_code') - if outcome.get('valid') and billing_code: - self._consumed_ticket = str(which_ticket) - return billing_code - if billing_code: + if result_is_valid(outcome): + billing_code = result_data(outcome) + if not billing_code: + self._emit_ticket_failure( + which_ticket, + result={ + 'valid': False, + 'error_type': ResultError.INVALID_API_REPLY, + 'message': 'Ticket use succeeded but no billing code was returned.', + }, + ) + return None self._consumed_ticket = str(which_ticket) return billing_code - self._ticket_error_emitted = True - self.change_page.emit(f'Ticket use failed: {outcome.get("message", "failed")}', True) + self._emit_ticket_failure(which_ticket, result=outcome) return None def handle_profile_status(self, profile, is_enabled):