Skip to content

fix: macOS window crash + Windows tray-open regression - #35

Merged
offbyonebit merged 11 commits into
mainfrom
fix/macos-window-crash-and-syncthing-extract
Aug 7, 2026
Merged

fix: macOS window crash + Windows tray-open regression#35
offbyonebit merged 11 commits into
mainfrom
fix/macos-window-crash-and-syncthing-extract

Conversation

@offbyonebit

Copy link
Copy Markdown
Owner

No description provided.

The release archives ship helper files that share the binary's basename
(etc/firewall-ufw/syncthing, etc/freebsd-rc/syncthing), so matching on
endswith(target_name) could select one of those instead of the binary.
Whichever the archive listed first won.

On macOS this wrote a 175-byte ufw config over the binary, and startup
then failed with "OSError: [Errno 8] Exec format error" on every launch.
The tar.gz path had the same flaw.

The real binary sits directly under the top-level release directory, so
prefer the shallowest matching path.
Pillow's ImageTk relies on _imagingtk resolving Tcl/Tk symbols at runtime
by locating the _tkinter shared library. On interpreters that compile
_tkinter directly into the binary -- as the python-build-standalone builds
uv installs do -- there is no such library, the PyImagingPhoto Tcl command
is never registered, and constructing an ImageTk.PhotoImage dies with
"TypeError: bad argument type for built-in operation".

That killed the whole UI child process. Because TabbedWindow builds all of
its tabs eagerly and the Pair tab renders a QR code on construction, every
tray menu entry -- Settings and Devices included -- flashed a window for a
moment and vanished. Builds on other platforms use a Python with a normal
shared _tkinter, which is why this only reproduced on macOS.

Tk 8.6 decodes PNG natively, so encode to PNG bytes and hand them to a
plain tkinter.PhotoImage, skipping Pillow's Tk bridge. This keeps the fix
independent of which interpreter built the bundle. CTkLabel accepts a plain
Tk image; the only loss is CTkImage's HiDPI rescaling, and both call sites
already render at a fixed pixel size.
The OUT loop reads the clipboard while the IN loop writes it, from two
different threads, and neither path took a lock. The existing self._lock
guards last-value bookkeeping, not the clipboard itself.

The native clipboard is a single shared object and is not thread-safe. On
macOS this is worse than it looks: pyperclip picks its PyObjC backend over
pbcopy/pbpaste whenever AppKit is importable, which it always is in the
bundled app because the tray needs it. So paste() drives
-[NSPasteboard stringForType:] and copy() drives declareTypes:owner:, and a
read landing inside a write faults in
-[_NSPasteboardOwnersCollection handleOwnershipChange], killing the whole
tray process with SIGSEGV after some minutes of ordinary use.

Route all four native entry points (text read/write, image read/write)
through one module-level RLock. Text and images share the lock because they
target the same pasteboard.

The regression tests instrument the clipboard calls with a sleep rather than
a spin: sleeping releases the GIL, so an unsynchronized second thread
reliably lands in the window. Both tests fail if the lock is removed.
Fixes the confirmed findings from the audit pass. Each has a regression
test that was checked to fail against the pre-fix code.

Supply chain, syncthing.py:
- _verify_archive_hash failed open. A fetch failure or a missing entry for
  this platform logged a warning and extracted anyway, so anyone able to
  drop or poison one request got an unverified binary executed. Both paths
  now raise. This deliberately reverses behaviour that was tested and
  documented as intentional; availability is the cost and it is the right
  trade for an archive we then run.
- ensure_binary trusted the on-disk binary purely because --version printed
  the pinned string, which a replaced binary can trivially forge. Record
  the binary's SHA-256 when we install it from an archive whose signed hash
  we just verified, and check it on every start. The published sums cover
  the archive, not the extracted binary, so comparing the binary to them
  directly is not possible. The digest file sits beside the binary, so this
  does not stop an attacker who can write both; it does catch Syncthing
  self-upgrading over its own binary, partial extraction, and tampering
  that misses the sidecar. The check is local, so a good binary still
  starts offline.

Sync folder, main.py:
- _on_folder_changed restarted only ClipboardSync. Syncthing reads the
  folder path once, when prepare_home patches config.xml, and FileTransfer
  schedules its observer at construction, so both kept pointing at the old
  directory: clipboard.txt was written where no peer replicated it and sync
  silently stopped. Now restarts Syncthing and FileTransfer too, and
  persists the setting itself rather than relying on the UI process.

OUT loop and IN dispatch, clipboard.py:
- A transient OSError left _last_synced set, so the "already sent" guard
  made every later tick skip that value and it was never synced again.
  EncryptedPayloadError already rolled back; OSError now does too, on both
  the text and image paths.
- One shared debounce deadline meant a clipboard.txt and clipboard.png
  update inside the same 100ms window suppressed each other. Now per path.
- The heartbeat truncation guard tested only str, so a synced image was
  repr()'d whole into the log every 6 seconds. Extracted as
  _truncate_for_log and applied to bytes as well.

file_transfer.py:
- _seen used an unguarded check-then-add, and watchdog dispatches from a
  thread pool on Windows, so one file could be delivered twice.

Also fixes tests that hardcoded the Linux archive name: on any other
platform the lookup missed, verification was skipped, and the assertions
were vacuous. They now derive the name for the running platform, which
also repairs a test that was already failing on macOS.
The release-key signature check needed gpg on PATH. Stock Windows and macOS
do not ship it, so for most users _verify_release_signature logged a warning
and returned, leaving the hash as the only protection -- and that hash was
fetched from the same origin as the download it was meant to vouch for. The
docstrings described a verified supply chain that end users were not
actually getting.

Move the trust anchor into the source tree. _syncthing_hashes.py holds the
SHA-256 of every release archive for the pinned version, and
_expected_archive_hash prefers it over any network lookup, so verification
for the shipped version no longer depends on a request that can be blocked
or poisoned, and no longer depends on gpg existing at all.

The hashes are not hand-copied. tools/refresh_syncthing_hashes.py fetches
sha256sum.txt.asc and feeds it to the app's own
_verify_release_signature, refusing to emit anything unless the PGP
signature verifies against the pinned fingerprint. Verification happens once
on a maintainer's machine at release-prep time rather than never on a user's.

The fetched-sums path stays as a fallback for versions we have not pinned,
still fail-closed.

Verified end to end: the pinned macOS hash was confirmed against a real
download of the v2.0.16 archive, and a clean install with the binary
directory deleted now reaches "archive hash verified" without ever
requesting sha256sum.txt.asc.

A test asserts the manifest covers config.SYNCTHING_VERSION, so bumping the
version without regenerating fails the suite rather than silently dropping
back to the network path.
_load() logged "Failed to decrypt clipboard history" and returned with an
empty in-memory list. Nothing marked the file as untouchable, so the next
add_entry() -- which clipboard.py issues on every OUT tick -- persisted that
empty list straight over it. Measured on the pre-fix code: a 5-entry
encrypted history became a 1-entry file, unrecoverable even with the
correct passphrase.

The sync file has been protected from precisely this since the #21 audit,
via ClipboardSync._refuse_if_unreadable_ciphertext. The history file holds
the same clipboard text and had no equivalent guard.

An unreadable file is now moved aside to
clipsync_history.unreadable-<timestamp>.json and a fresh history started, so
the ciphertext stays recoverable if the passphrase turns up while history
keeps working. If it cannot even be moved, the session goes in-memory only
rather than clobbering it. Both the "wrong passphrase" and "passphrase
cleared from settings" paths are covered, as is ciphertext written by a
newer build: is_encrypted() matches CSENC of any version precisely so a
downgrade cannot destroy a newer machine's data.

Also tighten permissions on the temp file before the rename rather than on
the final name after it. The old order left a window where a file of
clipboard text sat at its real name readable by other local users.

4 of the 14 new tests fail against the pre-fix code. The tests only ever
count entries; none assert on clipboard text.
send() was a bare shutil.copy2, so configuring a passphrase protected
clipboard text while every sent file sat in the shared folder as plaintext,
readable by anything with access to that directory and replicated that way
to each peer. Verified against the old code: with a passphrase set, the
marker bytes were present verbatim in the shared copy, at mode 0644.

Files now encrypt on the way into the folder (gaining a .csenc suffix) and
decrypt on the way out to Downloads. Decrypting there rather than in place
is deliberate: writing plaintext back inside the synced folder would hand it
straight to Syncthing and undo the encryption for every peer.

Fernet holds an entire payload in memory, so a naive port would have made
large sends fatal. crypto.py gains a chunked streaming format instead:
CSENCF magic, salt, then length-prefixed Fernet tokens over
(chunk index || data), ending in an authenticated empty chunk. The index is
inside the authenticated plaintext so chunks cannot be reordered or dropped
undetected, and the terminator catches truncation, which would otherwise
decrypt cleanly to a prefix and hand the user a corrupt file that looks
whole. A failed decrypt removes its partial output.

copy2 also preserved the source mode, so a world-readable original stayed
world-readable in the shared folder; both paths are now 0600.

A peer with no passphrase gets a clear notification instead of a file of
ciphertext dumped in Downloads. With no passphrase configured the previous
plaintext behaviour is unchanged, so older peers keep working.

17 new tests covering roundtrip at chunk boundaries, tamper and truncation
detection, permissions, and partial-file cleanup.
LogMirror copied this device's log into the shared folder every 10s. That
folder is replicated to every paired device, so the log -- hostnames, device
IDs, file names, error traces -- went to all of them. It was always on, had
no setting, and was not mentioned in the README. No clipboard text is ever
logged (every clipboard log line records a character count), but none of
that is visible from the tray.

Now off by default, with a Settings switch and an explanation of what it
shares. The thread self-gates each tick so the toggle applies without a
restart, and switching it off deletes our own published log from the shared
folder -- otherwise the last copy keeps replicating to peers forever. The
first disabled tick also retracts a file left by an older always-on build,
which is the upgrade path for every existing user. Peers' logs are never
touched.

Separately, ClipboardHistory parsed settings with a bare int(), and it is
built during startup, so a single malformed value in settings.json raised
before the tray appeared -- verified: history_max_items="not-a-number"
crashed with ValueError. Parsing is now defensive. bool("false") is True,
which is exactly the trap a JSON-stringified setting falls into, so
history_enabled="false" silently kept history on; strings are matched
explicitly. history_auto_clear_minutes now accepts "30" and falls back to 0
(never expire) rather than an arbitrary retention.

29 new tests. All three old behaviours were confirmed against the pre-fix
source before changing anything.
ClipboardSync builds a ClipboardHistory bound to config.HISTORY_FILE, so any
test constructing one writes wherever that module global points. Several
suites also set an encryption passphrase before syncing, so an unisolated
run does not merely read the developer's real clipboard history: it
overwrites it, encrypted with a throwaway key, and the installed app can
then never read its own history again.

Confirmed on a real machine: running the suite replaced a 76KB history with
a 1698-byte file that decrypts under 'shared-secret', repeatedly, once per
run. The app dutifully reported 'passphrase mismatch' every launch and the
cause looked like an app bug for hours.

Modules patched HISTORY_FILE individually and four did not:
test_cross_os_sync, test_image_sync, test_linux_paste_freeze,
test_mac_windows_sync. An autouse conftest fixture makes isolation the
default for every test rather than something each new file must remember,
and covers SETTINGS_FILE, LOG_FILE, APP_DATA_DIR and SYNC_FOLDER too.

Verified: a full suite run now leaves the real history byte-identical.
@offbyonebit
offbyonebit merged commit a1e25fb into main Aug 7, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant