Skip to content

Commit 540e731

Browse files
RMANOVclaude
andcommitted
fix(thread-safety): move all tkinter ops to main thread — eliminate Tcl_Panic crash
Tcl 9.0 (Python 3.14) enforces strict thread safety — creating Tk instances from non-main threads triggers Tcl_Panic → SIGILL. Restructured the architecture: main thread owns the Tk event loop, evdev thread communicates via queue.Queue. - Popup uses Toplevel(master) instead of standalone Tk() — single interpreter - Launched apps run in their own session (start_new_session=True) - Auto-daemonize when no terminal attached (logs to ~/.cache/launcher.log) - KDE autostart desktop entry for console-free operation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f85aaa5 commit 540e731

1 file changed

Lines changed: 47 additions & 22 deletions

File tree

launcher.pyw

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Install: pip install pynput pywin32
1616

1717
import json
1818
import os
19+
import queue
1920
import sys
2021
import threading
2122
import time
@@ -464,9 +465,12 @@ def launch_item(item: LaunchItem):
464465
if item.item_type == 'document':
465466
subprocess.Popen(['xdg-open', item.path])
466467
else:
467-
subprocess.Popen([item.path] + item.args)
468+
subprocess.Popen(
469+
[item.path] + item.args,
470+
start_new_session=True,
471+
)
468472
except Exception as e:
469-
print(f"Error launching {item.name}: {e}")
473+
print(f"Error launching {item.name}: {e}", flush=True)
470474

471475

472476
# ============== Clipboard ==============
@@ -835,25 +839,26 @@ class LauncherPopup:
835839
SEPARATOR_COLOR = "#3c3c46"
836840

837841
def __init__(self, position: tuple, config: Config, usage_tracker: UsageTracker,
838-
clipboard_manager: ClipboardManager, mouse_listener=None):
842+
clipboard_manager: ClipboardManager, mouse_listener=None, master=None):
839843
self.config = config
840844
self.usage_tracker = usage_tracker
841845
self.clipboard = clipboard_manager
842846
self.position = position
843847
self._mouse_listener = mouse_listener
848+
self._master = master
844849
self._shown_time = 0.0
845850
self._last_checked_press = 0.0
846851
self._tkinter_click_time = 0.0
847852

848-
self.root: Optional[tk.Tk] = None
853+
self.root: Optional[tk.Toplevel] = None
849854
self.shortcut_num = 1
850855
self._numbered_items: List[LaunchItem] = []
851856
self.clipboard_search_var: Optional[tk.StringVar] = None
852857
self.clipboard_frame: Optional[tk.Frame] = None
853858

854859
def show(self):
855860
"""Show the popup window"""
856-
self.root = tk.Tk()
861+
self.root = tk.Toplevel(self._master)
857862
self.root.title("Launcher")
858863

859864
# Remove window decorations
@@ -925,9 +930,6 @@ class LauncherPopup:
925930
self._last_checked_press = 0.0
926931
self.root.after(100, self._check_click_outside)
927932

928-
# Start main loop
929-
self.root.mainloop()
930-
931933
def _build_content(self, parent: ttk.Frame):
932934
"""Build the popup content"""
933935
self.shortcut_num = 1
@@ -1323,6 +1325,8 @@ class Launcher:
13231325
self.usage_tracker = UsageTracker()
13241326
self.clipboard = ClipboardManager(self.config.max_clipboard_history)
13251327
self.popup: Optional[LauncherPopup] = None
1328+
self._trigger_queue: queue.Queue = queue.Queue()
1329+
self._root: Optional[tk.Tk] = None
13261330

13271331
ListenerClass = EvdevMouseListener if _INPUT_BACKEND == 'evdev' else MouseInputListener
13281332
self.input_listener = ListenerClass(
@@ -1333,18 +1337,28 @@ class Launcher:
13331337
print(f"Input backend: {_INPUT_BACKEND}")
13341338

13351339
def _on_trigger(self, position: tuple):
1336-
"""Handle trigger event"""
1337-
print(f"Trigger at {position}")
1340+
"""Called from evdev thread — post to main thread via queue."""
1341+
self._trigger_queue.put(position)
1342+
1343+
def _poll_triggers(self):
1344+
"""Poll trigger queue on main thread."""
1345+
try:
1346+
while True:
1347+
pos = self._trigger_queue.get_nowait()
1348+
self._handle_trigger(pos)
1349+
except queue.Empty:
1350+
pass
1351+
if self._root:
1352+
self._root.after(50, self._poll_triggers)
13381353

1339-
# Close existing popup if any
1354+
def _handle_trigger(self, position: tuple):
1355+
"""Handle trigger on main thread — safe for tkinter."""
1356+
print(f"Trigger at {position}")
13401357
if self.popup and self.popup.root:
13411358
self.popup.close()
1342-
1343-
# Show new popup
1344-
self.popup = LauncherPopup(position, self.config, self.usage_tracker, self.clipboard, self.input_listener)
1345-
1346-
# Run in thread to not block input listener
1347-
threading.Thread(target=self.popup.show, daemon=True).start()
1359+
self.popup = LauncherPopup(position, self.config, self.usage_tracker,
1360+
self.clipboard, self.input_listener, self._root)
1361+
self.popup.show()
13481362

13491363
def run(self):
13501364
"""Start the launcher"""
@@ -1355,15 +1369,26 @@ class Launcher:
13551369
_write_helper_scripts()
13561370
self.input_listener.start()
13571371

1358-
try:
1359-
# Keep main thread alive
1360-
while True:
1361-
time.sleep(1)
1362-
except KeyboardInterrupt:
1372+
import signal
1373+
self._root = tk.Tk()
1374+
self._root.withdraw()
1375+
1376+
def on_sigint(sig, frame):
13631377
print("\nShutting down...")
13641378
self.input_listener.stop()
1379+
self._root.quit()
1380+
1381+
signal.signal(signal.SIGINT, on_sigint)
1382+
self._root.after(50, self._poll_triggers)
1383+
self._root.mainloop()
13651384

13661385

13671386
if __name__ == "__main__":
1387+
# Daemonize: redirect output to log when not on a terminal
1388+
if not sys.stdout.isatty():
1389+
_log = os.path.expanduser("~/.cache/launcher.log")
1390+
_f = open(_log, "a")
1391+
sys.stdout = sys.stderr = _f
1392+
13681393
launcher = Launcher()
13691394
launcher.run()

0 commit comments

Comments
 (0)