Files
2026-07-23 16:48:58 +02:00

398 lines
14 KiB
Python
Executable File

import math
import os
import threading
import time
import tkinter as tk
from tkinter import filedialog
import webbrowser
from pystray import Icon, MenuItem as item, Menu
from PIL import Image, ImageDraw
from core.config import Config
from gui.info import CustomDialog
from core.constants import *
if PLATFORM != WINDOWS:
import subprocess
class TaskbarPanel:
def __init__(
self,
on_connect_callback: callable = None,
on_disconnect_callback: callable = None,
on_logoff_callback: callable = None,
new_version_available: list = None,
github_url: str = GITHUB_URL,
donation_url: str = None,
ws_interface=None, # type= interfaces.ws_interface.WSInterface
config: Config = None,
):
self.on_connect_callback = on_connect_callback
self.on_disconnect_callback = on_disconnect_callback
self.on_logoff_callback = on_logoff_callback
self.new_version_available = new_version_available
self.github_url = github_url
self.donation_url = donation_url
self.ws_interface = ws_interface
self.config = config
self.is_disconnecting = False
self.disconnecting_items = None
self.is_file_download_enabled = False
self.file_download_items = None
self.previous_stats: str = ""
self.previous_stats_items = None
self.root = tk.Tk()
self.root.withdraw() # Hide the root window
# Hide dock icon on macOS after creating tkinter window
if PLATFORM == MACOS:
try:
from AppKit import NSApplication, NSApplicationActivationPolicyAccessory
NSApplication.sharedApplication().setActivationPolicy_(NSApplicationActivationPolicyAccessory)
except ImportError:
pass
# Initial state: Connected
self.is_connected = True
# Create the tray icon
self.icon = Icon(
"ClipCascade", self.create_clipboard_icon(), menu=self.create_menu()
)
self.icon.title = "ClipCascade"
self.update_stats() # Start the stats update thread
def run(self):
self.icon.run()
def _create_clipboard_base_image(self):
"""Shared clipboard artwork for normal tray icon and file-download badge variant."""
width, height = 64, 64
image = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(image)
fill_color = (220, 220, 220)
outline_color = (255, 255, 255)
board_coords = (12, 12, 52, 57)
try:
draw.rounded_rectangle(
board_coords, radius=5, fill=None, outline=outline_color, width=3
)
except (AttributeError, TypeError):
draw.rectangle(board_coords, fill=None, outline=outline_color)
clip_coords = (22, 7, 42, 17)
try:
draw.rounded_rectangle(
clip_coords, radius=3, fill=fill_color, outline=outline_color, width=3
)
except (AttributeError, TypeError):
draw.rectangle(clip_coords, fill=fill_color, outline=outline_color)
return image
def create_clipboard_icon(self):
return self._create_clipboard_base_image()
def create_clipboard_icon_with_dot(self):
"""Same clipboard as normal state, plus a blue notification dot for pending file download."""
image = self._create_clipboard_base_image().copy()
draw = ImageDraw.Draw(image)
width, height = 64, 64
dot_radius = 8
dot_center_x = width - dot_radius - 5
dot_center_y = dot_radius + 5
dot_bbox = [
dot_center_x - dot_radius,
dot_center_y - dot_radius,
dot_center_x + dot_radius,
dot_center_y + dot_radius,
]
draw.ellipse(dot_bbox, fill=(0, 128, 255, 255))
highlight_radius = dot_radius // 2
highlight_center_x = dot_center_x - highlight_radius // 2
highlight_center_y = dot_center_y - highlight_radius // 2
highlight_bbox = [
highlight_center_x - highlight_radius,
highlight_center_y - highlight_radius,
highlight_center_x + highlight_radius,
highlight_center_y + highlight_radius,
]
draw.ellipse(highlight_bbox, fill=(255, 255, 255, 180))
return image
def create_menu(self, item_: tuple = None):
"""Create the menu for the tray icon.
Args:
item_ (tuple, optional): The item that triggered the menu. Defaults to None.
item_ = (text, location, callback)
Returns:
Menu: The menu for the tray icon.
"""
# Menu items
menu_items = [
Menu.SEPARATOR,
item("🗒️ Open Logs", self._open_logs),
item("📂 Program Files", self._open_program_location),
Menu.SEPARATOR,
item("🏠 Homepage", self._open_homepage),
item("❓ Help", self._open_help),
item("💟 Donate", self._open_donate),
item("🌐 GitHub", self._open_github),
Menu.SEPARATOR,
item("🔒 Logoff and Quit", self._on_logoff),
item("❌ Quit", self._on_quit),
]
# Add connect/disconnect option (top of the menu - 0 index)
if not self.is_connected:
menu_items.insert(0, item("🔗 Connect", self._on_connect, default=True))
else:
if self.is_disconnecting and self.disconnecting_items is not None:
menu_items.insert(
self.disconnecting_items[1],
item(self.disconnecting_items[0], self.disconnecting_items[2]),
)
else:
menu_items.insert(0, item("⛓️‍💥 Disconnect", self._on_disconnect))
# Add update option (before the last 3 items)
if self.new_version_available is not None and self.new_version_available[0]:
menu_items.insert(
len(menu_items) - 3,
item(
f"🔄 Update ({self.new_version_available[2]}{self.new_version_available[1]})",
self._on_update,
),
)
# Add files download option (top of the menu - 0 index)
if self.is_file_download_enabled and self.file_download_items is not None:
menu_items.insert(
self.file_download_items[1],
item(
self.file_download_items[0],
self.file_download_items[2],
default=True,
),
)
# Add stats option (top of the menu - 0 index)
if self.previous_stats_items is not None:
menu_items.insert(
self.previous_stats_items[1],
item(
self.previous_stats_items[0],
self.previous_stats_items[2],
enabled=self.previous_stats_items[2] is not None,
),
)
return Menu(*menu_items)
def update_menu(self, item_: tuple = None):
self.icon.menu = self.create_menu(item_=item_)
def update_stats(self):
threading.Thread(target=self._update_stats_thread, daemon=True).start()
def _update_stats_thread(self):
while True:
current_stats = self.ws_interface.get_stats()
if current_stats is not None and self.previous_stats != current_stats:
self.previous_stats = current_stats
self.previous_stats_items = (current_stats, 0, None)
self.update_menu()
time.sleep(1) # Sleep for 1 second
@staticmethod
def open_webbrowser(url):
try:
webbrowser.open(url)
except Exception as e:
CustomDialog(
f"Failed to open the browser. Here is the URL: {url}\nError: {e}",
msg_type="error",
).mainloop()
def _on_update(self, icon, item):
TaskbarPanel.open_webbrowser(self.new_version_available[3])
def _on_connect(self, icon, item):
if self.on_connect_callback:
try:
self.on_connect_callback()
except Exception as e:
pass
self.is_connected = True
self.update_menu()
def _on_disconnect(self, icon, item):
if self.on_disconnect_callback:
self.on_disconnect_callback()
if self.ws_interface is not None and self.ws_interface.is_auto_reconnecting:
threading.Thread(
target=self._wait_to_disconnect, args=(icon, item), daemon=True
).start()
else:
self.is_connected = False
self.update_menu()
def _wait_to_disconnect(self, icon, item):
"""
Wait for the auto-reconnect timer to expire before disconnecting.
"""
if self.ws_interface is None:
return
timeout = math.ceil(self.ws_interface.get_total_timeout() / 1000)
while timeout > 0:
self.is_disconnecting = True
self.disconnecting_items = (
f"⏳ Disconnecting... ({timeout} sec)",
0,
None,
) # text, location, callback
self.update_menu()
time.sleep(1) # seconds
timeout -= 1
self.is_disconnecting = False
self.disconnecting_items = None
self.ws_interface.is_auto_reconnecting = False
self.is_connected = False
self.update_menu()
def _open_homepage(self, icon, item):
TaskbarPanel.open_webbrowser(self.config.data["server_url"])
def _open_github(self, icon, item):
TaskbarPanel.open_webbrowser(self.github_url)
def _open_help(self, icon, item):
TaskbarPanel.open_webbrowser(HELP_URL)
def _open_donate(self, icon, item):
if self.donation_url is not None:
TaskbarPanel.open_webbrowser(self.donation_url)
def open_location(self, path):
if PLATFORM == WINDOWS:
os.startfile(path)
elif PLATFORM == MACOS:
subprocess.run(["open", path])
elif PLATFORM.startswith(LINUX):
subprocess.run(["xdg-open", path])
def _open_logs(self, icon, item):
log_file_path = os.path.join(get_program_files_directory(), LOG_FILE_NAME)
if os.path.exists(log_file_path):
try:
self.open_location(log_file_path)
except Exception as e:
CustomDialog(
f"Failed to open the log file '{log_file_path}'.\nError: {e}",
msg_type="error",
).mainloop()
else:
CustomDialog(
f"Log file not found at '{log_file_path}'.", msg_type="error"
).mainloop()
def _open_program_location(self, icon, item):
try:
program_location = get_program_files_directory()
self.open_location(program_location)
except Exception as e:
CustomDialog(
f"Failed to open the program location '{program_location}'.\nError: {e}",
msg_type="error",
).mainloop()
def enable_files_download(self, files):
self.is_file_download_enabled = True
self.icon.icon = self.create_clipboard_icon_with_dot()
self.file_download_items = (
"📥 Download File(s)",
0,
lambda icon, item: self._on_download(icon, item, files),
)
self.update_menu()
def disable_files_download(self):
self.is_file_download_enabled = False
self.file_download_items = None
self.icon.icon = self.create_clipboard_icon()
self.update_menu()
def _on_download(self, icon, item, files):
"""Download the files to the user's Downloads folder."""
try:
try:
if self.config.data["default_file_download_location"] != "":
target_directory = self.config.data[
"default_file_download_location"
]
else:
target_directory = filedialog.askdirectory(
title="Select location to Save File(s)",
initialdir=get_downloads_folder(),
)
if not target_directory:
logging.debug("No directory selected. Exiting.")
return
except RuntimeError as re:
target_directory = os.path.join(
get_program_files_directory(), "downloads"
)
if not os.path.exists(target_directory):
os.makedirs(target_directory)
logging.error(
f"A runtime error occurred while starting filedialog to select a directory. Error: {re}.\n"
+ f"Setting the default location to the program directory '{target_directory}'."
)
CustomDialog(
f"ClipCascade 📥: Saving files to the program directory '{target_directory}'.",
msg_type="info",
timeout=5000,
).mainloop()
# Save each file to the chosen directory
for filename, file_obj in files.items():
file_path = os.path.join(target_directory, filename)
with open(file_path, "wb") as f:
f.write(file_obj.getvalue())
logging.debug(f"Saved: {file_path}")
except Exception as e:
msg = f"An error occurred while downloading files. Error: {e}"
logging.error(msg)
CustomDialog(
msg,
msg_type="error",
).mainloop()
def _on_logoff(self, icon, item):
try:
if self.on_logoff_callback:
self.on_logoff_callback()
self.icon.stop()
self.root.quit()
except Exception as e:
CustomDialog(
f"An error occurred while logging off: {e}", msg_type="error"
).mainloop()
def _on_quit(self, icon, item):
self.icon.stop()
self.root.quit()