Compare commits

...

36 commits

Author SHA1 Message Date
766d4e29fe Disconnect directly 2026-10-23 20:32:39 -04:00
031704471b Disconnect routed directly through actions to core 2026-10-23 20:06:34 -04:00
ffea00e1ff Paid status stable for individual and tickets, getting to prep page via new flow 2026-10-23 16:04:29 -04:00
4273baebc2 Use and connection with an existing billing code is now stable. 2026-10-22 21:31:41 -04:00
38535aee1c Ticket pages isolated to PyQt only, with calls to action functions that thread core 2026-10-22 03:32:59 -04:00
d1dfa788db Improved UI for notifications and billing feedback. Invoices now show connecting on repeat. And popups can be enabled by core push notification. 2026-10-21 13:39:20 -04:00
707935f80f Improved Status Updates with Notification system for payment prompts 2026-10-20 18:28:38 -04:00
ea12aecf05 Improved error handling for billing conflicts of individual profiles at the same time. Overall flow is stable in this version 2026-10-20 15:43:11 -04:00
39925ad546 Pass through json file writing to core for error handling 2026-10-20 13:54:12 -04:00
817b797701 Improved error handling for payment switching 2026-10-19 19:42:44 -04:00
8938643338 Improved State Management on Startup, to use core's new features 2026-10-19 16:34:52 -04:00
9a13ae1939 Main Menu connect/disconnect combined into a single action button, with it's state tied to a global repo. New notification system in core, pushes button changes to the main menu. Also the individual profiles get from this same global state repo, to toggle on and off by notification. 2026-10-18 21:15:02 -04:00
8b651aff0f Remove pointless popup messages when its just navigating 2026-10-17 17:27:53 -04:00
de19732055 Isolated Error translation to core repo. Slightly changed ticket flow 2026-10-17 17:12:22 -04:00
9cb3d607b4 Removing test comments 2026-10-17 15:18:06 -04:00
ac17183d7c stable repeated ticketing invoice fetching 2026-10-17 14:25:05 -04:00
2cf3b60540 Improved multi-payment cycle state management feedback 2026-10-17 11:31:07 -04:00
f7d792745a UI gets state from the Repo in Cache, instead of JSON disk calls in a loop 2026-10-16 18:09:54 -04:00
17c6a8e258 Subscription expiration checks refactored 2026-10-16 18:09:31 -04:00
db9553a0f5 Explicit connection UI graphical state updates 2026-10-15 16:24:27 -04:00
f25d2aad37 Core functions for profile enable, disable, & destroy are renamed. This gui change just reflects core's changes 2026-10-15 12:11:57 -04:00
e21905c101 Menu Coordinator now capable of enabling profiles with moderate error handling 2026-10-14 18:17:31 -04:00
1308b025c2 Individual Profile Payments Bigbang is now Stable. It allows for detached payment checks with proper error prompts, if users try to open a 2nd individual payments. Additionally we introduced core's threading plumbing, with a registry wrapper for actions to be routed to automatically on error prompt 2026-10-13 21:28:37 -04:00
20f405e878 Payment confirmation screen transition. Now the loop spits out the billing code for GUI 2026-10-12 09:52:38 -04:00
63778659ee ticket use 2026-10-11 12:30:30 -04:00
e43f7aa4fe feedback prints for debug, and commenting out sub flow in profile_operations 2026-10-11 07:32:15 -04:00
307e6800f1 Stable Ticket Invoices for the initial invoice 2026-10-10 18:28:13 -04:00
e071b0f502 Multiple invoices resumed, each one stops the timer on the other. 2026-10-10 15:24:36 -04:00
daa3815c38 Moved show error to be inherited from the parent 2026-10-09 15:13:39 -04:00
e1a3b920c9 Stable Bigbang GUI refactor for Payments of individual profiles. Features: Resume payments, new look, separation of UI from logic, coordinate data locally vs server-side, and put payment polling loop in the background, when navigating off the page. 2026-10-09 14:44:06 -04:00
41ac3a75ff Introduced Resume pre-existing invoice. Feature is stable for individual profiles. 2026-10-07 18:38:30 -04:00
40ee53197e Big Bang Payments Refactor. Moved core logic out of the PyQt classes, instead it uses payment_actions, which coordinates with core background threaded tasks 2026-10-07 13:59:24 -04:00
5b97f6ad1b Location edits now check which protocols are supported for systemwide 2026-10-03 16:57:02 -04:00
7d6d03367a Refactor to allow editing of systemwide protocol choice 2026-10-03 10:49:29 -04:00
ac51a01dd9 Merge remote-tracking branch 'origin/master' 2026-09-28 17:48:29 -04:00
e7e7df5bfa PyQt6 implementation of the cross-platform cross-framework threading plumbing error handler 2026-09-28 17:40:26 -04:00
45 changed files with 2966 additions and 463 deletions

View file

@ -107,8 +107,6 @@ def emergency_sync():
# BEGIN PROGRAM
# ============================================================================
# ============================================================================
# ASSETS FOLDER
# ============================================================================

View file

@ -17,10 +17,13 @@ from PyQt6 import QtGui
from PyQt6.QtCore import Qt, QTimer, QEvent
from core.Constants import Constants
from core.infrastructure.service_registry import ServiceLocator
from core.controllers.ConfigurationController import ConfigurationController
from core.controllers.ProfileController import ProfileController
from core.Errors import UnknownConnectionTypeError
from core.errors.logger import logger as core_logger
from core.models.Result import Result, ResultError
from core.observers.Notifications import Notifications, EventType
core_logger.propagate = False
@ -69,7 +72,6 @@ from gui.v2.actions.ticket_failure import (
)
from gui.v2.actions.should_be_synchronized import should_be_synchronized
class CustomWindow(QMainWindow):
def __init__(self, force_sync=False):
super().__init__()
@ -160,8 +162,12 @@ class CustomWindow(QMainWindow):
self.sync_button = bottom['sync_button']
self.page_stack.raise_()
# class based notification system:
self.observers = setup_observers(self.update_status)
# New EventBus-style notification system:
self.notfication_trigger()
self.update_image()
self.set_scroll_speed(7)
@ -175,6 +181,7 @@ class CustomWindow(QMainWindow):
self.check_logging()
self.navigator = Navigator(self)
ServiceLocator.register(name="navigator", service=self.navigator)
self.page_stack.currentChanged.connect(self.page_changed)
@ -751,6 +758,11 @@ class CustomWindow(QMainWindow):
self.status_label.setStyleSheet(
f"color: rgb(0, 255, 255); font-size: {font_size}px;")
@mainthread
def notfication_trigger(self):
Notifications.subscribe(EventType.STATUS_UPDATE, self.update_status)
@mainthread # wrapper of ThreadSafetyTool
def update_status(self, text, clear=False):
"""

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

View file

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

View file

@ -1,9 +1,9 @@
import json
import logging
from core.utils.basic_operations.write_or_read_from_json import read_entire_json, write_json_to_file
import os
def setup_gui_directory(gui_cache_home, gui_config_home, gui_config_file):
def setup_gui_directory(gui_cache_home, gui_config_home, gui_config_file) -> bool:
os.makedirs(gui_cache_home, exist_ok=True)
os.makedirs(gui_config_home, exist_ok=True)
@ -14,24 +14,17 @@ def setup_gui_directory(gui_cache_home, gui_config_home, gui_config_file):
"log_level": "INFO"
}
}
with open(gui_config_file, 'w') as f:
json.dump(default_config, f, indent=4)
return write_json_to_file(data=default_config, filepath=gui_config_file)
return True
def load_gui_config(gui_config_file) -> str|None:
"""Pass through to core for better error handling, such as UnicodeDecodeError."""
return read_entire_json(gui_config_file)
def load_gui_config(gui_config_file):
try:
with open(gui_config_file, 'r') as f:
return json.load(f)
except FileNotFoundError:
return None
except json.JSONDecodeError:
logging.error("Invalid GUI config file format")
return None
def save_gui_config(gui_config_file, config):
with open(gui_config_file, 'w') as f:
json.dump(config, f, indent=4)
def save_gui_config(gui_config_file, config) -> bool:
"""Pass through to core for better error handling"""
return write_json_to_file(data=config, filepath=gui_config_file)
def default_gui_config():

View file

@ -1,3 +1,4 @@
from core.services.profiles.profile_connections import disable_profile
from core.models.session.SessionProfile import SessionProfile
from core.models.system.SystemProfile import SystemProfile
from core.controllers.ProfileController import ProfileController
@ -56,8 +57,8 @@ def disable_profile_via_controller(profile: BaseProfile):
'ticket_observer': ticket_observer,
'connection_observer': connection_observer,
}
supported = inspect.signature(ProfileController.disable).parameters
ProfileController.disable(
supported = inspect.signature(disable_profile).parameters
disable_profile(
profile,
**{key: value for key, value in kwargs.items() if key in supported}
)

View file

@ -0,0 +1,19 @@
from gui.v2.infrastructure.action_registry import register_action
from core.infrastructure.global_state_coordinator import coordinate_evaluation
from core.models.Result import Result, ResultError
from gui.v2.infrastructure.setup_observers import connection_observer
def empty_filler(result: Result) -> Result:
"""To have a callback, even when one is not required."""
return result
@register_action(ResultError.ZOMBIE_CONNECTION)
def evaluate_bad_state() -> Result:
"""Start core's evaluation of state with a GUI observer."""
return coordinate_evaluation(connection_observer=connection_observer)
@register_action(ResultError.SUBSCRIPTION)
def navigate_to_new_sub_page() -> Result:
return Result(valid=False, error_type=ResultError.NAVIGATE_TO, navigate_to="id")

View file

@ -0,0 +1,281 @@
# core
from core.controllers.ConfigurationController import ConfigurationController
# from core.services.prepare_tickets.ticket_tracker import get_a_random_ticket
from core.services.profiles.profile_connections import enable_profile, disable_profile
from core.controllers.ProfileController import ProfileController
from core.models.Configuration import Configuration, ConnectionChoice
from core.services.subscriptions.subscriptions_by_protocol import get_profile_subscription, start_new_payment_cycle, wipe_subscription, evaluate_repeat_cycle
from core.services.payment_phase.pre_existing import check_then_save_billing_code
from core.utils.check_if_time_lapsed import check_if_time_lapsed
from core.models.enum_models import PaymentCategory
from core.utils.type_checker import type_checker
from core.models.Result import Result, ResultError
from core.errors.logger import logger
from core.errors.translate_error import translate_error, get_ignored_exceptions
from core.services.payment_phase.payment_state import OverallPaymentCycle
# gui
# from gui.v2.actions.payment_actions import wipe_expired_subscription
from gui.v2.actions.ticket_actions import dispatch_ticketing
from gui.v2.infrastructure.action_registry import register_action
from gui.v2.infrastructure.take_plumbing_action import take_action
from gui.v2.infrastructure.screen_size import get_max_screensize
from gui.v2.infrastructure.setup_observers import (
application_version_observer, connection_observer,
profile_observer, ticket_observer,
)
from gui.v2.actions.error_handlers import navigate_to_new_sub_page
# generic
from typing import Callable, Optional
from functools import partial
@type_checker
def get_profile(profile_id: int):
profile = ProfileController.get(int(profile_id))
if not profile:
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=f"There is no profile with the id {profile_id}")
else:
return Result(valid=True, data=profile)
def dispatch_individual(profile_id: int, ui_caller: Callable) -> Result:
payment_cycle = OverallPaymentCycle()
print(f"payment_cycle is {payment_cycle}")
if payment_cycle.active:
same_result = evaluate_repeat_cycle(payment_cycle=payment_cycle, profile_id=profile_id)
if not same_result.valid:
return same_result
else:
error_msg = "There's an error in the dispatch_individual function"
logger.error(f"This shouldn't be going off. {error_msg}")
return Result(valid=False, error_type=ResultError.NAVIGATE_TO, navigate_to="menu", message=error_msg)
else:
# Starting fresh. Pick a billing type:
return navigate_to_new_sub_page()
def filter_subscription(profile_id: int, passed_ui_caller: Callable) -> Result:
"""Evaluate if this profile has a subscription that's ready to connect."""
logger.info(f"Checking the subscription on profile id {profile_id}")
subscription = get_profile_subscription(profile_id=profile_id)
#-----------------------------
# No Subscription
#-----------------------------
if not subscription or not subscription.billing_code:
tickets_setup = ConfigurationController.are_tickets_setup()
if tickets_setup:
# already opted into ticketing:
return dispatch_ticketing(profile_id=profile_id, passed_ui_caller=passed_ui_caller)
else:
return dispatch_individual(profile_id=profile_id, ui_caller=passed_ui_caller)
#-----------------------------
# Pending Subscription
#-----------------------------
if subscription.billing_pending:
start_result = start_new_payment_cycle(category=PaymentCategory.INDIVIDUAL, profile_id=profile_id)
if start_result.valid:
logger.info("creating a new payment cycle was valid, it worked.")
return Result(valid=False, error_type=ResultError.NAVIGATE_TO, navigate_to="payment_details", message="Please finish your payment.")
else:
logger.info(f"Returning the error of start_result {start_result}")
return start_result
#-----------------------------
# Expired Subscription
#-----------------------------
expiration_date = subscription.expires_at
if not expiration_date:
# auto check tickets would be here.
return navigate_to_new_sub_page()
expired = check_if_time_lapsed(expiration_date)
if expired:
function_choice = partial(wipe_subscription, profile_id=profile_id)
return Result(valid=False, function_choice=function_choice, error_type=ResultError.EXPIRED_SUB, message="This subscription is expired, would you like to wipe it and get a new one?")
#-----------------------------
# Proceed with Valid Sub
#-----------------------------
logger.info(f"Profile id {profile_id} has a valid subscription ready to go..")
return Result(valid=True)
@register_action(ResultError.TOR_NEEDED)
def switch_to_tor_connection() -> Result:
ConfigurationController.set_connection_enum(ConnectionChoice.TOR)
return Result(valid=True)
@type_checker
def NOT_need_tor_warnings(profile_id: int) -> Result:
"""
Purpose:
Filters if we need to warn the user about a potential Tor leak.
Context:
If it's a Tor profile, and the user does NOT have Tor connection enabled, do we need to warn them?
Returns:
If we need it, it emits Result as False, with ResultError.TOR_NEEDED
If not, returns Result as True, to move on.
"""
# PROFILE
profile_result = get_profile(profile_id)
profile = profile_result.data
# Actual Profile is a Tor Profile:
if profile.connection.code != "tor":
return Result(valid=True) # no warning needed, it's not even Tor
# Config shows a non-Tor connection to download apps:
config = Configuration.get()
if config.connection != ConnectionChoice.TOR:
return Result(valid=True) # no warning needed, it's already using Tor to download apps
# Lacks Application:
if profile.application_version:
return Result(valid=True) # no warning needed, they have the app already.
error_msg = "This app is not yet installed. Since it's a Tor profile, do you want to switch to Tor to download it? (Hit 'No' to proceed over clearweb)"
return Result(valid=False, error_type=ResultError.TOR_NEEDED, message=error_msg)
@register_action(ResultError.ENDPOINT_VERIFICATION)
def literally_connect(
profile_id: int
) -> Result:
# Step 1: Get the Profile
profile_result = get_profile(profile_id)
if not profile_result:
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message="This profile does not match one of your configuration files.")
profile = profile_result.data
# EXCEPTIONS
ignored = get_ignored_exceptions(ignore_state_conflict=True)
# SCREEN SIZE:
max_resolution = get_max_screensize()
try:
final_result = enable_profile(
profile=profile,
ignore=ignored,
profile_observer=profile_observer,
application_version_observer=application_version_observer,
connection_observer=connection_observer,
ticket_observer=ticket_observer,
max_resolution=max_resolution
)
if final_result:
return final_result
else:
return Result(valid=True)
except Exception as error:
return translate_error(error=error, profile_id=profile.id)
@register_action(ResultError.STATE_CONFLICT)
def ignore_state_conflict(profile_id: int) -> Result:
return literally_connect(
profile_id=profile_id
)
def _connect_profile(profile_id: int, passed_ui_caller: Callable) -> Result:
"""Background Threaded Orchestration to Start a Connection"""
# Step 1: Subscription filter:
sub_result = filter_subscription(profile_id=profile_id, passed_ui_caller=passed_ui_caller)
if not sub_result.valid:
return sub_result
# Step 2: Tor filter check:
result = NOT_need_tor_warnings(profile_id=profile_id)
if not result.valid:
return result
# Step 3: Actual Connection:
logger.info(f"Profile id {profile_id} is ready to connect..")
return literally_connect(profile_id=profile_id)
@type_checker
def connect_profile(profile_id: int, ui_caller: Callable) -> Result:
"""Type check before we thread"""
take_action(
action=_connect_profile,
ui_caller=ui_caller,
profile_id=profile_id,
passed_ui_caller=ui_caller
)
return Result(valid=True)
def connect_with_billing_code(profile_id: int, billing_code: str) -> Result:
"""Apply a pre-existing billing code to a profile"""
print(f"billing_code is {billing_code}")
paid_result = check_then_save_billing_code(
profile_id=profile_id,
billing_code=billing_code,
connection_observer=connection_observer
)
if not paid_result.valid:
return paid_result
return literally_connect(profile_id=profile_id)
def literally_disconnect(profile_id: int) -> Result:
"""Get a profile and use core to disable it."""
# Step 1: Get the Profile
profile_result = get_profile(profile_id)
if not profile_result:
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message="This profile does not match one of your configuration files.")
profile = profile_result.data
# EXCEPTIONS
ignored = get_ignored_exceptions(ignore_state_conflict=False)
print("Literally disconnecting")
try:
final_result = disable_profile(
profile=profile,
ignore=ignored,
profile_observer=profile_observer,
connection_observer=connection_observer,
ticket_observer=ticket_observer
)
if final_result:
return final_result
else:
return Result(valid=True)
except Exception as error:
return translate_error(error=error, profile_id=profile_id)
def disconnect_profile(profile_id: int, ui_caller: Callable) -> Result:
print("GUI is disconnecting a profile")
return literally_disconnect(profile_id)
# take_action(
# action=literally_disconnect,
# ui_caller=ui_caller,
# profile_id=profile_id
# )

