-
Notifications
You must be signed in to change notification settings - Fork 41
folder sharing
Status: Experimental. The hooks, schema, and REST contract may change in minor releases — track this doc.
The owner of a desktop folder can grant read or read + write access to:
- specific WordPress users (anyone with
edit_posts), or - WordPress roles (default: roles carrying
edit_posts).
Recipients see a pending invite on their desktop, accept or deny,
and on accept the folder appears at their desktop root as an
icon. The icon's permissions are independent of the underlying
items inside the folder — sharing a folder that contains a post
does NOT grant edit_posts on that post.
Owners cannot transfer ownership. The folder's owner_id is
immutable.
+--------- Owner-side ---------+ +-------- Recipient-side --------+
| Folder "Marketing" | | Pending invite (heartbeat) |
| owner_id = 1 | invite | → first-sight prompt |
| share_mode = 'private' | ─────► | [Accept] [Deny] [Later] |
| Shares: | | |
| - user 2 → read | | After Accept: |
| - role 'editor' → write | | - Folder placement at root |
+------------------------------+ | - Heartbeat surfaces members |
| - Can move icons in/out |
| (only if 'write') |
+--------------------------------+
| Action | Owner | Writer | Reader | Pending | Denied |
|---|---|---|---|---|---|
| See the folder in their desktop | ✓ | ✓ | ✓ | - | - |
| Open and view icons inside | ✓ | ✓ | ✓ | - | - |
| Drag icons in / out | ✓ | ✓ | - | - | - |
| Trash icons inside | ✓ | ✓ | - | - | - |
| Rename the folder | ✓ | - | - | - | - |
| Manage shares (invite / revoke) | ✓ | - | - | - | - |
| Delete the folder for everyone (cascade) | ✓ | - | - | - | - |
| Move folder to Trash (root placement) | ✓ | - | - | - | - |
| Leave shared folder (recipient-side) | - | ✓ | ✓ | - | - |
Non-owners — read OR write — cannot move the shared-folder root placement to the Trash. The "Move to Trash" affordance is hidden and the recycle-bin drop target rejects the drag. The intended action is Leave shared folder (the dedicated tile-menu entry), which fires the share-leave flow: revoke the recipient's decision, scrub their placement, and leave the original intact.
Sharing is keyed on the folder row, not on where the folder happens to be placed. That's true in both directions:
- Owner moves their copy (root → sub-folder, between containers, etc.). Recipients' placements are untouched. Their capability stays the same.
-
Recipient moves their copy (out of root, into one of their
own folders, between their containers). Owner is untouched.
Recipient keeps write capability if granted; the folder's
contents stay visible because every read goes through
openstation_folder_share_user_capability( folder_id, viewer )— never through "what's the placement path." -
Recipient's placement starts at desktop root. Regardless of
where the owner has the folder placed (root, deep inside a
parent the recipient cannot see, …),
share_acceptplants the recipient's tile atparent_id = 0. Theopenstation_folder_share_accept_default_parentfilter lets a future "Shared with me" tray plugin override. - Cascade still works through moves. Sub-folders inherit access via the canonical (owner-side) ancestor chain. Moving a sub-folder OUT of a shared folder revokes the recipient's cascade access to it; moving one IN grants it.
The owner can rename a shared folder at any time. The implementation bumps two things in lock-step:
- The folder row's
updated_at_ms→ heartbeat re-delivers the folder upsert. - Every placement that points at the folder (
file_type='folder',file_ref=<id>) has itsupdated_at_msbumped → heartbeat re-delivers each placement with a freshfile.title.
Step 2 matters because the tile's title comes from
placement.file.title (captured at shape time). Without bumping
the placement rows, the heartbeat's folder upsert reaches every
client but the placement upsert query skips them — leaving tiles
showing the old name until F5.
Plugins can react via do_action( 'openstation_folder_renamed', $folder_id, $new_name, $old_name, $user_id ).
When the owner deletes a folder, everything that depends on it is cleaned up in one transaction:
- Sub-folders the owner owns are recursively deleted (their own shares, placements, and children chain through the same cleanup). Sub-folders owned by another user — e.g. a writer recipient who built their own folder inside this one — are left intact; only the containment placement inside the parent is removed.
- Every share row + per-user decision row for the folder is deleted.
- Every placement pointing at the folder is deleted across all users (each with a tombstone so heartbeat scrubs the tile on every connected client).
- Every placement inside the folder is deleted with a tombstone.
- The folder row itself is deleted with a folder tombstone.
Plugins can subscribe to:
-
apply_filters( 'openstation_files_can_delete_folder', $can, $folder_id, $user_id, $row )— returnfalseor aWP_Errorto veto the cascade (e.g. for a UX confirmation prompt when many recipients are involved). -
do_action( 'openstation_files_before_delete_folder', $folder_id, $user_id, $row )— runs once before the walk. -
do_action( 'openstation_files_share_revoked', $share_id, $row, $user_id )— fires per share row torn down by the cascade (same signature plugin authors already use for explicit revokes). -
do_action( 'openstation_files_after_delete_folder_cascade', $root_folder_id, $user_id, $summary )— single summary event with lists keyed byfolders_deleted,shares_revoked,placements_pointing,placements_inside.
Two tables back the feature:
CREATE TABLE wp_desktop_mode_folder_shares (
id BIGINT UNSIGNED AUTO_INCREMENT,
target_type VARCHAR(32) DEFAULT 'folder',
folder_id BIGINT UNSIGNED,
principal_type VARCHAR(16), -- 'user' | 'role'
principal_ref VARCHAR(191), -- user id stringified, or role slug
capability VARCHAR(8) DEFAULT 'read',
state VARCHAR(16) DEFAULT 'pending',
invited_by BIGINT UNSIGNED,
invited_at_ms BIGINT UNSIGNED,
decided_at_ms BIGINT UNSIGNED NULL,
PRIMARY KEY (id),
UNIQUE KEY uniq_principal (target_type, folder_id, principal_type, principal_ref)
);For user-principal shares (principal_type='user'), the row's
state column carries the single recipient's opt-in state — no
per-user fan-out is needed.
For role-principal shares (principal_type='role'), the
shares row's state is just the row's lifecycle marker. The
per-recipient opt-in lives in a second table so each role member
decides independently — without this, the first member to click
"Accept" or "Deny" would decide for the entire role.
CREATE TABLE wp_desktop_mode_share_user_decisions (
id BIGINT UNSIGNED AUTO_INCREMENT,
share_id BIGINT UNSIGNED,
user_id BIGINT UNSIGNED,
state VARCHAR(16) DEFAULT 'pending',
decided_at_ms BIGINT UNSIGNED,
PRIMARY KEY (id),
UNIQUE KEY uniq_share_user (share_id, user_id)
);Resolution rule, expressed as openstation_files_share_user_state():
-
principal_type='user'→ state from the shares row. -
principal_type='role'→ state from the decisions row for(share_id, user_id), defaulting to'pending'when absent.
Legacy sites kept share lists in wp_desktop_mode_folders.share_meta
as JSON. Those lists are NOT migrated; the column is retained for
diagnostics only and is never consulted for visibility. Owners
must re-invite recipients through the shares API.
The column ships from day one so a future plugin can register a NEW shareable target without a schema migration:
add_filter( 'openstation_files_shareable_types', function ( $types ) {
$types[] = 'post';
return $types;
} );
add_filter( 'openstation_files_share_target_owner', function ( $owner, $type, $ref ) {
if ( 'post' === $type ) {
return (int) get_post_field( 'post_author', (int) $ref );
}
return $owner;
}, 10, 3 );The default shareable types are 'folder' and 'file' (stored
uploads), filterable via openstation_files_shareable_types. The REST routes live under
/folders/{id}/shares; a future generic surface (e.g.
/share-targets/{type}/{id}/shares) can hit the same store
functions.
openstation_files_compute_visible_folders( $owned, $user_id )
returns the union of:
-
Owned folders (
$ownedis the seed — owner-side). - share_mode='all' folders the viewer doesn't own.
- Accepted shares matching the viewer — direct user grant OR any role the viewer holds.
share_meta on the folders row is diagnostic-only and is never
consulted for visibility (a legacy fallback was deliberately
dropped because it re-granted access to revoked recipients).
Filtered via openstation_files_visible_folders (priority 5) and
openstation_files_user_can_see_folder (per-row decision).
openstation_files_move(), _place(), and the
openstation_files_user_can_trash_placement gate consult
openstation_folder_share_user_capability( $folder_id, $user_id ):
- Owner always returns
'write'. -
share_mode='all'returns'read'by default (filterable). - Accepted shares contribute their
capability; most permissive wins (write > read > none). - Pending and denied shares do not contribute.
A non-write recipient that tries to move an icon into or out of the shared folder, or trash an icon inside it, gets a 403:
WP_Error( 'openstation_files_no_write_in_shared_folder', ... )
PATCH /files/placements/{id} and PATCH /files/folders/{id}
honour an optional If-Match: <updated_at_ms> header. When the
header is present AND the stored row's updated_at_ms differs,
the server returns 409 with a structured body:
{
"code": "openstation_files_conflict",
"message": "...",
"data": {
"status": 409,
"data": {
"reason": "parent_changed",
"actor": { "id": 7, "name": "Alice", "avatar": "https://..." },
"current": { "parentId": 42, "parentName": "Backlog", "updatedAtMs": 1737130000000 }
}
}
}The 409 body is viewer-scoped. The actor's identity and the
parent folder's id/name are included only when the requesting
viewer is in the same collaboration scope as the actor — they own
the row, own the parent folder, or hold at least read access to
the parent folder via the shares table. For any other viewer the
actor degrades to { "id": 0, "name": "", "avatar": "" } and
current.parentId / current.parentName degrade to 0 / "",
so a write attempt can't be used to enumerate other users'
display names or folder names.
The JS client throws FilesConflictError and the layer surfaces a
toast: "Alice moved this to 'Backlog' just now. [View folder]".
View folder opens the destination folder's window — the same
window a double-click on that folder's tile opens, because the action
dispatches through the registered folder opener rather than building
a window of its own. An already-open folder window is focused instead
of reopened. The button follows the viewer-scoping above: when
current.parentId degrades to 0 there is no folder to point at, so
the toast carries the message alone.
Clients that omit the header retain last-write-wins semantics for back-compat. The OpenStation shell always sends the header.
- Owner invites — share row written with
state='pending'. - Recipient's next heartbeat tick carries the invite in
openstation_files.shares.pending[]. - The shell shows an Accept / Deny / Later modal on first sight.
- Accept → row becomes
state='accepted', server places the folder at the recipient's desktop root viaopenstation_folder_share_accept_default_parentfilter. - Deny → row becomes
state='denied'; the shell adds the folder to a per-session denied set so the prompt does not re-fire. - Later → row stays
pending; the shell remembers it was prompted this session and won't re-open the modal until next heartbeat tick after a page reload.
A recipient can leave a folder they've previously accepted via the "Leave shared folder" item in the tile's right-click menu. The endpoint is principal-aware:
-
User-principal share — flips the shares row to
state='denied'. The owner sees the user dropped in the share list. -
Role-principal share — writes a per-user decision row with
state='denied'. Other role members are unaffected.
In both cases, the recipient's placement of the folder is soft-trashed (lands in their recycle bin; the underlying icons inside the folder, which belong to the shared namespace, are untouched).
Owners cannot leave their own folder (the endpoint returns 400
with openstation_files_owner_cannot_leave).
GET /desktop-mode/v1/files/folders/{id}/shares
POST /desktop-mode/v1/files/folders/{id}/shares
PATCH /desktop-mode/v1/files/folders/{id}/shares/{shareId}
DELETE /desktop-mode/v1/files/folders/{id}/shares/{shareId}
POST /desktop-mode/v1/files/folders/{id}/shares/{shareId}/accept
POST /desktop-mode/v1/files/folders/{id}/shares/{shareId}/deny
POST /desktop-mode/v1/files/folders/{id}/leave ← recipient-initiated
GET /desktop-mode/v1/files/users/search?q=&exclude=
POST /desktop-mode/v1/files/folder-sharing-tables/purge ← `manage_options` only
All require openstation_is_enabled + logged-in. Every share
route (including /leave) additionally requires the viewer's
folder-sharing OS Setting to be on; when it is off the routes
answer 404 (indistinguishable from the feature not being
installed). Share-management mutations (POST/PATCH/DELETE on
/shares) are gated by openstation_files_share_can_manage
(owner-only by default). /leave is open to any logged-in user
who is currently a recipient of the folder. /users/search
requires edit_posts. The table-purge route is a destructive
site-admin cleanup (drops the folder-sharing tables) and requires
manage_options.
See hooks-reference.md.
To react to share changes today, subscribe to the shares store —
a createSharedStore( 'desktop-files/shares' ) slot updated by
the heartbeat ingest.
Planned — wp.os.activity channels for the share
lifecycle are not yet published:
os/folder-share-invitedos/folder-share-acceptedos/folder-share-deniedos/folder-share-revokedos/folder-share-capability-changed
Programmatic entry points (from src/desktop-files/rest.ts):
import {
listShares,
inviteShare,
revokeShare,
updateShareCapability,
acceptShare,
denyShare,
FilesConflictError,
} from '...';The Share Settings modal (owner side) is opened via:
import { openShareSettingsModal } from '.../desktop-files/share-settings-modal';
openShareSettingsModal( { folderId, folderName } );Two pieces of state have to reach the client for a folder tile to present sharing correctly, and both ride the boot payload rather than a REST call:
shareSummary on the folder's file shape. A folder on the
desktop is a placement, and a placement's file is whatever
OpenStation_Folder_File::serialize() returns — so that is where
the summary lives:
'shareSummary' => array( 'shared' => bool, 'recipientCount' => int )shared is viewer-agnostic (a recipient needs the badge as much as
the owner does); recipientCount is owner-internal and reads 0
for anyone who can't manage the folder's shares, which keeps the
wire shape stable rather than making the key conditional.
openstation_files_folder_share_summary() computes it, and the
folder response shape (openstation_files_shape_folder()) calls the
same helper — a tile paints the same badge whichever response it was
rendered from. share_mode = 'all' counts as shared on its own; a
pending invitation does not.
filesBootFolders in the shell config. The client keeps folder
rows in a map separate from placements, and everything that needs a
folder's owner reads it there — the owner-only Share button most of
all, since a window hands its match predicate nothing but an id.
openstation_files_inject_boot_folders() inlines the viewer's
visible folders (owned, plus accepted shares, plus share_mode='all')
with the same shape and visibility resolution as GET /folders;
seedBootFolders() applies them once on boot and deletes the key, so
a later re-hydration still fetches fresh state.
Placement hydration alone never populated that map — listFolders()
ran only after a create, a rename or an untrash — which is why the
seed exists rather than being an optimization.
A recipient cannot re-share a folder they've received:
-
Server — every share mutation calls
openstation_files_share_can_manage. Default decision is owner-only. Plugins that ship a "team admin" concept can broaden it; non-owners always get a 403 otherwise. -
Client — the title-bar Share button's
matchpredicate consults the folders shared store (seeded fromfilesBootFolders, above) and only renders for the owner. The tile context menu's "Share folder…" / "Manage sharing…" entries follow the same rule; recipients see the "Leave shared folder" entry instead.
Stored uploads (the upload file type — see
files-on-desktop.md)
are shareable as single files, reusing this feature's tables via the
target_type='file' column (the folder_id column carries the
stored-file id on those rows). Deliberate divergences from folder
shares:
-
Read tier only. Recipients get view + download; the write tier
does not exist for files. An invite with
capability: writeis a 400. - User principals only in v1 (no role invites).
- Uploads are additionally owner-locked everywhere: even a
folder write-collaborator cannot move, rename, or trash an
uploadplacement inside a shared folder — only the stored file's owner can (openstation_files_upload_owner_locked). The read/write tiers of THIS page are unchanged for every other type.
Lifecycle mirrors folders: invite → heartbeat delivers the pending
shape (targetType: 'file', fileId, fileName riding the same
shares.pending channel) → accept plants an upload placement at
the recipient's desktop root (openstation_folder_share_accept_default_parent
filter applies) → deny / leave / revoke scrub the recipient's tile.
Routes live under /desktop-mode/v1/files/uploads/{id}/shares and
mirror the folder routes below, including the 404-when-disabled
masking. Store functions:
openstation_stored_file_share_{invite,accept,deny,leave,revoke}(),
state resolver openstation_stored_file_share_state(), manage gate
openstation_stored_files_share_can_manage (owner-only default,
filterable).
- Owner transfer.
- Sharing of non-upload, non-folder file types (posts, media references, …).
- Role-principal shares for single files.
- Cascade share (sub-folders need their own grant).
- Recipient-side rename of the shared folder.
This wiki is generated from the docs/ directory — edits made here are overwritten by the next sync.
To change a page, open a pull request against docs/.
Guides
- Development guide
- Releasing openstation
- Agents security model
- API Index
- Architecture
- Bridge protocol — wiring overview
- <os-*> component reference
- Native Desktop Host — Experimental
- Desktop themes
- Dock customization — two registries, one mental model
- The event-driven framework
- Files on the Desktop
- Folder sharing
- Getting Started
- Hooks Reference
- Icons
- JavaScript Reference
- The Living Tree — algorithm definition
- Mio
- Native Windows & Framework Interop
- Plugin compatibility layer
- Progressive Web App (PWA)
- Station Home
- Using openstation from your own plugin
Migration notes
- Migration: built-in activity channels move to the os/ namespace
- Migration: window, wallpaper and widget bundles load on demand
- Migration — the navigation model
- Migration: a native window's tabs move to the window chrome
All examples
- AI Agents — extend and invoke from a plugin
- wp.os.ai.ask() — programmatic AI Copilot
- Tune the AI model config
- Custom arrange-menu action
- Open a child window its owner can't cover
- Style a specific admin page inside the iframe
- Code Blue — register your plugin's log file
- Open a file in the Code editor (deep-link from any window)
- Connect to a window — title-bar button + iframe pub/sub
- Content changes — live-refresh every window listing your type
- Custom window chrome (Experimental)
- Register a custom unfocused-window effect
- Example: render a data table
- Real file storage — react to uploads, gate policy, share from PHP
- React to a window being set free onto the real desktop
- Cross-window devtools — instrumentation primitives
- Add a dock item with a badge
- Decorate the dock without forking the renderer
- Replace the dock rail entirely
- Retune the Drafts widget's AI writing assistant
- Gate OpenStation by role
- Iframe-initiated window opens
- Build a feed reader without the bookkeeping
- Inject data into openStationConfig
- Render a list without losing clicks — renderKeyedList()
- Example: layout primitives (body → panel → row → col)
- Use <os-*> components from a plugin that ships as a zip
- Restyle and drive Mio
- Add an action that works on a whole selection
- WP Explorer — custom post types and their folder
- Add an action button to a WP Explorer preview pane
- Example: native Posts window
- Example: native window with tabs
- Native windows
- Customize note → post conversion
- Send a notification
- OAuth relay — connect to an external service
- OS-file drop
- <os-flyout> — window-scoped sliding card
- Plugins window — extras
- Track who's around — wp.os.presence
- Example: progress bar
- PWA install — surface your own button
- React to window events
- Example: extend the Trash
- Register a slash-command
- Register a desktop theme from a plugin
- Register a game
- Example: register a desktop icon (Jorvy)
- Register a wallpaper
- Register a widget
- Related entities — extend the title bar's "Related" menu
- The native-window render ctx
- Programmatic folder sharing
- Share state across multi-bundle plugins — wp.os.createSharedStore()
- Example: loading spinner
- Add an opt-in card to Station Home
- Accept drops on your desktop icon
- Give a tile two icons, one per state
- Add a row to a window's ⋯ menu
- Example: window activity & the status ring
- Window controls
- Subscribe to window lifecycle events
- Window links — relate windows and restyle the ties (Experimental)
- Window loading state — spinner overlay & ready signal
- Show a banner at the top of a window
- Pulse a window's icon — Window.requestAttention()
- Register a custom window reveal
- Window slots
- Window themes
- Native window with bundle-bound config