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:
run_tray_icon
handle_exit
, still sometimes I find that some processes are never closed and end runningprocess_gui_queue
because I feel that without them, some processes end in the background.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:
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.
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.