added clipcascade

This commit is contained in:
liph
2026-07-23 16:48:58 +02:00
parent 90e44adbae
commit cba86d9dce
31 changed files with 6406 additions and 0 deletions
+648
View File
@@ -0,0 +1,648 @@
import re
import tkinter as tk
from tkinter import ttk, font
import gc
import time
import os
from utils.window_manager import center_window
from core.config import Config
from gui.info import CustomDialog
from core.constants import *
class HoverTooltip:
"""Simple hover tooltip for Tk widgets."""
def __init__(self, widget, text, wraplength=520):
self.widget = widget
self.text = text
self.wraplength = wraplength
self.tooltip_window = None
self.widget.bind("<Enter>", self._show, add="+")
self.widget.bind("<Leave>", self._hide, add="+")
self.widget.bind("<ButtonPress>", self._hide, add="+")
def _show(self, _event=None):
if self.tooltip_window or not self.text.strip():
return
# Position tooltip near the widget with an offset.
x = self.widget.winfo_rootx() + 18
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 8
self.tooltip_window = tw = tk.Toplevel(self.widget)
tw.wm_overrideredirect(True)
tw.wm_geometry(f"+{x}+{y}")
label = tk.Label(
tw,
text=self.text,
justify=tk.LEFT,
relief=tk.SOLID,
borderwidth=1,
background="#ffffe0",
foreground="#202020",
font=("Helvetica", 10),
wraplength=self.wraplength,
padx=8,
pady=6,
)
label.pack()
def _hide(self, _event=None):
if self.tooltip_window:
self.tooltip_window.destroy()
self.tooltip_window = None
class LoginForm(tk.Tk):
def __init__(
self,
config: Config,
on_login_callback=None,
on_quit_callback=None,
):
super().__init__()
self.config = config
self.on_login_callback = on_login_callback
self.on_quit_callback = on_quit_callback
if PLATFORM == MACOS or PLATFORM.startswith(LINUX):
# Set an initial size to avoid 0 size issue
# self.geometry("800x450")
self.update_idletasks()
self.withdraw() # Hide the root window
self.title("ClipCascade")
self._tooltips = []
# Make the window appear on top and grab focus
self.attributes("-topmost", True)
self.focus_force()
# Configure styles
style = ttk.Style(self)
# Use default theme (may vary by OS); consider 'clam', 'alt', or 'default'
style.configure("TLabel", font=("Helvetica", 13), padding=5)
style.configure("TEntry", font=("Helvetica", 13), padding=5)
style.configure("TButton", font=("Helvetica", 13), padding=5)
style.configure("TCheckbutton", font=("Helvetica", 13), padding=5)
# Main frame with padding
main_frame = ttk.Frame(self, padding=20)
main_frame.pack(fill=tk.BOTH, expand=True)
# Title Label
title_label = ttk.Label(
main_frame,
text="Please Log In",
font=("Helvetica", 16, "bold"),
takefocus=False,
)
title_label.pack(pady=(0, 10))
# Create a frame for the fields
self.field_frame = ttk.Frame(main_frame)
self.field_frame.pack(padx=10, pady=10, fill=tk.X)
# Username
user_label = ttk.Label(self.field_frame, text="Username:")
user_label.grid(row=0, column=0, padx=(0, 10), pady=5, sticky=tk.W)
self.username_entry = ttk.Entry(self.field_frame, width=50, font=("Helvetica", 13))
self.username_entry.insert(0, self.config.data["username"])
self.username_entry.grid(row=0, column=1, padx=10, pady=5, sticky=tk.W + tk.E)
self._add_tooltip(
[user_label, self.username_entry],
"Username used to log in to your ClipCascade account.\n\n"
"Use the same account name on all devices that should share clipboard data.",
)
# Password
pass_label = ttk.Label(self.field_frame, text="Password:")
pass_label.grid(row=1, column=0, padx=(0, 10), pady=5, sticky=tk.W)
self.password_frame = ttk.Frame(self.field_frame)
self.password_frame.grid(row=1, column=1, padx=10, pady=5, sticky=tk.W + tk.E)
self.password_entry = ttk.Entry(
self.password_frame, show="*", width=47, font=("Helvetica", 13)
)
self.password_entry.insert(0, self.config.data["password"])
self.password_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
self.eye_icon = ttk.Label(
self.password_frame, text="🙈", cursor="hand2", takefocus=False
)
self.eye_icon.pack(side=tk.RIGHT, padx=(5, 0))
self.eye_icon.bind("<Button-1>", self._toggle_password)
self._add_tooltip(
[pass_label, self.password_entry, self.eye_icon],
"Account password used for server authentication.\n\n"
"If encryption is enabled, this password is also used to derive your encryption key.\n"
"Use the same password on all devices for successful decrypt/sync.",
)
# Server URL
server_label = ttk.Label(self.field_frame, text="Server URL:")
server_label.grid(row=2, column=0, padx=(0, 10), pady=5, sticky=tk.W)
self.server_url_entry = ttk.Entry(self.field_frame, width=50, font=("Helvetica", 13))
self.server_url_entry.insert(0, self.config.data["server_url"])
self.server_url_entry.grid(row=2, column=1, padx=10, pady=5, sticky=tk.W + tk.E)
self._add_tooltip(
[server_label, self.server_url_entry],
"Address of your ClipCascade server.\n\n"
"Examples:\n"
"- Local server: http://localhost:8080\n"
"- LAN server: http://192.168.1.50:8080\n"
"- Reverse proxy/domain: https://clipcascade.example.com\n\n"
"Include protocol (http/https). Do not add /login or /clipsocket.",
)
# Configure grid weights to ensure entries expand
self.field_frame.columnconfigure(1, weight=1)
# Checkboxes frame
checks_frame = ttk.Frame(main_frame)
checks_frame.pack(pady=(5, 10), fill=tk.X)
# Encryption Checkbox
self.cipher_var = tk.BooleanVar(value=self.config.data["cipher_enabled"])
self.cipher_checkbox = ttk.Checkbutton(
checks_frame,
text="Enable Encryption (recommended)",
variable=self.cipher_var,
takefocus=False,
)
self.cipher_checkbox.pack(pady=3, anchor=tk.W)
self._add_tooltip(
[self.cipher_checkbox],
"Enables end-to-end encryption for clipboard content (recommended).\n\n"
"When enabled, clipboard data is encrypted on-device before sending.\n"
"All devices in the same account must use the same encryption settings "
"(password, salt, and hash rounds) to decrypt data correctly.",
)
# Notification Checkbox
self.notification_var = tk.BooleanVar(value=self.config.data["notification"])
self.notification_checkbox = ttk.Checkbutton(
checks_frame,
text="Enable Notification",
variable=self.notification_var,
takefocus=False,
)
self.notification_checkbox.pack(pady=3, anchor=tk.W)
self._add_tooltip(
[self.notification_checkbox],
"Shows local notifications for connection status events.\n\n"
"Useful to know when WebSocket disconnects/reconnects happen.\n"
"Turn this off if you prefer a quieter experience with fewer popups.",
)
# Button frame
button_frame = ttk.Frame(main_frame)
button_frame.pack(pady=(10, 0))
# Login Button
self.login_button = ttk.Button(button_frame, text="Login", command=self.on_login)
self.login_button.pack()
# Extra Configurations Toggle Label
# macOS: show advanced fields by default
self.show_extra = PLATFORM == MACOS
self.toggle_normal_color = "#007acc" # neutral blue with strong contrast
self.toggle_hover_color = "#005a9e" # slightly darker for hover feedback
self.toggle_label = tk.Label(
main_frame,
text="Hide Extra Config" if PLATFORM == MACOS else "Enable Extra Config",
fg=self.toggle_normal_color,
cursor="hand2",
font=font.Font(family="Helvetica", size=13, underline=True),
takefocus=False,
)
self.toggle_label.pack(pady=(5, 10))
self.toggle_label.bind("<Button-1>", self._toggle_extra_config)
self._add_tooltip(
[self.toggle_label],
"Shows advanced settings for encryption and clipboard behavior.\n\n"
"Most users can keep defaults. Open this section only if you need custom tuning.",
)
# hover effects for the toggle label
self.toggle_label.bind("<Enter>", self._on_hover)
self.toggle_label.bind("<Leave>", self._on_leave)
# Extra Configuration Frame with Scrollbar
self.extra_frame_container = ttk.Frame(main_frame)
if PLATFORM == MACOS:
self.extra_frame_container.pack(fill=tk.BOTH, expand=True)
else:
self.extra_frame_container.pack_forget()
# Create a canvas inside the extra_frame_container
self.extra_canvas = tk.Canvas(
self.extra_frame_container,
borderwidth=0,
background="#f0f0f0",
takefocus=False,
)
self.extra_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# Add a vertical scrollbar to the canvas
self.scrollbar = ttk.Scrollbar(
self.extra_frame_container,
orient="vertical",
command=self.extra_canvas.yview,
takefocus=False,
)
self.scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
# Configure the canvas to use the scrollbar
self.extra_canvas.configure(yscrollcommand=self.scrollbar.set)
# Create a frame inside the canvas to hold the extra configuration widgets
self.extra_frame = ttk.Frame(self.extra_canvas, padding=10)
self.extra_frame_window = self.extra_canvas.create_window(
(0, 0), window=self.extra_frame, anchor="nw"
)
# Bind the configure event to update the scrollregion
self.extra_frame.bind(
"<Configure>",
lambda event: self.extra_canvas.configure(scrollregion=self.extra_canvas.bbox("all")),
)
# Bind a configure event to the container to adjust the width
self.extra_frame_container.bind("<Configure>", self._on_extra_container_configure)
# Hash Rounds
hash_rounds_label = ttk.Label(self.extra_frame, text="Hash Rounds:")
hash_rounds_label.grid(row=0, column=0, padx=(0, 10), pady=5, sticky=tk.W)
self.hash_rounds_entry = ttk.Entry(self.extra_frame, width=50, font=("Helvetica", 13))
self.hash_rounds_entry.insert(0, str(self.config.data["hash_rounds"]))
self.hash_rounds_entry.grid(row=0, column=1, padx=10, pady=5, sticky=tk.W + tk.E)
self._add_tooltip(
[hash_rounds_label, self.hash_rounds_entry],
"Number of hash iterations used for encryption key derivation.\n\n"
"Higher value = stronger brute-force resistance, but slower processing.\n"
"Default is tuned for balance. Keep default unless you know why to change.\n"
"Must be a positive integer and must match on every device.",
)
# Salt
salt_label = ttk.Label(self.extra_frame, text="Salt:")
salt_label.grid(row=1, column=0, padx=(0, 10), pady=5, sticky=tk.W)
self.salt_entry = ttk.Entry(self.extra_frame, width=50, font=("Helvetica", 13))
self.salt_entry.insert(0, self.config.data["salt"])
self.salt_entry.grid(row=1, column=1, padx=10, pady=5, sticky=tk.W + tk.E)
self._add_tooltip(
[salt_label, self.salt_entry],
"Optional extra text mixed into encryption key generation.\n\n"
"It can be any combination of letters and numbers (you can also include symbols/spaces if desired).\n"
"Use the same exact salt on all devices if encryption is enabled.\n"
"If left blank, encryption still works using password + hash rounds.\n"
"Changing salt later will prevent old devices from decrypting new clipboard data.",
)
# Save Password Locally Checkbox
save_password_label = ttk.Label(
self.extra_frame,
text="Store Password Locally\n(not recommended; \nonly works if encryption is disabled):",
)
save_password_label.grid(row=2, column=0, padx=(0, 10), pady=5, sticky=tk.W)
self.save_password_var = tk.BooleanVar(value=self.config.data["save_password"])
self.save_password_checkbox = ttk.Checkbutton(
self.extra_frame,
variable=self.save_password_var,
takefocus=False,
)
self.save_password_checkbox.grid(row=2, column=1, padx=10, pady=5, sticky=tk.W)
self._add_tooltip(
[save_password_label, self.save_password_checkbox],
"Stores your plain password locally for automatic login convenience.\n\n"
"Security trade-off: avoid this on shared or untrusted machines.\n"
"This only works when encryption is disabled.",
)
# Maximum Clipboard Size - Local Limit
local_clipboard_size_label = ttk.Label(
self.extra_frame, text="Maximum Clipboard Size\nLocal Limit (in bytes):"
)
local_clipboard_size_label.grid(row=3, column=0, padx=(0, 10), pady=5, sticky=tk.W)
self.local_clipboard_size_entry = ttk.Entry(
self.extra_frame, width=50, font=("Helvetica", 13)
)
self.local_clipboard_size_entry.insert(
0, str(self.config.data["max_clipboard_size_local_limit_bytes"] or "")
)
self.local_clipboard_size_entry.grid(row=3, column=1, padx=10, pady=5, sticky=tk.W + tk.E)
self._add_tooltip(
[local_clipboard_size_label, self.local_clipboard_size_entry],
"Optional local safety limit for clipboard payload size (incoming and outgoing) in bytes.\n\n"
"Example: 1 MiB = 1048576 bytes.\n"
"Leave empty for no local limit.\n"
"Use a lower value if your device struggles with very large clipboard data "
"(large text/images/files). Must be a positive integer.",
)
# Enable Image Sharing Checkbox
enable_image_sharing_label = ttk.Label(self.extra_frame, text="Enable Image Sharing:")
enable_image_sharing_label.grid(row=4, column=0, padx=(0, 10), pady=5, sticky=tk.W)
self.enable_image_sharing_var = tk.BooleanVar(
value=self.config.data["enable_image_sharing"]
)
self.enable_image_sharing_checkbox = ttk.Checkbutton(
self.extra_frame,
variable=self.enable_image_sharing_var,
takefocus=False,
)
self.enable_image_sharing_checkbox.grid(row=4, column=1, padx=10, pady=5, sticky=tk.W)
self._add_tooltip(
[enable_image_sharing_label, self.enable_image_sharing_checkbox],
"Controls whether this device sends copied images to other devices.\n\n"
"If disabled, this device can still receive images sent from other devices.",
)
# Enable File Sharing Checkbox
enable_file_sharing_label = ttk.Label(self.extra_frame, text="Enable File Sharing:")
enable_file_sharing_label.grid(row=5, column=0, padx=(0, 10), pady=5, sticky=tk.W)
self.enable_file_sharing_var = tk.BooleanVar(value=self.config.data["enable_file_sharing"])
self.enable_file_sharing_checkbox = ttk.Checkbutton(
self.extra_frame,
variable=self.enable_file_sharing_var,
takefocus=False,
)
self.enable_file_sharing_checkbox.grid(row=5, column=1, padx=10, pady=5, sticky=tk.W)
self._add_tooltip(
[enable_file_sharing_label, self.enable_file_sharing_checkbox],
"Controls whether this device sends copied/shared files to other devices.\n\n"
"If disabled, this device can still receive files sent from other devices.",
)
# Default File Download Location
default_file_download_location_label = ttk.Label(
self.extra_frame, text="Default File Download Location:"
)
default_file_download_location_label.grid(
row=6, column=0, padx=(0, 10), pady=5, sticky=tk.W
)
self.default_file_download_location_entry = ttk.Entry(
self.extra_frame, width=50, font=("Helvetica", 13)
)
self.default_file_download_location_entry.insert(
0, self.config.data["default_file_download_location"]
)
self.default_file_download_location_entry.grid(
row=6, column=1, padx=10, pady=5, sticky=tk.W + tk.E
)
self._add_tooltip(
[default_file_download_location_label, self.default_file_download_location_entry],
"Optional default folder to save incoming files automatically.\n\n"
"Examples:\n"
"- Windows: C:\\Users\\YourName\\Downloads\n"
"- macOS/Linux: /Users/yourname/Downloads\n\n"
"Leave blank to choose a location manually when downloading files.\n"
"Path must already exist on this device.",
)
# SSL CA bundle (private / corporate PKI)
ssl_ca_label = ttk.Label(self.extra_frame, text="SSL CA bundle (optional):")
ssl_ca_label.grid(row=7, column=0, padx=(0, 10), pady=5, sticky=tk.W)
self.ssl_ca_bundle_entry = ttk.Entry(
self.extra_frame, width=50, font=("Helvetica", 13)
)
self.ssl_ca_bundle_entry.insert(
0, self.config.data.get("ssl_ca_bundle") or ""
)
self.ssl_ca_bundle_entry.grid(row=7, column=1, padx=10, pady=5, sticky=tk.W + tk.E)
self._add_tooltip(
[ssl_ca_label, self.ssl_ca_bundle_entry],
"Path to a PEM file that contains your root CA (or full chain) for HTTPS/WSS.\n\n"
"Leave empty for default verification (public CAs / OS defaults).\n"
"Use this when your server uses a certificate from a private or internal CA.\n\n"
"Example: /etc/ssl/corp-root.pem or C:\\\\certs\\\\corp-root.pem",
)
# Configure grid weights for extra_frame
self.extra_frame.columnconfigure(1, weight=1)
# Bind mouse wheel events for scrolling
self._bind_mousewheel()
# Handle window close
self.protocol("WM_DELETE_WINDOW", self.on_quit)
# Update idle tasks to calculate widget sizes
self.update_idletasks()
# Set a maximum height for the window
self.max_window_height = 600
# on press enter key
self.bind("<Return>", lambda event: self.on_login())
self._sync_login_tab_order()
self.deiconify() # Show window
self.update_idletasks()
center_window(self, self.max_window_height)
# Drop always-on-top after showing
self.after(300, lambda: self.attributes("-topmost", False))
if PLATFORM == MACOS:
self.after(400, self._macos_restore_focus)
def _sync_login_tab_order(self):
"""Tab cycles username → password → server → Login → [extra entries if shown] → repeat.
Checkboxes and chrome are excluded from keyboard focus."""
extra_entries = (
self.hash_rounds_entry,
self.salt_entry,
self.local_clipboard_size_entry,
self.default_file_download_location_entry,
self.ssl_ca_bundle_entry,
)
for w in extra_entries:
w.configure(takefocus=bool(self.show_extra))
def _add_tooltip(self, widgets, text):
for widget in widgets:
self._tooltips.append(HoverTooltip(widget, text))
def _macos_restore_focus(self):
"""Force macOS to properly activate and focus the window.
Needed after sequential tk.Tk() creation/destruction cycles."""
try:
from AppKit import NSApplication
app = NSApplication.sharedApplication()
app.activateIgnoringOtherApps_(True)
except ImportError:
pass
self.lift()
self.focus_force()
self.username_entry.focus_set()
def _toggle_password(self, event=None):
if self.password_entry.cget("show") == "*":
self.password_entry.config(show="")
self.eye_icon.config(text="🐵")
else:
self.password_entry.config(show="*")
self.eye_icon.config(text="🙈")
def _toggle_extra_config(self, event=None):
"""Toggle the visibility of the extra configuration fields."""
extra_entries = (
self.hash_rounds_entry,
self.salt_entry,
self.local_clipboard_size_entry,
self.default_file_download_location_entry,
self.ssl_ca_bundle_entry,
)
if self.show_extra:
focused = self.focus_get()
if focused in extra_entries:
self.login_button.focus_set()
self.extra_frame_container.pack_forget()
self.toggle_label.config(text="Enable Extra Config")
else:
self.extra_frame_container.pack(fill=tk.BOTH, expand=True)
self.toggle_label.config(text="Hide Extra Config")
self.show_extra = not self.show_extra
self._sync_login_tab_order()
self.update_idletasks()
self.geometry("")
self.update_idletasks()
center_window(self)
def _on_hover(self, event=None):
"""Change text color on hover."""
self.toggle_label.config(fg=self.toggle_hover_color)
def _on_leave(self, event=None):
"""Revert text color when not hovered."""
self.toggle_label.config(fg=self.toggle_normal_color)
def _bind_mousewheel(self):
"""Bind mouse wheel events to the canvas for scrolling."""
if PLATFORM == WINDOWS:
self.extra_canvas.bind_all("<MouseWheel>", self._on_mousewheel_windows)
elif PLATFORM == MACOS:
self.extra_canvas.bind_all("<MouseWheel>", self._on_mousewheel_mac)
elif PLATFORM.startswith(LINUX):
# Linux uses Button-4 and Button-5 for scroll up/down
self.extra_canvas.bind_all("<Button-4>", self._on_mousewheel_linux)
self.extra_canvas.bind_all("<Button-5>", self._on_mousewheel_linux)
def _on_mousewheel_windows(self, event):
"""Handle mouse wheel scrolling on Windows."""
self.extra_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
def _on_mousewheel_mac(self, event):
"""Handle mouse wheel scrolling on macOS."""
self.extra_canvas.yview_scroll(int(-1 * event.delta), "units")
def _on_mousewheel_linux(self, event):
"""Handle mouse wheel scrolling on Linux."""
if event.num == 4:
self.extra_canvas.yview_scroll(-1, "units")
elif event.num == 5:
self.extra_canvas.yview_scroll(1, "units")
def _on_extra_container_configure(self, event):
"""Adjust the width of the extra_frame as the container is resized."""
canvas_width = event.width - self.scrollbar.winfo_width()
self.extra_canvas.itemconfigure(self.extra_frame_window, width=canvas_width)
@staticmethod
def remove_trailing_slash(entry):
return re.sub(r"/+$", "", entry)
@staticmethod
def is_positive_integer(value):
if value.isdigit() and int(value) > 0:
return True
else:
return False
def on_login(self):
# save data to config
self.config.data["username"] = self.username_entry.get()
self.config.data["password"] = self.password_entry.get()
self.config.data["server_url"] = LoginForm.remove_trailing_slash(
self.server_url_entry.get().strip()
)
self.config.data["cipher_enabled"] = self.cipher_var.get()
self.config.data["notification"] = self.notification_var.get()
# extra fields
# Validate hash_rounds
hash_rounds_input = self.hash_rounds_entry.get().strip()
if not LoginForm.is_positive_integer(hash_rounds_input):
CustomDialog(
"Invalid Input\nHash Rounds must be a positive integer.",
msg_type="error",
).mainloop()
return # retry login
self.config.data["hash_rounds"] = int(hash_rounds_input)
self.config.data["salt"] = self.salt_entry.get()
self.config.data["save_password"] = self.save_password_var.get()
# Validate max_clipboard_size_local_limit_bytes
max_clipboard_size_local_limit_bytes_input = self.local_clipboard_size_entry.get().strip()
if max_clipboard_size_local_limit_bytes_input == "":
self.config.data["max_clipboard_size_local_limit_bytes"] = None
else:
if not LoginForm.is_positive_integer(max_clipboard_size_local_limit_bytes_input):
CustomDialog(
"Invalid Input\nMax Clipboard Size Local Limit must be a positive integer.",
msg_type="error",
).mainloop()
return # retry login
self.config.data["max_clipboard_size_local_limit_bytes"] = int(
max_clipboard_size_local_limit_bytes_input
)
self.config.data["enable_image_sharing"] = self.enable_image_sharing_var.get()
self.config.data["enable_file_sharing"] = self.enable_file_sharing_var.get()
# Validate file download location
default_file_download_location = self.default_file_download_location_entry.get().strip()
if default_file_download_location == "":
self.config.data["default_file_download_location"] = ""
else:
if not os.path.exists(default_file_download_location):
CustomDialog(
"Invalid Input\nDefault File Download Location does not exist.",
msg_type="error",
).mainloop()
return # retry login
self.config.data["default_file_download_location"] = default_file_download_location
ssl_ca_bundle = self.ssl_ca_bundle_entry.get().strip()
if ssl_ca_bundle:
if not os.path.isfile(ssl_ca_bundle):
CustomDialog(
"Invalid Input\nSSL CA bundle path is not a file or does not exist.",
msg_type="error",
).mainloop()
return
self.config.data["ssl_ca_bundle"] = ssl_ca_bundle
# call login callback
if self.on_login_callback:
self.on_login_callback()
self.close()
def on_quit(self):
if self.on_quit_callback:
self.on_quit_callback()
self.close()
def close(self):
self.destroy()
gc.collect()
time.sleep(0.5)