View file

@ -318,6 +318,11 @@ def profile_status_message(profile: BaseProfile, is_enabled: bool, idle: bool =
"""Generate status text outside Qt so workers and pages report identical subscription state."""
subscription = getattr(profile, "subscription", None)
profile_date = getattr(subscription, "expires_at", None)
billing_pending = getattr(subscription, "billing_pending", None)
if billing_pending:
return "Click Enable to Resume Billing."
if profile_date is None:
return "Offline. No subscription found."

View file

@ -1,8 +1,12 @@
from core.services.subscriptions.subscriptions_by_protocol import get_profile_subscription
from core.models.Result import Result, ResultError
from core.errors.logger import logger
from functools import partial
from typing import Any, Iterable, Mapping
from PyQt6.QtCore import Qt
from gui.v2.actions.menu_results import MenuCallback
from gui.v2.actions.profile_operations import execute_menu_task
from gui.v2.infrastructure.screen_size import get_max_screensize
@ -29,6 +33,14 @@ def _start_task(request: Mapping[str, Any], on_result: MenuCallback) -> Worker:
return worker
def pre_connect_clear(profile_id: int) -> Result:
subscription = get_profile_subscription(profile_id=profile_id)
if not subscription or not subscription.billing_code:
return Result(valid=False, error_type=ResultError.SUBSCRIPTION, message="No Subscription")
return Result(valid=True)
def enable_profile(profile_data: Mapping[str, Any], on_result: MenuCallback, allow_uninstalled_tor: bool = False) -> Worker:
"""Start the complete activation workflow with billing and retry data kept in one request."""
return _start_task({**profile_data, "task": "enable", "allow_uninstalled_tor": allow_uninstalled_tor}, on_result)

View file

@ -0,0 +1,401 @@
# core
from core.services.payment_phase.payment_state import OverallPaymentCycle, PaymentData, TicketPaymentData
from core.services.payment_phase.check_payment_loop import CheckPaidTimer
from core.services.subscriptions.subscriptions_by_protocol import (
create_new_invoice, save_billing_code,
get_profile_subscription, wipe_billing_cycle,
start_new_payment_cycle, return_pre_existing_invoice,
wipe_subscription,
)
from core.models.enum_models import PaymentType, PaymentCategory
from core.models.Result import Result, ResultError
from core.errors.logger import logger
from core.utils.type_checker import type_checker
from core.observers.Notifications import Notifications, EventType
# gui
from gui.v2.infrastructure.action_registry import register_action
from gui.v2.actions.ticket_actions import fetch_ticket_invoice
from gui.v2.infrastructure.ThreadSafetyTool import ThreadSafetyTool
from gui.v2.infrastructure.take_plumbing_action import take_action
from gui.v2.infrastructure.setup_observers import connection_observer
from gui.v2.actions.error_handlers import navigate_to_new_sub_page
# generic
from typing import Callable, Optional
def _wipe_and_navigate(profile_id: Optional[int] = None):
cleared_cycle = wipe_billing_cycle(profile_id)
if cleared_cycle:
return navigate_to_new_sub_page()
else:
return cleared_cycle
@register_action(ResultError.EXPIRED_INVOICE)
def reset_individual_billing_cycle() -> Result:
payment_cycle = OverallPaymentCycle()
if not payment_cycle.category == PaymentCategory.INDIVIDUAL:
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message="This function isn't for tickets.")
billing_data = payment_cycle.billing_data
if not billing_data:
return _wipe_and_navigate()
profile_id = billing_data.instance_id
if profile_id:
return wipe_subscription(profile_id)
else:
return _wipe_and_navigate()
# @register_action(ResultError.EXPIRED_SUB)
# def wipe_expired_subscription(profile_id: int) -> Result:
# """Call core to wipe a subscription, then navigate to the billing page"""
# logger.info(f"Wiping the subscription for {profile_id}")
# wiped_result = wipe_subscription(profile_id)
# if wiped_result.valid:
# logger.info("Successfully wiped")
# payment_cycle = OverallPaymentCycle()
# payment_cycle.wipe_billing_cycle()
# # also clear the whole billing cycle, so it's ready for an individual purchase,
# return _wipe_and_navigate(profile_id)
# else:
# return wiped_result
@type_checker
def connect_with_subscription_already(profile_id: int, billing_code: str, ui_caller: Callable) -> Result:
logger.info(f"Using a pre-existing billing code for profile {str(profile_id)}")
@type_checker
def start_individual_payment(profile_id: int, ui_caller: Callable) -> Result:
logger.info(f"Attempting to start individual billing state for profile {str(profile_id)}")
take_action(
action=start_new_payment_cycle,
ui_caller=ui_caller,
category=PaymentCategory.INDIVIDUAL,
profile_id=profile_id
)
def gui_starts_invoice(invoice_ui_caller: Callable, timer_resume_caller: Callable, profile_id: Optional[int] = None) -> str | None:
logger.info(f"gui_starts_invoice is being called with profile id {profile_id}")
# Is there currently a process?
payment_cycle = OverallPaymentCycle()
if not payment_cycle:
error_msg = "There is no payment cycle yet"
logger.error(error_msg)
return error_msg
if not payment_cycle.active:
error_msg = "Process is not yet active"
logger.error(error_msg)
return error_msg
if payment_cycle.got_invoice:
print("Cycle already got an invoice")
billing_code = payment_cycle.billing_code
if not billing_code:
error_msg = "Did not save the invoice from last time."
logger.error(error_msg)
# insert function here to get it via the data inside.
return error_msg
else:
pre_existing = return_pre_existing_invoice(billing_code=billing_code, connection_observer=connection_observer)
logger.info(f"Inside GUI's start of invoice, with {pre_existing.error_type}")
if not pre_existing.valid and pre_existing.error_type == ResultError.PAID and payment_cycle.category == PaymentCategory.TICKET:
logger.info("Navigating to ticket prep page")
return Result(valid=True, error_type=NAVIGATE_TO, navigate_to="ticket_prep")
else:
logger.info("returning normally..")
return pre_existing
notification = "Fetching Invoice.."
Notifications.emit(EventType.STATUS_UPDATE, notification)
take_action(
action=create_new_invoice,
ui_caller=invoice_ui_caller,
# profile_id=profile_id,
connection_observer=connection_observer
)
def start_polling(billing_code: str, profile_id: int, ui_caller: Callable) -> str | None:
"""Kickstart an independent timer on a background thread to check if it's paid."""
payment_cycle = OverallPaymentCycle()
if not payment_cycle.active:
logger.error("Not yet active to start polling.")
#----------------------------
# FILTER ALREADY POLLING
#----------------------------
if payment_cycle.already_polling:
error_msg = "This cycle is already polling, so we don't need to start another poll"
logger.info(error_msg)
return None
#----------------------------
# FILTER LACK OF DATA
#----------------------------
if not billing_code:
return "Requires a billing code"
#----------------------------
# CHECK PAID ON PRE-EXISTING INVOICE
#----------------------------
logger.info("Starting a check paid timer..")
# Setup PyQt6 GUI as mainthread to bring back to:
pyqt_threader = ThreadSafetyTool()
# update the global singleton:
payment_cycle.already_polling = True
notification = "Checking if Paid.."
Notifications.emit(EventType.STATUS_UPDATE, notification)
# setup the timer with the callback:
# it doesn't return result here. It calls the view_callback function.
check_paid_timer = CheckPaidTimer(
view_callback=ui_caller,
billing_code=billing_code,
ui_dispatcher=pyqt_threader,
payment_type=PaymentType.CRYPTO,
connection_observer=connection_observer,
profile_id=profile_id
)
check_paid_timer.start_polling()
return None
@register_action(ResultError.BILLING_CONFLICT)
def stop_current() -> Result:
wiped_result = wipe_billing_cycle()
logger.info("Stopping current billing cycle!")
# if not wiped_result.valid:
# return wiped_result
# new_cycle_result = start_new_payment_cycle(category=PaymentCategory.INDIVIDUAL, profile_id=profile_id)
# if not new_cycle_result.valid:
# return new_cycle_result
notification = "Stopped Checking if Paid"
Notifications.emit(EventType.STATUS_UPDATE, notification)
return Result(valid=True, error_type=ResultError.NAVIGATE_TO, navigate_to="menu")
@register_action(ResultError.ACTIVE_BILLING)
def stop_payment_timer() -> Result:
"""Gets the single instance of the payment checker timer, and stops it."""
logger.info("Stopping the check paid timer & wiping the cycle..")
try:
# Wipe the cycle via core, and stop the timer, so this is not the active poller (blocking others)
wiped_result = wipe_billing_cycle()
# stop the timer:
# check_paid_timer = CheckPaidTimer.get_instance()
# if check_paid_timer:
# check_paid_timer.stop_polling()
if wiped_result.valid:
content_to_display = "Stopped! You can resume a different profile's invoice now"
notification = "Stopped Checking if Paid"
Notifications.emit(EventType.STATUS_UPDATE, notification)
return Result(valid=True, message=content_to_display)
else:
error_msg = "Unable to stop the payment timer and wipe the profile inside stop_payment_timer. But didn't get an actual error."
logger.error(error_msg)
return Result(valid=False, error_type=ResultError.UNKNOWN, message=error_msg)
except Exception as e:
error_msg = f"Unable to stop the payment timer: {str(e)}"
logger.error(error_msg)
return Result(valid=False, error_type=ResultError.UNKNOWN, message=error_msg)
# def new_individual_billing(profile_id: int):
# """Wipes pre-existing global payment cycle. And Calls upon core to setup a new individual profile cycle & data state."""
# logger.info("starting a new individual profile state. And wiping the old one")
# return start_billing_state(
# category=PaymentCategory.INDIVIDUAL,
# profile_id=profile_id,
# wipe_existing=True
# )
# billing_data = payment_cycle.instance
# category = payment_cycle.category
# if not billing_data:
# msg = "There is a payment cycle, but no current billing data in it."
# print(msg)
# return msg
# #----------------------------
# # INDIVIDUAL
# #----------------------------
# if category == PaymentCategory.INDIVIDUAL:
# # already conflicting one:
# if not profile_id:
# return "Need a profile id for an individual profile."
# take_action(
# action=create_individual_invoice,
# ui_caller=invoice_ui_caller,
# profile_id=profile_id,
# connection_observer=connection_observer
# )
# #----------------------------
# # TICKETS
# #----------------------------
# elif category == PaymentCategory.TICKET:
# logger.info("Starting ticketing invoicing..")
# take_action(
# action=create_individual_invoice,
# ui_caller=invoice_ui_caller,
# connection_observer=connection_observer
# )
# else:
# error_msg = "Unsupported Payment Category"
# logger.error(error_msg)
# return error_msg
# # Finally:
# payment_cycle.got_invoice = True
# return None
# return fetch_individual_invoice(invoice_ui_caller=invoice_ui_caller, timer_resume_caller=timer_resume_caller, profile_id=profile_id)
# # if profile_id != payment_cycle.profile_id:
# # logger.info("The new profile id request is different than the pre-existing running one. So we're wiping it and stopping the timer.")
# # worked, message = stop_payment_timer()
# # if not worked:
# # return message
# # # start over:
# # payment_cycle.wipe()
# # new_data = new_individual_billing(profile_id)
# # if not new_data.valid:
# # return new_data # result object
# # category = PaymentCategory.INDIVIDUAL
# # # billing_data = new_cycle.data
# elif category == PaymentCategory.TICKET:
# logger.info("Starting ticketing invoicing..")
# return fetch_ticket_invoice(invoice_ui_caller=invoice_ui_caller, timer_resume_caller=timer_resume_caller)
# def fetch_individual_invoice(invoice_ui_caller: Callable, timer_resume_caller: Callable, profile_id: int) -> str | None:
# """
# Purpose:
# Create a new invoice if we don't have one yet, or re-uses the saved data.
# And starts polling if it's not already.
# Context:
# For individual profiles.
# Returns:
# Returns any errors as strings for UI to display.
# """
# # Fetch:
# take_action(
# action=create_individual_invoice,
# ui_caller=invoice_ui_caller,
# profile_id=profile_id,
# connection_observer=connection_observer
# )
# def save_and_begin_timer(
# ticketing: bool,
# gui_callback: Callable,
# billing_code: str,
# profile_id: Optional[int] = None,
# expires_at: Optional[str] = None,
# ) -> bool:
# """
# First saves the billing code & profile expiration, if it's an individual (non-ticket) crypto-based one.
# Then it Starts a loop of checking if the invoice is paid and saves it.
# """
# if ticketing:
# payment_type = PaymentType.TICKET
# logger.error("Ticketing already saves the billing code on it's own when it recieves it.")
# # NON-ticketing:
# elif profile_id and expires_at:
# payment_type = PaymentType.CRYPTO
# saved = save_billing_code(profile_id=profile_id, billing_code=billing_code, expires_at=expires_at)
# if not saved.valid:
# return False
# else:
# logger.error("You need a profile id and expires_at for individual profiles")
# return False
# # Setup PyQt6 GUI as mainthread to bring back to:
# pyqt_threader = ThreadSafetyTool()
# # setup the timer with the callback:
# # it doesn't return result here. It calls the gui_callback function.
# check_paid_timer = CheckPaidTimer(
# view_callback=gui_callback,
# billing_code=billing_code,
# ui_dispatcher=pyqt_threader,
# payment_type=payment_type,
# connection_observer=connection_observer,
# profile_id=profile_id
# )
# logger.info("starting polling from GUI action..")
# check_paid_timer.start_polling()
# return True # this is just that it started

View file

