Search code examples
pythonwindowstkinterpystray

Open the running process in the background (systray) with the pinned Taskbar app using Python & Windows


I've been trying to implement some Windows native functionality using tkinter, pystray and threading and some extra needed libraries unsuccessfully.

I'm trying to achieve this functionality:

  1. The systray icon, this is already done, mostly managed in run_tray_icon
  2. The process should not open again once it's running (there should never be two processes of this program at the same time because they can conflict). For this, I've implemented an "app lock" functionality, handling all possible exits to remove the app lock with handle_exit, still sometimes I find that some processes are never closed and end running
  3. I'm using some Queues in process_gui_queuebecause I feel that without them, some processes end in the background.
  4. And finally, and the most difficult part, I have found no documentation so far on how to accomplish: Windows has the possibility of pinning apps to the task bar. If the process is running in the background, thanks to threading and hidden in try with pystray, if I click the pinned taskbar icon, it tries to open a new process, instead of doing a deiconify.

The standard behavior of a task bar pinned icon, is a process run. Basically, I should be able to access the running process, to run the root.deiconify but I don't have a clue on how I can achieve this feature.

This is the whole code I've been using for testing purposes, I have decided not to omit anything because I feel that everything is intertwined.

import os
import sys
import atexit
import signal
from pystray import Icon, Menu, MenuItem
from PIL import Image, ImageDraw
import threading
import tkinter as tk
from queue import Queue
from ctypes import windll

LOCK_FILE = 'app.lock'
exit_flag = False
gui_queue = Queue()

def remove_lock_file():
    """Remove the lock file on exit."""
    if os.path.exists(LOCK_FILE):
        os.remove(LOCK_FILE)

def is_already_running():
    """Check if the application is already running."""
    if os.path.exists(LOCK_FILE):
        return True
    else:
        with open(LOCK_FILE, 'w') as f:
            f.write(str(os.getpid()))
        return False

def handle_exit(signum=None, frame=None):
    """Handle exit signals."""
    global exit_flag
    exit_flag = True
    remove_lock_file()
    
# To ensure that Lock File is always removed we register special exit signal handlers
# SIGINT is sent when the user presses Ctrl+C
signal.signal(signal.SIGINT, handle_exit)
# SIGTERM is sent when the system is shutting down
signal.signal(signal.SIGTERM, handle_exit)
# SIGTERM is sent when the system is shutting down
signal.signal(signal.SIGBREAK, handle_exit)

