Skip to content

Commit faf6ac0

Browse files
Sathvik-RaotastelessjoltOlamshin
authored
3.2.0 (#153)
* change version * Add `pyproject.toml` for the Linux desktop python package (#116) * Add files via upload * Update main.py * Update pyproject.toml * handle platform-specific dependencies with sys_platform markers * change clipboard tray icon from issue#139 #139 Author - https://github.com/bammerlaan * feat(macos): hide dock icon and add startup error dialog (#141) - Add PyInstaller spec file with LSUIElement to hide dock icon - Use NSApplicationActivationPolicyAccessory to fully hide dock icon - Add native macOS error dialog using osascript for startup crashes - Add tkinter and pyinstaller to requirements_mac.txt - Update .gitignore with build artifacts Co-authored-by: Sathvik Rao Poladi <36164509+Sathvik-Rao@users.noreply.github.com> * clean up * add windows PyInstaller build spec * Fix window centering on primary monitor * color change for config label * clean up * Fix negative peer counter; add macOS pasteboard lock; add Linux wl-watch support * fix -ve peers and peers disconnect after system sleep * increase wl-clipboard timeout * add linux kwargs flags gui, xmode, polling * remove tab focus from checkboxes * add login UI tooltips * add custom ssl ca field in login and update tray icon for download * show ssl ca at the end of cli version * cli continue retry if ssl ca has invalid path * refurbish p2p logic in desktop and mobile * fix race condition * cleanup * android remove escape sequence from msg * Update README.md * Update README.md * Update README.md * Update README.md * version update --------- Co-authored-by: Harshith Goka <harshith9399@gmail.com> Co-authored-by: Olamshin <olamide_kolawole@yahoo.com>
1 parent 5d12396 commit faf6ac0

27 files changed

Lines changed: 1566 additions & 411 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
private/
22
screenshots/
3+
.DS_Store
4+
.vscode/

ClipCascade_Desktop/src/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
**/__pycache__/
22
*.log
33
DATA
4+
build/
5+
dist/
6+
*.spec.bak
7+
.venv/
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# -*- mode: python ; coding: utf-8 -*-
2+
3+
import certifi
4+
5+
a = Analysis(
6+
['main.py'],
7+
pathex=[],
8+
binaries=[],
9+
datas=[(certifi.where(), 'certifi')],
10+
hiddenimports=['tkinter'],
11+
hookspath=[],
12+
hooksconfig={},
13+
runtime_hooks=[],
14+
excludes=[],
15+
noarchive=False,
16+
optimize=0,
17+
)
18+
pyz = PYZ(a.pure)
19+
20+
exe = EXE(
21+
pyz,
22+
a.scripts,
23+
a.binaries,
24+
a.datas,
25+
[],
26+
name='ClipCascade',
27+
debug=False,
28+
bootloader_ignore_signals=False,
29+
strip=False,
30+
upx=True,
31+
upx_exclude=[],
32+
runtime_tmpdir=None,
33+
console=False,
34+
disable_windowed_traceback=False,
35+
argv_emulation=False,
36+
target_arch=None,
37+
codesign_identity=None,
38+
entitlements_file=None,
39+
icon=['../../logo/logo.icns'],
40+
)
41+
app = BUNDLE(
42+
exe,
43+
name='ClipCascade.app',
44+
icon='../../logo/logo.icns',
45+
bundle_identifier=None,
46+
info_plist={
47+
'LSUIElement': True,
48+
},
49+
)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# -*- mode: python ; coding: utf-8 -*-
2+
3+
4+
a = Analysis(
5+
['main.py'],
6+
pathex=[],
7+
binaries=[],
8+
datas=[],
9+
hiddenimports=['plyer.platforms.win.notification'],
10+
hookspath=[],
11+
hooksconfig={},
12+
runtime_hooks=[],
13+
excludes=[],
14+
noarchive=False,
15+
)
16+
pyz = PYZ(a.pure)
17+
18+
exe = EXE(
19+
pyz,
20+
a.scripts,
21+
a.binaries,
22+
a.datas,
23+
[],
24+
name='ClipCascade',
25+
debug=False,
26+
bootloader_ignore_signals=False,
27+
strip=False,
28+
upx=True,
29+
upx_exclude=[],
30+
runtime_tmpdir=None,
31+
console=False,
32+
disable_windowed_traceback=False,
33+
argv_emulation=False,
34+
target_arch=None,
35+
codesign_identity=None,
36+
entitlements_file=None,
37+
icon=['../../logo/logo.ico'],
38+
)

ClipCascade_Desktop/src/cli/login.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import getpass
2+
import os
23
import re
34

45
from core.config import Config
@@ -13,7 +14,6 @@ def __init__(
1314
on_login_callback=None,
1415
on_quit_callback=None,
1516
):
16-
super().__init__()
1717
self.config = config
1818
self.on_login_callback = on_login_callback
1919
self.on_quit_callback = on_quit_callback
@@ -186,6 +186,25 @@ def mainloop(self):
186186
)
187187
break
188188

189+
while True:
190+
ssl_ca_bundle = (
191+
input(
192+
f"ssl ca bundle path (optional) [{self.config.data.get('ssl_ca_bundle') or ''}]: "
193+
)
194+
or (self.config.data.get("ssl_ca_bundle") or "")
195+
).strip()
196+
if ssl_ca_bundle == "":
197+
self.config.data["ssl_ca_bundle"] = ""
198+
break
199+
if not os.path.isfile(ssl_ca_bundle):
200+
CustomDialog(
201+
"Invalid Input\nSSL CA bundle path is not a file or does not exist.",
202+
msg_type="error",
203+
).mainloop()
204+
continue
205+
self.config.data["ssl_ca_bundle"] = ssl_ca_bundle
206+
break
207+
189208
CustomDialog("Logging in...").mainloop()
190209

191210
def on_quit(self):

ClipCascade_Desktop/src/clipboard/clipboard_manager.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from core.constants import *
1111
from core.config import Config
1212

13-
if PLATFORM.startswith(LINUX) and not XMODE:
13+
if PLATFORM.startswith(LINUX) and LINUX_USE_CLI_UI:
1414
from cli.tray import TaskbarPanel
1515
else:
1616
from gui.tray import TaskbarPanel
@@ -250,8 +250,7 @@ def paste(self, payload: any, payload_type: str = "text"):
250250
tiff_data = output.getvalue()
251251

252252
clipboard_monitor.enable_block_image_once() # Block image copy to prevent deadlock
253-
pb_image = pasteboard.Pasteboard()
254-
pb_image.set_contents(tiff_data, pasteboard.TIFF)
253+
clipboard_monitor.write_to_pasteboard(tiff_data, pasteboard.TIFF)
255254
elif PLATFORM.startswith(LINUX):
256255
# Save the image to a binary buffer in PNG format
257256
with io.BytesIO() as output:

ClipCascade_Desktop/src/clipboard/clipboard_monitor_linux.py

Lines changed: 164 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
_is_gdk_running = False
1515
_run_poll = threading.Event()
16+
_wl_watch_proc = None
1617

1718

1819
def _on_clipboard_changed(
@@ -64,10 +65,13 @@ def _monitor_x_wl_clipboard(
6465
r"no suitable type of content copied", # wl-clipboard pattern
6566
]
6667

67-
if x_mode:
68+
if LINUX_CLIPBOARD_POLL_INTERVAL_SEC is not None:
69+
timeout = LINUX_CLIPBOARD_POLL_INTERVAL_SEC
70+
logging.info(f"Clipboard polling interval (--polling): {timeout}s")
71+
elif x_mode:
6872
timeout = 0.3 # xclip seconds
6973
else:
70-
timeout = 1 # wl-clipboard seconds
74+
timeout = 3 # wl-clipboard seconds
7175

7276
while _run_poll.is_set():
7377
if x_mode:
@@ -179,6 +183,148 @@ def _monitor_x_wl_clipboard(
179183
time.sleep(timeout)
180184

181185

186+
def _monitor_wl_watch(enable_image_monitoring=False, enable_file_monitoring=False):
187+
"""Event-driven Wayland clipboard monitoring using wl-paste --watch.
188+
Uses the wlr-data-control-v1 protocol which does not create visible
189+
surfaces or steal focus. Supported by wlroots-based compositors
190+
(Sway, Hyprland, etc.) and KDE Plasma on Wayland.
191+
Returns True if watch mode ran successfully, False to fall back to polling."""
192+
global _block_image_once, _wl_watch_proc
193+
194+
last_error = None
195+
previous_clipboard = None
196+
ignore_patterns = [
197+
r"target .+ not available",
198+
r"no suitable type of content copied",
199+
]
200+
201+
try:
202+
_wl_watch_proc = subprocess.Popen(
203+
["wl-paste", "--watch", "echo"],
204+
stdout=subprocess.PIPE,
205+
stderr=subprocess.PIPE,
206+
)
207+
208+
time.sleep(0.5)
209+
if _wl_watch_proc.poll() is not None:
210+
stderr_out = _wl_watch_proc.stderr.read().decode("utf-8", errors="ignore")
211+
logging.warning(
212+
f"wl-paste --watch exited immediately: {stderr_out.strip()}"
213+
)
214+
_wl_watch_proc = None
215+
return False
216+
217+
logging.info(
218+
"Using wl-paste --watch for clipboard monitoring (no focus stealing)"
219+
)
220+
221+
while _run_poll.is_set():
222+
line = _wl_watch_proc.stdout.readline()
223+
if not line:
224+
break
225+
if not _run_poll.is_set():
226+
break
227+
228+
success, mime_output = execute_command("wl-paste", "-l")
229+
if not success:
230+
error_msg = f"Failed to retrieve MIME types: {mime_output}"
231+
if error_msg != last_error:
232+
logging.error(error_msg)
233+
last_error = error_msg
234+
continue
235+
236+
mime_list = mime_output.decode("utf-8")
237+
mime_list = mime_list.replace("\r\n", "\n").replace("\r", "\n").split("\n")
238+
mime_list = [m.strip() for m in mime_list if len(m.strip()) > 0]
239+
type_ = convert_mime_to_generic_type(mime_list)
240+
241+
# Text
242+
if type_ == "text":
243+
success, text = execute_command("wl-paste", "-n")
244+
if success:
245+
text = text.decode("utf-8")
246+
if len(text) > 0 and text != previous_clipboard:
247+
previous_clipboard = text
248+
if _callback_update:
249+
_callback_update("text", text)
250+
else:
251+
error_msg = (
252+
f"Failed to retrieve text content from clipboard. {text}"
253+
)
254+
if error_msg != last_error:
255+
if not any(
256+
re.search(pattern, error_msg.lower())
257+
for pattern in ignore_patterns
258+
):
259+
logging.error(error_msg)
260+
last_error = error_msg
261+
262+
# Image
263+
elif type_ == "image" and enable_image_monitoring:
264+
success, image = execute_command("wl-paste", "-t", "image/png")
265+
if success:
266+
if image != previous_clipboard:
267+
previous_clipboard = image
268+
if _callback_update and not _block_image_once:
269+
_callback_update("image", image)
270+
else:
271+
_block_image_once = False
272+
else:
273+
error_msg = (
274+
f"Failed to retrieve image content from clipboard. {image}"
275+
)
276+
if error_msg != last_error:
277+
if not any(
278+
re.search(pattern, error_msg.lower())
279+
for pattern in ignore_patterns
280+
):
281+
logging.error(error_msg)
282+
last_error = error_msg
283+
284+
# Files
285+
elif type_ == "files" and enable_file_monitoring:
286+
success, files = execute_command(
287+
"wl-paste", "-t", "text/uri-list", "-n"
288+
)
289+
if success:
290+
files = files.decode("utf-8")
291+
files = (
292+
files.replace("\r\n", "\n").replace("\r", "\n").split("\n")
293+
)
294+
files = [f.strip() for f in files if len(f.strip()) > 0]
295+
if files != previous_clipboard:
296+
previous_clipboard = files
297+
if _callback_update:
298+
_callback_update("files", files)
299+
else:
300+
error_msg = (
301+
f"Failed to retrieve files content from clipboard. {files}"
302+
)
303+
if error_msg != last_error:
304+
if not any(
305+
re.search(pattern, error_msg.lower())
306+
for pattern in ignore_patterns
307+
):
308+
logging.error(error_msg)
309+
last_error = error_msg
310+
311+
return True
312+
except FileNotFoundError:
313+
logging.warning("wl-paste not found, cannot use --watch mode")
314+
return False
315+
except Exception as e:
316+
logging.warning(f"wl-paste --watch failed: {e}")
317+
return False
318+
finally:
319+
if _wl_watch_proc is not None:
320+
_wl_watch_proc.terminate()
321+
try:
322+
_wl_watch_proc.wait(timeout=2)
323+
except subprocess.TimeoutExpired:
324+
_wl_watch_proc.kill()
325+
_wl_watch_proc = None
326+
327+
182328
def convert_mime_to_generic_type(mime_list):
183329
if "text/uri-list" in mime_list:
184330
return "files"
@@ -242,15 +388,23 @@ def _start_clipboard_polling(enable_image_monitoring, enable_file_monitoring):
242388
enable_file_monitoring=enable_file_monitoring,
243389
)
244390
else:
245-
_monitor_x_wl_clipboard(
246-
x_mode=XMODE,
391+
if not _monitor_wl_watch(
247392
enable_image_monitoring=enable_image_monitoring,
248393
enable_file_monitoring=enable_file_monitoring,
249-
)
394+
):
395+
logging.info(
396+
"Falling back to wl-paste polling mode for clipboard monitoring"
397+
)
398+
_monitor_x_wl_clipboard(
399+
x_mode=False,
400+
enable_image_monitoring=enable_image_monitoring,
401+
enable_file_monitoring=enable_file_monitoring,
402+
)
250403

251404

252405
def _runner(enable_image_monitoring=False, enable_file_monitoring=False):
253406
global _is_gdk_running, _run_poll
407+
logging.info(f"XMODE: {XMODE}")
254408
try:
255409
_run_poll.set()
256410
import gi
@@ -260,6 +414,7 @@ def _runner(enable_image_monitoring=False, enable_file_monitoring=False):
260414
from gi.repository import Gtk, Gdk
261415

262416
if "x11" in str(type(Gdk.Display.get_default())).lower(): # X11
417+
logging.info("Starting GTK clipboard monitoring for X11 display server.")
263418
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
264419
clipboard.connect(
265420
"owner-change",
@@ -293,7 +448,7 @@ def _start(enable_image_monitoring=False, enable_file_monitoring=False):
293448

294449

295450
def stop():
296-
global _clipboard_thread, _callback_update, _block_image_once, _run_poll, _is_gdk_running
451+
global _clipboard_thread, _callback_update, _block_image_once, _run_poll, _is_gdk_running, _wl_watch_proc
297452
if _clipboard_thread:
298453
if _is_gdk_running:
299454
import gi
@@ -304,10 +459,13 @@ def stop():
304459
Gtk.main_quit()
305460
_is_gdk_running = False
306461
_run_poll.clear()
462+
if _wl_watch_proc is not None:
463+
_wl_watch_proc.terminate()
307464
_clipboard_thread.join() # Wait for the thread to finish
308465
_clipboard_thread = None
309466
_callback_update = None
310467
_block_image_once = False
468+
_wl_watch_proc = None
311469
logging.info("Clipboard monitor stopped")
312470

313471

0 commit comments

Comments
 (0)