Pinning¶
A pin keeps a path and everything under it downloaded on the Mac and out of the TTL's reach.
Markers are stored in the index and enforced through the File Provider contentPolicy; the CLI and
two Finder context-menu entries are the ways in. Everything acts on kept, the effect, never on
the marker itself.
Markers and kept¶
- pinned / excluded are markers the user places on a path with
sshdrive pin/sshdrive unpinor the Finder entries. They are what is stored. - kept is the effect at an item: the nearest marker at or above it is a pin. The system, the eviction loop, the badge and the Finder menu all act on kept. Every pinned item is kept; most kept items are not pinned, they inherit it.
Each path carries one of three explicit states, and an item's effective state is the nearest explicit state at or above it:
| Explicit state | Meaning | contentPolicy returned |
|---|---|---|
pinned |
keep this subtree downloaded | .downloadEagerlyAndKeepDownloaded |
excluded |
inside a kept subtree, leave this subtree lazy | .downloadLazily |
| (none) | inherit from the nearest ancestor | .inherited |
An explicit .downloadLazily overrides an eager ancestor (MQ-027), and .inherited forces
nothing on its own (MQ-026). Exclusions exist because "that file's 4 GB of raw video can stay on
the server" is a reasonable thing to want inside a kept folder.
The three invariants¶
Every pin change obeys these
pinandunpinact on exactly the path named, never on an ancestor.- Any change to a path's explicit state first deletes every explicit state beneath it -
setting
pinned, settingexcluded, or removing either. A subtree's state is always "whatever the changed ancestor says", as if nothing below had ever been pinned or excluded. - Minimal markers. A request is "make this kept" or "make this not kept", and the handler writes the smallest explicit state that produces it: removing an exclusion rather than adding a pin inside a kept subtree, removing a pin rather than adding an exclusion when nothing above is kept.
Invariant 2 makes invariant 3 safe. A pin nested in a kept subtree with no exclusion between can never behave differently from inheriting, because any change to the ancestor wipes it, so such redundant markers are never created. It also makes toggling the top folder off and on the way to reset a complicated structure: two commands or two Finder clicks, and the subtree is clean.
One case needs spelling out. Markers move with their paths, so an exclusion can end up with no pin
above it. "Make this kept" on such an item removes the exclusion and writes pinned, since
removing the exclusion alone would leave it inheriting nothing.
The five situations¶
Because of invariant 3 there are two user-facing operations, pin ("Keep Downloaded") and unpin
("Don't Keep Downloaded"), and every item is in exactly one of five situations:
| Situation | Nearest marker at or above | Kept? | pin / Keep Downloaded |
unpin / Don't Keep Downloaded |
|---|---|---|---|---|
| A. plain | none | no | writes pinned here; subtree cleared |
no-op (CLI says so; Finder hides the entry) |
| B. pin root | pinned, on this item |
yes | CLI only: re-asserts the pin, clearing the subtree (the one-command reset). Finder hides the entry. | removes the pin; subtree cleared; content stays and goes under the TTL |
| C. inheriting a pin | pinned, on an ancestor |
yes | no-op (CLI names the covering ancestor; Finder hides the entry) | writes excluded here; subtree cleared; ancestor and siblings untouched |
| D. exclusion | excluded, on this item |
no | removes the exclusion, so the item is kept by the ancestor's pin again, or writes pinned in its place when no ancestor is pinned (an exclusion moved out of its kept tree); subtree cleared |
no-op |
| E. inheriting an exclusion | excluded, on an ancestor |
no | writes pinned here (re-include below the exclusion); subtree cleared |
no-op |
Finder always shows exactly the one entry that changes the item's effective state, so the user never needs to know which situation applies.
Consequences of the rule¶
- A new remote file inside a kept folder is downloaded; one inside an excluded subfolder is not.
- Exclusions nest: pin
Projects, excludeProjects/archive, re-pinProjects/archive/2026. Each level wins over the one above. - Unpinning the top folder forgets its exclusions too, so a re-pin starts clean.
pinandunpinprint what they cleared: "Pinned Projects (cleared 3 nested exclusions and 1 nested pin)". - Finder has no output channel, so the same clearing is silent there: "Don't Keep Downloaded" then
"Keep Downloaded" on a pin root discards every exclusion under it and downloads what they held.
This is accepted. A Finder path that cleared nothing would make the same two clicks mean
different things in Finder and the CLI.
sshdrive pinsshows what is left, and the badge reappearing on a formerly excluded folder is Finder's only feedback. - Moving an item keeps whatever explicit state its path carried and re-evaluates the effective
state at the new location: states are keyed by path and
modifyItemupdates the path. A file moved out of a kept folder stops being kept; one moved in becomes kept. - The recursive watch of kept subtrees (root set) skips excluded subtrees.
- The eviction loop uses kept, so an excluded file inside a kept folder is evicted like any other cached file.
userInfo.keptand the badge reflect kept. An excluded folder inside a kept one shows no badge; its kept parent still does.- Unreadable subtrees inside a pin are skipped and reported by
sshdrive pins, not treated as errors. Symlinks follow the symlink policy.
Where markers live¶
Markers live in the index (pin_state), which is the only authority. pins.json beside it
(item index) is a write-only copy for recovery, never read while the index is
healthy. Every pin change, from the CLI or from Finder, is one XPC call to the agent, so there is
one writer and no second store to keep in sync.
sshdrive pins --export writes the marker list as JSON and --import reads it back, for a
rebuilt index or a new Mac.
Pins are on paths, so a re-enumeration keeps them; deleting the path removes them (item index).
How a pin takes effect¶
Finder offers only "Download Now" and "Remove Download" for a third-party domain, with no built-in
"always keep on this Mac" (MQ-053). We build one on the declarative contentPolicy:
-
Store the pin.
sshdrive pin <location> <remote-path>, or Finder's "Keep Downloaded", setspin_state = 1on the row.- From Finder the row always exists.
- From the CLI the path may never have been enumerated, and the system cannot apply a policy to
an item whose ancestors it has never seen. So
pinlstats the path over SFTP, refuses if it does not exist or is a symlink,readdirs each missing ancestor into the index from the nearest known one, and reports those rows through the working set. - That is necessary and not sufficient: the system ingests none of it from the working set
alone (
MQ-029, gotcha 25). So the last step ofpinis a lookup of the path in the replica:NSFileProviderManager.getUserVisibleURLfor the pinned identifier, then onelstatof the returned path. The system then enumerates the chain and the eager download follows. Without it,pinon a path Finder has never shown writes and reports the markers and downloads nothing. The agent may read its own mount (eviction).
-
Declare the policy. Items are returned with the
contentPolicyof their effective state (table above). On any pin-state change the agent, in one transaction:- recomputes
keptandcapabilitieson the changed row and on every known descendant row, which moves each row's metadata version; - writes an anchor for each;
- signals the working set, so the system re-reads them and applies the new policy.
The descendants are required (gotcha 83).
contentPolicyis inherited by the system, butuserInfo.kept, the badge and the capabilities are per item and cached until that item's own metadata version moves; bumping the pinned folder alone would leave every file inside it offering the wrong menu entries. Descendants the index has never seen need nothing: their rows are created with the right state when first listed. The cost is O(subtree) rows and anchors per pin change, the same order as a directory rename.The system then downloads the subtree eagerly through our
fetchContents, including folders nothing has ever listed (MQ-028), fetches files that appear under the pin later, and holds at most sixfetchContentsopen at once (MQ-031) - the bound the SFTP transfer scheduler sits under. It refuses to evict the subtree because of the inheritedcontentPolicy, notallowsEvicting(MQ-024, gotcha 23). - recomputes
-
Keep it current. Pin roots are always in the root set, watched recursively. New or changed remote files show up in the working-set diff and the system fetches them under the eager policy.
-
Unpin. On an explicitly pinned item,
unpinclears it and every explicit state beneath it; on an item that inherits a pin it records an exclusion. Either way the content stays on disk and falls under the location's TTL from that moment. -
Eviction skips kept items, and so does
sshdrive evict --allunless--unpin-allis passed, which removes every pin first.
Offline and cost¶
A kept folder is fully readable and listable offline, including subfolders never opened, because the eager download already materialized them. Edits inside it queue like any other offline write.
A kept folder is downloaded in full, and at tier 0 every poll cycle readdirs every directory in
it over SFTP. Deeply nested pins on slow links without shell access are the main performance risk.
sshdrive pins shows subtree size and file count:
$ sshdrive pins nas
Documents/thesis pinned 2.1 GB, 412 files downloaded
raw-video excluded (14.8 GB on server, not downloaded)
Photos/2026 pinned 480 MB, 1,203 files downloaded
Pinning the root¶
"Keep this whole location offline" is a legitimate request, and the root is an item like any other in every mechanism above:
- It has a row. The index holds a permanent root row whose
pin_stateworks like a folder's. Pinning the root is situation A with no ancestor; every other item then becomes situation C and can be excluded; unpinning it is situation B and, by invariant 2, clears every marker in the location. - It has a path.
RelativePathallows zero components (security); that value is the root and is joined toremotePathunchanged.sshdrive pin <name> /andsshdrive pin <name> .both name it andsshdrive pinsrenders it as/.pinprints the location's total size and file count from the last probe first, since "keep everything" on a home directory or a media share is a large decision. - It gets the policy.
item(for: .rootContainer)returns the root row with.downloadEagerlyAndKeepDownloadedwhen pinned, and the system honours it as on a folder, downloading the whole location (MQ-030). - It is a watch root. A pinned root puts the whole location in the recursive part of the
root set:
findfrom the root, the helper watching its own--root. At tier 0 that is areaddirof every directory in the location per cycle, whichpinwarns about with the size. - It is reachable from Finder only through the window background of the location's top-level window, where the entry acts on the folder being shown. It is not offered on the sidebar row (below), so a whole location is pinned from the CLI.
- Excluding under a pinned root is how a user keeps "everything except
Videos". Moving items within the location never changes their kept state, since every path inherits from the root.
Finder's context menu¶
Two custom File Provider actions, declared in the extension's Info.plist and handled by
performAction(identifier:onItemsWithIdentifiers:), which forwards to the agent. No window and no
UI extension: Finder renders the entries and calls us.
Every item the extension returns carries userInfo = ["kept": 0|1], copied from the row's kept
column (item index), never derived in the extension. The activation rules read it:
| Menu label | Shown when | Handler |
|---|---|---|
| Keep Downloaded | at least one selected item is not kept: SUBQUERY(fileproviderItems, $item, $item.userInfo.kept == 0).@count > 0 |
pin semantics (situations A, D, E) for each selected item that is not kept; kept items are skipped. Bump metadata versions, signal the working set; the system then downloads eagerly. |
| Don't Keep Downloaded | at least one selected item is kept: SUBQUERY(fileproviderItems, $item, $item.userInfo.kept == 1).@count > 0 |
unpin semantics (situations B, C) for each selected kept item: remove the pin on a pin root, otherwise record an exclusion. Not-kept items are skipped. No eviction. |
Activation rule traps
- The bound key is
fileproviderItems, lower-case p, used as a key path, not a$substitution variable. Either mistake drops the entry silently (MQ-055, gotcha 31). - The
.@count > 0form is what makes an empty selection match neither entry. The "all selected items" form,SUBQUERY(k, …).@count == k.@count, is0 == nil-count, true with no selection, and would show both. fileproviderctl evaluate <path>prints each rule and its verdict, which is how to check a change without a screen.
Mixed selections show both entries, and each acts only on the items it applies to: selecting a kept
folder and a plain file and choosing "Keep Downloaded" pins the file and leaves the folder alone.
An item inside a kept folder shows only "Don't Keep Downloaded", which excludes just that item
(situation C); "Keep Downloaded" on it later removes the exclusion (situation D). Whether kept is
the item's own pin or inherited is not visible in Finder, since by invariant 3 the two never
behave differently; sshdrive pins shows the markers.
Our entries set policy; Finder's act now¶
The entries are worded as a policy ("keep" / "don't keep") so they read as a pair and never collide with Finder's "Download Now" / "Remove Download", which we leave to Finder.
- "Keep Downloaded" makes the item and its subtree eager and non-evictable.
- "Don't Keep Downloaded" returns it to lazy; the content stays and falls under the TTL.
- "Download Now" materializes an unkept item once; "Remove Download" evicts an unkept item immediately. To free space on a kept item, choose "Don't Keep Downloaded" first, then "Remove Download", or wait for the TTL.
The eager contentPolicy is what refuses an eviction of a kept item (MQ-024). allowsEvicting
is API_DEPRECATED("use NSFileProviderContentPolicy instead", macos(11.0, 13.0)), and dropping it
does nothing: the system reports the bit from isDownloaded whatever we serve (MQ-025). So
"Remove Download" is offered on a kept, downloaded item and fails there. The per-item menu entry is
not ours to remove.
The lever that exists is per provider:
Key in the appex's NSExtension dictionary |
Effect | Setting |
|---|---|---|
NSExtensionFileProviderAllowsUserControlledEviction = false |
hides "Remove Download" on every item, leaving OS and provider eviction working | unset: it would take the entry from unkept items too, a worse menu for the common case |
NSExtensionFileProviderAllowsContextualMenuDownloadEntry = 0 |
removes "Download Now" the same way | unset |
Finder 26.4 carries strings for a built-in "Keep Downloaded" entry and a "Kept Downloaded" badge,
and our items report isKeepDownloaded = 0 even under an eager policy, so that flag is the
system's and is not driven by contentPolicy. Finder draws neither for us: the one "Keep
Downloaded" on an unkept item in the mount is ours (MQ-053).
Where the entries land¶
- Finder's own entries are exactly two, in the third slot after "Open" and "Open With":
"Download Now" when dataless, "Remove Download" when materialized, chosen by
isDownloadedalone (MQ-053). - Ours are drawn at the very bottom, below "Quick Actions", at the top level, each with a
leading empty checkbox glyph, one of the pair at a time (
MQ-054). - They are also offered on a right-click of the window background, acting on the folder shown.
- They are not offered on the domain's sidebar row, whose menu is "Open in New Tab /
Show “SSH Drive” / Download Now / Remove from Sidebar / Get Info / Add to Dock" (
MQ-054, gotcha 32).
Kept files evicted anyway are re-asserted¶
materializedItemsDidChange is the safety net. A kept file turning dataless without our
handler having run is re-asserted, not read as intent:
- the agent bumps the item's metadata version and signals the working set, so the system re-applies the eager policy and fetches it again;
- it logs the event;
sshdrive statuscarries a note: "3 kept files were evicted outside SSH Drive and re-downloaded".
An eviction the user did not ask for, from a system that misbehaved or was short of disk, must not
rewrite a pin. If it recurs the status note tells the user, and unpin is one command away.
Finder's "Remove Download" on a kept item fails under the eager policy, so the ordinary route gives
the net nothing to do.
"Turning" is load-bearing. A freshly pinned tree is full of kept files that are dataless because
their eager download has not reached them. Only a file the agent saw as materialized in an earlier
enumeratorForMaterializedItems pass (eviction) counts, and the agent keeps that
set per domain for the purpose.
The pin badge¶
Kept items carry a decoration: a pin badge declared under NSFileProviderDecorations in the
extension's NSExtension dictionary and attached through decorations on the item
(NSFileProviderItemDecorating). It follows kept, not the marker, so an excluded folder inside a
kept one shows nothing and its kept parent still does.
Declaration traps, all silent (MQ-056, gotcha 81)
- The four keys are
Identifier,BadgeImageType,LabelandCategory, the bare words fromNSFileProviderItemDecoration.h: notNSFileProviderDecoration-prefixed, and not theNSExtensionFileProviderAction*shape the actions use. A wrong key leaves a decoration that is declared, returned by the item and drawn nowhere. BadgeImageTypeis a UTI conforming tocom.apple.icon-decoration.badge, not an asset name. Ours is the system'scom.apple.icon-decoration.badge.pinned(fromCoreTypes.bundle), so the appex needs no icon asset and no exported type.CategoryisBadge, which draws for files and folders alike.FolderBadgeembosses and is valid only for folders;Sharingdraws theLabelas text.
Finder 26.4 draws the Badge in list view as an orange disc with a white push-pin at the
trailing edge of the Name column, not on the icon, on every kept row including the ones that
inherit the pin (MQ-057).