def run_tray_icon(root):
    def show_window():
        gui_queue.put(lambda: root.deiconify())

    def hide_window():
        gui_queue.put(lambda: root.withdraw())

    def exit_application(icon, item):
        handle_exit()
        icon.stop()  # Stop the icon loop

    menu = Menu(
        MenuItem('Show', show_window),
        MenuItem('Hide', hide_window),
        MenuItem('Exit', exit_application)
    )
    
    def create_image(width, height, color1, color2):
        # Generate an image and draw a pattern
        image = Image.new('RGB', (width, height), color1)
        dc = ImageDraw.Draw(image)
        dc.rectangle(
            (width // 2, 0, width, height // 2),
            fill=color2)
        dc.rectangle(
            (0, height // 2, width // 2, height),
            fill=color2)
        return image

    def run_icon():
        icon = Icon("Windows Tester", icon=create_image(64, 64, 'black', 'white'), menu=menu)
        icon.run()

    thread = threading.Thread(target=run_icon)
    thread.daemon = True
    thread.start()

def create_main_window():
    root = tk.Tk()
    root.title("Windows Tester")
    root.geometry("300x200")
    
    # Modify close event to hide the window instead of closing the app
    root.protocol("WM_DELETE_WINDOW", lambda: root.withdraw())

    return root

def process_gui_queue(root):
    """Quit the GUI event loop if the exit flag is set."""
    while not gui_queue.empty():
        action = gui_queue.get()
        action()

    if not exit_flag:
        root.after(100, process_gui_queue, root)
    else:
        root.quit()  # Stop the main loop

def start_gui_event_loop(root):
    process_gui_queue(root)
    root.mainloop()

def main():
    # Check if another instance is running
    if is_already_running():
        print("Another instance is already running.")
        sys.exit(0)

    # Register cleanup function
    atexit.register(remove_lock_file)

    # Set the App User Model ID
    try:
        myappid = 'sirlouen.windows-testing.0.0.1'
        windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
    except Exception as e:
        print(f"Failed to set App User Model ID: {e}")
    
    root = create_main_window()

    # Start the system tray icon in a separate thread
    run_tray_icon(root)
    
    # Start the GUI event loop
    start_gui_event_loop(root)

    # Perform cleanup
    remove_lock_file()

if __name__ == "__main__":
    main()

One extra thing. To check the behavior, I'm using pyinstaller to:

  1. Generate the exe
  2. Pin it in the taskbar
  3. Execute and hide it
  4. Try to re-execute it from pinned icon in the task bar

pyinstaller --onefile main.py

PS: It's also killing me that to test each change, it takes like 2-3 minutes to generate another new EXE with this command.


Solution

  • After a ton of researching, I tried each single idea that I found out there, from the winfo_interps to trying to manage with some Windows libraries with the win32 package and a lock file state like I showed in my first code.

    The most important thing, I think I missed in my first part, is the fact that clicking on a Taskbar pinned icon actually opens an entirely new process. So this new process has to find the way to communicate with the previously initialized process living in the system tray.

    Tkinter happens to have built-in functionality, where you can call an initialized object method in another process if you happen to define the name of the Tkinter object. But for some reason, Windows doesn't support this mechanism: https://stackoverflow.com/a/73986115/4442122

    So the solution here was implementing sockets with the python socket library, which happens to have, similarly to Tkinter call, a mechanism to send a method to a previously initialized object in such socket implementation.

    Modifying the previous code, to remove all the LOCK_FILE references I made this new code:

    from pystray import Icon, Menu, MenuItem
    from PIL import Image, ImageDraw
    import threading
    import tkinter as tk
    import socket
    from ctypes import windll
    
    LISTEN_PORT = 21345
    
    class WindowsTester(tk.Tk):
        def __init__(self):
            super().__init__(className='WindowsTester')
            self.title("Windows Tester")
            
            self.label = tk.Label(self, text="This is a Tkinter app!")
            self.label.pack()
            
            self.protocol("WM_DELETE_WINDOW", self.on_close)
            self.start_server()
            
        def start_server(self):
            self.server_thread = threading.Thread(target=self.run_server)
            self.server_thread.daemon = True
            self.server_thread.start()
    
        def run_server(self):
            server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            server_socket.bind(('localhost', LISTEN_PORT))
            server_socket.listen(1)  # Listen for incoming connections
            while True:
                conn, addr = server_socket.accept()  # Accept a connection
                print('Connected by', addr)
                message = conn.recv(1024).decode()  # Receive the message
                print('Received:', message)
                if message == 'activate':
                    self.activate()  # Call activate method
                conn.close()  # Close the connection
            
        def on_close(self):
            self.withdraw()  # Hide the window instead of destroying it
            
        def activate(self):
            self.deiconify()  # Show the window
            self.lift()  # Bring the window to the foreground
    
    def run_tray_icon(root):
        def show_window():
            root.activate()
    
        def hide_window():
            root.on_close()
    
        def exit_application(icon):
            client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            try:
                client_socket.connect(('localhost', LISTEN_PORT))  # Connect to the server
                client_socket.sendall(b'exit')  # Send the exit message
                print("Exiting application")
            except ConnectionRefusedError:
                print("No running instance found")
            finally:
                client_socket.close()  # Close the client socket
            icon.stop()  # Stop the icon loop
    
        menu = Menu(
            MenuItem('Show', show_window),
            MenuItem('Hide', hide_window),
            MenuItem('Exit', exit_application)
        )
        
        def create_image(width, height, color1, color2):
            image = Image.new('RGB', (width, height), color1)
            dc = ImageDraw.Draw(image)
            dc.rectangle((width // 2, 0, width, height // 2), fill=color2)
            dc.rectangle((0, height // 2, width // 2, height), fill=color2)
            return image
    
        def run_icon():
            icon = Icon("Windows Tester", icon=create_image(64, 64, 'black', 'white'), menu=menu)
            icon.run()
    
        thread = threading.Thread(target=run_icon)
        thread.daemon = True
        thread.start()
    
    def main():  
        # Try to connect to an existing instance
        client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        try:
            client_socket.connect(('localhost', LISTEN_PORT))  # Connect to the server
            client_socket.sendall(b'activate')  # Send the activate message
            print("Activated existing instance")
        except ConnectionRefusedError:
            print("No existing instance found, starting a new one")
            
            try:
                myappid = 'sirlouen.windows-testing.0.0.1'
                windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
            except Exception as e:
                print(f"Failed to set App User Model ID: {e}")
            
            # Start a new instance
            root = WindowsTester()
    
            # Start the system tray icon in a separate thread
            run_tray_icon(root)
            
            # Start the GUI event loop
            root.mainloop()
        finally:
            print("Closing client socket")
            client_socket.close()  # Close the client socket
    
    if __name__ == "__main__":
        main()
    

    Maybe it could be greatly improved because I have not read the full docs for sockets and there are things I'm missing, but I feel it's a nice starting boilerplate to initialize a Windows app with systray and taskbar functionality.