ClipDiff is a tiny macOS menu bar utility for comparing the last two copied text values or text files.
scripts/create-local-release.shThe app is copied to releases/ClipDiff.app and opened.
Push the highest version tag to build and publish a GitHub Release:
git tag v0.9.0
git push origin v0.9.0Release tags start with v followed by a digit. The workflow checks the remote
tags using Git's version ordering before allocating a macOS build runner. Lower
tags, deleted tags, and runs whose tag has moved to another commit are skipped.
It checks again before publishing to skip builds superseded while they ran.
A typo such as v.09.1101 is excluded from release-tag selection.
The workflow runs the core tests and builds the app and Finder extension in
Release mode for Apple Silicon and Intel. Tagged builds sign both bundles with
Developer ID, submit the app to Apple for notarization, staple the accepted
ticket, and verify the signature, ticket, and Gatekeeper acceptance after
extracting the final ZIP. Only then does it publish a GitHub Release with
generated release notes, ClipDiff-macOS.zip, and a SHA-256 checksum. Extract
the ZIP to get ClipDiff.app. Tags containing a hyphen, such as v0.9.0-rc.1,
create prereleases. Rerunning a successful tag build replaces the assets on its
existing release. Missing credentials or failed notarization stop publication;
tagged builds never fall back to ad hoc signing.
To rebuild the latest release tag from the current commit, commit your changes, then run:
scripts/retag-latest.shThis selects the highest v[0-9]* version tag on origin, deletes it remotely,
recreates it locally as a lightweight tag at HEAD, and pushes it to trigger
the release workflow. It also works when the tag already points at HEAD.
The existing GitHub Release is retained; a successful build replaces its assets.
The script requires a clean checkout, leaves branches and other tags alone, and
attempts to restore the previous tag if recreation fails. An optional argument
selects another remote, for example scripts/retag-latest.sh upstream.
Its integration tests use temporary local Git repositories:
python3 scripts/tests/test_retag_latest.pyFor a build from a branch, choose Actions → Build Highest Release → Run workflow. Download the ClipDiff-macOS artifact from that run. Branch builds use ad hoc signing, require no Apple credentials, and do not publish a GitHub Release. They are not notarized, so macOS Gatekeeper may require approval before opening them. Manually running the workflow on a release tag uses the same highest-tag check and notarized release path.
After merging the highest-tag workflow, disable the legacy release.yml workflow
in Actions (or run gh workflow disable release.yml --repo stuartd/MacClipboardDiff).
The replacement is release-highest.yml, named Build Highest Release. Then
create a new highest tag, or use scripts/retag-latest.sh from the updated commit
to rebuild the existing highest tag with the replacement workflow.
This switch matters because tag pushes and reruns use the workflow stored in
the tagged commit. Updating main alone does not retrofit checks into historical
tags. Disabling the legacy workflow prevents those old definitions from building
or being rerun. Existing tags, releases, and release assets do not need removal.
The highest-tag checks have local integration tests using temporary Git remotes:
python3 scripts/tests/test_release_eligibility.pyAbout shows Version 0.9.321 for a tagged release such as v0.9.321, and the
eight-character commit for other builds (for example, Version 1a3445bd),
including local Release builds. The repository name links to GitHub below it.
The source commit is embedded in every build; the bundle version and build number
still come from the Xcode project. Publishing uses GitHub's built-in token.
In repository Actions secrets, add the following repository secrets before pushing a release tag:
| Secret | Value |
|---|---|
BUILD_CERTIFICATE_BASE64 |
Base64-encoded .p12 export of one Developer ID Application certificate and its private key. |
P12_PASSWORD |
Password chosen when exporting that .p12. |
APPLE_ID |
Apple Account email used for notarization. |
APPLE_TEAM_ID |
Apple Developer team ID, currently ZQ5KWSZ72K. |
APPLE_APP_SPECIFIC_PASSWORD |
App-specific password generated for notarization at account.apple.com. |
An existing Developer ID Application certificate and notarization account used
for another app, such as MacDiff, can be reused. In Keychain Access, select the
certificate under My Certificates and export it with its private key as a
password-protected .p12. An Apple Development certificate is not suitable.
For example, upload the certificate directly to GitHub without printing it or
copying it through ClipDiff's monitored clipboard:
base64 -i /path/to/DeveloperID.p12 | gh secret set BUILD_CERTIFICATE_BASE64 --repo stuartd/MacClipboardDiff
gh secret set P12_PASSWORD --repo stuartd/MacClipboardDiff
gh secret set APPLE_ID --repo stuartd/MacClipboardDiff
gh secret set APPLE_TEAM_ID --repo stuartd/MacClipboardDiff
gh secret set APPLE_APP_SPECIFIC_PASSWORD --repo stuartd/MacClipboardDiffThe commands without piped input prompt for the value. A local notarytool
Keychain profile is not automatically available on GitHub runners. The workflow
imports the certificate into a temporary Keychain, validates the notarization
credentials before building, and deletes the Keychain at the end of the job.
Apple's submission result and available notarization log are retained as a
separate ClipDiff-notarization workflow artifact, including on failure.
Never commit signing certificates, private keys, or passwords to this repository.
With the Developer ID certificate installed and a notarytool Keychain profile
already configured, the same signing and packaging script can notarize an
existing universal Release build:
SIGNING_IDENTITY="Developer ID Application: Stuart Dunkeld (ZQ5KWSZ72K)" \
NOTARY_PROFILE="ClipDiff-Notary" \
scripts/notarize-release.sh /path/to/ClipDiff.app releases/notarizedThe input app must contain both Intel and Apple Silicon executables, including the Finder extension. This modifies its signatures and adds the notarization ticket. The verified ZIP and checksum go in the output directory, alongside notarization diagnostics. This command does not publish a GitHub Release.
ClipDiff runs one instance per user. Launching it again activates the running instance and exits the duplicate without requesting a diff. To switch to a newly built version, quit the running app first; quitting discards its captured text.
On its first ordinary launch, ClipDiff asks whether to Start at Login. Either answer is remembered; the menu toggle lets you change it later. If first launched for a Finder comparison, the question waits until the next ordinary launch. Login launches stay quiet, showing only the menu bar icon. The toggle follows the actual macOS setting, including changes made in System Settings. If macOS requires approval, the menu says so and offers Open Login Items….
Turning off Start at Login affects future sign-ins. ClipDiff captures clipboard changes whenever it is running. Quit ClipDiff stops capture and discards the captured text.
Enable startup from the Release app at a stable location, rather than a temporary Xcode build, so macOS can find that copy at login.
- Copy the older text or file.
- Copy the newer text or file.
- Press
Control-Option-Command-C, or choose Show Diff from the menu bar item. Choose Change Keyboard Shortcut… in that menu to record a different global shortcut. - View the diff in the selected viewer. The built-in native window remains the default.
You can also copy exactly two files together in Finder. ClipDiff immediately treats the first file as Previous and the second as Current.
Custom shortcuts must include Option or Control; Command and Shift are optional. The shortcut recorder checks enabled macOS system shortcuts and ClipDiff’s own menu commands. The same checks run at startup before registering the saved shortcut. It explains known conflicts and registration failures, and keeps the current shortcut active if a replacement fails. While the recorder has focus, pressing the current shortcut records it instead of opening a diff. Use Default selects Control-Option-Command-C; Save applies it, and Cancel leaves the saved shortcut unchanged. These checks cannot identify every shortcut used inside other apps.
The default avoids Finder’s Option-Command-C shortcut for Copy as Pathname. Users without a saved shortcut adopt the new default automatically. Explicitly saved shortcuts are preserved, including the old default. If you saved Option-Command-C, it still conflicts with Finder; choose Use Default, then Save, to switch.
The app includes a Finder Sync extension. Choose Finder Menu Settings… in the ClipDiff menu to open macOS System Settings, then enable or disable ClipDiff Finder Integration there. With exactly one regular file selected, Finder shows Compare with current ClipDiff capture. It reads the selected file only after invocation, moves the current in-memory capture to Previous, makes the selected file Current, and opens the configured viewer without changing the clipboard. If there is no capture (including after a cold start), ClipDiff activates and explains that text or a file must first be copied while the app is running.
The one-file command is hidden when the selection has the same standardized path as the current file capture. Files with matching names in different folders remain comparable. The extension receives only an in-memory, session-salted path fingerprint, and ClipDiff rechecks the path before accepting the comparison.
Finder’s Copy as Pathname also works, including one or two paths copied together. Copy one pathname, then choose Compare with current ClipDiff capture on another text file. ClipDiff finishes any pending pathname capture before starting the comparison, and a new copy prevents an older capture from hiding that command.
With exactly two regular files selected, Finder shows Compare two selected files with ClipDiff. Choosing it opens ClipDiff if necessary, reads and classifies the pair, replaces the in-memory comparison pair, and immediately shows the diff; the first Finder selection is Previous and the second is Current. Binary and other unusable files use the documented filename-and-reason fallback, with both sides converted independently. Zero, more than two, or any folder/non-regular selection shows neither command.
Finder aliases are resolved to their saved targets before file conversion. This
works when copying alias objects or their paths, and through both Finder comparison
commands. Captures use the target's filename and path; target contents follow the
same encoding, binary and 16 MiB limits as other files. A binary target contributes
its target filename followed by (binary file), including when paired with text.
Alias resolution uses macOS bookmark APIs without dialogs or automatic volume
mounting. ClipDiff does not launch the target or rewrite the alias. Chains stop
after eight aliases or on a cycle. An unresolvable alias contributes its own name
with (file unreadable) rather than exposing the alias record as text; a resolved
but missing/unusable target uses the normal target filename-and-reason fallback.
Finder file URLs take precedence over incidental path text on the pasteboard. A single copied file becomes one capture; exactly two copied files atomically replace the comparison pair; copies containing more than two files are ignored.
Paths copied as text use the same file conversion. Copy one or two absolute paths, one per line, with optional matching double quotes around each path. Spaces and Unicode are preserved; LF/CRLF line endings and trailing newlines are accepted. The first path is Previous and the second Current.
If any path is unquoted, every path must identify an existing file; otherwise the entire clipboard payload stays ordinary text. Fully quoted paths use the usual filename/reason fallbacks for missing files and directories. Malformed quoting, relative paths, URLs, and paths mixed with prose stay ordinary text. Recognized lists of more than two paths are ignored. Binary paths use the same filename-and-reason conversion as binary file objects. File checks and reads stay off the main actor, and newer clipboard updates supersede pending reads.
Binary files, including ZIP archives, contribute their filename followed by
(binary file), for example photo.png (binary file). Each side of a pair is
converted independently, so text can be compared with a binary file's reason
label. This applies to copied files, copied paths, resolved aliases and both
Finder comparison commands. Raw binary contents are not captured as text.
Binary detection uses known executable/package extensions, format signatures
(including PDF, images, archives and Mach-O executables), and content inspection.
Renaming a binary file to .txt does not bypass these checks. Scripts, SVG and
terminal captures containing ANSI escapes remain text when their bytes are usable.
For a usable text file, ClipDiff reads and retains the complete decoded contents in memory. It supports UTF-8, BOM-marked UTF-16 and UTF-32, common BOM-less UTF-16, and Windows-1252. Files larger than 16 MiB are not read.
Other unusable files contribute their filename followed by a reason:
(directory)(empty file)(file not found)(file unreadable)(file too large)
The source filename is shown in side-by-side headings and unified diff headers. When both files have the same basename, ClipDiff adds only enough parent directories to distinguish them, such as branch-a/Sources/settings.json and branch-b/Sources/settings.json.
The full standardized file path is retained only with the corresponding in-memory history entry. A generated diff keeps its resolved display label but drops the full path. File contents and paths are never logged, uploaded, indexed, or persisted.
- The app monitors
NSPasteboard.changeCountand captures only future changes. - A per-user file lock prevents duplicate clipboard monitors and hotkeys. Its file
in
~/Library/Application Support/ClipDiff/instance.lockcontains only a process ID; macOS releases the lock when the owning process exits, including after a crash. - The optional Finder extension uses selection metadata and the current capture's path fingerprint to choose the one-file or two-file command. File contents are read only after that command is chosen; the app rechecks selection requests and asynchronous capture identity before committing.
- Plain text and one or two copied file URLs are supported.
- Separate copy events are captured even when their text is identical.
- Unsupported non-text changes are ignored without clearing history.
- A copied value immediately followed by an explicit clipboard clear within 60 seconds is removed as a best-effort privacy measure.
- Captured content is kept in memory only and lost when the app exits.
- Slow file reads are superseded if a newer pasteboard change arrives.
- Clipboard items marked
org.nspasteboard.ConcealedTypeororg.nspasteboard.TransientTypeare ignored before ClipDiff reads either their text or file URLs. These advisory markers are commonly used by password managers and other privacy-conscious clipboard producers. - The built-in diff is native SwiftUI and never writes captured text to disk.
The menu's Diff viewer submenu lists supported applications found on the Mac, provides Choose Application… for another app or executable, and lets you return to the built-in viewer. The selection is remembered.
If no dedicated diff viewer is found, the submenu also offers Get MacDiff…, linking to the latest MacDiff release. Visual Studio Code and Cursor remain selectable but do not hide this companion recommendation. Any detected dedicated viewer (including MacDiff), or a manually chosen custom viewer, hides it.
ClipDiff uses macOS preferences (UserDefaults), stored in ~/Library/Preferences/stuartd.clipdiff.plist. To test the recommendation with other viewers installed, quit ClipDiff, set this flag, and reopen it:
defaults write ~/Library/Preferences/stuartd.clipdiff.plist ExternalDiff.ForceMacDiffRecommendation -bool trueLook for Get MacDiff… under Diff viewer. This override forces the link to appear even if MacDiff is already installed; it does not change the selected viewer. To restore automatic detection, quit ClipDiff, set the flag back to false, and reopen it:
defaults write ~/Library/Preferences/stuartd.clipdiff.plist ExternalDiff.ForceMacDiffRecommendation -bool falseUse the explicit preferences-file path: on Macs that previously ran a sandboxed ClipDiff build, defaults write stuartd.clipdiff … can target the old container's preferences instead of the file used by the current app.
ClipDiff recognizes these viewer profiles:
- MacDiff
- FileMerge
- Kaleidoscope
- Beyond Compare
- Araxis Merge
- Visual Studio Code
- Cursor
- BBEdit
- KDiff3
- Meld
- P4Merge
- SourceGear DiffMerge
The app checks Launch Services, /Applications, ~/Applications, common Homebrew command locations, and its process PATH. A manually selected unknown executable receives the previous and current file paths as two separate positional arguments. If a selected viewer is missing or cannot be launched, Show Diff falls back to the built-in viewer without discarding either capture.
Known profiles use their supported wait, read-only, diff, and side-label options. File-backed captures preserve their basenames in separate Previous and Current directories, while viewers with title support receive the same disambiguated labels as the built-in diff.
MacDiff is detected by its app bundle or MacDiff/macdiff executable. Existing saved MacDiff selections are recognized automatically on the next launch. Compatible MacDiff app bundles receive both files through Launch Services, reusing the comparison window and keeping the temporary inputs until MacDiff quits. Older bundles and standalone executables receive the previous and current file paths as positional arguments.
ClipDiff supplies those labels to Beyond Compare, Araxis Merge, KDiff3, Meld, and SourceGear DiffMerge. For example, two files named README.md can appear as Previous clipboard — old/README.md and Current clipboard — new/README.md. These viewers still read temporary copies of the captured text; the labels describe the original sources. VS Code and Cursor's command-line diff integration does not accept custom side titles, so they may display the temporary paths. Other viewer profiles preserve the source basenames where available and let the viewer choose how to display them.
The local build is not App Sandbox-restricted because it must start the explicitly selected external executable. The app remains dependency-free and does not add network access or broaden its clipboard and copied-file workflow.
The default view is side-by-side:
- previous text on the left
- current text on the right
- changed and removed lines tinted red
- changed and added lines tinted green
There is also a unified view for copying or scanning a compact diff. Diff rows are rendered lazily so larger text files do not eagerly construct every visible row.
Both views highlight changed text within paired lines, keeping emoji and combining characters whole. Refinement is bounded: large or costly comparisons retain their line-level colours even when inline highlights are skipped.
Ignore spacing in the diff window trims leading/trailing whitespace and treats
internal whitespace runs as one space while comparing. It does not make a b
equal to ab or ignore line breaks. The checkbox applies to the displayed pair,
including its summary and copied diff; it never normalizes the captures or their
displayed text. Turn it off to restore exact comparison. The choice lasts for the
current app session and affects the built-in viewer only.
On macOS, smoke-test both views with timeout = 30 versus timeout = 60,
then with tabs, emoji and combining characters. Resize the window and select/copy
text across a highlighted line. Compare a b with a\tb, toggle Ignore
spacing on (No differences) and off (differences restored), and check that the
copied diff still contains the original whitespace rather than normalized text.
The recent-clear behavior is only a heuristic. An unmarked secret is otherwise indistinguishable from ordinary text, and Swift strings cannot be guaranteed to be securely zeroed. Operating-system paging, process dumps, other clipboard monitors, and macOS clipboard behavior are outside ClipDiff's control.
Concealed and transient pasteboard markers are advisory and depend on the source application providing them. ClipDiff does not guess whether unmarked text is sensitive from its contents, length, or source application.
Copy diff writes ordinary Unicode text together with
org.nspasteboard.ConcealedType and org.nspasteboard.TransientType on the same
pasteboard item. The complete item is prepared before publication, so clipboard
managers do not receive an intermediate unmarked text value. Normal text pasting
still works, and ClipDiff suppresses its own copy event.
These markers ask compatible clipboard managers not to retain the diff. They are advisory conventions, not a system-wide guarantee against clipboard history, Universal Clipboard syncing, storage or inspection by another app.
The built-in viewer keeps the memory-only privacy model. Selecting an external viewer creates an explicit exception because another application cannot compare the captured strings directly. Before the first external comparison, ClipDiff warns that clipboard text may contain secrets and asks for confirmation. Cancelling opens the built-in viewer and creates no files.
After confirmation, each comparison writes two read-only UTF-8 plaintext files to a unique directory below the system temporary directory. ClipDiff attempts to delete that directory after the launched comparison process exits, when ClipDiff exits, and on its next launch. Cleanup is best effort: a crash, power loss, open file handle, or external application may leave or retain a copy. Do not select an external viewer when that disk exposure is unacceptable.
Only the selected executable path, the one-time warning acknowledgement, the chosen global shortcut, and whether the Start at Login question was handled are stored in app preferences. macOS manages the login-item registration. Clipboard text, previews, diffs, and source paths are never stored there.
The clipboard tests check that copied diffs include both privacy markers and remain pasteable as exact text. A macOS-only integration test uses a private pasteboard, never the user's general pasteboard, to check the real item formats.
The SwiftPM XCTest suite covers clipboard history, privacy-marker payload exclusion, recent clears, copied-file decoding and fallback behavior, filename disambiguation, version formatting, and line-based diff behavior:
swift testLogin-item tests use fake services and presenters to cover the one-time prompt, menu changes, external setting changes, approval, failures, and quiet automatic or Finder launches. After building, check the first-launch question and menu toggle, change the setting in System Settings and reopen the menu, then sign out and back in to verify a quiet launch. Declining the question or turning startup off should not cause another question on later launches.
On macOS, the SwiftPM suite also creates real Finder alias files to check text and binary targets, unresolved aliases and unchanged alias-file bytes. Smoke-test copying an alias and its pathname, alias/file pairs, and both Finder comparison commands; confirm the target's filename appears and no target application opens.
For Finder changes, also build the app and extension on macOS with scripts/create-local-release.sh, then smoke-test one-file captured-text/captured-file comparisons, the visible no-capture message, capture continuing after a comparison, unchanged clipboard, same-basename labels, unsupported selections, two-file cold start, and external-viewer warning/fallback.
For external-viewer labels, compare two files with the same basename in different folders in Beyond Compare or Meld. Confirm the panes show the shortest distinguishing source labels, including names containing spaces or Unicode. Also check plain-text captures still show Previous clipboard and Current clipboard.