Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ed144fd
stability fixes, admin UI overhaul, captcha tuning
Sathvik-Rao Oct 17, 2026
4c7204f
Update .gitignore
Sathvik-Rao Oct 17, 2026
0d97a03
fix desktop compatibility, macOS crashes, and connection recovery
Sathvik-Rao Oct 18, 2026
7acc42c
show server version in footer and mark current versions as latest
Sathvik-Rao Oct 18, 2026
a1ba502
server add system-aware dark theme and signup back link
Sathvik-Rao Oct 18, 2026
78c359e
keep extra login config collapsed by default in macos
Sathvik-Rao Oct 18, 2026
95282bf
expire P2P fragments after inactivity instead of total transfer time
Sathvik-Rao Oct 18, 2026
6776b4e
desktop use fixed reconnect delay with jitter
Sathvik-Rao Oct 18, 2026
4d925cc
hide keepalive echoes from website monitoring
Sathvik-Rao Oct 18, 2026
be539a8
fix(mobile): preserve UTF-8 characters across P2P fragment boundaries
Sathvik-Rao Oct 19, 2026
9786a99
fix(macos): remove extra tray icon padding
Sathvik-Rao Oct 19, 2026
d7655bd
feat(server): add opt-in persistent sessions and admin revocation
Sathvik-Rao Oct 20, 2026
51cc681
Update additional-spring-configuration-metadata.json
Sathvik-Rao Oct 20, 2026
b879b2b
fix(macos): handle shutdown requests and clean up desktop client
Sathvik-Rao Oct 20, 2026
773f885
feat(android): add custom SSL CA bundle support
Sathvik-Rao Oct 20, 2026
c10b190
fix(macos): resolve Ctrl+C hangs and native quit handling
Sathvik-Rao Oct 20, 2026
26c4d38
fix(android): make Gradle wrapper executable
Sathvik-Rao Oct 20, 2026
4f25a41
chore: restore version baseline for automated releases
Sathvik-Rao Oct 22, 2026
8d6e5e9
Merge branch 'main' into 3.3.0
Sathvik-Rao Oct 22, 2026
c83424f
Save client device preferences independently of login
Sathvik-Rao Oct 23, 2026
b58b7c8
feat(clients): improve login labels, setting help, and path selection
Sathvik-Rao Oct 23, 2026
1957015
feat: add periodic client update checks and configurable alerts
Sathvik-Rao Oct 23, 2026
6a986f5
docs: expand server API guide for alternative client development
Sathvik-Rao Oct 23, 2026
075e36d
fix(macos): prevent bundled OpenSSL mismatch at startup
Sathvik-Rao Oct 24, 2026
343a1ef
fix(linux-cli): align version row with menu border
Sathvik-Rao Oct 24, 2026
7301e2a
Fix single-instance locking on Linux and macOS
Sathvik-Rao Oct 24, 2026
f1ef378
docs: update README
Sathvik-Rao Oct 24, 2026
95eb352
fix(ci): install Python SSL certificates in macOS builds
Sathvik-Rao Oct 24, 2026
cb47bde
ci: move Apple Silicon release builds to macos-15
Sathvik-Rao Oct 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/scripts/apply_app_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

Targets:
- ClipCascade_Desktop/src/core/constants.py (windows, macos, linux_gui, linux_non_gui)
- ClipCascade_Mobile/src/App.js (android)
- ClipCascade_Mobile/src/AppVersion.js (android)
- ClipCascade_Mobile/src/android/app/build.gradle (android versionName + versionCode)
- ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/ServerConstants.java (server)

Expand Down Expand Up @@ -76,7 +76,7 @@ def patch_constants_py() -> None:


def patch_app_js() -> None:
path = REPO_ROOT / "ClipCascade_Mobile" / "src" / "App.js"
path = REPO_ROOT / "ClipCascade_Mobile" / "src" / "AppVersion.js"
replace_in_file(
path,
r"const APP_VERSION = '[^']+';",
Expand Down
44 changes: 39 additions & 5 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ the same source constants on `main`:
| version.json key | Synced into |
|----------------------------------|------------------------------------------------------------------------------|
| `windows` / `macos` / `linux_*` | `ClipCascade_Desktop/src/core/constants.py` (`APP_VERSION` per platform) |
| `android` | `ClipCascade_Mobile/src/App.js` (`APP_VERSION`) |
| `android` | `ClipCascade_Mobile/src/AppVersion.js` (`APP_VERSION`) |
| `android` | `…/android/app/build.gradle` (`versionName` + computed `versionCode`) |
| `server` | `…/constants/ServerConstants.java` (`APP_VERSION`) |
| `server` | `ClipCascade_Server/docker-compose/docker-compose.yml` (image tag) |
Expand Down Expand Up @@ -190,7 +190,7 @@ The `macos` scope builds two native packages with the same macOS version:

| Processor | GitHub runner | Archive |
|---|---|---|
| Apple Silicon | `macos-14` (`arm64`) | `ClipCascade-Apple_macOS(ARM_M-Series).zip` |
| Apple Silicon | `macos-15` (`arm64`) | `ClipCascade-Apple_macOS(ARM_M-Series).zip` |
| Intel | `macos-15-intel` (`x86_64`) | `ClipCascade-Apple_macOS(Intel-Series).zip` |

Both architectures use exactly **Python 3.12.10** from the signed python.org
Expand All @@ -200,9 +200,15 @@ dependency installation if the Python version or running architecture differs
from the required values. Tcl/Tk versions are not separately checked.
This replaces the previous Intel Python 3.11.2 pin.

Each job also checks aiortc/PyAV imports, uses the existing PyInstaller
specification, and verifies the packaged executable's architecture before uploading
its ZIP. These checks do not replace testing P2P in the packaged app.
Each job also checks aiortc/PyAV imports, builds with PyInstaller's `--clean`
option, and verifies the packaged executable's architecture. Before uploading,
it runs the actual bundled executable with `--self-test`, allowing up to 5
minutes for launch and execution to accommodate macOS first-launch security
checks. The CI step has a separate 6-minute limit. These limits do not bypass
security checks or approve interactive prompts; a launch requiring manual
approval still needs attention. This headless check imports the native TLS/media dependencies,
generates a certificate, and creates a P2P data-channel offer without starting
the GUI. These checks do not replace testing P2P between devices.
Both builds must succeed for the release to publish. When macOS is
unchanged, both ZIPs are carried over from the previous release.

Expand All @@ -212,6 +218,34 @@ prebuilt PyAV wheels. If PyAV falls back to source, its supported FFmpeg version
must match the installed libraries; installing the tools alone does not guarantee
source-build compatibility.

`requirements_mac.txt` requires a binary wheel for `cryptography`; CI disables
pip's cache for this installation. The official macOS wheels statically link
OpenSSL. A dynamically linked source build can load a different `libssl.3.dylib`
after PyInstaller rewrites library paths, causing errors such as
`Symbol not found: _SSL_get0_group_name` at startup. The macOS spec checks the
Rust binding with `otool -L` and rejects shared OpenSSL dependencies before
packaging. Installing another OpenSSL on the user's Mac does not repair the
library already embedded in the executable.

For a local rebuild, start with a **new** virtual environment using the same
python.org Python 3.12.10 interpreter as CI. From `ClipCascade_Desktop/src`:

```sh
/Library/Frameworks/Python.framework/Versions/3.12/bin/python3.12 -m venv .venv-macos
source .venv-macos/bin/activate
python3 -m pip install --upgrade pip
python3 -m pip install --upgrade pyinstaller pyinstaller-hooks-contrib
python3 -m pip install --no-cache-dir -r requirements_mac.txt
python3 -m PyInstaller --clean --noconfirm ClipCascade_macos.spec
dist/ClipCascade.app/Contents/MacOS/ClipCascade --self-test
```

Use a new venv name if `.venv-macos` already exists. Reusing an environment with
an installed source build can leave that build in place even with
`--only-binary`. Run the self-test on each target architecture and test the GUI
on the oldest macOS release you intend to support; a successful build on a newer
GitHub runner does not establish compatibility with macOS 12.

Include `macos` (or `desktop` / `all`) in the first releasing PR after adding Intel
support so the new Intel ZIP is generated; older releases may not contain it.

Expand Down
24 changes: 20 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ jobs:
fail-fast: false
matrix:
include:
- runner: macos-14
- runner: macos-15
arch: arm64
archive: ClipCascade-Apple_macOS(ARM_M-Series).zip
- runner: macos-15-intel
Expand All @@ -246,6 +246,7 @@ jobs:
--output "$PKG"
pkgutil --check-signature "$PKG"
sudo installer -pkg "$PKG" -target /
sudo "/Applications/Python 3.12/Install Certificates.command"
/Library/Frameworks/Python.framework/Versions/3.12/bin/python3.12 \
-m venv "$RUNNER_TEMP/clipcascade-python"
echo "$RUNNER_TEMP/clipcascade-python/bin" >> "$GITHUB_PATH"
Expand Down Expand Up @@ -286,7 +287,7 @@ jobs:
run: |
python3 -m pip install --upgrade pip certifi
python3 -m pip install pyinstaller
python3 -m pip install -r requirements_mac.txt
python3 -m pip install --no-cache-dir -r requirements_mac.txt

- name: Check aiortc before packaging
run: |
Expand All @@ -303,14 +304,29 @@ jobs:

- name: Build ClipCascade.app with PyInstaller
working-directory: ClipCascade_Desktop/src
run: python3 -m PyInstaller ClipCascade_macos.spec --noconfirm
run: python3 -m PyInstaller ClipCascade_macos.spec --clean --noconfirm

- name: Verify packaged executable architecture
env:
EXPECTED_ARCH: ${{ matrix.arch }}
run: |
lipo -verify_arch "$EXPECTED_ARCH" ClipCascade_Desktop/src/dist/ClipCascade.app/Contents/MacOS/ClipCascade

- name: Test packaged native dependencies
timeout-minutes: 6
run: |
python3 - <<'PY'
import subprocess

# Allow extra time for macOS security checks on the first launch.
print("Starting packaged self-test; allowing up to 5 minutes for launch and checks.", flush=True)
subprocess.run(
["ClipCascade_Desktop/src/dist/ClipCascade.app/Contents/MacOS/ClipCascade", "--self-test"],
check=True,
timeout=300,
)
PY

- name: Stage and zip (on macOS to preserve attributes)
env:
ARCHIVE_NAME: ${{ matrix.archive }}
Expand Down Expand Up @@ -633,7 +649,7 @@ jobs:
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add version.json \
ClipCascade_Desktop/src/core/constants.py \
ClipCascade_Mobile/src/App.js \
ClipCascade_Mobile/src/AppVersion.js \
ClipCascade_Mobile/src/android/app/build.gradle \
ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/ServerConstants.java \
ClipCascade_Server/docker-compose/docker-compose.yml
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
private/
screenshots/
/database/
/logs/
.DS_Store
.vscode/

Expand Down
22 changes: 21 additions & 1 deletion ClipCascade_Desktop/src/ClipCascade_macos.spec
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
# -*- mode: python ; coding: utf-8 -*-

import certifi
import importlib.util
import re
import subprocess

# PyInstaller rewrites dylib paths inside the bundle. A dynamically linked
# cryptography can then resolve to Python's different OpenSSL with the same
# filename, even when imports in the unfrozen environment succeed.
rust_binding = importlib.util.find_spec('cryptography.hazmat.bindings._rust')
if rust_binding is None or not rust_binding.origin:
raise SystemExit('cryptography Rust binding is missing; install requirements_mac.txt')
linked_libraries = subprocess.check_output(
['otool', '-L', rust_binding.origin], text=True
)
if re.search(r'/lib(?:ssl|crypto)[^/\s]*\.dylib', linked_libraries):
raise SystemExit(
'cryptography links to a shared OpenSSL library, which can collide with '
'the bundled Python OpenSSL. Reinstall official wheels in a fresh venv: '
'python3 -m pip install --no-cache-dir -r requirements_mac.txt\n'
+ linked_libraries
)

a = Analysis(
['main.py'],
Expand All @@ -10,7 +30,7 @@ a = Analysis(
hiddenimports=['tkinter'],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
runtime_hooks=['packaging/macos_self_test.py'],
excludes=[],
noarchive=False,
optimize=0,
Expand Down
41 changes: 30 additions & 11 deletions ClipCascade_Desktop/src/cli/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,28 +63,36 @@ def mainloop(self):

self.config.data["cipher_enabled"] = LoginForm.str_to_bool(
input(
f"enabled encryption(recommended) [{LoginForm.bool_to_str(self.config.data['cipher_enabled'])}]: "
f"Enable encryption (recommended) [{LoginForm.bool_to_str(self.config.data['cipher_enabled'])}]: "
)
or LoginForm.bool_to_str(self.config.data["cipher_enabled"])
)

self.config.data["notification"] = LoginForm.str_to_bool(
input(
f"enabled notification [{LoginForm.bool_to_str(self.config.data['notification'])}]: "
f"Show connection status notifications [{LoginForm.bool_to_str(self.config.data['notification'])}]: "
)
or LoginForm.bool_to_str(self.config.data["notification"])
)
self._save_preference("notification")

# extra fields
show_extra_fields = input(f"show extra fields [n]: ") or "n"
show_extra_fields = input(f"Show advanced settings [n]: ") or "n"
if not LoginForm.str_to_bool(show_extra_fields):
CustomDialog("Logging in...").mainloop()
return

self.config.data["update_notifications"] = LoginForm.str_to_bool(
input(
f"Notify when an update is available [{LoginForm.bool_to_str(self.config.data['update_notifications'])}]: "
) or LoginForm.bool_to_str(self.config.data["update_notifications"])
)
self._save_preference("update_notifications")

# Validate hash_rounds
while True:
hash_rounds = input(
f"hash rounds [{self.config.data['hash_rounds']}]: "
f"Key derivation iterations [{self.config.data['hash_rounds']}]: "
) or str(
""
if self.config.data["hash_rounds"] is None
Expand All @@ -94,7 +102,7 @@ def mainloop(self):

if not LoginForm.is_positive_integer(hash_rounds):
CustomDialog(
"Invalid Input\nHash Rounds must be a positive integer.",
"Invalid Input\nKey derivation iterations must be a positive integer.",
msg_type="error",
)
continue
Expand All @@ -103,7 +111,7 @@ def mainloop(self):
break

self.config.data["salt"] = (
input(f"salt [{self.config.data['salt']}]: ") or self.config.data["salt"]
input(f"Encryption salt [{self.config.data['salt']}]: ") or self.config.data["salt"]
)

self.config.data["save_password"] = LoginForm.str_to_bool(
Expand All @@ -112,6 +120,7 @@ def mainloop(self):
)
or LoginForm.bool_to_str(self.config.data["save_password"])
)
self._save_preference("save_password")

# Validate max_clipboard_size_local_limit_bytes
while True:
Expand All @@ -123,7 +132,7 @@ def mainloop(self):

max_clipboard_size_local_limit_bytes = (
input(
f"maximum clipboard size local limit (in bytes) [{saved_mcsll}]: "
f"Local clipboard size limit (bytes) [{saved_mcsll}]: "
)
or saved_mcsll
)
Expand All @@ -132,18 +141,20 @@ def mainloop(self):
)
if max_clipboard_size_local_limit_bytes == "":
self.config.data["max_clipboard_size_local_limit_bytes"] = None
self._save_preference("max_clipboard_size_local_limit_bytes")
break

if not LoginForm.is_positive_integer(max_clipboard_size_local_limit_bytes):
CustomDialog(
"Invalid Input\nMax Clipboard Size Local Limit must be a positive integer.",
"Invalid Input\nLocal clipboard size limit must be a positive integer.",
msg_type="error",
)
continue

self.config.data["max_clipboard_size_local_limit_bytes"] = int(
max_clipboard_size_local_limit_bytes
)
self._save_preference("max_clipboard_size_local_limit_bytes")
break

self.config.data["enable_image_sharing"] = LoginForm.str_to_bool(
Expand All @@ -152,26 +163,29 @@ def mainloop(self):
)
or LoginForm.bool_to_str(self.config.data["enable_image_sharing"])
)
self._save_preference("enable_image_sharing")

self.config.data["enable_file_sharing"] = LoginForm.str_to_bool(
input(
f"enable file sharing [{LoginForm.bool_to_str(self.config.data['enable_file_sharing'])}]: "
)
or LoginForm.bool_to_str(self.config.data["enable_file_sharing"])
)
self._save_preference("enable_file_sharing")

# Validate default file download location
while True:
default_file_download_location = (
input(
f"default file download location [{self.config.data['default_file_download_location']}]: "
f"Default download folder [{self.config.data['default_file_download_location']}]: "
)
or self.config.data["default_file_download_location"]
)

default_file_download_location = default_file_download_location.strip()
if default_file_download_location == "":
self.config.data["default_file_download_location"] = ""
self._save_preference("default_file_download_location")
break

if not os.path.isdir(default_file_download_location):
Expand All @@ -184,12 +198,13 @@ def mainloop(self):
self.config.data["default_file_download_location"] = (
default_file_download_location
)
self._save_preference("default_file_download_location")
break

while True:
ssl_ca_bundle = (
input(
f"ssl ca bundle path (optional) [{self.config.data.get('ssl_ca_bundle') or ''}]: "
f"Custom CA certificate bundle path [{self.config.data.get('ssl_ca_bundle') or ''}]: "
)
or (self.config.data.get("ssl_ca_bundle") or "")
).strip()
Expand All @@ -198,7 +213,7 @@ def mainloop(self):
break
if not os.path.isfile(ssl_ca_bundle):
CustomDialog(
"Invalid Input\nSSL CA bundle path is not a file or does not exist.",
"Invalid Input\nCustom CA certificate bundle path is not a file or does not exist.",
msg_type="error",
).mainloop()
continue
Expand All @@ -207,6 +222,10 @@ def mainloop(self):

CustomDialog("Logging in...").mainloop()

def _save_preference(self, field):
if not self.config.save_preferences({field: self.config.data[field]}):
CustomDialog("Unable to save device preferences.", msg_type="error").mainloop()

def on_quit(self):
pass

Expand Down
Loading
Loading