From f0396b2b478f2761b034921e66fc7f6160bd0fe8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 19:51:05 +0000 Subject: [PATCH 1/8] Initial plan From 3864df4152ea812aed96ed4b0b74961f75a5db5e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 19:55:20 +0000 Subject: [PATCH 2/8] Fix Windows tray icon thread safety issues - Pass tkinter_root to Windows tray implementation - Schedule all callbacks on main Tkinter thread using root.after() - Fixes issue where quit and show window didn't work from tray menu - Ensures thread-safe UI updates from tray icon events Co-authored-by: bryfur <7673964+bryfur@users.noreply.github.com> --- wow_sync/tray/__init__.py | 7 +++++-- wow_sync/tray/tray_windows.py | 35 ++++++++++++++++++++++++++++------- wow_sync/ui/main_window.py | 3 ++- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/wow_sync/tray/__init__.py b/wow_sync/tray/__init__.py index 6f788e4..f504525 100644 --- a/wow_sync/tray/__init__.py +++ b/wow_sync/tray/__init__.py @@ -22,12 +22,14 @@ def __init__(self, on_show: Optional[Callable] = None, on_quit: Optional[Callable] = None, on_pull: Optional[Callable] = None, on_push: Optional[Callable] = None, - on_toggle_monitor: Optional[Callable] = None): + on_toggle_monitor: Optional[Callable] = None, + tkinter_root = None): self.on_show = on_show self.on_quit = on_quit self.on_pull = on_pull self.on_push = on_push self.on_toggle_monitor = on_toggle_monitor + self.tkinter_root = tkinter_root self._impl = None self._impl_type = None @@ -54,7 +56,8 @@ async def setup(self): from .tray_windows import WindowsTrayImpl self._impl = WindowsTrayImpl( self.on_show, self.on_quit, self.on_pull, - self.on_push, self.on_toggle_monitor + self.on_push, self.on_toggle_monitor, + self.tkinter_root ) await self._impl.setup() self._impl_type = "windows" diff --git a/wow_sync/tray/tray_windows.py b/wow_sync/tray/tray_windows.py index 0e7b840..543f84d 100644 --- a/wow_sync/tray/tray_windows.py +++ b/wow_sync/tray/tray_windows.py @@ -33,12 +33,14 @@ def __init__(self, on_show: Optional[Callable] = None, on_quit: Optional[Callable] = None, on_pull: Optional[Callable] = None, on_push: Optional[Callable] = None, - on_toggle_monitor: Optional[Callable] = None): + on_toggle_monitor: Optional[Callable] = None, + tkinter_root = None): self.on_show = on_show self.on_quit = on_quit self.on_pull = on_pull self.on_push = on_push self.on_toggle_monitor = on_toggle_monitor + self.tkinter_root = tkinter_root self.hwnd = None self.menu = None @@ -126,7 +128,10 @@ def _wnd_proc(self, hwnd, msg, wparam, lparam): elif lparam == win32con.WM_LBUTTONDBLCLK: # Double-click - show window if self.on_show: - self.on_show() + if self.tkinter_root: + self.tkinter_root.after(0, self.on_show) + else: + self.on_show() return 0 elif msg == win32con.WM_COMMAND: # Menu item selected @@ -181,21 +186,37 @@ def _show_menu(self): def _handle_menu_command(self, menu_id): """Handle menu item selection.""" + # Schedule callbacks on the Tkinter main thread to avoid threading issues if menu_id == 1001: # Show Window if self.on_show: - self.on_show() + if self.tkinter_root: + self.tkinter_root.after(0, self.on_show) + else: + self.on_show() elif menu_id == 1002: # Pull from Remote if self.on_pull: - self.on_pull() + if self.tkinter_root: + self.tkinter_root.after(0, self.on_pull) + else: + self.on_pull() elif menu_id == 1003: # Push to Remote if self.on_push: - self.on_push() + if self.tkinter_root: + self.tkinter_root.after(0, self.on_push) + else: + self.on_push() elif menu_id == 1004: # Toggle Monitor if self.on_toggle_monitor: - self.on_toggle_monitor() + if self.tkinter_root: + self.tkinter_root.after(0, self.on_toggle_monitor) + else: + self.on_toggle_monitor() elif menu_id == 1005: # Quit if self.on_quit: - self.on_quit() + if self.tkinter_root: + self.tkinter_root.after(0, self.on_quit) + else: + self.on_quit() def update_monitor_menu(self, is_enabled: bool): """Update the auto-sync menu item label.""" diff --git a/wow_sync/ui/main_window.py b/wow_sync/ui/main_window.py index f15ea56..7bf3772 100644 --- a/wow_sync/ui/main_window.py +++ b/wow_sync/ui/main_window.py @@ -164,7 +164,8 @@ async def _setup_tray(self): on_quit=self.quit_app, on_pull=self._pull_from_remote, on_push=self._push_to_remote, - on_toggle_monitor=self._tray_toggle_monitor + on_toggle_monitor=self._tray_toggle_monitor, + tkinter_root=self.root ) success, message = await self.tray_icon.setup() From ca0528365e933d964097b0bd4b2acd9d7d43019b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 19:57:11 +0000 Subject: [PATCH 3/8] Add type annotations for tkinter_root parameter Co-authored-by: bryfur <7673964+bryfur@users.noreply.github.com> --- wow_sync/tray/__init__.py | 4 ++-- wow_sync/tray/tray_windows.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/wow_sync/tray/__init__.py b/wow_sync/tray/__init__.py index f504525..6075c1b 100644 --- a/wow_sync/tray/__init__.py +++ b/wow_sync/tray/__init__.py @@ -9,7 +9,7 @@ Each implementation provides native OS integration for the best user experience. """ -from typing import Optional, Callable +from typing import Optional, Callable, Any import asyncio import sys from threading import Thread @@ -23,7 +23,7 @@ def __init__(self, on_show: Optional[Callable] = None, on_pull: Optional[Callable] = None, on_push: Optional[Callable] = None, on_toggle_monitor: Optional[Callable] = None, - tkinter_root = None): + tkinter_root: Optional[Any] = None): self.on_show = on_show self.on_quit = on_quit self.on_pull = on_pull diff --git a/wow_sync/tray/tray_windows.py b/wow_sync/tray/tray_windows.py index 543f84d..ba34e2e 100644 --- a/wow_sync/tray/tray_windows.py +++ b/wow_sync/tray/tray_windows.py @@ -5,7 +5,7 @@ system tray icon integration. """ -from typing import Optional, Callable +from typing import Optional, Callable, Any import asyncio from pathlib import Path import threading @@ -34,7 +34,7 @@ def __init__(self, on_show: Optional[Callable] = None, on_pull: Optional[Callable] = None, on_push: Optional[Callable] = None, on_toggle_monitor: Optional[Callable] = None, - tkinter_root = None): + tkinter_root: Optional[Any] = None): self.on_show = on_show self.on_quit = on_quit self.on_pull = on_pull From b31f84bdb722ce110108ffa7da276d57954e764a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 19:58:34 +0000 Subject: [PATCH 4/8] Refactor callback scheduling to reduce code duplication - Extract common pattern into _schedule_callback helper method - Improves code maintainability and readability Co-authored-by: bryfur <7673964+bryfur@users.noreply.github.com> --- wow_sync/tray/tray_windows.py | 44 +++++++++++------------------------ 1 file changed, 14 insertions(+), 30 deletions(-) diff --git a/wow_sync/tray/tray_windows.py b/wow_sync/tray/tray_windows.py index ba34e2e..918a794 100644 --- a/wow_sync/tray/tray_windows.py +++ b/wow_sync/tray/tray_windows.py @@ -66,6 +66,14 @@ async def setup(self): # Small delay to ensure everything is initialized await asyncio.sleep(0.2) + def _schedule_callback(self, callback: Callable): + """Schedule a callback on the main Tkinter thread if available.""" + if callback: + if self.tkinter_root: + self.tkinter_root.after(0, callback) + else: + callback() + def _create_tray(self, icon_path): """Create the tray icon (runs in separate thread).""" try: @@ -127,11 +135,7 @@ def _wnd_proc(self, hwnd, msg, wparam, lparam): self._show_menu() elif lparam == win32con.WM_LBUTTONDBLCLK: # Double-click - show window - if self.on_show: - if self.tkinter_root: - self.tkinter_root.after(0, self.on_show) - else: - self.on_show() + self._schedule_callback(self.on_show) return 0 elif msg == win32con.WM_COMMAND: # Menu item selected @@ -188,35 +192,15 @@ def _handle_menu_command(self, menu_id): """Handle menu item selection.""" # Schedule callbacks on the Tkinter main thread to avoid threading issues if menu_id == 1001: # Show Window - if self.on_show: - if self.tkinter_root: - self.tkinter_root.after(0, self.on_show) - else: - self.on_show() + self._schedule_callback(self.on_show) elif menu_id == 1002: # Pull from Remote - if self.on_pull: - if self.tkinter_root: - self.tkinter_root.after(0, self.on_pull) - else: - self.on_pull() + self._schedule_callback(self.on_pull) elif menu_id == 1003: # Push to Remote - if self.on_push: - if self.tkinter_root: - self.tkinter_root.after(0, self.on_push) - else: - self.on_push() + self._schedule_callback(self.on_push) elif menu_id == 1004: # Toggle Monitor - if self.on_toggle_monitor: - if self.tkinter_root: - self.tkinter_root.after(0, self.on_toggle_monitor) - else: - self.on_toggle_monitor() + self._schedule_callback(self.on_toggle_monitor) elif menu_id == 1005: # Quit - if self.on_quit: - if self.tkinter_root: - self.tkinter_root.after(0, self.on_quit) - else: - self.on_quit() + self._schedule_callback(self.on_quit) def update_monitor_menu(self, is_enabled: bool): """Update the auto-sync menu item label.""" From eb12bfa310c4223024cfdde27bb01d1b2e9f6347 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 19:59:29 +0000 Subject: [PATCH 5/8] Fix type annotation for _schedule_callback to accept Optional[Callable] Co-authored-by: bryfur <7673964+bryfur@users.noreply.github.com> --- wow_sync/tray/tray_windows.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wow_sync/tray/tray_windows.py b/wow_sync/tray/tray_windows.py index 918a794..b072a94 100644 --- a/wow_sync/tray/tray_windows.py +++ b/wow_sync/tray/tray_windows.py @@ -66,7 +66,7 @@ async def setup(self): # Small delay to ensure everything is initialized await asyncio.sleep(0.2) - def _schedule_callback(self, callback: Callable): + def _schedule_callback(self, callback: Optional[Callable]): """Schedule a callback on the main Tkinter thread if available.""" if callback: if self.tkinter_root: From 9eeba2e12b6a444445ed7acb33e2aca9fdf2d2d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 20:27:17 +0000 Subject: [PATCH 6/8] Refactor: wrap callbacks with root.after() at call site instead of in tray implementations - Remove tkinter_root parameter from TrayIcon and WindowsTrayImpl - Wrap all callbacks with lambda: root.after(0, callback) in MainWindow._setup_tray() - This keeps tray implementations simpler and platform-agnostic - Thread safety is now handled at the callback registration point Co-authored-by: bryfur <7673964+bryfur@users.noreply.github.com> --- wow_sync/tray/__init__.py | 9 +++------ wow_sync/tray/tray_windows.py | 33 ++++++++++++++------------------- wow_sync/ui/main_window.py | 14 ++++++++------ 3 files changed, 25 insertions(+), 31 deletions(-) diff --git a/wow_sync/tray/__init__.py b/wow_sync/tray/__init__.py index 6075c1b..6f788e4 100644 --- a/wow_sync/tray/__init__.py +++ b/wow_sync/tray/__init__.py @@ -9,7 +9,7 @@ Each implementation provides native OS integration for the best user experience. """ -from typing import Optional, Callable, Any +from typing import Optional, Callable import asyncio import sys from threading import Thread @@ -22,14 +22,12 @@ def __init__(self, on_show: Optional[Callable] = None, on_quit: Optional[Callable] = None, on_pull: Optional[Callable] = None, on_push: Optional[Callable] = None, - on_toggle_monitor: Optional[Callable] = None, - tkinter_root: Optional[Any] = None): + on_toggle_monitor: Optional[Callable] = None): self.on_show = on_show self.on_quit = on_quit self.on_pull = on_pull self.on_push = on_push self.on_toggle_monitor = on_toggle_monitor - self.tkinter_root = tkinter_root self._impl = None self._impl_type = None @@ -56,8 +54,7 @@ async def setup(self): from .tray_windows import WindowsTrayImpl self._impl = WindowsTrayImpl( self.on_show, self.on_quit, self.on_pull, - self.on_push, self.on_toggle_monitor, - self.tkinter_root + self.on_push, self.on_toggle_monitor ) await self._impl.setup() self._impl_type = "windows" diff --git a/wow_sync/tray/tray_windows.py b/wow_sync/tray/tray_windows.py index b072a94..0e7b840 100644 --- a/wow_sync/tray/tray_windows.py +++ b/wow_sync/tray/tray_windows.py @@ -5,7 +5,7 @@ system tray icon integration. """ -from typing import Optional, Callable, Any +from typing import Optional, Callable import asyncio from pathlib import Path import threading @@ -33,14 +33,12 @@ def __init__(self, on_show: Optional[Callable] = None, on_quit: Optional[Callable] = None, on_pull: Optional[Callable] = None, on_push: Optional[Callable] = None, - on_toggle_monitor: Optional[Callable] = None, - tkinter_root: Optional[Any] = None): + on_toggle_monitor: Optional[Callable] = None): self.on_show = on_show self.on_quit = on_quit self.on_pull = on_pull self.on_push = on_push self.on_toggle_monitor = on_toggle_monitor - self.tkinter_root = tkinter_root self.hwnd = None self.menu = None @@ -66,14 +64,6 @@ async def setup(self): # Small delay to ensure everything is initialized await asyncio.sleep(0.2) - def _schedule_callback(self, callback: Optional[Callable]): - """Schedule a callback on the main Tkinter thread if available.""" - if callback: - if self.tkinter_root: - self.tkinter_root.after(0, callback) - else: - callback() - def _create_tray(self, icon_path): """Create the tray icon (runs in separate thread).""" try: @@ -135,7 +125,8 @@ def _wnd_proc(self, hwnd, msg, wparam, lparam): self._show_menu() elif lparam == win32con.WM_LBUTTONDBLCLK: # Double-click - show window - self._schedule_callback(self.on_show) + if self.on_show: + self.on_show() return 0 elif msg == win32con.WM_COMMAND: # Menu item selected @@ -190,17 +181,21 @@ def _show_menu(self): def _handle_menu_command(self, menu_id): """Handle menu item selection.""" - # Schedule callbacks on the Tkinter main thread to avoid threading issues if menu_id == 1001: # Show Window - self._schedule_callback(self.on_show) + if self.on_show: + self.on_show() elif menu_id == 1002: # Pull from Remote - self._schedule_callback(self.on_pull) + if self.on_pull: + self.on_pull() elif menu_id == 1003: # Push to Remote - self._schedule_callback(self.on_push) + if self.on_push: + self.on_push() elif menu_id == 1004: # Toggle Monitor - self._schedule_callback(self.on_toggle_monitor) + if self.on_toggle_monitor: + self.on_toggle_monitor() elif menu_id == 1005: # Quit - self._schedule_callback(self.on_quit) + if self.on_quit: + self.on_quit() def update_monitor_menu(self, is_enabled: bool): """Update the auto-sync menu item label.""" diff --git a/wow_sync/ui/main_window.py b/wow_sync/ui/main_window.py index 7bf3772..3e4b576 100644 --- a/wow_sync/ui/main_window.py +++ b/wow_sync/ui/main_window.py @@ -159,13 +159,15 @@ def _create_widgets(self): async def _setup_tray(self): """Setup system tray icon with automatic fallback between implementations.""" try: + # Wrap callbacks with root.after() to ensure they run on the main Tkinter thread + # This is necessary because tray implementations (especially Windows) may invoke + # callbacks from different threads, and Tkinter is not thread-safe self.tray_icon = TrayIcon( - on_show=self._show_window, - on_quit=self.quit_app, - on_pull=self._pull_from_remote, - on_push=self._push_to_remote, - on_toggle_monitor=self._tray_toggle_monitor, - tkinter_root=self.root + on_show=lambda: self.root.after(0, self._show_window), + on_quit=lambda: self.root.after(0, self.quit_app), + on_pull=lambda: self.root.after(0, self._pull_from_remote), + on_push=lambda: self.root.after(0, self._push_to_remote), + on_toggle_monitor=lambda: self.root.after(0, self._tray_toggle_monitor) ) success, message = await self.tray_icon.setup() From 3e08d416e660627ff6e178cdc70eefa77e339d51 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 20:35:18 +0000 Subject: [PATCH 7/8] Simplify macOS tray implementation by removing redundant thread marshalling - Remove performSelectorOnMainThread calls since callbacks are already wrapped with root.after() - Call setupStatusBar and updateMonitorMenuTitle_ directly - already on main thread via async_mainloop - Reduce setup delay from 0.3s to 0.1s - Update documentation to reflect simplified implementation Co-authored-by: bryfur <7673964+bryfur@users.noreply.github.com> --- wow_sync/tray/tray_macos.py | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/wow_sync/tray/tray_macos.py b/wow_sync/tray/tray_macos.py index 5462379..e0076bc 100644 --- a/wow_sync/tray/tray_macos.py +++ b/wow_sync/tray/tray_macos.py @@ -2,8 +2,7 @@ macOS native implementation using PyObjC. Uses PyObjC to directly access macOS NSStatusBar APIs for native -status bar icon integration. This integrates with the main thread -using performSelectorOnMainThread to avoid threading issues. +status bar icon integration. """ from typing import Optional, Callable @@ -200,32 +199,21 @@ async def setup(self): str(icon_path), callbacks ) - # Setup status bar on main thread - # performSelectorOnMainThread doesn't return the selector's result, - # so we call it and then check if status_item was created - self.controller.performSelectorOnMainThread_withObject_waitUntilDone_( - objc.selector(self.controller.setupStatusBar, signature=b'B@:'), - None, - True # Wait until done - ) + # Setup status bar directly - we're already on the main thread via async_mainloop + success = self.controller.setupStatusBar() - # Check if setup was successful by verifying status_item exists - if not self.controller.status_item: + if not success: raise RuntimeError("Failed to setup status bar") - # Longer delay to ensure the menu is fully initialized - await asyncio.sleep(0.3) + # Small delay to ensure the menu is fully initialized + await asyncio.sleep(0.1) def update_monitor_menu(self, is_enabled: bool): """Update the auto-sync menu item label.""" if self.controller and self.controller.monitor_menu_item: label = "Disable Auto-Sync" if is_enabled else "Enable Auto-Sync" - # Update on main thread - self.controller.performSelectorOnMainThread_withObject_waitUntilDone_( - objc.selector(self.controller.updateMonitorMenuTitle_, signature=b'v@:@'), - label, - False # Don't wait - async update is fine - ) + # Update directly - we're already on the main thread + self.controller.updateMonitorMenuTitle_(label) def cleanup(self): """Cleanup tray icon.""" From 01f76fce5ff5cac6f53f260b25a1cc8e6f2fa750 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 20:36:30 +0000 Subject: [PATCH 8/8] Improve comments explaining main thread execution context Co-authored-by: bryfur <7673964+bryfur@users.noreply.github.com> --- wow_sync/tray/tray_macos.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/wow_sync/tray/tray_macos.py b/wow_sync/tray/tray_macos.py index e0076bc..ad84c5d 100644 --- a/wow_sync/tray/tray_macos.py +++ b/wow_sync/tray/tray_macos.py @@ -199,7 +199,10 @@ async def setup(self): str(icon_path), callbacks ) - # Setup status bar directly - we're already on the main thread via async_mainloop + # Setup status bar directly + # This is called from async_mainloop which runs on the main thread, + # satisfying AppKit's requirement for UI operations on the main thread. + # Callbacks are already wrapped with root.after() for thread safety. success = self.controller.setupStatusBar() if not success: @@ -212,7 +215,7 @@ def update_monitor_menu(self, is_enabled: bool): """Update the auto-sync menu item label.""" if self.controller and self.controller.monitor_menu_item: label = "Disable Auto-Sync" if is_enabled else "Enable Auto-Sync" - # Update directly - we're already on the main thread + # Update directly - called from MainWindow which is on the main thread self.controller.updateMonitorMenuTitle_(label) def cleanup(self):