Skip to content

Commit 0c2fa4c

Browse files
committed
small fixes
1 parent a84054a commit 0c2fa4c

File tree

8 files changed

+250
-184
lines changed

8 files changed

+250
-184
lines changed

scripts/build_release.ps1

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,22 @@ if ($Legacy -and $IsWinBuild) {
491491
}
492492
}
493493

494+
# --- 7. CLEANUP / RESTORE SPLASH ---
495+
Write-Host "`nRestoring original Splash Screen..." -ForegroundColor Cyan
496+
try {
497+
$SplashScript = Join-Path $RepoRoot "scripts/generate_splash.py"
498+
if (Test-Path $SplashScript) {
499+
if ($IsWinBuild) {
500+
python $SplashScript --restore
501+
}
502+
else {
503+
python3 $SplashScript --restore
504+
}
505+
}
506+
} catch {
507+
Write-Warning "Failed to restore splash screen: $_"
508+
}
509+
494510
# --- Capture Build End Time and Calculate Duration ---
495511
$BuildEndTime = Get-Date
496512
$BuildEndTimeString = $BuildEndTime.ToString("yyyy-MM-dd HH:mm:ss")

scripts/generate_splash.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,13 +124,42 @@ def generate_splash(version=None):
124124
width=2
125125
)
126126

127+
# Backup original splash if it exists and backup doesn't
128+
backup_path = output_path.with_suffix(".png.bak")
129+
if output_path.exists() and not backup_path.exists():
130+
import shutil
131+
shutil.copy2(output_path, backup_path)
132+
print(f"Backed up original splash to: {backup_path}")
133+
127134
# Save
128135
img.save(output_path)
129136
print(f"Splash screen generated at: {output_path}")
130137

138+
def restore_splash():
139+
base_dir = Path(__file__).resolve().parent.parent
140+
splash_path = base_dir / "src" / "switchcraft" / "assets" / "splash.png"
141+
backup_path = splash_path.with_suffix(".png.bak")
142+
143+
if backup_path.exists():
144+
import shutil
145+
shutil.copy2(backup_path, splash_path)
146+
print(f"Restored original splash from: {backup_path}")
147+
# Optional: Delete backup? User might want to keep it safe.
148+
# But if we want to ensure 'git status' is clean, we should keep the original 'splash.png'
149+
# intact. The backup file is untracked usually.
150+
# Let's remove the backup to keep folder clean.
151+
os.remove(backup_path)
152+
print("Removed backup file.")
153+
else:
154+
print("No backup found to restore.")
155+
131156
if __name__ == "__main__":
132157
parser = argparse.ArgumentParser()
133158
parser.add_argument("--version", help="Version string to display", default=None)
159+
parser.add_argument("--restore", help="Restore original splash screen", action="store_true")
134160
args = parser.parse_args()
135161

136-
generate_splash(args.version)
162+
if args.restore:
163+
restore_splash()
164+
else:
165+
generate_splash(args.version)

src/switchcraft/assets/splash.png

-2.78 KB
Loading

src/switchcraft/gui_modern/views/settings_view.py

Lines changed: 97 additions & 166 deletions
Original file line numberDiff line numberDiff line change
@@ -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
"""

src/switchcraft/services/auth_service.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,45 @@ def initiate_device_flow(cls) -> Optional[Dict[str, Any]]:
4545
logger.error(f"Failed to initiate device flow: {e}")
4646
return None
4747

48+
@classmethod
49+
def check_token_once(cls, device_code: str) -> Optional[str]:
50+
"""
51+
Performs a single check for the access token.
52+
Returns token if successful, None otherwise.
53+
Used by async loops that manage their own sleeping.
54+
"""
55+
headers = {"Accept": "application/json"}
56+
data = {
57+
"client_id": cls.CLIENT_ID,
58+
"device_code": device_code,
59+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code"
60+
}
61+
62+
try:
63+
response = requests.post(cls.TOKEN_URL, headers=headers, data=data, timeout=10)
64+
response.raise_for_status()
65+
resp_data = response.json()
66+
67+
if "access_token" in resp_data:
68+
return resp_data["access_token"]
69+
70+
error = resp_data.get("error")
71+
if error in ["authorization_pending", "slow_down"]:
72+
return None
73+
elif error == "expired_token":
74+
logger.error("Device code expired.")
75+
return None
76+
elif error == "access_denied":
77+
logger.error("User denied access.")
78+
return None
79+
else:
80+
logger.debug(f"Polling error: {error}")
81+
return None
82+
83+
except Exception as e:
84+
logger.debug(f"Token check failed (network): {e}")
85+
return None
86+
4887
@classmethod
4988
def poll_for_token(cls, device_code: str, interval: int = 5, expires_in: int = 900) -> Optional[str]:
5089
"""

0 commit comments

Comments
 (0)