The folder bridge¶
How a native shell (the Mac app, the Windows app, the Android app) gives the page a folder to
keep the library in. It is Dvarim's bridge (dvarim/docs/BRIDGE.md), with
Moshe's names, without Dvarim's calendar ops, and with a few shell services a
writing app needs (§5). The page's half is engine/src/storage-bridge.js.
- Reference implementation:
tools/bridge_sim.py, about a hundred lines of Python over a real folder (Dvarim's, renamed). - The check a shell must pass:
engine/src/storage-conformance.js. From a self-test:await window.__mosheLocal.conformance()→{ok, results: [{name, ok, ms, error?}]}.
If a shell and this document disagree, this document wins.
The shape of it¶
┌──────────── the shell's WebView ─────────────┐
│ the page, from the app's own bundle │
│ engine (store, markup, merge) │
│ BridgeStorage ── message ──┐ │
└───────────────────────────────┼───────────────┘
▼
the shell: one message at a time, a folder on disk
◄── window.__mosheFilesChanged() when someone else writes it
The shell does four things: serves the page from inside the app, answers the bridge's messages from the folder, tells the page when something else changed the folder, and offers the few services in §5. Everything else is the page's.
The folder is the library (docs/FORMAT.md): Notes/, Projects/,
.moshe/. Often it is a folder inside an Obsidian vault, one Obsidian and
Syncthing already see.
1. Serving the page¶
Serve the build (frontend/dist, made by npm ci && npm run build in
frontend/) from inside the app:
| origin | how | |
|---|---|---|
| macOS | moshe-app://app |
a WKURLSchemeHandler registered for moshe-app |
| Android | https://appassets.androidplatform.net |
shouldInterceptRequest answering from assets/web/ |
| Windows | https://moshe-app.example |
WebView2's WebResourceRequested, answering from web/ beside the app |
- A path that names a file in the build returns that file with its MIME type
(
.jsistext/javascript,.css,.svg,.png,.webmanifest,.html,.woff2). Any other path returnsindex.html. /api/needs no answer: in folder mode the page does its own work./_library/<path>answers with that file of the library folder, as bytes, with its MIME type (.png,.jpg,.gif,.webp,.svg,.pdf): how the editor shows the images a sheet uses (). Refuse a path that leaves the folder.- No service worker inside a shell.
- User agent: add
Moshe/<version> (macOS shell)on the Mac (the page leaves room for the traffic lights when it sees it),Moshe/<version> (Android shell)on Android,Moshe/<version> (Windows shell)on Windows.
2. The messages¶
Transport¶
macOS. A WKScriptMessageHandlerWithReply named mosheStorage, added to
the page content world:
config.userContentController.addScriptMessageHandler(bridge, contentWorld: .page, name: "mosheStorage")
The page calls window.webkit.messageHandlers.mosheStorage.postMessage(msg);
reply with replyHandler(reply, nil). Check
message.frameInfo.securityOrigin: its protocol must be moshe-app.
Android. A @JavascriptInterface object added as MosheStorage:
web.addJavascriptInterface(bridge, "MosheStorage");
@JavascriptInterface public void request(int id, String json) { … } // return at once; work on a single-thread executor
// reply on the UI thread:
web.post(() -> web.evaluateJavascript("window.__mosheStorageReply(" + id + "," + JSONObject.quote(replyJson) + ")", null));
Replies may arrive in any order; the page matches them by id. Add the
interface only in the folder-mode activity, and only ever load the app's own
origin there.
Windows. Android's transport, carried over WebView2's web messages: a
document-start script defines window.MosheStorage.request(id, json), which
posts {channel: 'storage', id, json}; the shell answers with
PostWebMessageAsJson({channel: 'storage', id, reply}), and the script hands
reply to window.__mosheStorageReply. Check the message's Source: it must
be the app's origin. (windows/Moshe/Scripts.cs, FolderBridge.cs.)
Messages and replies¶
{ "v": 1, "op": "write", "args": { "path": "Notes/Blog/Post.md", "text": "# A post\n" } }
{ "ok": <result> }
{ "error": { "code": "EACCES", "message": "what went wrong, in words a person can act on" } }
One at a time, in the order sent (a serial queue). A write followed by a read of the same file must read the new text.
| op | args | ok | notes |
|---|---|---|---|
list |
— | {files, dirs, mtimes?} |
Every file and directory under the root, empty directories included. May leave out what is inside .git, .obsidian, .trash, .stfolder, .stversions, node_modules. Must include .moshe/. mtimes (optional, recommended): {path: ms} for every file — with it the page reads only what changed on a rescan instead of every file. |
read |
path |
{text, mtime} or null |
null: no such file, not an error. |
readMany |
paths |
[{text, mtime} or null, …] |
One answer per path, in order: a whole scan in one round trip. |
write |
path, text |
{mtime} |
Creates parent directories. Atomic: write a temporary file beside it (*.moshe-tmp), rename it over. |
writeBytes |
path, base64 |
{mtime} |
Bytes, for an image pasted or dropped into a sheet (saved in _media/ beside it). Atomic, like write. |
remove |
path |
null |
A file; quiet if absent. |
move |
from, to |
null |
A file or a directory with everything in it; creates to's parents. EEXIST if a file or non-empty directory is at to; a directory may replace an empty one (rename(2)). ENOENT if from is absent. Groups are moved and renamed this way, so it must be fast for a folder. |
mkdir |
path |
null |
And its parents; quiet if present. |
removeDir |
path |
true/false |
Only if empty. true if no directory is there afterwards. |
info |
— | {name, detail, canPick, canUseServer, pollSeconds, http, changes} |
name: the folder's name; detail: where it is. pollSeconds: how often to rescan while in front (0 if the shell reports changes itself, as the Mac does; about 15 on Android). |
pick |
— | {changed} |
Offer the folder picker; reload the page if one was chosen. |
useServer |
— | null |
Switch to server mode (the server's page). |
http |
url, method, headers, body |
{status, headers, body} |
Optional: one HTTP request made by the shell, for the built-in WebDAV sync to a server that answers no page from another origin (Nextcloud). No redirects, no cookies, 30 s timeout, https: or a tailnet http: only. Runs beside the serial queue. |
changed |
paths |
null |
Optional, page → shell, if info says changes: true: the library changed. |
Paths and text¶
- Relative to the folder,
/-separated, no empty,.or..segment; refuse anything else (and anything that leads out through a symlink) withEINVAL. - Names exactly as written (NFC): Hebrew and accented names must come back
as they were written. On the Mac, create with
rename(2)/mkdir(2)and list withenumerator(atPath:)— Foundation decomposes names otherwise (Dvarim'sFolderBridge.swiftalready does this; reuse it). - UTF-8 both ways;
writestores exactly the string's bytes;readreturns exactly the file (a\r\n, a BOM, kept).mtimein milliseconds. - Error codes:
EINVAL,ENOENT,EEXIST,EACCES(say how to fix it),ENOSPC,EIO,ENOSYS(unknown op),EPROTO(unknownv).
3. When someone else writes the folder¶
Obsidian, Syncthing and iCloud Drive all write to the same folder. When the
shell notices a change it did not make, it calls window.__mosheFilesChanged().
Mac: FSEventStream on the folder, ~0.3 s latency. Windows: a
FileSystemWatcher on it, the same. Android: pollSeconds.
Too often is harmless: the page debounces and compares contents. It also
rescans when the window comes to the front.
While a sheet is open and someone else changes its file, the editor takes the
change if nothing typed is unsaved, and merges the two if something is
(engine/src/store.js, saveSheet). Conflict copies that Syncthing leaves
(x.sync-conflict-….md) are merged in and moved to .moshe/conflicts/.
4. Which mode the shell is in¶
- Folder (the default for Moshe): the shell serves the page from inside the app and installs the bridge. Mac: a path, not sandboxed. Android: a Storage Access Framework tree URI with a persisted permission — or, with no Syncthing on the phone, an app-private folder synced over WebDAV with the server (Settings → Sync on the server makes the device's token).
- Server: the shell opens the server's address (
https://<host>/), no bridge. Useful as a fallback.
5. Shell services (Moshe's, not Dvarim's)¶
The page looks for window.mosheShell and uses what it offers; each is
optional, and the page has a browser fallback for all of them.
| call | what the shell does | fallback |
|---|---|---|
mosheShell.savePdf(html, fileName) → Promise |
Render the print-ready HTML (it has @page size and margins) to a PDF — Mac: an offscreen WKWebView + createPDF/printOperation; Android: PrintManager or WebView.createPrintDocumentAdapter — and offer a save panel (Mac) or the share sheet/ACTION_CREATE_DOCUMENT (Android). |
an iframe and print() |
mosheShell.saveFile(fileName, base64, mime) → Promise |
Save an export (DOCX, ePub, Markdown…) with a save panel / create-document intent. | <a download> |
mosheShell.share(fileName, base64, mime) → Promise |
The system share sheet, for "Send as Mail", AirDrop… | — |
mosheShell.openExternal(url) |
Open a link in the default browser (links in the text, ⌘-click). | window.open |
Report them in the user agent or by defining the object before the page
loads (WKUserScript at document start; addJavascriptInterface + a small
injected wrapper on Android).
Windows defines it in a document-created script (windows/Moshe/Scripts.cs),
each call a web message with a reply: savePdf and sharePdf print the HTML
in a hidden WebView2 (Page.printToPDF, the @page size and its margin boxes
drawn by Chromium itself), saveFile has a save dialog, share Windows' share
sheet, copy both kinds of clipboard. Print… stays the page's own, in WebView2's
print dialog. Where a Whisper model is on the PC, it adds mosheShell.voice
(VOICE.md, Windows), and it lets the page have the microphone, on the
app's own origin only, for the page's own Whisper.
Checking a shell¶
- Folder mode on a copy of the library or an empty folder.
await window.__mosheLocal.conformance()→ every checkok.- By hand: edit a sheet in Obsidian and see it in the app; type in the app and see the file; rename a group in the app and see the folder renamed; let Syncthing bring a change in with the sheet open.
tools/bridge_sim.pyshows the expected answers for any op.