@@ -920,187 +920,118 @@ def _start_github_login(self, e):
920920 # Force update to show loading dialog
921921 self .app_page .update ()
922922
923- # Start device flow in background (network call)
924-
925- def _init_flow ():
923+ # Start device flow in background (using run_task for async compatibility)
924+ async def _run_login_flow ():
926925 try :
927- flow = AuthService .initiate_device_flow ()
928- if not flow :
929- # Marshal UI updates to main thread
930- # Capture original button state in closure using default parameter to avoid scope issues
931- def _handle_no_flow (orig_text = original_text , orig_icon = original_icon ):
932- self ._close_dialog (loading_dlg )
933- self ._show_snack ("Login init failed" , "RED" )
934- # Restore button state
935- if hasattr (self , 'login_btn' ):
936- if hasattr (self .login_btn , 'text' ):
937- self .login_btn .text = orig_text
938- else :
939- self .login_btn .content = orig_text
940- self .login_btn .icon = orig_icon
941- if self .page :
942- self .login_btn .update ()
943- self ._run_task_with_fallback (_handle_no_flow , error_msg = "Failed to initialize login flow" )
944- return None
945- return flow
946- except Exception as ex :
947- logger .exception (f"Error initiating device flow: { ex } " )
948- # Marshal UI updates to main thread
949- error_msg = f"Failed to initiate login flow: { ex } "
950- # Capture error_msg and original button state in closure using default parameter to avoid scope issues
951- def _handle_error (msg = error_msg , orig_text = original_text , orig_icon = original_icon ):
952- self ._close_dialog (loading_dlg )
953- self ._show_snack (f"Login error: { msg } " , "RED" )
954- # Restore button state
955- if hasattr (self , 'login_btn' ):
956- if hasattr (self .login_btn , 'text' ):
957- self .login_btn .text = orig_text
958- else :
959- self .login_btn .content = orig_text
960- self .login_btn .icon = orig_icon
961- if self .page :
962- self .login_btn .update ()
963- self ._run_task_with_fallback (_handle_error , error_msg = error_msg )
964- return None
965-
966- # Show dialog with flow data on main thread
967- def _show_dialog_with_flow (flow ):
968- if not flow :
969- return
926+ # 1. Init Flow (Network IO)
927+ # We run this in a thread executor if it's synchronous, or directly if async
928+ # AuthService.initiate_device_flow is likely sync (requests), so we wrap it
929+ import asyncio
970930
971- def copy_code (e ):
972- try :
973- import pyperclip
974- pyperclip .copy (flow .get ("user_code" ))
975- except Exception as e :
976- logger .debug (f"Failed to copy user code to clipboard: { e } " )
977- self ._launch_url (flow .get ("verification_uri" ))
931+ logger .debug ("Starting GitHub login flow (Async)..." )
978932
979- btn_copy = ft . TextButton ( "Copy & Open" , on_click = copy_code )
980- btn_cancel = ft . TextButton ( "Cancel" , on_click = lambda e : self . _close_dialog ( dlg ) )
933+ # Helper to run sync blocking IO in a thread executor (safe for async loop )
934+ flow = await asyncio . to_thread ( AuthService . initiate_device_flow )
981935
982- dlg = ft .AlertDialog (
983- title = ft .Text (i18n .get ("github_login" ) or "GitHub Login" ),
984- content = ft .Column ([
985- ft .Text (i18n .get ("please_visit" ) or "Please visit:" ),
986- ft .Text (flow .get ("verification_uri" ), color = "BLUE" , selectable = True ),
987- ft .Text (i18n .get ("and_enter_code" ) or "And enter code:" ),
988- ft .Text (flow .get ("user_code" ), size = 24 , weight = ft .FontWeight .BOLD , selectable = True ),
989- ], height = 150 , scroll = ft .ScrollMode .AUTO ),
990- actions = [btn_copy , btn_cancel ]
991- )
936+ if not flow :
937+ self ._close_dialog (loading_dlg )
938+ self ._show_snack ("Login init failed" , "RED" )
939+ self ._restore_login_button (original_text , original_icon )
940+ return
992941
993- # Close loading dialog first
994- self ._close_dialog (loading_dlg )
942+ # 2. Show Dialog
943+ def copy_code (e ):
944+ try :
945+ import pyperclip
946+ pyperclip .copy (flow .get ("user_code" ))
947+ except Exception as e :
948+ logger .debug (f"Failed to copy user code: { e } " )
949+ self ._launch_url (flow .get ("verification_uri" ))
950+
951+ btn_copy = ft .TextButton ("Copy & Open" , on_click = copy_code )
952+ # We need a way to cancel the polling loop
953+ cancel_event = asyncio .Event ()
954+
955+ def on_cancel (e ):
956+ cancel_event .set ()
957+ self ._close_dialog (dlg )
958+ self ._restore_login_button (original_text , original_icon )
959+
960+ btn_cancel = ft .TextButton ("Cancel" , on_click = on_cancel )
961+
962+ dlg = ft .AlertDialog (
963+ modal = True ,
964+ title = ft .Text (i18n .get ("github_login" ) or "GitHub Login" ),
965+ content = ft .Column ([
966+ ft .Text (i18n .get ("please_visit" ) or "Please visit:" ),
967+ ft .Text (flow .get ("verification_uri" ), color = "BLUE" , selectable = True ),
968+ ft .Text (i18n .get ("and_enter_code" ) or "And enter code:" ),
969+ ft .Text (flow .get ("user_code" ), size = 24 , weight = ft .FontWeight .BOLD , selectable = True ),
970+ ], height = 150 , scroll = ft .ScrollMode .AUTO ),
971+ actions = [btn_copy , btn_cancel ]
972+ )
995973
996- # Show dialog on main thread
997- logger .debug (f"Opening device flow dialog for code { flow .get ('user_code' )} " )
998- logger .info ("Showing GitHub login dialog..." )
974+ self ._close_dialog (loading_dlg )
975+ self ._open_dialog_safe (dlg )
999976
1000- # Use safe opener instead of manual property setting
1001- self ._open_dialog_safe (dlg )
1002- logger .debug ("Device flow dialog opened" )
977+ # 3. Poll for Token (Async Loop)
978+ device_code = flow .get ("device_code" )
979+ interval = flow .get ("interval" , 5 )
980+ expires_in = flow .get ("expires_in" , 900 )
981+ start_time = asyncio .get_event_loop ().time ()
1003982
1004- # Poll for token in background thread
1005- def _poll_token ():
1006- try :
1007- token = AuthService .poll_for_token (flow .get ("device_code" ), flow .get ("interval" ), flow .get ("expires_in" ))
983+ while not cancel_event .is_set ():
984+ # Check timeout
985+ if asyncio .get_event_loop ().time () - start_time > expires_in :
986+ self ._close_dialog (dlg )
987+ self ._show_snack ("Login timed out" , "RED" )
988+ self ._restore_login_button (original_text , original_icon )
989+ return
990+
991+ # Poll
992+ # AuthService.poll_for_token is usually blocking with its own sleep,
993+ # but we want to control the sleep here for cancellation.
994+ # We'll use a modified poll check that strictly checks ONCE.
995+ # Use to_thread to keep UI responsive
996+ token = await asyncio .to_thread (AuthService .check_token_once , device_code )
1008997
1009- # Close dialog and show result on main thread
1010- async def _close_and_result ():
998+ if token :
1011999 self ._close_dialog (dlg )
1012- # Restore button state
1013- if hasattr (self , 'login_btn' ):
1014- self .login_btn .text = original_text
1015- self .login_btn .icon = original_icon
1016- self .login_btn .update ()
1017- if token :
1018- AuthService .save_token (token )
1019- self ._update_sync_ui ()
1020- self ._show_snack (i18n .get ("login_success" ) or "Login Successful!" , "GREEN" )
1021- else :
1022- self ._show_snack (i18n .get ("login_failed" ) or "Login Failed or Timed out" , "RED" )
1000+ AuthService .save_token (token )
1001+ self ._update_sync_ui ()
1002+ self ._show_snack (i18n .get ("login_success" ) or "Login Successful!" , "GREEN" )
1003+ self ._restore_login_button (original_text , original_icon )
1004+ return
10231005
1024- if hasattr (self .app_page , 'run_task' ):
1025- self .app_page .run_task (_close_and_result )
1026- else :
1027- # Fallback: execute synchronously if run_task not available
1028- # Note: This is not ideal but provides backward compatibility
1029- import asyncio
1030- try :
1031- # In a background thread, there's no running loop, so go directly to asyncio.run
1032- asyncio .run (_close_and_result ())
1033- except Exception as e :
1034- logger .warning (f"Failed to run async close_and_result: { e } " , exc_info = True )
1035- # Last resort: try to execute the logic directly
1036- dlg .open = False
1037- self .app_page .update ()
1038- if token :
1039- AuthService .save_token (token )
1040- self ._update_sync_ui ()
1041- self ._show_snack (i18n .get ("login_success" ) or "Login Successful!" , "GREEN" )
1042- else :
1043- self ._show_snack (i18n .get ("login_failed" ) or "Login Failed or Timed out" , "RED" )
1044- except Exception as e :
1045- # Catch all exceptions including KeyboardInterrupt to prevent unhandled thread exceptions
1046- logger .exception (f"Unexpected error in token polling background thread: { e } " )
1006+ # Wait for interval
1007+ try :
1008+ await asyncio .wait_for (cancel_event .wait (), timeout = interval )
1009+ if cancel_event .is_set ():
1010+ logger .info ("Login cancelled by user" )
1011+ return
1012+ except asyncio .TimeoutError :
1013+ continue # Interval passed, poll again
10471014
1048- threading .Thread (target = _poll_token , daemon = True ).start ()
1015+ except Exception as ex :
1016+ logger .exception (f"Error in GitHub Login Flow: { ex } " )
1017+ self ._close_dialog (loading_dlg )
1018+ # Ensure dlg is closed if open
1019+ if 'dlg' in locals ():
1020+ self ._close_dialog (dlg )
10491021
1050- # Start flow initiation in background, then show dialog on main thread
1051- def _flow_complete ():
1052- flow = _init_flow ()
1053- if flow :
1054- # Create a wrapper function that binds the flow argument
1055- # This avoids lambda and ensures proper integration with run_task
1056- def _show_dialog_wrapper ():
1057- _show_dialog_with_flow (flow )
1022+ self ._show_snack (f"Login Error: { ex } " , "RED" )
1023+ self ._restore_login_button (original_text , original_icon )
10581024
1059- def _fallback_show_dialog ():
1060- try :
1061- _show_dialog_with_flow (flow )
1062- except Exception as ex2 :
1063- logger .exception (f"Error showing dialog directly: { ex2 } " )
1064- loading_dlg .open = False
1065- self .app_page .update ()
1066- raise # Re-raise to trigger error handling in helper
1067-
1068- # Use shared helper for run_task with fallback
1069- self ._run_task_with_fallback (
1070- _show_dialog_wrapper ,
1071- fallback_func = _fallback_show_dialog ,
1072- error_msg = "Failed to show login dialog"
1073- )
1025+ self ._run_task_safe (_run_login_flow )
10741026
1075- def _bg_wrapper ():
1076- try :
1077- logger .debug ("Starting GitHub login background task..." )
1078- _flow_complete ()
1079- except Exception as e :
1080- logger .exception (f"CRITICAL ERROR in GitHub Login background thread: { e } " )
1081- # Ensure UI is restored even on critical thread crash
1082- def restore_ui ():
1083- try :
1084- if hasattr (self , 'login_btn' ):
1085- if hasattr (self .login_btn , 'text' ):
1086- self .login_btn .text = original_text
1087- else :
1088- self .login_btn .content = original_text
1089- self .login_btn .icon = original_icon
1090- self .login_btn .update ()
1091- except Exception as ex :
1092- logger .error (f"Failed to restore button state: { ex } " )
1093-
1094- try :
1095- loading_dlg .open = False
1096- self .app_page .update ()
1097- except Exception as ex :
1098- logger .error (f"Failed to close loading dialog: { ex } " )
1099-
1100- self ._show_snack (f"Critical Login Error: { e } " , "RED" )
1101- self ._run_task_safe (restore_ui )
1102-
1103- threading .Thread (target = _bg_wrapper , daemon = True ).start ()
1027+ def _restore_login_button (self , text , icon ):
1028+ if hasattr (self , 'login_btn' ):
1029+ if hasattr (self .login_btn , 'text' ):
1030+ self .login_btn .text = text
1031+ else :
1032+ self .login_btn .content = text
1033+ self .login_btn .icon = icon
1034+ self .login_btn .update ()
11041035
11051036 def _logout_github (self , e ):
11061037 """
0 commit comments