Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cannot catch KeyboardInterrupt on blocking icon.run() call #177

Open
arija-ari opened this issue Nov 14, 2024 · 4 comments
Open

Cannot catch KeyboardInterrupt on blocking icon.run() call #177

arija-ari opened this issue Nov 14, 2024 · 4 comments

Comments

@arija-ari
Copy link

arija-ari commented Nov 14, 2024

Hey :)

I want to use this in a tray-only script, so I attempt to block it. Some extra logic runs on a different thread that must be shutdown properly, so I have to catch ctrl+c, but it doesn't work:

    try:
        icon.run()
    except KeyboardInterrupt:
        print("Catched KeyboardInterrupt...")
    finally:
        print("Shutting down...")

The icon shows up, but hitting ctrl+c will crash icon.run and subsequently the entire process, icon.run does not raise KeyboardInterrupt or allows to continue operation, so all my sibling workers crash, too.

I tried to introduce signals, but icon.run is blocking on the main thread and thus takes precedence when sending the SIGINT. I also tried to spawn icon.run_detached in another thread, but this seems to only work if I have a mainloop in a GUI framework on the main thread, such as Tkinter, but I don't have that, so icon.run() should clearly be my mainloop.

I'm testing this on MacOS.

@arija-ari
Copy link
Author

arija-ari commented Nov 14, 2024

Thanks to #87 I was able to circumvent the issue for me:

from PIL import Image, ImageOps, ImageDraw
import multiprocessing
import threading

icon = None

def gen_dot_img(size, color):
    # generate an image and draw a pattern
    image = Image.new('RGBA', (size, size), (255, 0, 0, 0))
    dc = ImageDraw.Draw(image)
    dc.circle((size // 2, size // 2), size // 5, fill=color)
    return image

def main(shutdown_event):        
    while not shutdown_event.is_set():
        print("Background Worker Running...")

def ui():
    global icon
    import pystray
    icon = pystray.Icon(name, icon=gen_dot_img(64, 'orange'), menu=pystray.Menu(
        pystray.MenuItem(
            text="Exit", action=lambda: icon and icon.stop())
    ))
    icon.run()


if __name__ == "__main__":
    shutdown_event = threading.Event()
    main_thread = threading.Thread(target=lambda: main(shutdown_event))
    main_thread.start()
    ui_process = multiprocessing.Process(target=ui)
    ui_process.start()
    try:
        ui_process.join()
    except KeyboardInterrupt:
        pass
    shutdown_event.set()
    main_thread.join()

So the key was to use a process for the UI instead of the thread, however I can only imagine this bringing serious limitations down the line, because now I would have to tackle process-to-process communication if I want more than a simple exit menu.

Only using a separate process for the icon would allow me to run it detached and wait for it. Now my KeyboardInterrupt is triggered, I can ignore it and send my shutdown event. If I exit from the icon menu, it will just end the ui_process so its join() is satisfied and the shutdown event is also set.

In both cases my main background thread can finally end gracefully. That's not what I should have to do, though, I hope we can find a fix for this, so I can just try: icon.run(). 😊

@arija-ari
Copy link
Author

Okay, so it is working and I have no thread errors when ending my application, but it never stops jumping, lmao.

2024-11-14 15 30 49

@mardukbp
Copy link

I just solved a very similiar problem. I hope my solution helps :)

import subprocess
from threading import Thread
from PIL import Image, ImageDraw
from pystray import Icon, Menu, MenuItem


def gen_dot_img(size, color):
    # generate an image and draw a pattern
    image = Image.new('RGBA', (size, size), (255, 0, 0, 0))
    dc = ImageDraw.Draw(image)
    dc.circle((size // 2, size // 2), size // 5, fill=color)
    return image


class DaemonThread(Thread):
    def __init__(self, icon: Icon, name: str|None=None):
        self.icon = icon
        Thread.__init__(self, daemon=True, name=name, target=icon.run)

    def join(self, timeout: float | None = None) -> None:
        self.icon.stop()
        return super().join(timeout)


def exit_app(icon: Icon, query):
    """
    Callback for exiting KeyTA

    :param icon: The object of the tray icon
    :param query: The text that is displayed on the pressed menu item
    """

    icon.stop()
    process.terminate()


if __name__ == "__main__":
    process = subprocess.Popen("irobot")
    tray_icon = Icon(
        name='TrayIcon',
        icon=gen_dot_img(64, 'orange'),
        menu=Menu(
            MenuItem(
                'Exit',
                exit_app
            )
        )
    )
    icon_thread = DaemonThread(tray_icon)
    icon_thread.start()

    try:
        process.wait()
    except KeyboardInterrupt:
        process.terminate()

I took the idea for the DaemonThread from #99 .

@phpjunkie420
Copy link

You can use pystray.Icon().run_detached(). It will load the Tray Icon into a thread.

From the base Icon class in _base.py . . .

    def run_detached(self, setup=None):
        self._start_setup(setup)
        self._run_detached()

From the Icon class in _win32.py which inherits the _base.Icon class.

    def _run_detached(self):
        threading.Thread(target=lambda: self._run()).start()

_win32.py is a backend that is loaded in the main init.py if Windows is the OS on the computer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants