This page is generated from MANUAL.md in the repository; it is the same guide bundled in the app's Help menu.

Unison-UI-Mac, User Manual

A feature-by-feature guide to the macOS app. For install steps see INSTALL.md; for architecture overview and a build cheatsheet see README.md. For the underlying file-synchronization concepts (profiles, roots, paths, ignore patterns, conflict resolution, the merge preference, archive files), the authoritative reference is the upstream Unison documentation:

The sections below cover how the macOS GUI exposes those concepts, not the concepts themselves. When a feature touches a .prf preference, it's linked to the section of the upstream docs that defines it.


Table of contents

  1. Concepts in one paragraph
  2. Getting started
  3. The unison command
  4. The Profile Picker
  5. The Profile Editor (manager window)
  6. The Profile Form (single-profile content editor)
  7. The Reconcile window
  8. The Diff viewer
  9. Settings
  10. The menu bar reference
  11. Keyboard shortcuts
  12. Troubleshooting
  13. What this app does NOT do

Concepts in one paragraph

Unison synchronizes a pair of file trees ("replicas") rooted at two paths ("roots"), both can be local directories, or one or both can be remote over SSH. A profile is a .prf file under ~/Library/Application Support/Unison/ that declares the two roots plus any preferences (paths to sync, paths to ignore, merge command, etc.). Running a sync involves two phases: reconcile (Unison scans both sides and lists the differences it found) and propagate (Unison applies the decisions the user made about each difference). Between reconciles, Unison keeps a per-replica archive file describing the last-known state so the next scan can tell what's changed.


Getting started

1. Install Unison on remote machines (SSH profiles only)

The app embeds Unison's OCaml runtime, so the local machine doesn't need a separate unison install. But profiles whose root is ssh://… spawn unison -server on the remote host, so that remote needs a unison command its non-interactive shell can find (or a servercmd = … line in the profile).

Ways to provide it on the remote side:

This project's embedded Unison is v2.54.0 (see README's "Unison version" section for the exact upstream commit). The compatibility boundary is Unison's 2.52.0 wire protocol, not an exact version match: any remote at >= 2.52.0 interoperates (so 2.53.x and 2.54.x work together), while 2.51.x and earlier are on the other side of the boundary and cannot connect. The app runs a one-shot ssh ... unison -version probe on profile open and surfaces a suppressible alert only when the two sides straddle that boundary (see Version-mismatch warning below).

2. Launch the app

The app opens with the Profile Picker (see next section). On first launch, if you have no profiles yet, the list will be empty, use Edit → Profile Editor… (⌘⇧E) to create one.

3. Pick a profile, hit Run

Double-click a profile (or select it and click Run) to start reconcile. The reconcile window opens immediately in "scanning" mode and populates when init1+init2 complete.


The unison command

The app bundle carries a command-line launcher at unison-ui-mac.app/Contents/MacOS/cltool. Linked onto PATH under the name unison, it makes one command serve both of Unison's roles through this app:

Command What runs
unison -ui graphic <profile> This app. The named profile opens and its connection and scan start right away, with no further click; it stops at the reconciliation results, so nothing is applied until you act. The profile must be one the picker lists and whose row opens the same file Unison would (p.prf given while a file named plainly p also exists is refused, since the picker's p would open that other file); a hidden profile is refused with a message. Two roots are refused.
unison -ui graphic (no profile) This app, at the profile picker.
unison -ui text <profile> Unison's text interface in the terminal, on the embedded engine.
unison -version, unison -doc …, unison -help Printed by the embedded engine.
unison -server, unison -socket … The embedded engine in server mode. This is what a remote peer's ssh invocation runs, so a Mac with this app installed needs no other Unison to be the far side of an SSH profile.
unison <profile>, unison -batch <profile>, with no -ui The interface set by Default interface in Settings ▸ Command Line, which is Graphical unless you save Text, so this opens the app by default. This is a change from earlier versions, where a bare unison <profile> ran the text interface; set Default interface to Text, or pass -ui text, to keep that. An explicit -ui text or -ui graphic overrides the preference.
unison root1 root2 (two roots), with no -ui Roots are not supported by the graphical interface, so they require Text: pass -ui text. Under the Graphical default they are refused with a message.
unison -ui graphic …, or the Graphical default, where no graphical session exists (over ssh, cron, launchd) Refused with a message; pass -ui text.
unison with no arguments, over ssh Uses the Default interface preference. With Text it runs the text interface, which like upstream's unison uses the default profile if one exists and prints usage otherwise. With Graphical selected, it requires an available graphical session; otherwise it is refused. Pass -ui text to request the text interface explicitly.

The command hands Unison's engine the default interface (Text or Graphical, per the Default interface preference above) followed by the arguments exactly as typed, and Unison interprets them: a later -ui graphic or -ui text wins over that default, -server and -version take effect before any interface choice, -ui=text means the same as -ui text, and options take exactly one leading dash (--ui is an unknown option to Unison). Unknown options are reported by Unison with its usage text.

When the graphical interface is already running and a unison <profile> request reaches it, the request goes to that instance rather than starting a second one. The request carries its session-scoped options, so the profile opens scoped exactly as it would at a fresh launch, with order, repeated options, and profile precedence preserved; how the running app was itself started no longer matters. These are Unison's session preferences, with -path, -include, and -source as common examples rather than an exhaustive list. A -path is root-relative and adds to the profile's configured path preferences rather than replacing them, following Unison's usual precedence: with path = Pictures in the profile, unison home -path Documents syncs both Pictures and Documents; only a profile with no configured paths is then restricted to Documents alone. These options apply for that session: the opened profile and any rescans or reconnects it makes. Opening a profile from the picker afterward, the same profile or a different one, uses that profile's saved configuration, not the earlier command line's options.

How the request is handled depends on what the app is doing:

A request is still refused, with a reason, while the profile editor is open (close it first; your edits are kept) and when the app needs to be quit and reopened after a connection problem, as is one from a different copy of the app or one that uses a different Unison directory.

A Started reply means the app began opening the profile; the open may still be connecting, and its scan has not necessarily begun. An accepted and waiting reply means the request was queued and will open when the app is free. The two are told apart by the reply message, not by exit status: both are successful and exit 0, and neither certifies that a scan or synchronization has finished. If the reply is lost, because the running instance did not answer in time, the outcome is left unconfirmed: the command says so and asks you to check the app before running it again, since the original request may already have been accepted.

Three quick examples:

For scripts and scheduled jobs, pass -ui text explicitly rather than relying on the default. A script that omits it runs the graphical interface wherever the preference is Graphical, and a unison <profile> with no graphical session (over ssh, from cron, or from launchd) is then refused with a message instead of running in the terminal.

unison -ui graphic keeps the terminal busy until the app quits, like any foreground command. Append & to get the prompt back.

Putting it on PATH

Settings → Command Line shows what unison resolves to and adds the app's command to your login shell's PATH for you, with no administrator password (see Settings). The command lives inside the bundle at unison-ui-mac.app/Contents/SharedSupport/bin/unison; Add Terminal Setup writes a marked, app-managed block to your login shell's startup file that puts that directory on PATH. When the app cannot edit the file safely on its own it falls back to Manual setup, naming the directory to add and, when it can identify one, the file to edit.

The Settings setup and a hand-edit are separate, and the app manages only its own. Settings adds a marked block it records, repairs that block when the app moves (while Keep unison in Terminal pointing at this app is on), and removes it with Remove Terminal Setup. A line you add by hand is yours to keep up to date: the app never reads, repairs, or removes it.

To do it by hand, put the app's command directory on your login shell's PATH. This example is for a login shell that reads ~/.zprofile (stock zsh); use your own shell's startup file, and substitute the app's actual location if it is not in /Applications:

echo 'export PATH="/Applications/unison-ui-mac.app/Contents/SharedSupport/bin:$PATH"' >> ~/.zprofile

Only one command can own the name. Check what unison resolves to before and after, in a fresh login shell:

which -a unison

A Homebrew unison formula or upstream Unison.app's own launcher may already hold it. Leave it and skip this section if you prefer: the app works from the profile picker either way. For Homebrew installs of the app: with the formula linked, Homebrew installs the app but skips the unison link with a warning and the formula keeps the command; to give the command to the app, run brew unlink unison and then brew reinstall --cask unison-ui.

Remote peers

Peers whose profiles target this Mac run unison -server here through ssh. The PATH that command receives is not the one your Terminal has: it depends on this Mac's SSH server configuration and on the login shell's non-interactive startup files, and it may or may not include the directory that holds the link. Do not rely on it: set servercmd in the peer's profile to the full path of the executable you intend to run, for example servercmd = /Applications/unison-ui-mac.app/Contents/SharedSupport/bin/unison or servercmd = /opt/homebrew/bin/unison, after checking with readlink that the path resolves into this app (see Repair and migration).

Launch a freshly installed app once from Finder before relying on it as an ssh peer. That first launch is where macOS runs its check of the downloaded bundle, and it needs a graphical session; a headless ssh invocation cannot answer it. A successful launch shows that macOS accepts the bundle for launching; it is not a statement about the bundle's quarantine metadata.

Repair and migration

Which command runs. Three things can answer to the name unison on a Mac with this app:

Typing bare unison runs whichever comes first on that shell's PATH. A peer's servercmd with an absolute path runs exactly that path on this Mac, whatever PATH says. To see what a path is, use readlink <path>, which shows the executable it resolves to; a path inside unison-ui-mac.app is this app. <path> -version then shows that the executable starts and which Unison and OCaml versions it was built with; the OCaml version tells builds apart (this app's engine and Homebrew's formula are usually built with different ones) but it is the resolved path, not the version string, that identifies the installation.

Repairing the app's setup after a move. When the app is moved or replaced, its command directory changes and a PATH block that named the old location stops resolving. While Keep unison in Terminal pointing at this app is on, the app rewrites its own block to the current location at the next launch; the Command Line tab also offers to add or repair it. The app only ever rewrites its own marked block: a link or PATH entry you created by hand is shown but left for you to fix.

Formula and app. Homebrew's unison formula and this app can be installed together. With the formula linked, brew install --cask unison-ui (or an upgrade) installs the app but keeps the formula's command: Homebrew prints "already a Binary … from formula unison; skipping link", and /opt/homebrew/bin/unison, and any servercmd naming it, runs the formula, not the app. To give the command to the app:

brew unlink unison
brew reinstall --cask unison-ui

The formula stays installed, only unlinked. To give the command back to the formula later, remove the app's cask (brew uninstall --cask unison-ui) and run brew link unison.

Migrating from the legacy unison-app cask. Older setups have /opt/homebrew/bin/unison linked to upstream's Unison.app launcher by the now-disabled unison-app cask. That link is the working ssh server for peers whose servercmd names it, so keep the upstream app until the replacement is verified.

  1. Record the current target: readlink /opt/homebrew/bin/unison.
  2. Free the name without losing it, only if the backup name is unused: mv /opt/homebrew/bin/unison /opt/homebrew/bin/unison.upstream-link. From here until step 5 passes, peers that name this path cannot connect.
  3. Install or refresh the cask so that Homebrew runs its artifacts, which is what creates /opt/homebrew/bin/unison pointing at this app's launcher. The command depends on what Homebrew already records: - the app is not installed through Homebrew: brew install --cask unison-ui; - it is installed and a newer version exists: brew upgrade --cask unison-ui; - it is installed and already current: brew reinstall --cask unison-ui. An upgrade with nothing newer to install does not recreate the link. Homebrew records the installed version in /opt/homebrew/Caskroom/unison-ui/ (also shown by brew info --cask unison-ui); check it and pick the matching case. Check afterwards that the link exists and points into this app: readlink /opt/homebrew/bin/unison.
  4. Launch the app once from Finder.
  5. Verify as described under Verifying the intended remote server below.
  6. If installation or verification fails, restore service: check that /opt/homebrew/bin/unison is either absent or the new app link, remove it if present, then mv /opt/homebrew/bin/unison.upstream-link /opt/homebrew/bin/unison. The upstream app is untouched, so peers work again at once. When you retry later, check the version Homebrew recorded before choosing the step 3 command: if the installation had succeeded and only service was rolled back, the receipt is current and the reinstall case applies; if Homebrew's own failed upgrade rolled the app back, the receipt may still be the older version and the upgrade case applies.

Do not run brew uninstall --cask unison-app as part of this migration. That cask's uninstall removes whatever link sits at /opt/homebrew/bin/unison, including the new one, and deletes Unison.app. Treat it as a separate step for later, with a recovery ready: recreate the link with ln -s /Applications/unison-ui-mac.app/Contents/MacOS/cltool /opt/homebrew/bin/unison and re-verify.

Sparkle and Homebrew. Sparkle updates the app bundle in place; Homebrew's receipt keeps the version Homebrew itself installed. A later brew upgrade --cask --greedy unison-ui reinstalls the current version and brings the receipt up to date; when the receipt already matches the current version, brew reinstall --cask unison-ui is the command that reruns the artifacts. If the formula owns the command at that time, the upgrade succeeds and leaves the command with the formula, as described above; that is the expected outcome, not a failed upgrade.

Verifying the intended remote server. Three checks, each proving one thing:

The app never rewrites profiles. Set servercmd yourself.


The Profile Picker

The launch view. A simple list of every .prf file in ~/Library/Application Support/Unison/, sorted by your custom order if you've set one (see Profile Editor), otherwise alphabetical. Hidden profiles (also a Profile Editor feature) don't appear here.

Run (⏎ / Enter): selects the highlighted profile and opens the reconcile window in scanning mode.

Double-click also runs.

Quit: at the far left of the bottom bar, quits the app (same as ⌘Q). Placed away from Run so it isn't clicked by reflex.

The button is labeled "Run" rather than "Open" because picking a profile kicks off the sync workflow, same verb as the CLI unison <profile>. Nothing here opens a document for editing.

Initial selection:

Profile creation, editing, deletion, etc.

Not on the picker, those live in the Profile Editor manager. The picker is intentionally minimal: list + Run.

Refresh

The picker re-reads the Unison directory automatically every time its window becomes key, so creating a .prf via the CLI, copying one in from a backup, or editing a name in Finder is picked up the moment you switch back to the app. No manual refresh action on the picker; the Profile Editor has an explicit Refresh button (and ⌘R) for the edge case where you want a re-read without losing focus.


The Profile Editor (manager window)

Opens via Edit → Profile Editor… (⌘⇧E).

A table of every profile, with three columns:

Above the list, a path line shows the Unison directory. The folder glyph at its right opens that directory in Finder, handy for inspecting .prf files or archive files directly.

Bottom-bar buttons (left to right by profile lifecycle):

If the profile has matching archive files in the Unison directory (ar<hash>, fp<hash>, tm<hash>, sc<hash> — never the lock lk<hash>), the confirmation grows a checkbox: "Also move N archive file(s) to Trash", checked by default. The removal runs through the crash-safe archive-mutation transaction (it acquires each archive's lock, stages the files, and Trashes them as one unit; the lock is held, never trashed). The hash is computed from the profile's roots before the .prf is deleted, so we still know which archives belong to it. If you uncheck the box, the .prf goes but the archives stay; useful when you plan to restore the profile from Trash and resume syncing. When there are no matching archives (e.g. both roots are remote, archives already cleaned up), the checkbox is hidden. - Reset Archives…: for the selected profile, compute its archive hash (see archive files in the upstream wiki), find the matching payload files ar<hash>, fp<hash>, tm<hash>, sc<hash> (never the lock lk<hash>) in the Unison directory, and remove them through the crash-safe transaction (acquire lock → stage → Trash as one unit; the lock stays in place). The confirmation dialog lists exactly which files will be moved plus the computed hash (you can cross-check against unison -showArchiveName <profile> on the CLI if you want). The next sync of this profile will then rebuild reconciliation state from scratch, a full re-scan of both replicas. Use this when you want to clean archives but keep the profile itself; Delete's archive-cleanup checkbox is the right tool for "I'm done with this profile entirely." - Refresh (⌘R): re-reads the .prf directory. The editor also auto-refreshes whenever its window becomes key, so this button is for the edge case where you modify files in another tool without losing focus on the editor (e.g. running a command in Terminal that's already visible in a split with the editor window key). - Done: closes the manager window. ⏎ activates.

Hidden profiles are still listed (dimmed) so you can unhide them.

Footer text: "Hide and reorder only affect this app's picker. The CLI unison <profile> still sees every .prf in the directory."


The Profile Form (single-profile content editor)

Opens from the manager's New… or Edit… buttons. A sidebar on the left lists the editor's sections, with a search box above it that filters the list as you type. A small pop-out button in the window's top-right corner opens the raw .prf in your default editor for that file type (enabled once the profile has been saved at least once). Each section is described below.

General

Roots

Two text fields (First root / Second root), each with a Browse… button for picking a local directory. Either root can be:

Both roots can be local. There is no client/server distinction in the .prf. "First" and "Second" just match the order of the root = … lines.

Remote Connection. When either root is ssh:// or socket://, an extra group appears with the remote-connection prefs: servercmd (path to Unison on the remote), sshcmd, sshargs, and clientHostName. It hides again when both roots are local.

Paths

One path per line; each becomes a path = … entry. An empty list means "sync everything under the roots." See path specification in the upstream wiki for the syntax.

Lines beginning with # are comments and are kept in place across edits. When a long line wraps, the continuation is shown with a hanging indent so you can tell a wrap apart from a new entry. (The same applies to the Ignore and Exceptions fields.)

Ignore

File Attributes

Which file metadata Unison keeps in sync. Each control has a Default state that leaves the setting out of the profile (Unison's standard behavior):

Options

How a sync runs, as opposed to what is synced:

Includes

Pull in another prefs file with an include directive. Each row is a file name (the dropdown lists the other .prf files in your Unison directory, or you can type a name), an optional comment line, and a Top / Bottom position:

For list-valued prefs (ignore, ignorenot, path) the position has no effect; they accumulate either way. Use this to share a list across profiles, for example a "common" file of ignore patterns. Note that an include pulls in the entire target file, so a shared list file should contain only the relevant lines. A banner at the top of the editor notes when a profile includes others.

Profile names with spaces are fine, they're written back-slash-escaped (include File\ System\ Ignores.prf), which is how Unison reads a name as a single word. The .prf extension is added on the saved line for clarity; the editor still shows you the bare profile name.

Advanced (other prefs)

A raw key = value box for any preference the form doesn't surface. One entry per line. Unknown keys, comments, and blank lines all survive a load-edit-save round trip unchanged. Settings that have their own section (and include directives) can't be saved from here: the editor stops you and points you to the right section, so nothing is silently dropped.

Full reference: the Preferences section of the upstream wiki.

Save / Cancel

Save writes the .prf atomically after backing up the previous version to .prf.bak. Cancel discards everything.


The Reconcile window

Opens when you click Run on the picker. Initially in "scanning" mode showing the indeterminate progress bar; populates with the list of differences once Unison's init1 + init2 complete (init1 sets up the roots and any SSH connection, that's where credential prompts appear, and init2 walks both replicas to compute differences).

Anatomy

┌─────────────────────────────────────────────────────────────────────┐
│ [Profiles] [Rescan] | [← First | → Second | Skip | Merge] | [Go] │ ← toolbar
├─────────────────────────────────────────────────────────────────────┤
│ 142 items · 1.2 GB · 3 conflicts · 12 First → Second              │ ← summary
│ ▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░  35%                              │ ← global progress
├─────────────────────────────────────────────────────────────────────┤
│ Path                | First | Action | Second | Size | Progress     │ ← columns
├─────────────────────────────────────────────────────────────────────┤
│ ▾ 📁 Documents      |   ●   |    →   |        |      |              │
│   📄 notes.txt      |   ●   |    →   |        | 24 KB|              │ ← row
│   📄 photo.jpg      |       |    ←   |   ●    | 1 MB │              │
│ ▸ 📁 Pictures       |   ●   |    →   |   ●    |      │              │
├─────────────────────────────────────────────────────────────────────┤
│ /Users/you/Documents/notes.txt                                       │ ← details
│ ...mtime/size details from Unison...                                │
└─────────────────────────────────────────────────────────────────────┘

Columns

Folder rows show an aggregate badge: same glyph/tint as a leaf when every descendant agrees, empty when descendants disagree. - Size: file size, formatted by ByteCountFormatter. - Progress: populated during sync. Shows a standard macOS NSProgressIndicator bar (follows your System Settings → Appearance → Accent color) for in-flight rows, full bar when finished, and a bold red marker on rows whose transfer didn't complete. Hover the ⚠ to see the full failure reason in a tooltip; the same text is also shown in the details panel at the bottom of the window when the failed row is selected. Empty when idle. - Type: FILE, DIR, SYMLINK, etc., as Unison reports.

Summary line

Above the row list. Live-updates with status messages from OCaml during scanning ("Looking for changes...", "Reconciling...") and shows a count summary once the reconcile completes ("142 items · 1.2 GB · 3 conflicts · 12 First → Second · 4 Second → First").

The status word, when there is one, always leads. Six forms:

State Example
Ready, items to sync 121 items · 1.2 GB · 121 First → Second
Ready, nothing to do Everything is up to date
Sync in progress Synchronizing · 121 items · 1.2 GB · 121 First → Second
Sync complete, all clean Synchronization complete · 121 items · 1.2 GB · 121 First → Second
Sync complete, partial failure Synchronization completed with 5 errors · 121 items · 1.2 GB · 121 First → Second
Sync complete, zero items synced Synchronization complete · nothing to transfer

During an active sync, the summary line stays pinned to the Synchronizing · … form so the at-a-glance totals (item count, total bytes, direction split) remain visible throughout the transfer. The dynamic state, which file is currently moving and how far along, is conveyed by the global progress bar above the file list and by the per-row Progress column inside it. Mid-sync errors that attach to a row surface as the per-row marker (hover for reason); the post-sync summary's Synchronization completed with N error(s) covers the aggregate count. The full raw diagnostic stream (every displayStatus message Unison emits) is also logged to Console.app under subsystem net.courbage.unison-ui-mac for deeper debugging if needed.

The profile name is not included in the summary; the window title carries it (Unison — <profile>). Each direction breakdown reads <count> <source> → <destination> so the arrow always points left-to-right in reading order, regardless of which side data flows toward. "First" and "Second" refer to the two replicas (the two root = … lines in the .prf), matching the column headers and the toolbar's ← First / → Second buttons.

When one or more rows failed during sync, the summary prefix switches to Synchronization completed with N error(s) and each failed row gets a red marker in its Progress column. Hover the marker to read the per-row failure reason; the same text is also shown in the details panel at the bottom of the window when the failed row is selected.

The size figure between the item count and the breakdown is the total bytes that will move if you hit Go now. Sum of file sizes for rows with a clear direction arrow ( First or Second); conflicts and <-M-> merge rows are excluded, conflicts won't transfer until resolved, and merge runs an external command whose byte output isn't predictable in advance. User overrides on conflict rows aren't reflected in this total (same convention as the count breakdown), rescan refreshes the snapshot after manual changes if you want an updated total.

If a status message has multiple lines (typically SSH connect failures dumping stderr), a "Details…" button appears next to the summary line. Clicking it pops up a scrollable, selectable view with the full text. The summary label's hover tooltip also carries the full text as a one-hover alternative.

Progress column

During a sync, each file row shows its own transfer progress in the Progress column (a bar that fills as bytes move; done when complete; a red on failure, hover for the reason).

A collapsed folder row shows an aggregate bar summarizing the transfer of everything hidden beneath it, so you can watch a whole branch advance without expanding it. The fraction is byte-weighted, a large file contributes more than a small one, and falls back to a simple done-count when the folder's items carry no size (e.g. a folder of deletions). Expanding the folder clears its aggregate bar (its children then show their own); collapsing it again brings the summary back.

Global progress bar

Visible during init2 (indeterminate) and during sync (determinate). Hidden when idle.

Toolbar

A non-editable NSTextView at the bottom. Selecting a leaf row shows the OCaml-computed details (size, mtime, conflict reason). Selecting a folder shows the folder's full path plus the count of items under it.

Row context menu (right-click)


The Diff viewer

Opens via:

When the Diff item is greyed: Unison's canDiff predicate rejects the row. That happens for directories, symlinks, rows with update-detection problems, and rows where the only change on both sides is metadata (PropsChanged). It does not reject binary files, those just produce uninformative output from the configured diff command (typically Binary files X and Y differ).

The diff itself runs through whatever you've configured as Unison's diff pref (default diff -u CURRENT1 CURRENT2). With the default, the output is unified-diff format, which the diff window colorizes:

The diff window:

When displayDiffErr fires (Unison can't produce a diff for some reason), the window switches to an error view: red header + raw error text. Pick another row's Diff to recover.


Settings

Opens via Unison-UI-Mac → Settings… (⌘,). A toolbar-tab window (System Settings style) with six tabs that resize the window to fit:

Settings and the profile editor can't be open at the same time. Because a logging change here can rewrite .prf files, the Settings menu item is greyed out while a profile is open for editing; and opening a profile editor while Settings is open is blocked with a "Close Settings first" prompt (Settings is brought forward instead). This prevents a Settings change from conflicting with unsaved edits.

Most of the Saved State items are reset/clear actions on remembered state (you hide a profile by clicking its eye icon, dismiss a version-mismatch alert via its checkbox, etc.); the Sync and Updates tabs are where you toggle preferences directly.

Profile picker layout

Shows counts ("3 hidden profiles · 7 in custom order") for the keys profiles.hidden and profiles.order. The Reset button clears both, after a reset, every .prf is visible in alphabetical order in the picker. The .prf files themselves are untouched; this is purely UI presentation state.

SSH version-mismatch suppressions

A table of (host, this-Mac-version, remote-version) triples that you've dismissed via the "Don't remind me again" checkbox on the Version-mismatch warning. Each row can be selected and removed individually via Remove Selected, or wiped en masse via Clear All (which has a confirm sheet because it's a bulk destructive action; per-row removal doesn't prompt, one accidental removal is cheap to re-suppress next time the alert fires).

Removing a suppression doesn't immediately do anything; the next time you open a profile whose SSH peer matches the triple, the version-check probe runs and re-prompts you with the alert.

Window & toolbar layout

Counts of how many window-frame autosaves and toolbar configurations the app has on file. The Reset Window Positions button clears all of them; the next time each window reopens, it uses its default position and size. Useful when:

Currently-open windows are not moved by the reset, autosaves are written on close and read on open, so the effect only takes hold the next time each window is opened. The reset alert spells this out.

Reconcile display

Two pickers that control how the reconcile window renders the list of differences. Mirrors upstream Unison's "Switch table nesting" segmented control plus a smart-expand option that upstream calls expandConflictedParent.

Layout:

Expand on open (applies on every fresh populate: initial scan or rescan; user-driven expand/collapse during a session is untouched):

Both settings take effect on the next reconcile populate (rescan or profile open). Already-open reconcile windows aren't re-laid out live, the section description in Settings spells this out.

Post-sync failure reveal. When a sync finishes with one or more failures, the app expands the ancestor chain of every ⚠ FAILED row, even when the user's configured policy is Smart or Top level only and those rows would otherwise stay collapsed out of view. The user's setting isn't mutated; this is a one-shot widening for the current sync result, reverted on the next rescan. The configured policy still governs the pre-sync (just-rescanned) view.

Sync completion

Two checkboxes controlling the optional cues fired when a sync finishes (both on by default):

Unlike the other sections (which inspect/reset implicit state), these are real, explicitly-set preferences, stored under sync.complete.notify and sync.complete.sound.

Independent of these toggles, the reconcile summary always shows an inline result badge when a sync ends, a green ✓ (clean) or red ⚠ (errors), with the summary text tinted to match.

Banner not appearing? macOS suppresses notification banners while you're screen sharing, and routes them silently to Notification Center when Scheduled Summary ("Summarize notifications") or a Focus filter is active. The notification still lands in Notification Center, and the inline ✓/⚠ badge and the sound are unaffected. See Troubleshooting.

Logging

Controls how each profile's logfile is chosen. A Mode popup with three choices, and a path field whose label follows the mode:

The default location is Unison's own directory (~/Library/Application Support/Unison), alongside your profiles and archives.

When you switch into a shared mode (or change its file/folder), the app asks whether to apply it to every profile that already has logging on. This is all-or-nothing: choose Update All to rewrite those .prf files, or Don't Update to leave them as they are (new saves still use the current shared location). The prompt supports Return (Update All) and Escape (Don't Update).

The logfile line is written into each profile's .prf (which is what Unison reads). The matching per-profile controls live in the editor's Options section: see Write a log file.

Command Line

Shows what unison resolves to in your login shell right now, and offers one action to manage the app's own Terminal setup. The resolution is read by running your login shell each time the tab is shown, so it is not a stored preference; the Keep unison in Terminal pointing at this app and Default interface preferences below are stored.

The verdict names what the check found (this app, another unison, no unison, or that the shell's PATH could not be read), with the resolved command shown as evidence. From that, and whether the app already owns a setup block in your startup file, the pane offers at most one action. None needs an administrator password, and nothing is written outside your own files:

Copy This App's Command Path copies the full path for a peer's servercmd.

Startup offer. While Keep unison in Terminal pointing at this app is on, the app offers, once at launch, to add the setup when it owns no block and unison does not already resolve to this app. Turn the preference off to stop the offer; the Command Line tab remains the way to set it up later.

Automatic maintenance. Separately, and without asking, the app repairs its own block at launch when that block records a location the app has moved away from, rewriting it to the app's current path. It only ever touches its own marked block; a link or line you added by hand is left alone.

Default interface. The Default interface control (Graphical or Text) sets which interface unison uses when a command omits -ui, described under The unison command. It is Graphical unless you save Text, so a bare unison <profile> opens this app; this is a change from earlier versions, where it ran the text interface. An explicit -ui graphic or -ui text on the command line always overrides it. Scripts and scheduled jobs should pass -ui text explicitly rather than depend on this setting.

What's stored, and where

Everything lives in ~/Library/Preferences/net.courbage.unison-ui-mac.plist, accessed via the standard UserDefaults API. That domain also holds the Sparkle-owned update preferences (the Updates tab writes them through Sparkle, not as app keys of its own). The keys this app writes:

Key Purpose
profiles.hidden Basenames of profiles hidden from the picker
profiles.order Custom picker order
versionMismatch.suppressed List of suppressed host\|local\|remote triples
reconcile.layoutMode flat / nestedCollapsed / nestedFull
reconcile.expandPolicy smart / all / rootOnly
sync.complete.notify Show a Notification Center banner on sync completion (default on)
sync.complete.sound Play a sound on sync completion (default on)
commandLine.keepInTerminal Whether the app keeps unison in Terminal pointing at it (offers setup at launch when missing, and repairs it if the app moves)
commandLine.defaultInterface graphic or text: the interface unison uses when a command omits -ui
NSWindow Frame <name> AppKit auto: window position/size per window
NSToolbar Configuration ReconcileToolbar.v6 Reconcile toolbar customization

Sparkle also manages update keys in this same domain, set from the Updates tab and the first-launch prompt: SUEnableAutomaticChecks (automatic update checks) and SUSendProfileInfo (send the anonymous system profile), plus its own bookkeeping (e.g. SULastCheckTime). These are owned by Sparkle; change them through the Updates tab rather than by hand.

You can inspect them directly with defaults read net.courbage.unison-ui-mac, or wipe everything in one shot with defaults delete net.courbage.unison-ui-mac, but the Settings window gives you fine-grained control by category.


The menu bar reference

Unison-UI-Mac menu

Standard macOS app menu: About, Check for Updates… (the Sparkle updater), Settings… (⌘,), Services, Hide, Quit. The menu uses the app's display name (CFBundleDisplayName = "Unison-UI-Mac"). The About panel shows the embedded Unison version (queried via unison_bridge_get_version). The Settings entry opens the inspect-and-reset window described in Settings above.

Edit menu

Item Shortcut Action
Undo ⌘Z Standard responder action
Redo ⌘⇧Z Standard responder action
Cut / Copy / Paste / Select All ⌘X / ⌘C / ⌘V / ⌘A Standard text ops
Ignore Path (none) Add ignore = Path "<row's path>" to the profile
Ignore Extension (none) Add ignore = Name {,.}*"<.ext>"
Ignore Name (none) Add ignore = Name "<basename>"
Profile Editor… ⌘⇧E Open the manager window

The three Ignore items dispatch to the reconcile window when it's key; otherwise they're greyed.

Action menu (reconcile-window operations)

Item Shortcut Action
Go ⌘⏎ Start synchronizing the current row decisions. Disabled mid-sync and before init2 has populated rows.
Stop ⌘. Abort a running sync. Disabled when no sync is running. (See Stop button for the abort semantics.)
Rescan ⌘⇧R Re-run init2 against the current profile. Disabled while a scan or sync is already running.
Rescan Ignoring Archives… (none) Re-open the profile with a one-shot ignorearchives to recover from an "archive inconsistency" (confirms first). Enabled only with a reconcile window open. See Troubleshooting.
Show Profile Picker ⌘⇧P Close the reconcile window and return to the launch picker (the just-run profile stays highlighted). Disabled mid-sync, use Stop or ⌘W (which triggers the mid-sync confirm sheet) instead.
→ Second > Propagate first → second for selected leaves
← First < Propagate second → first
Skip / Mark selected leaves as user-skipped
Merge (none) Run the configured merge command on selected leaves (greyed if merge pref isn't set)
Force Older (none) Pick the older-mtime side for each selected leaf
Force Newer (none) Pick the newer-mtime side
Diff (none) Open the diff viewer for the selected (or right-clicked) leaf
Select Conflicts (none) Select every leaf row that's still unresolved (<-?-> with no user override)
Revert to Unison's Recommendation (none) Clear user overrides on selected leaves

All items dispatch via the responder chain to ReconcileWindowController when the reconcile window is key. Disabled when no reconcile window is key. Direction items are disabled during sync.

The three workflow shortcuts follow macOS conventions: ⌘⏎ "submit / run" (matches Mail's Send and similar primary actions), ⌘. for "cancel currently running operation" (system-wide since System 6), and ⌘⇧R for Rescan, parallel to Safari/Mail's "Reload" and distinct from Profile Editor's ⌘R refresh so the two never collide.

Window menu

Standard: Minimize, Zoom, Bring All to Front.

Help menu

No File menu, this isn't a document-based app. ⌘W still closes the focused window via the standard responder action.


Keyboard shortcuts

Shortcut Action
⏎ (in picker) Run selected profile
⌘⇧E Profile Editor…
⌘R (in Profile Editor) Refresh profile list
⌘, Settings…
⌘⏎ (in reconcile window) Go (start sync)
⌘. (in reconcile window) Stop (abort sync)
⌘⇧R (in reconcile window) Rescan
> / < (in reconcile window) Set selected rows to → Second / ← First
/ (in reconcile window) Skip selected rows
⌘⇧P (in reconcile window) Show Profile Picker
⌘? App help
⌘W Close focused window
⌘M Minimize focused window
⌘Q Quit
⌘F (in diff window) Find bar

No keyboard shortcuts on Action-menu items by design, they're frequently applied to multi-row selections where a mouse is usually already in play. Add custom shortcuts via System Settings → Keyboard → Keyboard Shortcuts → App Shortcuts if you want them.


Troubleshooting

"Archive inconsistency" fatal mid-reconcile

Unison detected that its archive files are out of sync (typically from a crashed sync). A modal alert appears with the offending archive list, offering two recoveries:

You can also trigger the first option yourself any time from Action → Rescan Ignoring Archives…. For pre-emptive cleanup (no error yet, but suspect): use Reset Archives… in the Profile Editor.

SSH "permission denied" / "host key changed"

Unison runs SSH with whatever your sshcmd / sshargs prefs specify, and the macOS system SSH config. If you need a specific key or non- default host, set them in the Advanced field of the Profile Form. The multi-line error appears in the reconcile window's summary line, click Details… for the full text (especially useful when the SSH client returns a multi-line key fingerprint check or similar).

If you are re-entering your password on every reconnect, switch that profile to SSH key authentication instead of passwords: see Avoiding repeated password prompts below.

unison-ui-mac never stores or replays your credentials. Every SSH prompt is passed straight through from the system ssh, and the app cannot tell whether a given prompt is a password, a key passphrase, or a one-time code. So the place to stop re-entering a password on every reconnect is your SSH configuration, not the app: switch the profile to public-key authentication with Apple's system client at /usr/bin/ssh, and let macOS hold the key's passphrase.

The steps below call the tools by their explicit /usr/bin/ paths on purpose. UseKeychain and --apple-use-keychain are features of Apple's OpenSSH; a non-Apple OpenSSH earlier in your PATH does not honor them. Such a client rejects UseKeychain in a config it reads (hence the IgnoreUnknown guard below), and ssh-add --apple-use-keychain fails on the unknown option rather than caching the passphrase. A bare ssh / ssh-add could be that wrong client.

1. Create a key (skip if you already have one):

/usr/bin/ssh-keygen -t ed25519

2. Verify the server's host key before sending any secret. Connect once by hand and check the fingerprint against one you obtained through a trusted channel (the server's administrator, or ssh-keygen -lf <host-key-file> run on the server itself):

/usr/bin/ssh my-user@server.example.com

ssh prints the host-key fingerprint and waits; answer yes only once it matches. It then asks for your account password, which now goes to a host you have verified. This order matters: never accept an unseen host key and type a password in the same breath, or a machine-in-the-middle could capture it.

3. Install your public key so future logins use the key, not the password. The host is already trusted from step 2, so this does not re-prompt for it. ssh-copy-id resolves its own child ssh through PATH (its script sets SSH="ssh -a -x"), so pin PATH to the system directories for this one command:

PATH=/usr/bin:/bin:/usr/sbin:/sbin \
  /usr/bin/ssh-copy-id -i ~/.ssh/id_ed25519.pub my-user@server.example.com

4. Add a host block to ~/.ssh/config:

Host my-server
    HostName server.example.com
    User my-user
    IdentityFile ~/.ssh/id_ed25519
    AddKeysToAgent yes
    UseKeychain yes

5. Store the key's passphrase in the macOS Keychain, once:

/usr/bin/ssh-add --apple-use-keychain ~/.ssh/id_ed25519

6. Confirm key-based login works, using the alias:

/usr/bin/ssh my-server

It should log in with no password prompt. If it still asks for a password, the key most likely was not installed or accepted (this command already pins the client, so it is not a PATH issue): run /usr/bin/ssh -v my-server and check that the server's ~/.ssh/authorized_keys contains your public key, that ~/.ssh and authorized_keys have safe permissions (700 and 600), and that the identity being offered is the one you installed.

7. Point the profile at the alias, not the raw host. In the app, set the remote root to use the my-server alias, for example ssh://my-server//Users/me/data. Using server.example.com directly does not match the Host my-server block, so the IdentityFile and UseKeychain settings would not apply and you would be back to password prompts. If the app is using a non-Apple ssh from your PATH, set the profile's sshcmd to /usr/bin/ssh, or rely on the agent from step 5 (any client can use a key already loaded there).

What is and isn't stored. The macOS Keychain holds your private key's passphrase, never the remote account's password. unison-ui-mac itself stores nothing and replays nothing; all key handling belongs to OpenSSH and macOS.

If you share ~/.ssh/config with a non-Apple OpenSSH (for example on Linux), guard the macOS-only option so those clients don't reject the file:

Host my-server
    IgnoreUnknown UseKeychain
    UseKeychain yes
    AddKeysToAgent yes
    IdentityFile ~/.ssh/id_ed25519

Avoid these "shortcuts"; each trades real protection for convenience:

A profile won't open from the GUI but works from the CLI

Likely cause: the .prf references ~ or relative paths that resolve differently in the GUI's process environment. Use absolute paths in the Profile Form's root fields.

Also: any preference that requires interactive CLI flags (e.g., -ignorearchives) won't work here, those are command-line-only and this app builds its own argv. Put their equivalent in the .prf if possible.

The Merge button is missing

The Merge toolbar item and Action → Merge menu item are hidden / greyed when the active profile's .prf doesn't declare a merge preference. Add a merge = … line to the profile's Advanced field (see Profile Form > Advanced) and re-open the profile.

Stop button: how abort works

Clicking Stop in the toolbar (or invoking it through the menu/ context menu) calls Unison's Abort.all on the OCaml side (the mid-sync abort was upstreamed in bcpierce00/unison#1198, so this is no longer a fork patch). The in-flight sync worker observes the abort at its next Abort.check checkpoint, typically between file transfers, and unwinds by raising the internal Aborted by user request transient.

What you'll see: - One or two more rows may complete naturally before the abort takes effect (any file already in mid-transfer at the moment Stop was clicked). - Queued rows that haven't started yet show FAILED in the Progress column. - The reconcile window stays open so you can inspect the FAILED rows. The progress bar disappears; the summary line shows that the sync was aborted. - Clicking Stop again is a no-op (the abort flag is already set). - You can then rescan to see the post-abort state of both replicas, or close the window manually.

What it does NOT do: - Doesn't roll back partial transfers. If a file was mid-write when the abort propagated, it's in whatever state OCaml's transfer layer left it (typically the destination has a partial copy plus Unison's .unison.tmp sidecar). Unison's next reconcile will see the difference and let you decide how to fix it. - Doesn't kill the OCaml worker. It just sets a flag; the worker exits its current task cooperatively, then idles in bridgeThreadWait ready for the next bridge call.

If you close the reconcile window mid-sync via ⌘W (or the red close button), you get a three-option prompt: Keep Window Open / Continue in Background / Stop Syncing & Close. Continue in Background closes the window but lets the sync continue until natural completion, useful when you want to reclaim screen space but not interrupt the transfer.

Sync-complete notification doesn't pop up

You finished a sync but never saw the banner. This is almost always macOS holding it, not the app failing to send it, the notification still lands in Notification Center (click the clock to check). The common causes:

The inline green ✓ / red ⚠ badge in the reconcile window and the completion sound don't depend on any of this, they always fire (when the sound toggle is on). See Settings → Sync completion.

The app starts slowly / OCaml takes time to spin up

First launch involves caml_startup and a few hundred milliseconds of OCaml runtime setup. Subsequent launches reuse the same .app bundle.

How do I read this app's diagnostic logs?

The app emits diagnostic info to macOS Unified Logging under subsystem net.courbage.unison-ui-mac. View it via Console.app (filter the "Subsystem" column) or the log CLI:

# Live tail of everything the app is logging:
log stream --predicate 'subsystem == "net.courbage.unison-ui-mac"'

# Filter to just bridge events (OCaml↔Swift):
log stream --predicate 'subsystem == "net.courbage.unison-ui-mac" AND category == "bridge"'

# Or just the OCaml status messages forwarded from displayStatus:
log stream --predicate 'subsystem == "net.courbage.unison-ui-mac" AND category == "ocaml-status"'

# Last hour of everything, in one shot:
log show --predicate 'subsystem == "net.courbage.unison-ui-mac"' --last 1h

Categories: lifecycle (app/window open-close), bridge (OCaml↔Swift events), reconcile (reconcile-window state), ocaml-status (status messages forwarded from Unison's displayStatus), version-check (the SSH version probe; see below), general (the legacy TraceLog catch-all).

Unified Logging gives you filterable categories, structured search, and persistence across reboots without the disk-hygiene problem of a stray /tmp file.

Version-mismatch warning on profile open

When you open a profile that has an ssh://… root, the app spawns a one-shot ssh -o BatchMode=yes host servercmd -version in the background and compares the result with the locally-embedded Unison version. You only see a warning when the two sides straddle Unison's 2.52.0 wire-protocol boundary, i.e., the local side is >= 2.52.0 and the remote is < 2.52.0, or vice versa. Same-side-of-boundary differences (e.g., 2.54.02.53.8) negotiate features through the new wire protocol and don't warn.

The alert looks like:

Unison wire-protocol incompatibility
This Mac has Unison 2.54.0. The remote (server.example.com) is running 2.51.5. Unison changed its wire protocol at version 2.52.0, and the two sides here are on opposite sides of that change, they cannot connect to each other. Update the older side to a release

= 2.52.0.

☐ Don't remind me again for this host (until either version changes)

The checkbox persists per (host, localVersion, remoteVersion) triple. Once you suppress it, you won't see it again for that exact combination, but as soon as you upgrade either side, the triple changes and you'll see the alert again so you can re-confirm.

Rationale for the 2.52 boundary: Unison 2.52.0 introduced the "new wire protocol" with feature negotiation, so any pair of versions

= 2.52.0 interoperates regardless of which exact minor release each side runs. The app alerts only when the wire protocol itself is incompatible.

The probe is silent in normal operation: - Uses BatchMode=yes, won't prompt for a password. If the remote requires password auth, the probe fails silently and no alert appears (Unison's own connection will prompt as usual for the actual sync). - Uses ConnectTimeout=5 for the TCP/SSH connection; the overall probe has a 20-second wall-clock deadline for the remote work, after which it is terminated and reported as a timeout. - StrictHostKeyChecking=yes: the probe never adds or changes a host key, so it cannot alter your trust state. A first-time or changed host key makes the probe fail (no alert); Unison's own connection then handles host-key acceptance for the actual sync.

Limitations: - Honors an absolute sshcmd and the profile's sshargs (so it authenticates like the real sync, including an -i <key> in sshargs); a bare, non-absolute sshcmd falls back to /usr/bin/ssh. If your Unison SSH config differs significantly from your shell SSH config, the probe still mirrors the profile's own settings. - Doesn't check socket:// profiles. There's no -version probe for socket-mode Unison servers without going through the actual Unison protocol. - Only checks the FIRST ssh:// root in the profile. The pathological case of two ssh:// roots pointing at different hosts is not fully covered.

What happens when a new version of Unison is released?

Two halves of the answer, for the local side (the embedded Unison) and for the remote side (any ssh://… peer).

Local side (the embedded copy). This app links in the committed vendored blob (vendor/unison-blob-<version>-<arch>.o); the version is frozen at build time. An ordinary make build uses that committed blob, so it does not recompile from a local upstream checkout and does not change the embedded version.

The About panel shows the embedded version so you can verify after an upgrade.

Remote side (ssh://… profiles). Unrelated to local rebuilds. You update the remote Unison the way you'd update any CLI on that machine (brew upgrade unison, apt upgrade unison, etc.). The two Unisons negotiate compatibility at connection, see the upstream wiki's Cross-Platform Issues / Compatibility section. As a rule of thumb:

If you have multiple Unison versions on the remote and need to pin a specific one for a profile, set servercmd = /path/to/unison-X.Y in the Advanced field of the Profile Form.

Archive format compatibility. Unison's archive files have an internal format version (currently 23). When upstream bumps this in a future release, existing archives become unreadable and Unison forces a full re-scan to rebuild them. Two consequences for this app:

  1. Existing archives from before the version bump get orphaned. Use Reset Archives… in the Profile Editor to clean them up after the update.
  2. This app's ArchiveHash.swift has archiveFormat = 23 pinned. If upstream bumps it and we don't update, the Reset Archives button will compute the wrong hash and miss the orphaned files. The fix is a one-line change to ArchiveHash.archiveFormat; the unit tests (using md5(1)-verified reference values) will fail loudly on a stale constant.

For day-to-day usage: a remote at >= 2.52.0 interoperates with this app's embedded 2.54.0, so upgrading the remote (brew upgrade unison, etc.) does not require rebuilding the app. You would only need a newer embedded engine (a maintainer vendor-blob bump, delivered in an app release) if a remote is stuck below 2.52.0 and cannot be upgraded.

Profile won't sync but CLI unison <profile> works

Three things to check, in order of likelihood:

1. clientHostName divergence. If you've set this pref in your .prf to a value that differs from what gethostname() reports (which is what ProcessInfo.hostName returns), the archive hashes diverge between CLI and GUI. Either remove the clientHostName override, or set UNISONLOCALHOSTNAME=<your-hostname> in the launch environment so both pick up the same value.

2. Command-line session options. A unison <profile> request delivers its session-scoped options to the app, which opens the profile scoped by them exactly as a fresh launch would. -path, -include, and -source are common examples; Unison's other session preferences are carried the same way. See The unison command.

3. Session boundary for command-line options. A request's session options apply to the session it opens, that profile and any rescans or reconnects it makes. Choosing a different profile from the picker afterward uses that profile's saved configuration; the app does not re-apply the earlier command line's options to a picker selection, so each request is scoped only by its own options.


What this app does NOT do


For everything else, the upstream wiki is the authoritative reference. This GUI is just a way of driving Unison; the synchronization semantics, the .prf format, the conflict-resolution rules, all of that is Unison.