@ -8,6 +8,9 @@ from core.controllers.ClientController import ClientController
from core.controllers.ConfigurationController import ConfigurationController
from core.controllers.LocationController import LocationController
from core.controllers.ProfileController import ProfileController
from core.services.profiles.profile_connections import enable_profile, disable_profile, destroy_profile
from core.controllers.SubscriptionController import SubscriptionController
from core.controllers.SyncController import new_sync
from core.controllers.tickets.UseTicketController import do_we_use_a_random_ticket, get_unused_tickets, use_ticket
@ -103,6 +106,10 @@ def _consume_ticket(profile: BaseProfile, request: dict[str, Any], ticket: int |
def _prepare_subscription(profile: BaseProfile, request: dict[str, Any]) -> MenuResult | None:
"""Prepare billing once before activation while retaining recovery data across prompts."""
print(f"request is entering: {request}")
print(f"request type is {type(request)}")
if request.get("subscription_attached"):
return None
ticket = request.get("use_ticket")
@ -167,6 +174,9 @@ def _enable_error(error: Exception, profile: BaseProfile, request: dict[str, Any
def _enable(request: dict[str, Any], emit: MenuCallback, max_resolution: str | None) -> MenuResult:
"""Own readiness, billing, activation and recovery as one connect task."""
print(f"request enters _enable. {request}")
synced = synchronize(current_connection()) if request.pop("sync_before_enable", False) else None
if synced is not None:
emit({**synced, "busy": True})
@ -182,21 +192,43 @@ def _enable(request: dict[str, Any], emit: MenuCallback, max_resolution: str | N
if needs_download and current_connection() != "tor" and not request.get("allow_uninstalled_tor"):
return _confirmation(request, "tor_download", "The Tor browser is not installed; switch to Tor to download it or proceed over clearweb.")
request["allow_uninstalled_tor"] = True
# Payment not finished:
try:
if profile.subscription.billing_pending:
print("This is resuming a pre-done payment")
return {"status": "navigate", "destination": "payment_details"}
except Exception as e:
print(f"Skipping billing resume check")
# print("checking sub")
# if not profile.subscription.billing:
# raise MissingSubscriptionError()
print(f"checking preparation")
preparation = _prepare_subscription(profile, request)
if preparation is not None:
return preparation
print("entering observers")
ignored = {"ignore_endpoint_verification": EndpointVerificationError,
"ignore_profile_state_conflict": ProfileStateConflictError}
observer = ProfileObserver()
observer.subscribe("enabled", partial(_profile_event, emit, True))
observer.subscribe("disabled", partial(_profile_event, emit, False))
print("doing the enable")
try:
ProfileController.enable(profile, ignore=tuple(error for flag, error in ignored.items() if request.get(flag)),
enable_profile(profile, ignore=tuple(error for flag, error in ignored.items() if request.get(flag)),
profile_observer=observer, application_version_observer=application_version_observer,
connection_observer=connection_observer, ticket_observer=ticket_observer,
max_resolution=max_resolution)
except Exception as error:
return _enable_error(error, profile, request)
print("returning success..")
return {"status": "success", "request": dict(request), "refresh": True}
@ -248,7 +280,8 @@ def _delete(request: dict[str, Any]) -> MenuResult:
if not request.get("confirmed"):
return _confirmation(request, "delete", "Are you sure you want to delete this profile?", "Delete")
profile = _require_profile(request["id"])
ProfileController.destroy(profile, profile_observer, ticket_observer, connection_observer)
destroy_profile(profile, profile_observer, ticket_observer, connection_observer)
# ProfileController.destroy(profile, profile_observer, ticket_observer, connection_observer)
return {"status": "success", "message": f"Profile {profile.id} deleted", "profile_id": profile.id,
"refresh": True, "clear_selection": True}
@ -280,6 +313,9 @@ def _update() -> MenuResult:
def execute_menu_task(request: dict[str, Any], emit: MenuCallback, max_resolution: str | None) -> MenuResult:
"""Dispatch background operations independently of Qt and normalize their failure results."""
print(f"request is in execute_menu_task as {request}")
handlers = {"enable": partial(_enable, request, emit, max_resolution), "disable": partial(_disable, request),
"disable_all": lambda: _disable_profiles([_require_profile(profile_id) for profile_id in request["connected_ids"]]),
"delete": partial(_delete, request), "create": partial(_navigate, request, emit),

View file

@ -1,3 +1,4 @@
from core.infrastructure.global_state import ProfileStateRepository
from core.controllers.ProfileController import ProfileController
from core.controllers.SystemStateController import SystemStateController
from core.models.system.SystemProfile import SystemProfile
@ -21,7 +22,16 @@ def _system_state_profile_id():
return None
def is_profile_enabled_for_gui(profile):
if isinstance(profile, SystemProfile) and _connection_code(profile) in SINGBOX_PROTOCOLS:
return _system_state_profile_id() == int(profile.id)
return ProfileController.is_enabled(profile)
def is_profile_enabled_for_gui(profile) -> bool:
"""Checks Profile State Against in-memory Repo"""
is_enabled = ProfileStateRepository().is_profile_active(profile.id)
# print(f"profile id: {profile.id} | Enabled: {is_enabled}")
return is_enabled
# if isinstance(profile, SystemProfile) and _connection_code(profile) in SINGBOX_PROTOCOLS:
# return _system_state_profile_id() == int(profile.id)
# return ProfileController.is_enabled(profile)

View file

@ -102,8 +102,10 @@ def extract_endpoint_ip(profile):
wg_conf_path = f'{profile_path}/wg.conf.bak'
if not os.path.exists(wg_conf_path):
return None
with open(wg_conf_path, 'r') as f:
content = f.read()
for line in content.split('\n'):
if line.strip().startswith('Endpoint = '):
endpoint = line.strip().split(' = ')[1]

View file

@ -0,0 +1,178 @@
# core
from core.services.using_tickets.use_ticket_orchestrator import use_ticket_orchestrator
from core.controllers.tickets.TicketPrepController import prepare_tickets
from core.controllers.ConfigurationController import ConfigurationController
from core.services.prepare_tickets.ticket_tracker import get_a_random_ticket
from core.services.subscriptions.subscriptions_by_protocol import start_new_payment_cycle, get_ticket_invoice
from core.services.payment_phase.ticket_cycle import wipe_ticket_cycle
from core.services.payment_phase.payment_state import TicketPaymentData
from core.models.enum_models import PaymentCategory
from core.services.prepare_tickets.ticket_tracker import get_all_unused_tickets
from core.utils.type_checker import type_checker
from core.models.Result import Result, ResultError
from core.errors.logger import logger
from core.observers.Notifications import Notifications, EventType
# gui
from gui.v2.infrastructure.action_registry import register_action
from gui.v2.infrastructure.take_plumbing_action import take_action
from gui.v2.infrastructure.setup_observers import connection_observer, ticket_observer
from gui.v2.actions.error_handlers import navigate_to_new_sub_page
# generic:
from typing import Callable
# from core.controllers.tickets.UseTicketController import use_ticket
# from core.models.BaseProfile import BaseProfile as Profile
# from core.controllers.ProfileController import ProfileController
# from core.controllers.tickets.TicketPayController import initiate_payment
@register_action(ResultError.TICKET_EXPIRED)
def wipe_ticket_billing() -> Result:
"""Wipe existing data and start over."""
wiped = wipe_ticket_cycle()
if wiped.valid:
return Result(valid=True, error_type=ResultError.NAVIGATE_TO, navigate_to="plan_picker")
else:
return Result(valid=False, error_type=ResultError.NAVIGATE_TO, navigate_to="plan_picker")
@register_action(ResultError.NO_TICKET)
def start_and_navigate_to_ticketing() -> Result:
"""Starts a new ticketing cycle. (assumes the user opted in)."""
start_result = start_new_payment_cycle(category=PaymentCategory.TICKET)
if start_result.valid:
logger.info("creating a new payment cycle was valid, it worked.")
return Result(valid=False, error_type=ResultError.NAVIGATE_TO, navigate_to="plan_picker")
else:
logger.info(f"Returning the error of start_result {start_result}")
return start_result
@type_checker
def random_ticket_dispatch(profile_id: int, passed_ui_caller: Callable) -> Result:
"""
User explicitly wants a random ticket.
The reason this is in GUI repo, is because use_a_ticket is.
"""
random_ticket_result = get_a_random_ticket()
if not random_ticket_result.valid:
return random_ticket_result
which_ticket = random_ticket_result.data
# this is a redundant sanity check:
if not which_ticket:
Result(valid=False, error_type=ResultError.NO_TICKET, message="No tickets left. Would you like to get some?")
# thread work that calls the caller, not return:
take_action(
action=use_ticket_orchestrator,
ui_caller=passed_ui_caller,
profile_id=profile_id,
which_ticket=which_ticket,
connection_observer=connection_observer,
ticket_observer=ticket_observer
)
return Result(valid=True)
@type_checker
def dispatch_ticketing(profile_id: int, passed_ui_caller: Callable) -> Result:
"""Evaluate if a random ticket will be used."""
use_random_setting = ConfigurationController.use_random()
if use_random_setting:
return random_ticket_dispatch(profile_id=profile_id, passed_ui_caller=passed_ui_caller)
# User does NOT want an automated selection, so they can pick from their tickets:
do_they_have_tickets = get_ticket_data()
if do_they_have_tickets.valid:
return Result(valid=False, error_type=ResultError.NAVIGATE_TO, navigate_to="ticket_or_billing_choice")
else:
# user can decide if they want to setup tickets or do an individual profile:
return navigate_to_new_sub_page()
def start_ticket(ui_caller: Callable):
"""Start the entire billing cycle for tickets"""
logger.info("started ticketing billing state")
notification = "Started Ticket Billing"
Notifications.emit(EventType.STATUS_UPDATE, notification)
take_action(
action=start_new_payment_cycle,
category=PaymentCategory.TICKET,
ui_caller=ui_caller
)
def fetch_ticket_invoice(ui_caller: Callable):
"""Fetch the invoice for tickets"""
take_action(
action=get_ticket_invoice,
ui_caller=ui_caller,
connection_observer=connection_observer
)
def start_bulk_ticket_prep(ui_caller: Callable) -> Result:
"""After paid for, start the actual math"""
logger.info("Starting a bulk ticket prep")
ticket_cycle = TicketPaymentData()
how_many_profiles = ticket_cycle.how_many_profiles
if not ticket_cycle.paid:
logger.error("Not yet paid")
return Result(valid=False, error_type=ResultError.NAVIGATE_TO, navigate_to="id", message="Tickets are not yet paid for, yet the prep started.")
logger.info("Threading to prepare_tickets")
take_action(
action=prepare_tickets,
ui_caller=ui_caller,
how_many_profiles=how_many_profiles,
connection_observer=connection_observer,
ticket_observer=ticket_observer,
which_ticket=None,
billing_code=None
)
def get_ticket_data() -> Result:
"""This is running in the background ALREADY. Get & format a list of unused tickets from core"""
ticket_results = get_all_unused_tickets()
valid = ticket_results.get('valid')
if not valid:
message = ticket_results.get('message')
if not message:
message = "Error gathering ticket data"
Result(valid=False, error_type=ResultError.MISSING_FILE, message=message)
raw_tickets = ticket_results.get('data')
print(f"raw_tickets is {raw_tickets}")
if not raw_tickets:
Result(valid=False, error_type=ResultError.NO_TICKET, message="No tickets available. Would you like to get some?")
formatted_data = [(f"Ticket #{t}", t) for t in raw_tickets]
print(f"formatted_data is {formatted_data}")
return Result(valid=True, data=formatted_data)
def use_a_ticket(profile_id: int, which_ticket: int, ui_caller: Callable):
"""Use of a given ticket."""
take_action(
action=use_ticket_orchestrator,
ui_caller=ui_caller,
profile_id=profile_id,
which_ticket=which_ticket,
ticket_observer=ticket_observer,
connection_observer=connection_observer
)

View file

@ -1,3 +1,5 @@
# from core.infrastructure.thread_dispatch import MainThreadDispatcher
import sys
import time
from PyQt6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget, QPushButton, QLabel
@ -13,9 +15,15 @@ class ThreadSafetyTool(QObject):
def _execute(self, func, args, kwargs):
func(*args, **kwargs)
# --- PROTOCOL REQUIRES THIS ---
def run_on_main_thread(self, func, *args, **kwargs):
self._signal.emit(func, args, kwargs)
# -----------------------------------------
def __call__(self, func):
"""Decorator that forces a function to run on the main thread."""
def wrapper(*args, **kwargs):
self._signal.emit(func, args, kwargs)
return wrapper

View file

@ -0,0 +1,49 @@
from core.models.Result import Result, ResultError
from core.controllers.SyncController import new_sync
from core.services.subscriptions.subscriptions_by_protocol import wipe_billing_cycle
# from core.services.payment_phase.check_payment_loop import mark_as_paid
# from gui.v2.actions.payment_actions import stop_payment_timer
ENUM_MAP = {
ResultError.UNKNOWN: None,
ResultError.NEED_SYNC: (new_sync),
ResultError.ACTIVE_BILLING: (wipe_billing_cycle),
ResultError.EXPIRED_INVOICE: (wipe_billing_cycle),
# ResultError.PAID: (mark_as_paid),
ResultError.INVALID_API_REPLY: None,
ResultError.ENCRYPTION: None
}
def register_action(error_code):
def wrapper(func):
ENUM_MAP[error_code] = (func)
return func
return wrapper
# Add later potentially:
# ResultError.ACTIVE_BILLING: (stop_payment_timer),
# ResultError.NO_TICKET: (start_billing),
# ResultError.NO_PROFILE: (go_to_new_profile_page),
# ResultError.ALREADY_EXISTS: (get_invoice), # bypass_existing: True
# ResultError.BILLING_CODE_EXISTS: (get_invoice) # bypass_existing: True
# Potential future upgrade to multiple error codes per function:
# def register_action(*error_codes):
# def wrapper(func):
# for code in error_codes:
# ACTION_REGISTRY[code] = func
# return func
# return wrapper

View file

@ -16,7 +16,8 @@ class ConnectionManager:
return self.profile_button_objects.get(profile_id, None)
def set_profile_button_objects(self, profile_id, profile_button_objects):
self.profile_button_objects[profile_id] = profile_button_objects
pass
# self.profile_button_objects[profile_id] = profile_button_objects
def get_available_resolutions(self, profile_id):
try:

View file

@ -2,7 +2,7 @@ import importlib
import time
import traceback
from PyQt6.QtCore import QObject, QTimer
from PyQt6.QtCore import QObject, QTimer, pyqtSignal
from gui.v2.infrastructure.page_registry import (
PAGE_REGISTRY,
@ -17,6 +17,8 @@ from core.errors.logger import logger
class Navigator(QObject):
external_navigation = pyqtSignal(str)
def __init__(self, custom_window):
super().__init__()
self.custom_window = custom_window
@ -37,6 +39,9 @@ class Navigator(QObject):
self._preload_timer.setSingleShot(True)
self._preload_timer.timeout.connect(self._preload_next)
# external navigation:
self.external_navigation.connect(self.navigate)
def get_class(self, name):
module_path, class_name = PAGE_REGISTRY[name]
module = importlib.import_module(module_path)
@ -80,25 +85,25 @@ class Navigator(QObject):
def _preload_one(self, name):
if name in self._instances:
logger.debug(f"[preload] {name:30s} SKIP (already cached)")
# logger.debug(f"[preload] {name:30s} SKIP (already cached)")
return False
if name not in PAGE_REGISTRY:
logger.debug(f"[preload] {name:30s} SKIP (not in registry)")
# logger.debug(f"[preload] {name:30s} SKIP (not in registry)")
return False
module_path, class_name = PAGE_REGISTRY[name]
logger.debug(f"[preload] {name:30s} LOAD {module_path}.{class_name}")
# logger.debug(f"[preload] {name:30s} LOAD {module_path}.{class_name}")
t0 = time.perf_counter()
try:
page = self._instantiate(name)
self.register_instance(name, page)
dt_ms = (time.perf_counter() - t0) * 1000.0
logger.debug(f"[preload] {name:30s} OK ({dt_ms:6.1f} ms, "
f"cached={len(self._instances)}, stack_size={self.page_stack.count()})")
# logger.debug(f"[preload] {name:30s} OK ({dt_ms:6.1f} ms, "
# f"cached={len(self._instances)}, stack_size={self.page_stack.count()})")
return True
except Exception as e:
dt_ms = (time.perf_counter() - t0) * 1000.0
logger.debug(f"[preload] {name:30s} FAIL ({dt_ms:6.1f} ms) "
f"{type(e).__name__}: {e}")
# logger.debug(f"[preload] {name:30s} FAIL ({dt_ms:6.1f} ms) "
# f"{type(e).__name__}: {e}")
traceback.print_exc()
from core.errors.logger import logger as core_logger
core_logger.warning(f"Background preload failed for '{name}': "
@ -121,29 +126,29 @@ class Navigator(QObject):
self._preload_inflight = 0
self._preload_summary_emitted = False
self._preload_t_start = time.perf_counter()
logger.debug(f"[preload] ============================================================")
logger.debug(f"[preload] starting background preload: {self._preload_total} pages queued")
logger.debug(f"[preload] order : {', '.join(self._preload_queue)}")
logger.debug(f"[preload] skipped : {', '.join(sorted(PRELOAD_SKIP))} (unsafe init)")
if fast_mode:
logger.debug(f"[preload] fastmode: ON -> skipping regular-flow pages: "
f"{', '.join(skipped_regular) if skipped_regular else '(none)'}")
logger.debug(f"[preload] gap : 250 ms between loads, 700 ms initial delay")
logger.debug(f"[preload] ============================================================")
# logger.debug(f"[preload] ============================================================")
# logger.debug(f"[preload] starting background preload: {self._preload_total} pages queued")
# logger.debug(f"[preload] order : {', '.join(self._preload_queue)}")
# logger.debug(f"[preload] skipped : {', '.join(sorted(PRELOAD_SKIP))} (unsafe init)")
# if fast_mode:
# logger.debug(f"[preload] fastmode: ON -> skipping regular-flow pages: "
# f"{', '.join(skipped_regular) if skipped_regular else '(none)'}")
# logger.debug(f"[preload] gap : 250 ms between loads, 700 ms initial delay")
# logger.debug(f"[preload] ============================================================")
self._preload_timer.start(700)
def _preload_next(self):
while self._preload_queue and self._preload_queue[0] in self._instances:
skipped = self._preload_queue.pop(0)
self._preload_skipped_runtime += 1
logger.debug(f"[preload] {skipped:30s} SKIP (user navigated here first)")
# logger.debug(f"[preload] {skipped:30s} SKIP (user navigated here first)")
if not self._preload_queue:
self._maybe_emit_preload_summary()
return
self._preload_dispatched += 1
idx = self._preload_dispatched
name = self._preload_queue.pop(0)
logger.debug(f"[preload] ---- {idx}/{self._preload_total} ----")
# logger.debug(f"[preload] ---- {idx}/{self._preload_total} ----")
if name in ASYNC_PREPARE:
self._dispatch_async_preload(name)
else:
@ -177,29 +182,30 @@ class Navigator(QObject):
def _on_preload_data_ready(self, name, payload):
self._preload_inflight -= 1
if name in self._instances:
logger.debug(f"[preload] {name:30s} SKIP (already cached, async result dropped)")
# logger.debug(f"[preload] {name:30s} SKIP (already cached, async result dropped)")
pass
else:
try:
page = self._instantiate(name, prepared=payload)
self.register_instance(name, page)
self._preload_done += 1
logger.debug(f"[preload] {name:30s} OK (async, cached={len(self._instances)}, "
f"stack_size={self.page_stack.count()})")
# logger.debug(f"[preload] {name:30s} OK (async, cached={len(self._instances)}, "
# f"stack_size={self.page_stack.count()})")
except Exception as e:
self._preload_failed += 1
logger.debug(f"[preload] {name:30s} FAIL (async build) {type(e).__name__}: {e}")
# logger.debug(f"[preload] {name:30s} FAIL (async build) {type(e).__name__}: {e}")
self._maybe_emit_preload_summary()
def _on_preload_failed(self, name, error):
self._preload_inflight -= 1
logger.debug(f"[preload] {name:30s} FAIL (async prepare) {error}")
# logger.debug(f"[preload] {name:30s} FAIL (async prepare) {error}")
if name not in self._instances:
ok = self._preload_one(name)
if ok:
self._preload_done += 1
else:
self._preload_failed += 1
self._maybe_emit_preload_summary()
# self._maybe_emit_preload_summary()
def _cleanup_preload_worker(self, name):
worker = self._preload_workers.pop(name, None)
@ -214,11 +220,12 @@ class Navigator(QObject):
self._emit_preload_summary()
def _emit_preload_summary(self):
total_s = time.perf_counter() - self._preload_t_start
logger.debug(f"[preload] ============================================================")
logger.debug(f"[preload] complete: {self._preload_done} loaded, "
f"{self._preload_failed} failed, "
f"{self._preload_skipped_runtime} skipped (user beat us to it) "
f"in {total_s:.2f}s")
logger.debug(f"[preload] cached pages now: {sorted(self._instances.keys())}")
logger.debug(f"[preload] ============================================================")
pass
# total_s = time.perf_counter() - self._preload_t_start
# logger.debug(f"[preload] ============================================================")
# logger.debug(f"[preload] complete: {self._preload_done} loaded, "
# f"{self._preload_failed} failed, "
# f"{self._preload_skipped_runtime} skipped (user beat us to it) "
# f"in {total_s:.2f}s")
# logger.debug(f"[preload] cached pages now: {sorted(self._instances.keys())}")
# logger.debug(f"[preload] ============================================================")

View file

@ -23,7 +23,7 @@ PAGE_REGISTRY = {
"policy_suggestion": ("gui.v2.ui.pages.policy_suggestion_page", "PolicySuggestionPage"),
"duration_selection": ("gui.v2.ui.pages.duration_selection_page", "DurationSelectionPage"),
"currency_selection": ("gui.v2.ui.pages.currency_selection_page", "CurrencySelectionPage"),
"payment_details": ("gui.v2.ui.pages.payment_details_page", "PaymentDetailsPage"),
"payment_details": ("gui.v2.ui.pages.payments2", "PaymentDetailsPage"),
"plan_picker": ("gui.v2.ui.pages.plan_picker_page", "PlanPickerPage"),
"ticket_crypto_picker": ("gui.v2.ui.pages.ticket_crypto_picker_page", "TicketCryptoPickerPage"),
"ticket_prep": ("gui.v2.ui.pages.ticket_prep_page", "TicketPrepPage"),
@ -61,12 +61,8 @@ REGULAR_FLOW_ONLY = {
PRELOAD_ORDER = [
"settings",
"editor",
"ticket_or_billing_choice",
"payment_details",
"plan_picker",
"ticket_crypto_picker",
"duration_selection",
"currency_selection",
"policy_suggestion",
"systemwide_prompt",
"fast_mode_prompt",
@ -85,3 +81,8 @@ PRELOAD_ORDER = [
"install_system_package",
"sync_screen",
]
# "duration_selection",
# "payment_details",
# "ticket_or_billing_choice",
# "currency_selection",

View file

@ -33,6 +33,12 @@ def setup_observers(update_status):
profile_observer.subscribe(
'destroyed', lambda event: update_status('Profile destroyed'))
profile_observer.subscribe(
'enabled', lambda event: update_status('Profile Enabled'))
profile_observer.subscribe(
'disabled', lambda event: update_status('Profile Disabled'))
# client_observer.subscribe(
# 'synchronizing', lambda event: update_status('Sync in progress...'))

View file

@ -0,0 +1,28 @@
from core.infrastructure.plumbing import ThreadPlumbing
from core.models.Result import ResultError
from gui.v2.infrastructure.action_registry import ENUM_MAP
from gui.v2.infrastructure.ThreadSafetyTool import ThreadSafetyTool
from gui.v2.ui.popups.error_popup import ErrorPopup
from typing import Callable
AUTO_NAVIGATE_REGISTRY = {
ResultError.WRONG_LOCATION: "settings"
}
def take_action(action: Callable, ui_caller: Callable, *args, **kwargs):
"""PyQt6 GUI implementation of plumbing"""
controller = ThreadPlumbing(
action=action,
ui_dispatcher=ThreadSafetyTool(), # PyQt6 as mainthread
error_implementation=ErrorPopup, # PyQt6 ErrorPopup
ENUM_MAP=ENUM_MAP,
auto_navigate_dict=AUTO_NAVIGATE_REGISTRY,
ui_caller=ui_caller,
*args,
**kwargs
)
controller.execute()

View file

@ -1,8 +1,24 @@
import os
from core.models.Result import Result, ResultError
from core.errors.logger import logger
from gui.v2.ui.popups.error_popup import ErrorPopup
from gui.v2.infrastructure.ThreadSafetyTool import ThreadSafetyTool
mainthread = ThreadSafetyTool()
import os
from typing import Any
from PyQt6.QtWidgets import QWidget, QLabel, QPushButton
from PyQt6.QtGui import QIcon
from PyQt6 import QtCore
from enum import Enum
# For formatting errors graphically:
class DataError(Enum):
FORMAT: "format"
BLANK: "blank"
class Page(QWidget):
@ -101,3 +117,54 @@ class Page(QWidget):
except TypeError:
pass
button.clicked.connect(handler)
@mainthread
def show_a_popup(self, message: str, title: str = "Error"):
"""Display a popup with an error"""
# MISTAKE CATCHER:
if isinstance(message, Result):
final_message = message.message
title = message.error_type.value
else:
final_message = message
# FINAL REAL CHECK
if isinstance(final_message, str) and isinstance(title, str):
error_popup = ErrorPopup(
choice=False,
title=title,
message=final_message,
end_app = False,
on_answer = None,
passed_result = None,
)
error_popup.show()
else:
logger.error("Show a popup was sent non valid strings to display.")
def display_error(self, whats_wrong: DataError, whats_it_called: str, the_thing: Any):
"""Generic re-usable error prompt for various data related issues"""
# Diagnosis
if whats_wrong == DataError.FORMAT:
error_msg = f"The {whats_it_called} is in the wrong format"
status_update = f'Error: Bad {whats_it_called} Format'
elif whats_wrong == DataError.BLANK:
error_msg = f"The {whats_it_called} is blank! But you need a {whats_it_called} to continue!"
status_update = f'Error: Blank {whats_it_called}'
else:
error_msg = f"The {whats_it_called} has unknown errors!"
status_update = f'Unknown Error with {whats_it_called}'
# Display
if the_thing and isinstance(the_thing, str):
self.show_a_popup(f"{error_msg}: {the_thing}")
elif the_thing and isinstance(the_thing, dict):
message = the_thing.get('message', "!")
self.show_a_popup(f"{error_msg} {message}")
else:
self.show_a_popup(error_msg)
self.update_status.update_status(status_update)

View file

@ -1,11 +1,18 @@
import os
# core
from core.services.subscriptions.subscriptions_by_protocol import paying_for_tickets, get_current_billing_data
from core.controllers.profile_state.data_filters import ALL_CURRENCIES
from core.models.enum_models import Currency
from core.services.payment_phase.payment_state import PaymentData
from PyQt6.QtWidgets import QButtonGroup, QLabel, QPushButton
# gui
from gui.v2.ui.pages.Page import Page, DataError
import os
from PyQt6 import QtCore
from PyQt6.QtWidgets import QLabel, QPushButton, QButtonGroup
from PyQt6.QtWidgets import QVBoxLayout, QWidget, QSpacerItem, QSizePolicy
from PyQt6.QtGui import QIcon
from PyQt6.QtCore import QSize
from PyQt6 import QtCore
from gui.v2.ui.pages.Page import Page
class CurrencySelectionPage(Page):
@ -13,53 +20,99 @@ class CurrencySelectionPage(Page):
super().__init__("Select Currency", page_stack, main_window, parent)
self.update_status = main_window
self.selected_duration = None
self.create_interface_elements()
# Map to link the button object to its currency string value
self.button_to_currency = {}
self.create_initial_elements()
self.button_reverse.setVisible(True)
self.button_next.setVisible(False)
def create_interface_elements(self):
def create_initial_elements(self):
self.title = QLabel("Payment Method", self)
self.title.setGeometry(QtCore.QRect(510, 30, 250, 40))
self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.button_reverse.clicked.connect(self.reverse)
button_info = [
("monero", (545, 75)),
("bitcoin", (545, 290)),
("lightnering", (545, 180)),
("litecoin", (545, 395))
]
# container widget to hold the 'block' of buttons
self.button_container = QWidget(self)
# Position of the entire group, not each individual button
self.button_container.setGeometry(545, 75, 185, 400)
# Vertical Layout
self.layout = QVBoxLayout(self.button_container)
# margins avoid cutting off image:
self.layout.setContentsMargins(0, 0, 0, 0)
# Consistent gap between buttons
self.layout.setSpacing(20)
self.layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.buttonGroup = QButtonGroup(self)
self.buttons = []
for j, (icon_name, position) in enumerate(button_info):
# loop through enums and get the relevant image,
for currency_val in ALL_CURRENCIES:
button = QPushButton(self)
button.setGeometry(position[0], position[1], 185, 75)
# size required for the image
button.setFixedSize(185, 75)
button.setIconSize(QSize(190, 120))
button.setCheckable(True)
self.buttons.append(button)
self.buttonGroup.addButton(button, j)
button.setIcon(
QIcon(os.path.join(self.btn_path, f"{icon_name}.png")))
button.setIcon(QIcon(os.path.join(self.btn_path, f"{currency_val}.png")))
button.clicked.connect(self.on_currency_selected)
self.button_to_currency[button] = currency_val
self.buttonGroup.addButton(button)
self.layout.addWidget(button)
def on_currency_selected(self):
self.custom_window.navigator.navigate("payment_details")
payment_details_page = self.custom_window.navigator.get_cached("payment_details")
if payment_details_page is not None:
payment_details_page.selected_duration = self.selected_duration
payment_details_page.selected_currency = self.get_selected_currency()
payment_details_page.check_invoice()
self.update_status.update_status('Loading payment details...')
"""Sets the value selected to the singleton used across pages"""
def get_selected_currency(self):
profile_id = int(self.update_status.current_profile_id)
# get the singleton (this was created on a previous page)
print("doing the new method")
data_result = get_current_billing_data(profile_id=profile_id)
if not data_result.valid:
message = data_result.message
if not message:
message = "Error fetching data"
self.show_a_popup(str(message))
# get the singleton (this was created on a previous page)
billing_singleton = data_result.data
if not billing_singleton:
message = "Error, no billing data"
self.show_a_popup(str(message))
# prior direct method:
# self.billing_data = PaymentData(instance_id=profile_id)
# set the value as an Enum to be used in a different page,
billing_singleton.currency = self._get_selected_currency()
self.update_status.update_status('Loading payment details...')
self.custom_window.navigator.navigate("payment_details")
def _get_selected_currency(self):
selected_button = self.buttonGroup.checkedButton()
if selected_button:
index = self.buttonGroup.id(selected_button)
currencies = ["monero", "bitcoin", "lightning", "litecoin"]
return currencies[index]
return None
# Retrieve the value from the map using the button object as the key
raw_selection = self.button_to_currency.get(selected_button)
# convert to enum:
return Currency(raw_selection)
def reverse(self):
self.custom_window.navigator.navigate("duration_selection")
if paying_for_tickets():
self.custom_window.navigator.navigate("plan_picker")
else:
self.custom_window.navigator.navigate("duration_selection")

View file

@ -1,17 +1,31 @@
# Core
from core.services.payment_phase.payment_state import PaymentData
from gui.v2.infrastructure.take_plumbing_action import take_action
from core.controllers.profile_state.data_filters import get_plans_for_profile
from core.services.subscriptions.subscriptions_by_protocol import get_current_billing_data
from core.models.enum_models import PaymentCategory
from core.models.Result import Result
# GUI
# from gui.v2.actions.payment_actions import new_individual_billing
from gui.v2.ui.styles.payment_styles import DURATION_SELECTION_STYLE, NEXT_BUTTON_STYLE
from gui.v2.ui.pages.Page import Page
from gui.v2.infrastructure.ThreadSafetyTool import ThreadSafetyTool
mainthread = ThreadSafetyTool()
from PyQt6.QtWidgets import QComboBox, QLabel, QPushButton
from PyQt6 import QtCore
from gui.v2.ui.pages.Page import Page
class DurationSelectionPage(Page):
def __init__(self, page_stack, main_window=None, parent=None):
super().__init__("Select Duration", page_stack, main_window, parent)
self.update_status = main_window
self.create_interface_elements()
self.create_intitial_elements()
self.button_reverse.setVisible(True)
def create_interface_elements(self):
def create_intitial_elements(self):
self.title = QLabel("Select Duration", self)
self.title.setGeometry(QtCore.QRect(530, 30, 210, 40))
self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
@ -19,74 +33,89 @@ class DurationSelectionPage(Page):
self.combo_box = QComboBox(self)
self.combo_box.setGeometry(200, 200, 400, 60)
self.combo_box.addItems(
["1 month (€1.50)", "3 months (€4.30)", "6 months (€8)", "12 months (€16)"])
self.combo_box.setEditable(False)
self.combo_box.setMaxVisibleItems(4)
self.combo_box.setDuplicatesEnabled(True)
self.combo_box.setPlaceholderText("Select options")
self.combo_box.setStyleSheet("""
QComboBox {
font-size: 16px;
padding: 10px;
background-color: #000000;
color: #00ffff;
border: 2px solid #00ffff;
border-radius: 8px;
}
QComboBox::drop-down {
width: 30px;
background-color: #1a1a1a;
border-left: 1px solid #00ffff;
}
QComboBox::down-arrow {
width: 12px;
height: 12px;
color: #00ffff;
}
QComboBox QAbstractItemView {
background-color: #0a0a0a;
border: 1px solid #00ffff;
border-radius: 4px;
selection-background-color: #003333;
selection-color: #00ffff;
}
QComboBox QAbstractItemView::item {
padding: 8px;
border: none;
color: #00ffff;
background-color: #0a0a0a;
}
QComboBox QAbstractItemView::item:hover {
background-color: #1a1a1a;
color: #00ffff;
}
QComboBox QAbstractItemView::item:selected {
background-color: #003333;
color: #00ffff;
}
""")
self.combo_box.setStyleSheet(DURATION_SELECTION_STYLE)
self.next_button = QPushButton("Next", self)
self.next_button.setGeometry(350, 300, 100, 40)
self.next_button.setStyleSheet("""
QPushButton {
font-size: 18px;
padding: 10px;
border: 2px solid #ccc;
border-radius: 10px;
background-color: cyan;
color: black;
font-weight: bold;
}
""")
self.next_button.setStyleSheet(NEXT_BUTTON_STYLE)
self.next_button.clicked.connect(self.show_next)
# Background thread launch:
self.get_data()
def get_data(self):
"""Launches background thread to get data"""
# get profile id:
profile_id = int(self.update_status.current_profile_id)
# Thread SQL in background:
take_action(
action=get_plans_for_profile,
ui_caller=self.populate_with_data,
profile_id=profile_id,
)
@mainthread
def populate_with_data(self, result: Result):
if not result.valid:
self.show_a_popup(result.message)
return
self.choice_to_id = {}
self.dict_of_choices = result.data
if not self.dict_of_choices:
self.show_a_popup("There is no plan data. Go sync or remove the database and try again.")
return
if not isinstance(self.dict_of_choices, dict):
self.show_a_popup("Plan data is in an invalid format.")
return
for plan_string, plan_id in self.dict_of_choices.items():
self.combo_box.addItem(str(plan_string))
def show_next(self):
# get current selection:
current_choice = self.combo_box.currentText()
# which plan id is that:
plan_id = self.dict_of_choices[current_choice]
# save to singleton across pages:
profile_id = int(self.update_status.current_profile_id)
data_result = get_current_billing_data(category=PaymentCategory.INDIVIDUAL, profile_id=profile_id)
if not data_result.valid:
message = data_result.message
if not message:
message = "Error fetching data"
self.show_a_popup(str(message))
billing_data = data_result.data
if billing_data:
print(f"we have billing data: {billing_data}")
print(f"saving plan_id to the billing data")
billing_data.plan_id = plan_id
else:
message = "No Billing Data"
self.show_a_popup(message)
# go to next page:
print("going to the next page")
self.custom_window.navigator.navigate("currency_selection")
currency_page = self.custom_window.navigator.get_cached("currency_selection")
if currency_page is not None:
currency_page.selected_duration = self.combo_box.currentText()
def reverse(self):
self.custom_window.navigator.navigate("id")
# currency_page.selected_duration
# self.combo_box.addItems(
# ["1 month (€1.50)", "3 months (€4.30)", "6 months (€8)", "12 months (€16)"])

View file

@ -12,12 +12,15 @@ from core.controllers.ProfileController import ProfileController
from core.controllers.LocationController import LocationController
from core.models.Result import Result, ResultError
from core.controllers.profile_state.update_profile import update_profile
from core.controllers.profile_state.data_filters import get_supported_protocols_for_location
from core.models.BaseProfile import ProfileType
from gui.v2.ui.pages.Page import Page
from gui.v2.ui.pages.browser_page import BrowserPage
from gui.v2.ui.pages.location_page import LocationPage
from gui.v2.ui.pages.screen_page import ScreenPage
from gui.v2.ui.popups.confirmation_popup import ConfirmationPopup
from gui.v2.ui.popups.error_popup import ErrorPopup
from gui.v2.workers.page_data_worker import PageDataWorker
from gui.v2.actions.menu_profiles import match_core_profiles
@ -201,46 +204,71 @@ class EditorPage(Page):
self.data_profile[key] = value
self.name.textChanged.connect(self.update_name_value)
self.verificate(self.data_profile, selected_profiles_str)
self.evaluate_option_labels(self.data_profile, selected_profiles_str)
def verificate(self, data_profile, selected_profile_str):
protocol = data_profile.get("protocol", "")
try:
if protocol == "wireguard":
self.process_and_show_labels(data_profile, {
"protocol": ['wireguard', 'residential', 'hidetor'],
"connection": ['browser-only', 'system-wide'],
"location": self.connection_manager.get_location_list(),
"browser": self.connection_manager.get_browser_list(),
"dimentions": self.connection_manager.get_available_resolutions(data_profile.get('id', ''))
}, selected_profile_str)
def evaluate_option_labels(self, data_profile, selected_profile_str):
"""Figure out which data can be changed, so the UI only displays those choices"""
elif protocol in self.SINGBOX_PROTOCOLS:
self.process_and_show_labels(data_profile, {
"protocol": [protocol],
"connection": ['system-wide'],
"location": self.connection_manager.get_location_list(),
}, selected_profile_str)
protocol = data_profile.get("protocol", None)
profile_type = data_profile.get("connection", None)
location_codes = data_profile.get("location", None)
elif protocol == "residential" or protocol == "hidetor":
self.process_and_show_labels(data_profile, {
"protocol": ['residential', 'wireguard', 'hidetor'],
"connection": ['tor', 'just proxy'],
"location": self.connection_manager.get_location_list(),
"browser": self.connection_manager.get_browser_list(),
"dimentions": self.connection_manager.get_available_resolutions(data_profile.get('id', ''))
}, selected_profile_str)
if not profile_type or not protocol or not location_codes:
error_msg = "This profile lacks a profile type, protocol, or location."
self.show_a_popup(message=error_msg, title="Missing fields")
return
elif protocol == "open":
self.process_and_show_labels(data_profile, {
"protocol": ['open']
}, selected_profile_str)
supported_protocols_result = get_supported_protocols_for_location(location_codes=location_codes, profile_type=profile_type)
if not supported_protocols_result.valid:
error_msg = "This profile lacks supported locations."
self.show_a_popup(message=error_msg, title="Invalid location")
return
supported_protocols = supported_protocols_result.data
if not supported_protocols:
error_msg = "This profile lacks supported locations."
self.show_a_popup(message=error_msg, title="Invalid location")
if profile_type == "system-wide":
self.process_and_show_labels(data_profile, {
"protocol": supported_protocols,
"connection": ['system-wide'],
"location": self.connection_manager.get_location_list(),
}, selected_profile_str)
else:
# Session profiles can't change protocols yet.
self.process_and_show_labels(data_profile, {
"protocol": [protocol],
"connection": ['browser-only', 'system-wide'],
"location": self.connection_manager.get_location_list(),
"browser": self.connection_manager.get_browser_list(),
"dimentions": self.connection_manager.get_available_resolutions(data_profile.get('id', ''))
}, selected_profile_str)
# elif protocol in self.SINGBOX_PROTOCOLS:
# elif protocol == "residential" or protocol == "hidetor":
# self.process_and_show_labels(data_profile, {
# "protocol": ['residential', 'wireguard', 'hidetor'],
# "connection": ['tor', 'just proxy'],
# "location": self.connection_manager.get_location_list(),
# "browser": self.connection_manager.get_browser_list(),
# "dimentions": self.connection_manager.get_available_resolutions(data_profile.get('id', ''))
# }, selected_profile_str)
# elif protocol == "open":
# self.process_and_show_labels(data_profile, {
# "protocol": ['open']
# }, selected_profile_str)
except Exception as e:
print(f'An error occurred in verificate: {e}')
def process_and_show_labels(self, data_profile, parameters, selected_profile_str):
"""Populate the images in the center of the arrows, on the right hand side, with relevant choices"""
protocol = data_profile.get('protocol', "")
connection = data_profile.get('connection', "")
location = data_profile.get('location', "")
@ -568,6 +596,7 @@ class EditorPage(Page):
next_button = QPushButton(self)
next_button.setGeometry(750, 90 + i * 80, 30, 75)
next_button.clicked.connect(
lambda _, k=key, idx=value_index: self.show_next_value(k, idx, parameters))
next_button.show()
@ -611,14 +640,24 @@ class EditorPage(Page):
prev_button.setVisible(True)
next_button.setVisible(True)
if key == 'protocol' or (connection == 'system-wide' and key == 'connection'):
# can't change profile type
if key == 'connection':
prev_button.setDisabled(True)
next_button.setDisabled(True)
# can't change session protocols (yet), because Tor is a different billing system than wireguard/vless/hy,
if connection == 'browser-only' and key == 'protocol':
prev_button.setDisabled(True)
next_button.setDisabled(True)
# disables session profile options for system-profiles, where they are not relevant:
if connection == 'system-wide' and (key == 'browser' or key == 'dimentions'):
prev_button.setVisible(False)
next_button.setVisible(False)
def _protocol_button_asset(self, protocol):
asset_name = self.PROTOCOL_BUTTON_ASSETS.get(protocol, protocol)
return os.path.join(self.btn_path, f"{asset_name}_button.png")
@ -906,6 +945,7 @@ class EditorPage(Page):
self.edit_to_session()
else:
self.show_a_popup(message=result.message, title=str(result.error_type.value))
self.update_status.update_status(result.message)
@ -932,3 +972,17 @@ class EditorPage(Page):
if resume_page:
resume_page.handle_core_action_create_profile(
'CREATE_SESSION_PROFILE', profile_data, 'session')
def show_a_popup(self, message: str, title: str = "Error"):
"""Display a popup with an error"""
error_popup = ErrorPopup(
choice=False,
title=title,
message=message,
end_app = False,
on_answer = None,
passed_result = None,
)
error_popup.show()

View file

@ -8,13 +8,21 @@ from PyQt6.QtGui import QIcon, QPixmap
from PyQt6.QtCore import Qt
from PyQt6 import QtCore
from core.services.payment_phase.ticket_config_tools import do_we_have_billing_id
# core
# from core.models.enum_models import PaymentCategory
from core.services.prepare_tickets.ticket_tracker import delete_ticket_data
from core.services.payment_phase.payment_state import TicketPaymentData
from core.models.Result import Result
# from core.services.payment_phase.ticket_config_tools import do_we_have_billing_id
# gui
from gui.v2.infrastructure.take_plumbing_action import take_action
from gui.v2.ui.pages.Page import Page
from gui.v2.ui.popups.message_box import style_message_box, mark_confirm_button
from gui.v2.actions.menu_tasks import enable_profile
from gui.v2.actions.menu_coordinator import connect_with_billing_code
from gui.v2.actions.payment_actions import start_individual_payment
from gui.v2.actions.ticket_actions import start_ticket, fetch_ticket_invoice
from gui.v2.ui.styles.css.id_page_style import COLUMN_BUTTON_CSS
HOW_MANY_PROFILES_DEFAULT = 6
@ -44,20 +52,30 @@ class IdPage(Page):
pattern = r'^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
if re.match(pattern, text):
self.update_status.update_status('Enabling profile in progress...')
profile_data = {
'id': int(self.update_status.current_profile_id),
'billing_code': str(text)
}
menu_page = self.find_menu_page()
if menu_page:
self.profile_enable_worker = enable_profile(
profile_data, menu_page.handle_task_result, allow_uninstalled_tor=True,
)
else:
billing_code = str(text)
if not billing_code:
self.update_status.update_status(
'Incorrect format for the billing code')
# profile id:
profile_id = int(self.update_status.current_profile_id)
menu_page = self.custom_window.navigator.get_cached("menu")
if not menu_page:
return
# menu page recieves result:
take_action(
action=connect_with_billing_code,
ui_caller=menu_page.post_connect,
profile_id=profile_id,
billing_code=billing_code
)
# navigate to the main menu to see/recieve result:
self.custom_window.navigator.navigate("menu")
def find_menu_page(self):
return self.custom_window.navigator.get_cached("menu")
@ -69,18 +87,7 @@ class IdPage(Page):
self.button_go.clicked.connect(self.go_selected)
column_title_style = "color: #00ffff; font-size: 18px; font-weight: bold;"
column_button_style = """
QPushButton {
background-color: #000000;
color: #00ffff;
border: 2px solid #00ffff;
border-radius: 10px;
font-size: 15px;
font-weight: bold;
padding: 10px;
}
QPushButton:hover { background-color: #003333; }
"""
column_button_style = COLUMN_BUTTON_CSS
column_desc_style = "color: #888888; font-size: 12px; font-style: italic;"
self.single_title = QLabel("Single Profile", self)
@ -110,7 +117,7 @@ class IdPage(Page):
self.multiple_button.clicked.connect(self.go_multiple_profiles)
self.multiple_desc = QLabel(
f"Buy a bundle of {HOW_MANY_PROFILES_DEFAULT} tickets at once, which are cryptographically seperated from each other.", self)
f"Buy a bundle of {HOW_MANY_PROFILES_DEFAULT} tickets at once, which are cryptographically separated from each other.", self)
self.multiple_desc.setGeometry(285, 335, 240, 80)
self.multiple_desc.setStyleSheet(column_desc_style)
self.multiple_desc.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
@ -152,14 +159,59 @@ class IdPage(Page):
self.separator_right.setGeometry(540, 60, 2, 420)
self.separator_right.setStyleSheet(separator_style)
def go_single_profile(self):
self.text_edit.clear()
self.custom_window.navigator.navigate("duration_selection")
print("starting a single profile payment cycle")
start_individual_payment(
profile_id=int(self.update_status.current_profile_id),
ui_caller=self.single_profile_callback
)
def single_profile_callback(self, result: Result):
if not result:
print("no result.")
return
if result.valid:
self.custom_window.navigator.navigate("duration_selection")
else:
if result.message:
self.show_a_popup(str(result.message))
def go_multiple_profiles(self):
billing_id = do_we_have_billing_id()
if billing_id:
self._prompt_existing_tickets(billing_id)
start_ticket(ui_caller=self.multi_ticket_callback)
def multi_ticket_callback(self, result: Result):
if not result.valid:
if result.message:
self.show_a_popup(str(result.message))
return
payment_cycle = result.data
if not payment_cycle:
return
billing_data = payment_cycle.billing_data
if not billing_data:
return
billing_code = payment_cycle.billing_code
paid = TicketPaymentData().paid
print(f"paid {paid}")
if paid:
print("paid going to prep..")
self.custom_window.navigator.navigate("ticket_prep")
# prep_page = self.custom_window.navigator.get_cached("ticket_prep")
# if prep_page is not None:
# prep_page.start_prep()
elif billing_code:
self._prompt_existing_tickets(billing_code)
else:
self._continue_multiple_flow()
@ -198,11 +250,17 @@ class IdPage(Page):
failed_delete.exec()
def _resume_existing_ticket(self, billing_id):
print("resuming existing ticket in GUI")
self.update_status.update_status("Checking existing ticket payment...")
self.custom_window.navigator.navigate("payment_details")
payment_page = self.custom_window.navigator.get_cached("payment_details")
if payment_page is not None:
payment_page.resume_ticket_billing(billing_id)
print("calling fetch_ticket_invoice")
fetch_ticket_invoice(ui_caller=payment_page.receive_invoice)
print("navigating to payment details page..")
self.custom_window.navigator.navigate("payment_details")
# payment_page.resume_ticket_billing(billing_id)
def toggle_button_state(self):
text = self.text_edit.toPlainText()
@ -237,3 +295,4 @@ class IdPage(Page):
def reverse(self):
self.custom_window.navigator.navigate("menu")

View file

@ -1,3 +1,11 @@
from core.infrastructure.global_state_coordinator import connected_session_profile_ids, connected_system_profile_id, is_systemwide_on, is_connected, evaluate_state_on_startup
from gui.v2.infrastructure.take_plumbing_action import take_action
from gui.v2.actions.error_handlers import empty_filler
from core.controllers.ProfileController import ProfileController
from core.models.BaseProfile import ProfileType
from core.models.Result import Result, ResultError
from core.observers.Notifications import Notifications, EventType
import functools
import os
from itertools import chain
@ -10,19 +18,31 @@ from PyQt6.QtWidgets import (
QMessageBox, QPushButton, QScrollArea, QTextEdit, QVBoxLayout, QWidget,
)
from gui.v2.actions.menu_coordinator import connect_profile, disconnect_profile
from gui.v2.actions.menu_profiles import find_verification_icon, get_profile_details, load_menu_profiles, menu_icons
from gui.v2.actions.menu_tasks import (
continue_menu_task, create_profile, delete_profile, disable_profile,
download_update, edit_profile, enable_profile,
download_update, edit_profile, enable_profile
)
from gui.v2.actions.menu_results import MenuResult
from gui.v2.ui.pages.Page import Page
from gui.v2.ui.pages.location_verification_page import LocationVerificationPage
from gui.v2.ui.styles.styles import SCROLLBAR_CYAN_QSS
from gui.v2.ui.styles.css.menu_css import CREATE_BUTTON_STYLE
from gui.v2.ui.popups.confirmation_popup import ConfirmationPopup
from gui.v2.ui.popups.endpoint_verification_popup import EndpointVerificationPopup
from gui.v2.ui.popups.ticket_data_loss_popup import TicketDataLossPopup
from gui.v2.infrastructure.setup_observers import (
application_version_observer, connection_observer,
profile_observer
)
from gui.v2.infrastructure.ThreadSafetyTool import ThreadSafetyTool
mainthread = ThreadSafetyTool()
from typing import Optional
class MenuPage(Page):
def __init__(self, page_stack=None, main_window=None, parent=None):
@ -66,10 +86,12 @@ class MenuPage(Page):
self.font_style = f"font-family: '{main_window.open_sans_family}';" if main_window.open_sans_family else ""
self.create_interface_elements()
self.observers_subscribe()
def showEvent(self, event):
super().showEvent(event)
self._set_verification_icons_enabled()
self.state_refresh()
def _set_verification_icons_enabled(self):
parents = filter(None, (button.parent() for button in getattr(self, 'buttons', [])))
@ -94,41 +116,98 @@ class MenuPage(Page):
button_definitions = {
"create_profile": (self.create_prof, (410, 440, 110, 60), True, True),
"edit_profile": (self.edit_prof, (540, 440, 110, 60), False, True),
"launch": (self.connect, (0, 440, 185, 63), False, False),
"disconnect": (self.disconnect, (200, 433, 185, 73), True, False),
"disconnect_system_wide": (self.disconnect, (200, 433, 185, 73), True, False),
"just": (self.connect, (200, 433, 185, 73), False, False),
"just_session": (self.connect, (200, 433, 185, 73), False, True),
"settings": (self.settings_gui, (670, 440, 110, 60), True, True),
"action_button": (self.handle_action_click, (200, 433, 185, 73), True, True),
}
buttons = {}
for icon_name, (function, position, enabled, visible) in button_definitions.items():
boton = QPushButton(self)
boton.setGeometry(QtCore.QRect(*position))
boton.setObjectName("create_edit_settings_buttons")
icon = QtGui.QIcon(os.path.join(self.btn_path, f"{icon_name}.png"))
boton.setIcon(icon)
boton.setIconSize(boton.size())
# Note: action_button icon is handled dynamically in update_action_button()
if icon_name != "action_button":
icon = QtGui.QIcon(os.path.join(self.btn_path, f"{icon_name}.png"))
boton.setIcon(icon)
boton.setIconSize(boton.size())
boton.clicked.connect(functools.partial(function))
boton.setEnabled(enabled)
boton.setVisible(visible)
buttons[icon_name] = boton
button_attributes = {
"disconnect_button": "disconnect",
"boton_launch": "launch",
"boton_create": "create_profile",
"boton_edit": "edit_profile",
"boton_just": "just",
"boton_just_session": "just_session",
"connect_button": "just_session",
"disconnect_system_wide_button": "disconnect_system_wide",
"settings_button": "settings",
}
self.__dict__.update({attribute: buttons[name] for attribute, name in button_attributes.items()})
# Map the specific buttons back to the class
self.boton_create = buttons["create_profile"]
self.boton_edit = buttons["edit_profile"]
self.settings_button = buttons["settings"]
self.action_button = buttons["action_button"]
self.action_button.setIconSize(self.action_button.size())
self.refresh_menu_buttons()
def trigger_action_button_visuals(self, desired_status: bool):
"""Backend uses direct Notifications to trigger the action button status, without a profile_id"""
if desired_status:
self.action_button.setEnabled(True)
self.action_button.setVisible(True)
def update_action_button_visuals(self, triggered_profile_id: Optional[int] = None):
"""Syncs the button art with enabled state with the current external truth."""
# Which profile is selected:
profile_id = getattr(self.update_status, 'current_profile_id', None)
if profile_id is None:
self.action_button.setVisible(False)
return
# triggered_profile_id is coming via a Notification.
# Don't update the button via from a notification, if the user clicked to a different profile,
if triggered_profile_id and profile_id != triggered_profile_id:
return
self.action_button.setVisible(True)
self.action_button.setEnabled(True) # Re-enable after the Event has fired
connected = is_connected(profile_id)
p_type = ProfileController.what_type_is_profile(profile_id)
# if it's connected, then it can't be edited.
self.boton_edit.setEnabled(not connected)
# Logic Matrix for Art
if p_type == ProfileType.SESSION:
icon_name = "disconnect" if connected else "just_session"
else: # SYSTEM
icon_name = "disconnect_system_wide" if connected else "just"
print(f"Setting icon_name to {icon_name}")
icon = QtGui.QIcon(os.path.join(self.btn_path, f"{icon_name}.png"))
self.action_button.setIcon(icon)
def handle_action_click(self):
"""Route the action button to connect or disconnect, depending on state"""
# 1. Relevant profile ID:
profile_id = getattr(self.update_status, 'current_profile_id', None)
if profile_id is None:
return
# 2. Immediately disable to prevent double-clicks during async transition
self.action_button.setEnabled(False)
# 3. Connection Status
connected = is_connected(profile_id)
print(f"is {profile_id} connected? {connected}")
# 4. Route:
if connected:
self.disconnect()
else:
self.connect()
def _make_confirmation_popup(self, message, button_text='Proceed'):
popup = ConfirmationPopup(
self, message=message, action_button_text=button_text, cancel_button_text="Cancel")
@ -154,7 +233,8 @@ class MenuPage(Page):
icon_paths = {"session_tor": self.connected_tor_profile,
"session": self.connected_profile, "system": self.system_wide_connected_profile}
self.button_states = {profile_id: icon_paths[kind] for profile_id, kind in menu_data["enabled_kinds"].items()}
self.update_status.update_image(appearance_value=menu_data["background"])
for button in getattr(self, 'buttons', []):
button.parent().deleteLater()
if hasattr(self, 'button_group'):
@ -189,12 +269,8 @@ class MenuPage(Page):
self.update_status.current_profile_id = None
self.current_profile_location = None
self.reverse_id = None
self.boton_edit.setEnabled(False)
self.boton_launch.setEnabled(False)
self.boton_just.setVisible(False)
self.boton_just_session.setVisible(False)
self.disconnect_button.setVisible(False)
self.disconnect_system_wide_button.setVisible(False)
if not hasattr(self, 'button_group'):
return
self.button_group.setExclusive(False)
@ -223,25 +299,13 @@ class MenuPage(Page):
parent_label.setVisible(True)
return parent_label
def create_profile_button(self, parent_label, profile_id, key):
button = QPushButton(parent_label)
button.setGeometry(0, 0, 175, 60)
button.setStyleSheet("""
QPushButton {
background-color: transparent;
border: none;
}
QPushButton:hover {
background-color: rgba(200, 200, 200, 50);
}
QPushButton:checked {
background-color: rgba(200, 200, 200, 80);
}
""")
button.setStyleSheet(CREATE_BUTTON_STYLE)
icon = QIcon(self.button_states.get(
profile_id, self.not_connected_profile))
button.setIcon(icon)
button.setIcon(QIcon(self.not_connected_profile))
button.setIconSize(QtCore.QSize(175, 60))
button.show()
@ -392,19 +456,22 @@ class MenuPage(Page):
self._render_profile_details(details)
def _render_profile_details(self, details):
# print(f"details entering _render_profile_details is {details}")
self.reverse_id = details["id"]
# THIS IS UPDATING THE PROFILE ID
self.update_status.current_profile_id = self.reverse_id
self.current_profile_location = details["location"]
target_button = self.profile_button_map.get(self.reverse_id)
if target_button is not None:
target_button.setChecked(True)
self.connection_manager.set_profile_button_objects(self.reverse_id, target_button)
self.boton_edit.setEnabled(not details["is_connected"] and not self._task_busy)
self.boton_launch.setEnabled(not self._task_busy)
self.boton_just.setEnabled(not self._task_busy)
self.boton_just_session.setEnabled(not self._task_busy)
# Update buttons:
self.update_action_button_visuals(self.reverse_id)
# verification icons:
self._set_verification_icons_enabled()
self.update_button_visibility(details["is_connected"], details["is_session"])
self.update_status_message(details)
for label in self.additional_labels:
label.deleteLater()
@ -504,40 +571,102 @@ class MenuPage(Page):
self.page_stack.indexOf(verification_page))
def _set_task_busy(self, busy):
self._task_busy = busy
for button in (self.boton_just, self.boton_just_session, self.disconnect_button,
self.disconnect_system_wide_button, self.boton_create, self.boton_edit):
button.setEnabled(not busy)
self.scroll_area.setEnabled(not busy)
self.boton_just.setEnabled(not busy and self.reverse_id is not None)
self.boton_just_session.setEnabled(not busy and self.reverse_id is not None)
self.boton_edit.setEnabled(not busy and self.reverse_id is not None
and not self.connection_manager.is_profile_connected(self.reverse_id))
pass
# self._task_busy = busy
# for button in (self.boton_just, self.boton_just_session, self.disconnect_button,
# self.disconnect_system_wide_button, self.boton_create, self.boton_edit):
# button.setEnabled(not busy)
# self.scroll_area.setEnabled(not busy)
# self.boton_just.setEnabled(not busy and self.reverse_id is not None)
# self.boton_just_session.setEnabled(not busy and self.reverse_id is not None)
# self.boton_edit.setEnabled(not busy and self.reverse_id is not None
# and not self.connection_manager.is_profile_connected(self.reverse_id))
def post_disconnect(self, result: Result):
"""Recieve the callback after disconnection"""
print(f"result is {result}")
# --------------------
# BOTTOM LEFT STATUS BAR
# --------------------
# if result.message:
# if len(result.message) > 20:
# self.update_status.disable_marquee
# self.update_status.update_status(result.message)
# --------------------
# WORKED
# --------------------
if result.valid:
self.boton_edit.setEnabled(True)
return
self.deal_with_errors(result)
def post_connect(self, result: Result):
"""Recieve the callback after a connection attempt."""
# --------------------
# BOTTOM LEFT STATUS BAR
# --------------------
if result.message:
if len(result.message) > 20:
self.update_status.enable_marquee
self.update_status.update_status(result.message)
# --------------------
# CONNECTION WORKED
# --------------------
if result.valid:
return
self.deal_with_errors(result)
def deal_with_errors(self, result: Result):
if result.error_type == ResultError.NAVIGATE_TO:
print(f"We are auto-navigating to page {result.navigate_to}")
self.custom_window.navigator.navigate(result.navigate_to)
elif result.error_type == ResultError.SUBSCRIPTION:
print("We are navigating to the subscription page")
self.update_status.update_status("Select Subscription Choice")
self.custom_window.navigator.navigate("id")
else:
error_type = result.error_type.value
print(f"We do not currently have a pre-made solution for the unknown situation of {error_type}")
self.update_status.update_status(error_type)
def connect(self):
self._set_task_busy(True)
self.update_status.update_status("Attempting to connect...")
self.worker = enable_profile({"id": self.reverse_id}, self.handle_task_result)
"""Enable the profile id tied to reverse_id"""
print("Starting to connect..")
# # Step 1. Disable edit button.
self.boton_edit.setEnabled(False)
# Step 2. Connect with post_connect setup to recieve the result.
immediate_error = connect_profile(profile_id=self.reverse_id, ui_caller=self.post_connect)
# Step 3. Evaluate immediate errors (if it never even gets to the callback)
if not immediate_error.valid:
self.update_status.update_status(immediate_error.message)
return
def disconnect(self):
self._set_task_busy(True)
self.update_status.update_status("Disconnecting...")
self.worker = disable_profile(self.reverse_id, self.connection_manager.get_connected_profiles(),
self.handle_task_result)
print(f"Disconnecting profile {self.reverse_id}")
result = disconnect_profile(profile_id=self.reverse_id, ui_caller=self.post_disconnect)
print("Disconnected")
# self._set_task_busy(True)
# self.worker = disable_profile(self.reverse_id, self.connection_manager.get_connected_profiles(),
# self.handle_task_result)
def create_prof(self):
self._set_task_busy(True)
self.worker = create_profile(getattr(self.update_status, "gui_config_file", None),
self.connection_manager.is_synced(), self.handle_task_result)
def update_button_visibility(self, is_connected, is_session_profile):
self.boton_just_session.setVisible(
not is_connected and is_session_profile)
self.boton_just.setVisible(not is_connected and not is_session_profile)
self.disconnect_button.setVisible(is_connected and is_session_profile)
self.disconnect_system_wide_button.setVisible(
is_connected and not is_session_profile)
def update_status_message(self, state):
if not state["profile_loaded"]:
self.update_status.update_status("Profile data could not be loaded. Sync or restart and try again.")
@ -576,19 +705,27 @@ class MenuPage(Page):
@pyqtSlot(object)
def handle_task_result(self, result: MenuResult):
print(f"result in handle_task_result is {result}")
if "sync_data" in result:
self.update_status.update_values(*result["sync_data"])
if result.get("clear_selection") and result.get("profile_id") == self.reverse_id:
self.clear_profile_details()
if result.get("refresh"):
self.refresh_menu_buttons(result)
self._set_task_busy(result.get("busy", False))
if result.get("message"):
handlers = {True: self.update_status.enable_marquee, False: self.update_status.update_status}
handlers[bool(result.get("is_enabled"))](result["message"])
handlers = {"confirmation": self._show_confirmation, "verification": self._show_verification,
"navigate": self._navigate_result, "install": self._show_install_result,
"ticket_data_loss": self._show_ticket_loss_result}
handler = handlers.get(result["status"])
if handler is not None:
handler(result)
@ -627,3 +764,128 @@ class MenuPage(Page):
def _show_ticket_loss_result(self, result):
self.show_ticket_data_loss_popup(result["ticket_number"], result["billing_code"])
def _update_profile_icon(self, profile_id: int, image_path: str, is_active: bool = False):
"""Helper to update profile icons and the 'checked' state for the border/highlight."""
button = self.profile_button_map.get(profile_id)
if button:
button.setIcon(QIcon(image_path))
# This ensures the UI 'checked' state matches the connection state
button.setChecked(is_active)
def profile_is_currently_selected(self, profile_id: int) -> bool:
"""Checks if the profile that's currently selected by the user, matches profile_id"""
return self.update_status.current_profile_id == profile_id
@mainthread
def turn_on_session_visuals(self, profile_id: int):
"""Flip off the Visual buttons for a given profile."""
print(f"Turning ON Session {profile_id}!")
# Update the specific profile button
self._update_profile_icon(profile_id, self.connected_profile, is_active=True)
# Update disconnect/action button:
self.update_action_button_visuals(profile_id)
@mainthread
def turn_off_session_visuals(self, profile_id: int, surpress_output: Optional[bool] = False):
"""
Flip off the Visual buttons for a given profile.
And re-enable the edit button if its currently selected.
"""
if not surpress_output:
print(f"Stopping Session {profile_id}!")
# If you want the border to stay when off, set is_active=True
# If you want the border to disappear when off, set is_active=False
self._update_profile_icon(profile_id, self.not_connected_profile, is_active=True)
# Update disconnect/action button:
self.update_action_button_visuals(profile_id)
# Re-enable edit button:
if self.profile_is_currently_selected(profile_id):
self.boton_edit.setEnabled(True)
@mainthread
def turn_on_system_visuals(self, profile_id: int):
print(f"Turning ON Systemwide on {profile_id}!")
# Update the specific profile button
self._update_profile_icon(profile_id, self.system_wide_connected_profile, is_active=True)
# Update the general background status
self.update_status.update_image(appearance_value="background_connected")
# Update disconnect/action button:
self.update_action_button_visuals(profile_id)
@mainthread
def turn_off_system_visuals(self, profile_id: int):
"""
Flip off the Visual buttons for a given System-wide.
Change the entire app's background for connected.
And re-enable the edit button if its currently selected.
"""
print(f"Turning OFF Systemwide on {profile_id}!")
# Update the specific profile button
self._update_profile_icon(profile_id, self.not_connected_profile, is_active=True)
# Update the general background status
self.update_status.update_image(appearance_value="original")
# Update connect/action button:
self.update_action_button_visuals(profile_id)
# Re-enable edit button:
if self.profile_is_currently_selected(profile_id):
self.boton_edit.setEnabled(True)
def observers_subscribe(self):
Notifications.subscribe(EventType.SESSION_PROFILE_ENABLE, self.turn_on_session_visuals)
Notifications.subscribe(EventType.SESSION_PROFILE_DISABLE, self.turn_off_session_visuals)
Notifications.subscribe(EventType.SYSTEM_ENABLE, self.turn_on_system_visuals)
Notifications.subscribe(EventType.SYSTEM_DISABLE, self.turn_off_system_visuals)
Notifications.subscribe(EventType.RESUME_ACTIONS, self.trigger_action_button_visuals)
Notifications.subscribe(EventType.SHOW_POPUP, self.show_a_popup)
take_action(
action=evaluate_state_on_startup,
ui_caller=empty_filler # do nothing on success.
)
def state_refresh(self):
"""Update the UI with the current Global State Repo"""
# 1. Collect current state
sys_on = is_systemwide_on()
sys_id = connected_system_profile_id()
sess_ids = connected_session_profile_ids() or []
# 2. Set the global background
if sys_on:
self.update_status.update_image(appearance_value="background_connected")
else:
self.update_status.update_image(appearance_value="original")
# 3. Sync every button
for pid in self.profile_button_map.keys():
# We check for the "On" states first.
# If none of these match, it falls through to the default 'off' state.
if sys_on and pid == sys_id:
# Priority 1: System-wide active
self.turn_on_system_visuals(pid)
elif pid in sess_ids:
# Priority 2: Session active
self.turn_on_session_visuals(pid)
else:
# DEFAULT STARTING PLACE:
# If it's not system-wide and not a session, it must be off.
self.turn_off_session_visuals(profile_id=pid, surpress_output=True)

View file

@ -0,0 +1,505 @@
# core
from core.services.subscriptions.subscriptions_by_protocol import paying_for_tickets, get_current_billing_data
from core.services.payment_phase.payment_state import PaymentData
from core.models.pydantic_models.ServerReply import SubscriptionInvoice
from core.models.invoice.TicketInvoice import TicketInvoice
from core.models.enum_models import Currency
from core.models.Result import Result, ResultError
from core.observers.Notifications import Notifications, EventType
# GUI
from gui.v2.actions.payment_actions import (
start_polling,
gui_starts_invoice,
stop_payment_timer,
)
from gui.v2.ui.pages.Page import Page, DataError
from gui.v2.ui.popups.qrcode_dialog import QRCodeDialog
from gui.v2.ui.styles.css.payment_styles import PAYMENT_STYLE_SHEET
# Bring background threads to the main thread UI:
from gui.v2.infrastructure.ThreadSafetyTool import ThreadSafetyTool
mainthread = ThreadSafetyTool()
# generic
from typing import Optional
import os
from PyQt6 import QtCore, QtWidgets
from PyQt6.QtWidgets import (
QVBoxLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QWidget,
QApplication,
QFrame,
QSpacerItem,
QSizePolicy,
QStackedWidget,
QMainWindow,
)
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QIcon
from enum import Enum
# For formatting errors graphically:
class DataError(Enum):
FORMAT: "format"
BLANK: "blank"
class PaymentDetailsPage(Page):
"""
Display Payment Details, fetch an invoice, and launch a repeat timer to check payment status.
From Page: Inherits display_error, show_a_popup
From Navigator: gets the functions navigate, get_cached, page_stack object
"""
def __init__(
self,
page_stack: QStackedWidget,
main_window: Optional[QMainWindow] = None,
parent: Optional[QWidget] = None,
):
super().__init__("Payment Details", page_stack, main_window, parent)
self.update_status = main_window
self.fields = {}
self.ticketing = paying_for_tickets()
self.polling = False
self.setStyleSheet(PAYMENT_STYLE_SHEET)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.create_initial_elements()
self.button_reverse.setVisible(True)
# self.begin_invoice()
def showEvent(self, event):
"""Refresh data. This runs every time the widget is displayed"""
# Call the parent class showEvent first
super().showEvent(event)
self.ticketing = paying_for_tickets()
self.begin_invoice()
def create_initial_elements(self):
"""Setup Payment UI Buttons as part of a dict by field key."""
main_layout = QVBoxLayout(self)
# put the entire group (card + buttons) in the middle:
main_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
main_layout.setSpacing(15)
main_layout.setContentsMargins(10, 10, 10, 10)
# --- THE INFO CARD ---
card = QFrame()
card.setObjectName("InfoCard")
card_layout = QVBoxLayout(card)
card_layout.setSpacing(8)
card_layout.setContentsMargins(12, 12, 12, 12)
def add_info_row(label_text, field_key=None, show_copy=False):
row_layout = QHBoxLayout()
row_layout.setContentsMargins(0, 0, 0, 0)
lbl = QLabel(label_text)
lbl.setStyleSheet("font-size: 12px;")
lbl.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
row_layout.addWidget(lbl)
if field_key is not None:
edit = QLineEdit()
edit.setReadOnly(True)
edit.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.fields[field_key] = edit
row_layout.addWidget(edit)
if show_copy:
btn = QPushButton()
btn.setFixedSize(28, 28)
btn.setIcon(QIcon(os.path.join(self.btn_path, "paste_button.png")))
btn.clicked.connect(
lambda checked, key=field_key: self.copy_text(key)
)
row_layout.addWidget(btn)
card_layout.addLayout(row_layout)
add_info_row("Billing ID:", field_key="billing_code", show_copy=True)
add_info_row("Subscription End-date", field_key="duration", show_copy=False)
add_info_row("Currency:", field_key="currency", show_copy=False)
add_info_row("Amount:", field_key="amount", show_copy=True)
addr_sep = QLabel("Wallet Address")
addr_sep.setStyleSheet(
"font-size: 11px; font-weight: bold; color: #89b4fa; margin-top: 2px;"
)
card_layout.addWidget(addr_sep)
add_info_row("", field_key="address", show_copy=True)
main_layout.addWidget(card)
action_bar = QHBoxLayout()
action_bar.setSpacing(10)
action_bar.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.qr_code_button = QPushButton(" Scan QR Code")
self.qr_code_button.setIcon(QIcon(os.path.join(self.btn_path, "qr-code.png")))
self.qr_code_button.setMinimumHeight(40)
self.qr_code_button.clicked.connect(self.show_qr_code)
self.qr_code_button.setDisabled(True)
self.stop_button = QPushButton(" Stop Checking")
self.stop_button.setObjectName("StopButton")
self.stop_button.setMinimumHeight(40)
self.stop_button.clicked.connect(self.stop_or_start_check)
self.stop_button.setDisabled(True)
self.switch_to_background_button = QPushButton(" Check in Background")
self.switch_to_background_button.setMinimumHeight(40)
self.switch_to_background_button.clicked.connect(self.background_fetch_mode)
self.switch_to_background_button.setDisabled(True)
action_bar.addWidget(self.qr_code_button)
action_bar.addWidget(self.stop_button)
action_bar.addWidget(self.switch_to_background_button)
main_layout.addLayout(action_bar)
self.button_reverse.clicked.connect(self.reverse)
def clear_pre_existing_invoice(self):
text = "connecting.."
self.fields["billing_code"].setText(text)
self.fields["duration"].setText(text)
self.fields["amount"].setText(text)
self.fields["address"].setText(text)
self.fields["currency"].setText(text)
def begin_invoice(self):
""" "Fetches an Invoice from GUI's actions modules"""
self.update_status.update_status("Getting Invoice...")
self.profile_id = int(self.update_status.current_profile_id)
print(f"profile_id is {self.profile_id}")
if not self.ticketing and not self.profile_id:
self.show_a_popup(f"Missing a profile id for an individual profile.")
return
# this is not used here, but this is the right place to put it. Since we should have it by now:
self.currency = self._get_currency()
if not self.currency:
self.custom_window.navigator.navigate("currency_selection")
return # error is already displayed in subfunction.
self.clear_pre_existing_invoice()
# launches background thread tasks:
error_msg = gui_starts_invoice(
invoice_ui_caller=self.receive_invoice,
timer_resume_caller=self.timer_updates_gui,
profile_id=self.profile_id,
)
if error_msg:
self.show_a_popup(error_msg)
else:
self.polling = True
# And make sure it's allowed to be stopped:
self.stop_button.setDisabled(False)
self.switch_to_background_button.setDisabled(False)
def stop_or_start_check(self):
"""Toggle On/Off Polling"""
# If it's on:
if self.polling:
timer_result = stop_payment_timer()
message = timer_result.message
if not timer_result.valid:
self.update_status.update_status("Stopped checking!")
self.stop_button.setDisabled(True)
self.switch_to_background_button.setDisabled(True)
# regardless of if it worked or not:
if message:
self.show_a_popup(str(message))
self.custom_window.navigator.navigate("menu")
# # Turn it back on if off:
# else:
# self.begin_invoice()
def background_fetch_mode(self):
"""Continue checking if paid, but navigate back to the menu page"""
if self.polling:
notification = "Checking if Paid in Background.."
Notifications.emit(EventType.STATUS_UPDATE, notification)
self.custom_window.navigator.navigate("menu")
else:
message = "It was not polling to begin with."
self.show_a_popup(message)
def copy_text(self, key: str):
"""Copy/Paste fields and update the bottom status"""
try:
# Use the key to determine if we use elided text or the full raw address
text = self.full_address if key == "address" else self.fields[key].text()
QApplication.clipboard().setText(text)
# messages based on the key
msg_map = {
"billing_code": "Billing code copied!",
"address": "Address copied!",
"amount": "Pay amount copied!",
}
self.update_status.update_status(msg_map.get(key, "Copied!"))
except Exception as e:
self.show_a_popup(str(e))
self.update_status.update_status("No content available for copying")
@mainthread
def receive_invoice(self, invoice_result: Result):
"""Callback for the background API thread to update the UI"""
print("receive_invoice is recieving the invoice result")
if not invoice_result:
self.display_error(
whats_wrong=DataError.BLANK,
whats_it_called="Backend Invoice Result",
the_thing=invoice_result,
)
return
if not isinstance(invoice_result, Result):
self.display_error(
whats_wrong=DataError.FORMAT,
whats_it_called="Backend Invoice Result",
the_thing=invoice_result,
)
return
if not invoice_result.valid:
# Paid already, then navigate away:
if invoice_result.error_type == ResultError.PAID:
print("already paid")
print("already paid")
print("already paid")
print("already paid")
if paying_for_tickets():
print("triggering ticketing confirmed")
self.ticket_payment_confirmed()
else:
self.update_status.update_status('Paid. Please proceed.')
self.custom_window.navigator.navigate("menu")
return
# otherwise display the error:
self.update_status.update_status(str(invoice_result.error_type.value))
self.show_a_popup(str(invoice_result.message))
return
# pull the actual invoice out of the result wrapper,
invoice = invoice_result.data
if not invoice:
self.show_a_popup("Missing Invoice from API call's data")
self.custom_window.navigator.navigate("id")
return
# Fill in UI:
self.fill_in_invoice_data(invoice)
# Start polling:
error_msg = start_polling(
billing_code=invoice.billing_code,
profile_id=int(self.update_status.current_profile_id),
ui_caller=self.timer_updates_gui
)
if error_msg:
self.show_a_popup(error_msg)
else:
self.update_status.update_status("Invoice generated. Awaiting payment...")
def fill_in_invoice_data(self, invoice: SubscriptionInvoice):
"""Extract and populate invoice data into the labels"""
print(f"filling in {invoice}")
# Extract from the already validated object:
billing_code = str(invoice.billing_code)
duration = str(invoice.expires_at)
amount_text = str(invoice.due_amount)
self.full_address = str(invoice.address)
if billing_code:
self.fields["billing_code"].setText(billing_code)
if duration:
self.fields["duration"].setText(duration)
self.fields["duration"].setStyleSheet("font-size: 13px;")
if amount_text:
self.fields["amount"].setText(amount_text)
# Dynamic font sizing for amount
font_size = 14
if len(amount_text) > 9:
font_size = 15
if len(amount_text) > 13:
font_size = 10
self.fields["amount"].setStyleSheet(
f"font-size: {font_size}px; font-weight: bold;"
)
if self.full_address:
metrics = self.fields["address"].fontMetrics()
elided_text = metrics.elidedText(
self.full_address,
Qt.TextElideMode.ElideMiddle,
self.fields["address"].width(),
)
self.fields["address"].setText(elided_text)
if self.currency:
self.fields["currency"].setText(self.currency.value)
# Now that we have the data populated, show the QR code:
self.qr_code_button.setDisabled(False)
# And make sure it's allowed to be stopped:
self.stop_button.setDisabled(False)
self.switch_to_background_button.setDisabled(False)
@mainthread
def timer_updates_gui(self, paid: bool, stopped: bool, message: str, billing_code: str):
"""Callback for the background thread timer to repeatedly update the UI"""
# CONTINUED UPDATES
if message and isinstance(message, str):
self.update_status.update_status(message)
# PAID
if paid:
self.update_status.update_status("Payment received.")
if self.ticketing:
self.ticket_payment_confirmed()
else:
self.regular_payment_confirmed(billing_code=billing_code)
# NOT PAID BUT ENDED
elif not paid and stopped:
self._display_it_stopped(message)
def _display_it_stopped(self, message: str):
"""Notify the user if it's NOT paid, but stopped"""
if not message:
error_msg = "It stopped checking if it's paid. Could be a connection timeout or other issue"
else:
error_msg = f"It stopped checking if it's paid. {message}"
self.update_status.update_status("Timer Stopped")
self.show_a_popup(error_msg)
def show_qr_code(self):
"""Launch an external QR scanning popup screen"""
if self.full_address and self.currency:
qr_dialog = QRCodeDialog(
self.full_address,
self.fields["amount"].text(),
self.currency.value,
self,
)
qr_dialog.exec()
else:
self.update_status.update_status("No address available for QR code")
def reverse(self):
"""Navigate away from the page, depending on which payment flow."""
if self.ticketing:
self.ticketing = False
self.custom_window.navigator.navigate("ticket_crypto_picker")
else:
self.custom_window.navigator.navigate("menu")
def _get_currency(self):
"""Get the currency from prior cached Payment Data"""
if not self.profile_id or not isinstance(self.profile_id, int):
self.display_error(
whats_wrong=DataError.FORMAT,
whats_it_called="profile_id",
the_thing=self.profile_id,
)
# get the singleton (this was created on a previous page)
data_result = get_current_billing_data(profile_id=self.profile_id)
if not data_result.valid:
message = data_result.message
if not message:
message = "Error fetching data"
self.show_a_popup(str(message))
# get the singleton (this was created on a previous page)
billing_singleton = data_result.data
print(f"Inside payments screen GUI of _get_currency, the billing_singleton is {billing_singleton}")
if not billing_singleton:
message = "Error, no billing data"
self.show_a_popup(str(message))
currency = billing_singleton.currency
if not currency:
return None
# self.show_a_popup('Missing Currency')
if not isinstance(currency, Currency):
self.display_error(
whats_wrong=DataError.FORMAT,
whats_it_called="currency",
the_thing=currency,
)
return currency
def regular_payment_confirmed(self, billing_code: str):
"""Conclude a regular payment by putting the billing code on the final confirmation window."""
print("calling regular_payment_confirmed")
payment_page = self.custom_window.navigator.get_cached("payment_confirmed")
if payment_page is not None:
for line in self.text_fields:
line.setText('')
payment_page.set_billing_code(billing_code)
self.custom_window.navigator.navigate("payment_confirmed")
else:
print("PaymentConfirmed page not found")
self.update_status.update_status('Paid. Please proceed.')
self.custom_window.navigator.navigate("menu")
def ticket_payment_confirmed(self):
"""Conclude a ticket payment by forwarding to the prep page."""
self.update_status.update_status('Payment received.')
self.custom_window.navigator.navigate("ticket_prep")
# prep_page = self.custom_window.navigator.get_cached("ticket_prep")
# if prep_page is not None:
# prep_page.start_prep()

View file

@ -1,16 +1,30 @@
from PyQt6.QtWidgets import QLabel, QListWidget, QListWidgetItem
from PyQt6 import QtCore
# core
from core.models.enum_models import PaymentCategory
from core.models.Result import Result
from core.services.payment_phase.payment_state import TicketPaymentData
# gui
from gui.v2.actions.ticket_actions import start_ticket
from gui.v2.ui.pages.Page import Page
from gui.v2.ui.styles.styles import TERMINAL_LIST_QSS
from gui.v2.workers.ticketing_worker_thread import TicketingWorkerThread
# generic
from PyQt6.QtWidgets import QLabel, QListWidget, QListWidgetItem
from PyQt6 import QtCore
class PlanPickerPage(Page):
def __init__(self, page_stack, main_window=None, parent=None):
super().__init__("PickPlan", page_stack, main_window, parent)
self.update_status = main_window
# this page only, immediate pricing display:
self.pricing_data = None
# cross page cache overall data:
self.billing_data = None
self.worker = None
self.title.setText("Pick a Plan")
@ -39,6 +53,10 @@ class PlanPickerPage(Page):
self.status_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
def start_sync(self):
# STATE
result = start_ticket(ui_caller=self.recieve_initial_billing_data)
# Begin Tickets:
self.plan_list.clear()
self.status_label.setText("Syncing prices...")
self.update_status.update_status("Syncing ticket prices...")
@ -47,27 +65,18 @@ class PlanPickerPage(Page):
self.worker.error.connect(self.on_error)
self.worker.start()
def on_sync_done(self, pricing_data):
if isinstance(pricing_data, dict) and pricing_data.get('valid') is False:
err = pricing_data.get('error_code', 'unknown')
self.status_label.setText(f"Sync failed: {err}")
self.update_status.update_status(f"Sync failed: {err}")
def on_sync_done(self, pricing_data: Result):
if not pricing_data.valid:
self.status_label.setText("Pricing data fetch failed.")
self.show_a_popup(pricing_data.message)
return
if isinstance(pricing_data, dict) and isinstance(pricing_data.get('data'), dict):
plans = pricing_data['data']
elif isinstance(pricing_data, dict):
plans = pricing_data
else:
self.status_label.setText("Unexpected pricing data.")
return
self.pricing_data = plans
self.populate_plans(plans)
self.pricing_data = pricing_data.data
self.populate_plans(self.pricing_data)
self.status_label.setText("Pick a plan above.")
self.update_status.update_status("Plans loaded.")
def populate_plans(self, pricing_data):
def populate_plans(self, pricing_data: dict):
self.plan_list.clear()
if not isinstance(pricing_data, dict):
self.status_label.setText("Unexpected pricing data.")
@ -84,14 +93,57 @@ class PlanPickerPage(Page):
item.setData(QtCore.Qt.ItemDataRole.UserRole, plan_key)
self.plan_list.addItem(item)
def recieve_initial_billing_data(self, result: Result):
"""Callback to recieve initial billing data instance"""
if not isinstance(result, Result):
self.show_a_popup("Developer Error: recieve_initial_billing_data should not have gotten a non-result object.")
if not result.valid:
error_msg = result.message
if error_msg:
self.show_a_popup(str(error_msg))
else:
self.show_a_popup("Error with Starting Billing")
# cache data to save the choice to it:
self.billing_data = result.data
if self.billing_data:
print("got pre-existing data")
def on_plan_clicked(self, item):
print(f"on_plan_clicked is {item}")
plan_key = item.data(QtCore.Qt.ItemDataRole.UserRole)
if not plan_key:
self.show_a_popup("Developer Error where the selected item is not showing up in on_plan_clicked.")
return
self.custom_window.navigator.navigate("ticket_crypto_picker")
crypto_page = self.custom_window.navigator.get_cached("ticket_crypto_picker")
if crypto_page is not None:
crypto_page.set_selected_plan(plan_key)
print(f"plan_key is {plan_key}")
# in temp cache save choice:
if not self.billing_data:
self.show_a_popup("Developer Error that ticket flow didn't produce a billing_data object in plan picker page.")
# self.billing_data.which_key = plan_key
print("Getting TicketPaymentData()")
billing_data = TicketPaymentData()
print(f"billing_data is {billing_data}")
billing_data.which_key = plan_key
print("saved the which_key. navigating to currency selection..")
print(f"FINALLY billing_data is {billing_data}")
# go to next page:
# currency_page = self.custom_window.navigator.get_cached("currency_selection")
# if currency_page is not None:
self.custom_window.navigator.navigate("currency_selection")
# self.custom_window.navigator.navigate("ticket_crypto_picker")
# crypto_page = self.custom_window.navigator.get_cached("ticket_crypto_picker")
# if crypto_page is not None:
# crypto_page.set_selected_plan(plan_key)
def on_error(self, msg):
self.status_label.setText(f"Error: {msg}")
@ -99,3 +151,17 @@ class PlanPickerPage(Page):
def reverse(self):
self.custom_window.navigator.navigate("id")
# LEGACY:
# if isinstance(pricing_data, dict) and pricing_data.get('valid') is False:
# err = pricing_data.get('error_code', 'unknown')
# self.status_label.setText(f"Sync failed: {err}")
# self.update_status.update_status(f"Sync failed: {err}")
# return
# if isinstance(pricing_data, dict) and isinstance(pricing_data.get('data'), dict):
# plans = pricing_data['data']
# elif isinstance(pricing_data, dict):
# plans = pricing_data
# else:

View file

@ -66,9 +66,9 @@ class TicketCryptoPickerPage(Page):
currency = selected_button.property('currency')
self.start_initiate_payment(currency)
def check_if_paid_for_existing(self, temp_billing_code):
def check_if_paid_for_existing(self, billing_code):
self.update_status.update_status("Checking if paid...")
result = check_if_paid(temp_billing_code, ticket_observer, connection_observer)
result = check_if_paid(billing_code, ticket_observer, connection_observer)
if isinstance(result, dict) and result.get('valid') and result.get('payment_status') == 'paid':
self.update_status.update_status("Already Paid!")
self.custom_window.navigator.navigate("ticket_prep")
@ -78,8 +78,8 @@ class TicketCryptoPickerPage(Page):
else:
self.update_status.update_status("Not yet paid.")
clipboard = QApplication.clipboard()
clipboard.setText(temp_billing_code)
not_paid_msg = f"The billing code is not yet showing paid for {temp_billing_code}. Right now, your clipboard has the billing code, to paste it in any text editor. If you did pay, either wait longer for blockchain confirmation, or contact customer support with the code in your clipboard now."
clipboard.setText(billing_code)
not_paid_msg = f"The billing code is not yet showing paid for {billing_code}. Right now, your clipboard has the billing code, to paste it in any text editor. If you did pay, either wait longer for blockchain confirmation, or contact customer support with the code in your clipboard now."
info = QMessageBox(self)
info.setWindowTitle("Not Paid")
info.setText(not_paid_msg)
@ -120,10 +120,10 @@ class TicketCryptoPickerPage(Page):
self._prompt_wipe_existing(invoice)
return
elif error_code == ResultError.BILLING_CODE_EXISTS and not self.bypass_existing:
temp_billing_code = getattr(invoice, 'temp_billing_code', None)
print(f"temp_billing_code is {temp_billing_code}")
if temp_billing_code:
self._prompt_wipe_billingcode(temp_billing_code)
billing_code = getattr(invoice, 'billing_code', None)
print(f"billing_code is {billing_code}")
if billing_code:
self._prompt_wipe_billingcode(billing_code)
return
else:
self._prompt_wipe_billingcode("NONE")
@ -151,7 +151,7 @@ class TicketCryptoPickerPage(Page):
else:
self.update_status.update_status("Cancelled.")
def _prompt_wipe_billingcode(self, temp_billing_code):
def _prompt_wipe_billingcode(self, billing_code):
msg = QMessageBox(self)
msg.setWindowTitle("Existing billing code found")
msg.setText("You already have a ticket billing code. Do you want to use it? Only hit YES if you ALREADY paid.")
@ -161,7 +161,7 @@ class TicketCryptoPickerPage(Page):
result = msg.exec()
if result == QMessageBox.StandardButton.Yes:
self.update_status.update_status("Reusing same code")
self.check_if_paid_for_existing(temp_billing_code)
self.check_if_paid_for_existing(billing_code)
else:
self.bypass_existing = True
delete_ticket_data()

View file

@ -1,18 +1,31 @@
from PyQt6.QtWidgets import QLabel, QListWidget, QListWidgetItem, QPushButton
from PyQt6 import QtCore
from core.controllers.ProfileController import ProfileController
from core.controllers.tickets.UseTicketController import get_unused_tickets
# core
# from core.controllers.tickets.UseTicketController import get_unused_tickets
from core.models.Result import Result, ResultError
# gui
from gui.v2.ui.styles.css.ticket_billing_styles import TICKET_LIST_STYLE_ONE, TICKET_BUTTON_A, USE_BILLING_BUTTON_STYLE
from gui.v2.actions.locations import location_candidates
from gui.v2.actions.menu_tasks import enable_profile
from gui.v2.infrastructure.setup_observers import ticket_observer
from gui.v2.ui.pages.Page import Page
from gui.v2.infrastructure.take_plumbing_action import take_action
from gui.v2.actions.ticket_actions import get_ticket_data, use_a_ticket
# Bring background threads to the main thread UI:
from gui.v2.infrastructure.ThreadSafetyTool import ThreadSafetyTool
mainthread = ThreadSafetyTool()
from PyQt6.QtWidgets import QLabel, QListWidget, QListWidgetItem, QPushButton
from PyQt6 import QtCore
class TicketOrBillingChoicePage(Page):
def __init__(self, page_stack, main_window=None, parent=None):
super().__init__("TicketOrBilling", page_stack, main_window, parent)
print("loading TicketOrBillingChoicePage")
self.update_status = main_window
self.title.setText("Pick a Ticket or Use Billing")
@ -32,86 +45,103 @@ class TicketOrBillingChoicePage(Page):
self.ticket_list = QListWidget(self)
self.ticket_list.setGeometry(60, 140, 380, 320)
self.ticket_list.setStyleSheet("""
QListWidget {
background-color: #000000; color: #00ffff;
border: 2px solid #00ffff; border-radius: 8px;
font-size: 14px;
}
QListWidget::item { padding: 10px; }
QListWidget::item:hover { background-color: #003333; }
QListWidget::item:selected { background-color: #006666; color: white; }
""")
self.ticket_list.setStyleSheet(TICKET_LIST_STYLE_ONE)
self.use_ticket_button = QPushButton("Use Selected Ticket", self)
self.use_ticket_button.setGeometry(60, 470, 380, 40)
self.use_ticket_button.setStyleSheet("""
QPushButton {
background-color: #00aaff; color: white; border: none;
border-radius: 5px; font-size: 14px; font-weight: bold;
}
QPushButton:disabled { background-color: #555555; }
""")
self.use_ticket_button.setStyleSheet(TICKET_BUTTON_A)
self.use_ticket_button.clicked.connect(self.on_use_ticket)
self.use_billing_button = QPushButton("Use a Billing Code Instead", self)
self.use_billing_button.setGeometry(470, 200, 280, 60)
self.use_billing_button.setStyleSheet("""
QPushButton {
background-color: #000000; color: #00ffff;
border: 2px solid #00ffff; border-radius: 10px;
font-size: 14px; font-weight: bold;
}
QPushButton:hover { background-color: #003333; }
""")
self.use_billing_button.setStyleSheet(USE_BILLING_BUTTON_STYLE)
self.use_billing_button.clicked.connect(self.on_use_billing)
self.status_label = QLabel("", self)
self.status_label.setGeometry(20, 520, 760, 20)
self.status_label.setStyleSheet("color: #cccccc; font-size: 12px;")
self.status_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.get_tickets()
def populate(self):
def get_tickets(self):
"""Gets tickets in the background"""
print("doing get_tickets in background")
take_action(
action=get_ticket_data,
ui_caller=self.populate
)
@mainthread
def populate(self, result: Result):
"""Callback to Populate tickets."""
print(f"populate recieved {result}")
self.ticket_list.clear()
try:
unused = get_unused_tickets(ticket_observer)
except Exception as e:
self.status_label.setText(f"Error reading tickets: {e}")
message = result.message
if not result.valid:
self.handle_error(result)
return
if unused.get('valid'):
for t in unused.get('data', []):
item = QListWidgetItem(f"Ticket #{t}")
item.setData(QtCore.Qt.ItemDataRole.UserRole, t)
self.ticket_list.addItem(item)
else:
self.status_label.setText(unused.get('message', 'No tickets available.'))
data = result.data
if not data:
if message:
self.status_label.setText(message) # this is valid state of none
return
# POPULATE
for display_text, ticket_id in result.data:
item = QListWidgetItem(display_text)
item.setData(QtCore.Qt.ItemDataRole.UserRole, ticket_id)
self.ticket_list.addItem(item)
def handle_error(result: Result):
self.status_label.setText("Error")
if message:
self.show_a_popup(message) # this is an error
if result.error_type == ResultError.NAVIGATE_TO:
self.custom_window.navigator.navigate(result.navigate_to)
def on_use_ticket(self):
# 1. Get the visual item the user clicked on
item = self.ticket_list.currentItem()
if not item:
self.status_label.setText("Pick a ticket from the list first.")
return
# 2. This returns the actual ID (e.g., 1), NOT the string "Ticket #1"
which_ticket = item.data(QtCore.Qt.ItemDataRole.UserRole)
profile_id = self.update_status.current_profile_id
try:
profile = ProfileController.get(int(profile_id))
except Exception:
profile = None
candidates = location_candidates(profile)
if not candidates:
self.status_label.setText("Could not determine profile location.")
print(f"which_ticket is {which_ticket}")
profile_id = int(self.update_status.current_profile_id)
print(f"profile_id is {profile_id}")
if not profile_id:
self.update_status.update_status("Can not find the correct profile id")
return
profile_data = {
'id': int(profile_id),
'use_ticket': which_ticket,
'ticket_location': candidates[0]
}
use_a_ticket(profile_id=profile_id, which_ticket=which_ticket, ui_caller=self.recieve_use_result)
self.update_status.update_status(f"Using ticket #{which_ticket}...")
def recieve_use_result(self, result: Result):
"""Recieves the callback from gui_uses_ticket"""
if not result or not isinstance(result, Result):
self.show_a_popup("This is a developer error, the use of the ticket failed to produce a result object")
if not result.valid:
self.update_status.update_status(str(result.message))
return
menu_page = self.custom_window.navigator.get_cached("menu")
if menu_page:
self.update_status.update_status(f"Using ticket #{which_ticket}...")
self.profile_enable_worker = enable_profile(
profile_data, menu_page.handle_task_result, allow_uninstalled_tor=True,
)
self.update_status.update_status("Ticket Use Worked!")
self.custom_window.navigator.navigate("menu")
def on_use_billing(self):
@ -119,3 +149,31 @@ class TicketOrBillingChoicePage(Page):
def reverse(self):
self.custom_window.navigator.navigate("menu")
# def on_use_ticket(self):
# item = self.ticket_list.currentItem()
# if not item:
# self.status_label.setText("Pick a ticket from the list first.")
# return
# which_ticket = item.data(QtCore.Qt.ItemDataRole.UserRole)
# profile_id = self.update_status.current_profile_id
# try:
# profile = ProfileController.get(int(profile_id))
# except Exception:
# profile = None
# candidates = location_candidates(profile)
# if not candidates:
# self.status_label.setText("Could not determine profile location.")
# return
# profile_data = {
# 'id': int(profile_id),
# 'use_ticket': which_ticket,
# 'ticket_location': candidates[0]
# }
# self.profile_enable_worker = enable_profile(
# profile_data, menu_page.handle_task_result, allow_uninstalled_tor=True,
# )

View file

@ -1,11 +1,15 @@
from PyQt6.QtWidgets import QLabel, QPushButton
from PyQt6 import QtCore
from core.models.Result import Result, ResultError
from gui.v2.actions.ticket_actions import start_bulk_ticket_prep
from gui.v2.ui.styles.css.ticket_billing_styles import TICKET_TERMINAL_STYLE, TICKET_TERMINAL_STYLE_SHEET
from gui.v2.infrastructure.setup_observers import ticket_observer
from gui.v2.actions.menu_tasks import enable_profile
from gui.v2.ui.pages.Page import Page
from gui.v2.ui.widgets.terminal_widget import TerminalWidget
from gui.v2.workers.ticketing_worker_thread import TicketingWorkerThread
# from gui.v2.workers.ticketing_worker_thread import TicketingWorkerThread
from PyQt6.QtWidgets import QLabel, QPushButton
from PyQt6 import QtCore
HOW_MANY_PROFILES_DEFAULT = 6
@ -18,7 +22,11 @@ class TicketPrepPage(Page):
self.worker = None
self._terminal_bound = False
self._tickets_ready = False
self.setup_initial_layout()
self.active_prep = False
self.start_prep()
def setup_initial_layout(self):
self.title.setText("Preparing Tickets")
self.title.setGeometry(QtCore.QRect(280, 20, 240, 40))
self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
@ -28,18 +36,12 @@ class TicketPrepPage(Page):
self.status_label = QLabel("", self)
self.status_label.setGeometry(20, 460, 760, 30)
self.status_label.setStyleSheet("color: #00ffff; font-size: 14px;")
self.status_label.setStyleSheet(TICKET_TERMINAL_STYLE_SHEET)
self.status_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.continue_button = QPushButton("Continue", self)
self.continue_button.setGeometry(340, 490, 120, 35)
self.continue_button.setStyleSheet("""
QPushButton {
background-color: #2ecc71; color: white; border: none;
border-radius: 5px; font-size: 14px; font-weight: bold;
}
QPushButton:disabled { background-color: #555555; }
""")
self.continue_button.setStyleSheet(TICKET_TERMINAL_STYLE)
self.continue_button.setEnabled(True)
self.continue_button.clicked.connect(self.on_continue)
@ -61,60 +63,82 @@ class TicketPrepPage(Page):
self.status_label.setText("Preparing tickets...")
self.update_status.update_status("Preparing tickets...")
self.worker = TicketingWorkerThread('PREPARE_TICKETS', params={
'how_many_profiles': HOW_MANY_PROFILES_DEFAULT,
})
self.worker.prep_done.connect(self.on_prep_done)
self.worker.error.connect(self.on_error)
self.worker.start()
if not self.active_prep:
# Actually Start it:
start_bulk_ticket_prep(ui_caller=self.on_prep_done)
self.active_prep = True
def on_prep_done(self, result):
if not isinstance(result, dict):
self.terminal.append("ERROR: invalid result format.")
self.status_label.setText("Preparation failed.")
return
if result.get('valid') is True:
self._tickets_ready = True
self.update_status.clear_ticket_verification_failure()
self.terminal.append("=== Ticket preparation complete ===")
self.status_label.setText("Tickets ready. Click Continue to apply one to your profile.")
self.update_status.update_status("Tickets ready.")
self.continue_button.setEnabled(True)
else:
msg = result.get('message', 'failed')
self.terminal.append(f"ERROR: {msg}")
if msg == 'verification_failed':
how_many_failed = result.get('how_many_failed', '?')
failed_validations = result.get('failed_validations', [])
self.terminal.append(f" failed count: {how_many_failed}")
self.terminal.append(f" failed indices: {failed_validations}")
if self.update_status.save_ticket_verification_failure(result):
self.terminal.append("Recovery data saved. Open Settings > Tickets to evaluate or resume.")
self.status_label.setText("Verification failed. Open Settings > Tickets for recovery.")
self.update_status.update_status("Ticket verification failed")
self.continue_button.setEnabled(True)
return
self.status_label.setText(f"Preparation failed: {msg}")
self.update_status.update_status(f"An error occurred")
self.continue_button.setEnabled(True)
def on_error(self, msg):
self.terminal.append(f"EXCEPTION: {msg}")
self.status_label.setText(f"An unkown error occured")
def on_prep_done(self, result: Result):
print(f"on_prep_done called with {result}")
self.continue_button.setEnabled(True)
if not result or not isinstance(result, Result):
self.terminal.append("Results returned to the graphical display in an invalid format.")
return
if result.valid:
self.terminal.append("=== Ticket preparation complete ===")
self.status_label.setText("Tickets ready. Click Continue to continue to the main page.")
self.update_status.update_status("Tickets ready.")
return
self.terminal.append("Recovery data saved. Open Settings > Tickets to evaluate or resume.")
self.status_label.setText("Verification failed. Open Settings > Tickets for recovery.")
self.update_status.update_status("Ticket verification failed")
def on_continue(self):
menu_page = self.custom_window.navigator.get_cached("menu")
profile_id = getattr(self.update_status, 'current_profile_id', None)
if menu_page:
self.custom_window.navigator.navigate("menu")
if not self._tickets_ready:
self.update_status.update_status("Ticket preparation was not completed.")
return
if profile_id is None or menu_page is None:
self.update_status.update_status("Tickets ready. Random ticket use is ON.")
return
self.update_status.update_status("Applying random ticket to profile...")
self.profile_enable_worker = enable_profile(
{'id': int(profile_id)}, menu_page.handle_task_result, allow_uninstalled_tor=True,
)
self.custom_window.navigator.navigate("menu")
# self.status_label.setText("Preparation failed.")
# if not isinstance(result, dict):
# self.terminal.append("ERROR: invalid result format.")
# return
# if result.get('valid') is True:
# self._tickets_ready = True
# self.update_status.clear_ticket_verification_failure()
# else:
# msg = result.get('message', 'failed')
# self.terminal.append(f"ERROR: {msg}")
# if msg == 'verification_failed':
# how_many_failed = result.get('how_many_failed', '?')
# failed_validations = result.get('failed_validations', [])
# self.terminal.append(f" failed count: {how_many_failed}")
# self.terminal.append(f" failed indices: {failed_validations}")
# if self.update_status.save_ticket_verification_failure(result):
# self.terminal.append("Recovery data saved. Open Settings > Tickets to evaluate or resume.")
# self.status_label.setText("Verification failed. Open Settings > Tickets for recovery.")
# self.update_status.update_status("Ticket verification failed")
# self.continue_button.setEnabled(True)
# return
# self.status_label.setText(f"Preparation failed: {msg}")
# self.update_status.update_status(f"An error occurred")
# self.continue_button.setEnabled(True)
# def on_error(self, msg):
# self.terminal.append(f"EXCEPTION: {msg}")
# self.status_label.setText(f"An unknown error occured")
# self.continue_button.setEnabled(True)
# def on_continue(self):
# menu_page = self.custom_window.navigator.get_cached("menu")
# profile_id = getattr(self.update_status, 'current_profile_id', None)
# if menu_page:
# self.custom_window.navigator.navigate("menu")
# if not self._tickets_ready:
# self.update_status.update_status("Ticket preparation was not completed.")
# return
# if profile_id is None or menu_page is None:
# self.update_status.update_status("Tickets ready. Random ticket use is ON.")
# return
# self.update_status.update_status("Applying random ticket to profile...")
# self.profile_enable_worker = enable_profile(
# {'id': int(profile_id)}, menu_page.handle_task_result, allow_uninstalled_tor=True,
# )

View file

@ -0,0 +1,96 @@
from typing import Optional, Callable, Any
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QApplication
from PyQt6.QtCore import Qt
class ErrorPopup(QDialog):
def __init__(
self,
choice: bool,
title: str,
message: str,
end_app: Optional[bool] = False,
on_answer: Optional[Callable] = None,
passed_result: Optional[Any] = None,
**kwargs
):
super().__init__(**kwargs)
# Protocol state
self.title_text = title
self.on_answer = on_answer
self.passed_result = passed_result
self.end_app = end_app
# Window Configuration
self.setWindowTitle(title)
self.setFixedSize(400, 300)
self.setModal(True)
# Dark Theme Styling (Replacing Kivy Canvas/RoundedRectangle)
self.setStyleSheet("""
QDialog {
background-color: #1a1a1a;
border-radius: 20px;
}
QLabel {
color: #33ff33;
font-size: 24px;
background: transparent;
border: none;
}
QPushButton {
color: white;
font-size: 20px;
border-radius: 10px;
padding: 10px;
min-height: 50px;
}
""")
# Layout setup
main_layout = QVBoxLayout()
main_layout.setContentsMargins(20, 20, 20, 20)
main_layout.setSpacing(20)
# MESSAGE LABEL
self.message_label = QLabel(message)
self.message_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.message_label.setWordWrap(True)
main_layout.addWidget(self.message_label)
if choice:
# BUTTON LAYOUT for Yes/No
button_layout = QHBoxLayout()
button_layout.setSpacing(20)
yes_btn = QPushButton("Yes")
yes_btn.setStyleSheet("background-color: #1b4d1b;")
yes_btn.clicked.connect(lambda: self._choice_response(True))
no_btn = QPushButton("No")
no_btn.setStyleSheet("background-color: #801a1a;")
no_btn.clicked.connect(lambda: self._choice_response(False))
button_layout.addWidget(yes_btn)
button_layout.addWidget(no_btn)
main_layout.addLayout(button_layout)
else:
# OK button
ok_btn = QPushButton("OK")
ok_btn.setStyleSheet("background-color: #1b4d1b;")
ok_btn.clicked.connect(self.accept) # Closes the dialog
main_layout.addWidget(ok_btn)
self.setLayout(main_layout)
def _choice_response(self, user_choice: bool):
if self.on_answer:
self.on_answer(user_choice, self.passed_result)
self.accept()
def _terminate_app(self):
"""Desktop equivalent of App.get_running_app().stop()"""
QApplication.quit()
def show(self):
self.exec()

View file

@ -0,0 +1,14 @@
COLUMN_BUTTON_CSS = """
QPushButton {
background-color: #000000;
color: #00ffff;
border: 2px solid #00ffff;
border-radius: 10px;
font-size: 15px;
font-weight: bold;
padding: 10px;
}
QPushButton:hover { background-color: #003333; }
"""

View file

@ -0,0 +1,14 @@
CREATE_BUTTON_STYLE = """
QPushButton {
background-color: transparent;
border: none;
}
QPushButton:hover {
background-color: rgba(200, 200, 200, 50);
}
QPushButton:checked {
background-color: rgba(200, 200, 200, 80);
}
"""

View file

@ -0,0 +1,57 @@
PAYMENT_STYLE_SHEET = """
QWidget {
color: #cdd6f4;
font-family: 'Segoe UI', Arial, sans-serif;
background-color: transparent; /* Force the main container to be invisible */
}
QLabel {
color: #bac2de;
background-color: transparent; /* Ensure labels don't bring back the color */
}
/* The 'Card' still keeps its color because it's a specific ID */
#InfoCard {
background-color: #313244;
border-radius: 15px;
border: 1px solid #45475a;
}
QLineEdit {
background-color: #181825;
border: 2px solid #45475a;
border-radius: 8px;
padding: 5px;
color: #f5e0dc;
font-weight: bold;
}
QLineEdit:read-only {
color: #a6adc8;
border: 1px solid #585b70;
}
QPushButton {
background-color: #89b4fa;
color: #11111b;
border-radius: 10px;
font-weight: bold;
}
QPushButton:hover {
background-color: #b4befe;
}
QPushButton:disabled {
background-color: #585b70;
color: #7f849c;
}
#StopButton {
background-color: #f38ba8;
}
#StopButton:hover {
background-color: #eba0ac;
}
"""

View file

@ -0,0 +1,41 @@
TICKET_LIST_STYLE_ONE = """
QListWidget {
background-color: #000000; color: #00ffff;
border: 2px solid #00ffff; border-radius: 8px;
font-size: 14px;
}
QListWidget::item { padding: 10px; }
QListWidget::item:hover { background-color: #003333; }
QListWidget::item:selected { background-color: #006666; color: white; }
"""
TICKET_BUTTON_A = """
QPushButton {
background-color: #00aaff; color: white; border: none;
border-radius: 5px; font-size: 14px; font-weight: bold;
}
QPushButton:disabled { background-color: #555555; }
"""
USE_BILLING_BUTTON_STYLE = """
QPushButton {
background-color: #000000; color: #00ffff;
border: 2px solid #00ffff; border-radius: 10px;
font-size: 14px; font-weight: bold;
}
QPushButton:hover { background-color: #003333; }
"""
TICKET_TERMINAL_STYLE = """
QPushButton {
background-color: #2ecc71; color: white; border: none;
border-radius: 5px; font-size: 14px; font-weight: bold;
}
QPushButton:disabled { background-color: #555555; }
"""
TICKET_TERMINAL_STYLE_SHEET = "color: #00ffff; font-size: 14px;"

View file

@ -0,0 +1,56 @@
DURATION_SELECTION_STYLE = """
QComboBox {
font-size: 16px;
padding: 10px;
background-color: #000000;
color: #00ffff;
border: 2px solid #00ffff;
border-radius: 8px;
}
QComboBox::drop-down {
width: 30px;
background-color: #1a1a1a;
border-left: 1px solid #00ffff;
}
QComboBox::down-arrow {
width: 12px;
height: 12px;
color: #00ffff;
}
QComboBox QAbstractItemView {
background-color: #0a0a0a;
border: 1px solid #00ffff;
border-radius: 4px;
selection-background-color: #003333;
selection-color: #00ffff;
}
QComboBox QAbstractItemView::item {
padding: 8px;
border: none;
color: #00ffff;
background-color: #0a0a0a;
}
QComboBox QAbstractItemView::item:hover {
background-color: #1a1a1a;
color: #00ffff;
}
QComboBox QAbstractItemView::item:selected {
background-color: #003333;
color: #00ffff;
}
"""
NEXT_BUTTON_STYLE = """
QPushButton {
font-size: 18px;
padding: 10px;
border: 2px solid #ccc;
border-radius: 10px;
background-color: cyan;
color: black;
font-weight: bold;
}
"""

View file

@ -48,7 +48,7 @@ class TicketingWorkerThread(QThread):
)
self.invoice_ready.emit(result)
elif self.action == 'CHECK_PAID':
result = check_if_paid(self.params['temp_billing_code'], ticket_observer, connection_observer)
result = check_if_paid(self.params['billing_code'], ticket_observer, connection_observer)
if isinstance(result, dict) and result.get('valid') and result.get('payment_status') == 'paid':
self.paid.emit()
elif isinstance(result, dict) and result.get('valid'):