@brftech/filex-app-ui 0.55.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,16 +30,16 @@ with a `<script src>`: it defines `window.FilexAppUI.connect`.
30
30
 
31
31
  | Call | What it does |
32
32
  |---|---|
33
- | `fx.session` | the app and view, the reader's language and direction, filex's colours, the person's display name, the files (each with `encrypted: 'folder' \| 'vault' \| 'file'` when it is end-to-end encrypted, filex 0.55 - information only: filex decrypts nothing for an app, the file is `readOnly` and its read is refused), the grants |
33
+ | `fx.session` | the app and view, the reader's language and direction, filex's colours, the person's display name, the files (each with `encrypted: 'folder' \| 'vault' \| 'file'` when it is end-to-end encrypted, filex 0.55 - on its own information only: the file is `readOnly` and its read is refused; `plaintext: true` beside `encrypted: 'folder'` or `'vault'`, filex 0.56: the app asked for `encrypted_folders` and filex hands the file over in the clear - decrypted in the person's browser for `open()`, encrypted again on `save()`, at most `LIMITS.maxEncryptedBytes`; `saveAs` and the module are not offered while such a file is open), the grants |
34
34
  | `fx.open(i)` / `fx.save(data)` / `fx.saveAs(name, data)` | read an opened file; save over it (a new version, or the draft it is); save a new file where the person picks |
35
35
  | `fx.dirty(on)`, `fx.title(t)`, `fx.toast(t, tone)`, `fx.confirm(opts)`, `fx.close()` | tell filex about unsaved changes; ask filex to draw a line, a question, to close (a toast and a question carry your app's name) |
36
36
  | `fx.copy(text)` | put text on the clipboard - call it from a click or a key press: without one, filex asks the person first |
37
37
  | `fx.download(name, data, mime?)` | hand the person a file for their own disk (`"ui": { "download": true }` in the manifest) - from a click, or filex asks; 256 MiB at most; Chromium streams it into the file they pick |
38
38
  | `fx.print(name, pdf)` | print a PDF (filex 0.55; `"ui": { "print": true }` in the manifest): a sandboxed frame may not open the print dialog, so filex prints it from its own page - filex asks every time and prints on the person's Allow, 64 MiB at most (`LIMITS.maxPrintBytes`); filex 0.54 and older answer `unknown_method`, a host whose print page may not be shown `unavailable` |
39
- | `fx.coedit.join()` / `.subscribe(from)` / `.append(kind, body)` / `.lease(op, seen)` / `.cursor(c)` / `.putBlob` / `.getBlob` / `.leave()` | editing together: filex seals the session's log and orders it through its relay, the app never holds a key. Defined in 0.55 so an app can be written against them; filex 0.55 answers each `unavailable` - edit alone then |
39
+ | `fx.coedit.join()` / `.subscribe(from)` / `.append(kind, body)` / `.lease(op, seen)` / `.cursor(c)` / `.putBlob` / `.getBlob` / `.leave()` | editing together (filex 0.56, the manifest's `"co_edit": {"open": true}`, granted as `files:co-edit`): filex seals the session's log and orders it through its relay, the app never holds a key. `join` answers `created` when this member started the session - it puts the base document (`putBlob('base', …)`) before anybody else may join; `fx.save(data, {through})` tells the session what a save holds. filex 0.55 answers each `unavailable` - edit alone then |
40
40
  | `fx.call(method, params)` | ask the app's own module (`ui_call`) |
41
41
  | `fx.submit(action, params)` | queue one of the app's actions on the opened files - like `copy`, from a click, or filex asks |
42
- | `fx.state.get(key)` / `fx.state.set(key, value)` | a small store for this app and this person |
42
+ | `fx.state.get(key)` / `fx.state.set(key, value)` | a small store for this app and this person (with a file of an encrypted folder open in the clear: a separate store, sealed in the browser) |
43
43
  | `fx.license.get()` | the app's license, for a paid app installed from a store (filex 0.52.0): `{status: 'free'}`, or `{status: 'valid', valid_until, updates_until}` - never the key, never the licensee |
44
44
  | `fx.on('theme' \| 'locale' \| 'file.changed' \| 'close.request' \| 'app.updated', fn)` | listen to filex (editing together adds `coedit.entry`, `coedit.cursor`, `coedit.dropped`) |
45
45
 
@@ -1,2 +1,2 @@
1
- var FilexAppUI=function(o){"use strict";const S="filex:hello",B="filex:port",O=["session.get","file.read","file.save","file.saveAs","ui.dirty","ui.title","ui.toast","ui.confirm","ui.close","clipboard.write","ui.download","engine.call","job.submit","state.get","state.set","license.get","ui.print","coedit.join","coedit.subscribe","coedit.append","coedit.lease","coedit.cursor","coedit.blob.put","coedit.blob.get","coedit.leave"],v={maxTextBytes:33554432,maxStateBytes:8192,maxLineChars:300,connectTimeoutMs:1e4,maxDownloadBytes:268435456,maxPrintBytes:67108864};function R(n){return!!n&&typeof n=="object"&&n.type===S&&typeof n.v=="number"}function L(n){return!!n&&typeof n=="object"&&n.type===B}function x(n){return!!n&&typeof n=="object"&&typeof n.id=="number"&&typeof n.method=="string"}function I(n){return O.includes(n)}class p extends Error{constructor(f){super(f.message||f.code),this.name="FilexError",this.code=f.code}}let E=null;function T(n={}){return E||(E=j(n)),E}function j(n){return new Promise((f,m)=>{if(typeof window>"u"||window.parent===window){m(new p({code:"unavailable",message:"not running inside filex"}));return}const a=window.parent,d=P(),c=setTimeout(()=>{window.removeEventListener("message",b),m(new p({code:"unavailable",message:"filex did not answer"}))},n.timeoutMs??v.connectTimeoutMs);function b(g){if(g.source!==a||!L(g.data)||!g.ports[0]||d&&g.origin!==d)return;window.removeEventListener("message",b),clearTimeout(c);const s=g.ports[0],u=k(s,n);u.request("session.get").then(l=>{u.session=l,n.applyTheme!==!1&&(M(l.theme,l.locale,l.dir),u.on("theme",y=>M(y)),u.on("locale",y=>{const e=y;M(void 0,e.locale,e.dir)})),f(u)}).catch(m)}window.addEventListener("message",b),a.postMessage({type:S,v:1},"*")})}function P(){try{const n=window.location.ancestorOrigins;if(n&&n.length>0&&n[0]!=="null")return n[0]}catch{}return""}function k(n,f){let m=0;const a=new Map,d=new Map;let c=null;n.onmessage=e=>{const t=e.data;if(!(!t||typeof t!="object")){if(typeof t.id=="number"){const r=a.get(t.id);if(!r)return;a.delete(t.id),t.error?r.reject(new p(t.error)):r.resolve(t.result);return}if(typeof t.hid=="number"){b(t);return}if(typeof t.event=="string")for(const r of d.get(t.event)??[])try{r(t.data)}catch(i){console.error("[filex-app-ui] listener failed",i)}}};async function b(e){if(e.request==="save"){if(!c){n.postMessage({hid:e.hid,error:{code:"unavailable",message:"this app has no save handler"}});return}try{const t=await g();n.postMessage({hid:e.hid,result:t??{saved:!0}})}catch(t){const r=t instanceof p?{code:t.code,message:t.message}:{code:"failed",message:String(t?.message??t)};n.postMessage({hid:e.hid,error:r})}return}n.postMessage({hid:e.hid,error:{code:"unknown_method"}})}async function g(){if(!c)return null;const e=await c();return e==null?null:y.save(e)}function s(e,t,r=[]){const i=++m;return new Promise((w,h)=>{a.set(i,{resolve:w,reject:h});try{n.postMessage({id:i,method:e,params:t},r)}catch(D){a.delete(i),h(D)}})}function u(e,t){s(e,t).catch(r=>console.warn("[filex-app-ui]",e,r))}async function l(e){if(typeof e=="string")return{data:e,transfer:[]};if(e instanceof ArrayBuffer)return{data:e,transfer:[e]};if(ArrayBuffer.isView(e)){const t=e.buffer.slice(e.byteOffset,e.byteOffset+e.byteLength);return{data:t,transfer:[t]}}if(typeof Blob<"u"&&e instanceof Blob){const t=await e.arrayBuffer();return{data:t,transfer:[t]}}if(typeof ReadableStream<"u"&&e instanceof ReadableStream)return{data:e,transfer:[e]};throw new p({code:"invalid",message:"save takes a string, a Blob, bytes or a ReadableStream"})}const y={session:void 0,async open(e=0){const t=y.session?.files?.[e];if(!t)throw new p({code:"not_found",message:`no file at ${e}`});const r=(i,w)=>s("file.read",{index:e,as:i}).then(h=>h[w]);return{...t,text:()=>r("text","text"),bytes:()=>r("bytes","bytes"),stream:()=>r("stream","stream"),save:(i,w)=>y.save(i,{index:e,mime:w})}},async save(e,t={}){const r=await l(e);return s("file.save",{index:t.index??0,data:r.data,mime:t.mime},r.transfer)},async saveAs(e,t,r){const i=await l(t);return s("file.saveAs",{name:e,data:i.data,mime:r},i.transfer)},async download(e,t,r){const i=await l(t);return s("ui.download",{name:e,data:i.data,mime:r},i.transfer)},async print(e,t){const r=await l(t);return s("ui.print",{name:e,data:r.data,mime:"application/pdf"},r.transfer)},coedit:{join(e=0){return s("coedit.join",{index:e})},subscribe(e){return s("coedit.subscribe",{from:e})},append(e,t){return s("coedit.append",{kind:e,body:t})},async lease(e,t){return!!(await s("coedit.lease",{op:e,changesSeen:t}))?.granted},cursor(e){u("coedit.cursor",{cursor:e})},async putBlob(e,t){const r=await l(t);if(!(r.data instanceof ArrayBuffer))throw new p({code:"invalid",message:"a blob is bytes"});await s("coedit.blob.put",{name:e,data:r.data},r.transfer)},async getBlob(e){return(await s("coedit.blob.get",{name:e})).bytes},async leave(){await s("coedit.leave")}},dirty(e){u("ui.dirty",{dirty:!!e})},title(e){u("ui.title",{text:String(e).slice(0,v.maxLineChars)})},toast(e,t){u("ui.toast",{text:String(e).slice(0,v.maxLineChars),tone:t})},confirm(e){return s("ui.confirm",typeof e=="string"?{text:e}:e)},close(){u("ui.close")},copy(e){return s("clipboard.write",{text:String(e)})},call(e,t){return s("engine.call",{method:e,params:t})},submit(e,t){return s("job.submit",{action:e,params:t})},state:{get(e){return s("state.get",{key:e})},set(e,t){return s("state.set",{key:e,value:t})}},license:{get(){return s("license.get")}},on(e,t){let r=d.get(e);return r||d.set(e,r=new Set),r.add(t),()=>r.delete(t)},onSave(e){return c=e,()=>{c===e&&(c=null)}},request:s};return f.saveShortcut!==!1&&typeof document<"u"&&document.addEventListener("keydown",e=>{(e.ctrlKey||e.metaKey)&&!e.altKey&&!e.shiftKey&&(e.key==="s"||e.key==="S")&&c&&(e.preventDefault(),g().catch(t=>y.toast(String(t?.message??t),"error")))},!0),y}function M(n,f,m){if(typeof document>"u")return;const a=document.documentElement;if(n){a.dataset.theme=n.mode,a.style.colorScheme=n.mode;for(const[d,c]of Object.entries(n.tokens??{}))/^--fe-[a-z0-9-]+$/.test(d)&&typeof c=="string"&&a.style.setProperty(d,c)}f&&(a.lang=f),m&&(a.dir=m)}return o.BRIDGE_VERSION=1,o.FilexError=p,o.HELLO=S,o.LIMITS=v,o.METHODS=O,o.PORT=B,o.connect=T,o.isHello=R,o.isKnownMethod=I,o.isPortMessage=L,o.isRequest=x,Object.defineProperty(o,Symbol.toStringTag,{value:"Module"}),o}({});
1
+ var FilexAppUI=function(o){"use strict";const S="filex:hello",B="filex:port",O=["session.get","file.read","file.save","file.saveAs","ui.dirty","ui.title","ui.toast","ui.confirm","ui.close","clipboard.write","ui.download","engine.call","job.submit","state.get","state.set","license.get","ui.print","coedit.join","coedit.subscribe","coedit.append","coedit.lease","coedit.cursor","coedit.blob.put","coedit.blob.get","coedit.leave"],h={maxTextBytes:33554432,maxStateBytes:8192,maxLineChars:300,connectTimeoutMs:1e4,maxDownloadBytes:268435456,maxPrintBytes:67108864,maxEncryptedBytes:209715200};function L(n){return!!n&&typeof n=="object"&&n.type===S&&typeof n.v=="number"}function x(n){return!!n&&typeof n=="object"&&n.type===B}function R(n){return!!n&&typeof n=="object"&&typeof n.id=="number"&&typeof n.method=="string"}function I(n){return O.includes(n)}class p extends Error{constructor(f){super(f.message||f.code),this.name="FilexError",this.code=f.code}}let E=null;function T(n={}){return E||(E=j(n)),E}function j(n){return new Promise((f,m)=>{if(typeof window>"u"||window.parent===window){m(new p({code:"unavailable",message:"not running inside filex"}));return}const a=window.parent,d=P(),c=setTimeout(()=>{window.removeEventListener("message",b),m(new p({code:"unavailable",message:"filex did not answer"}))},n.timeoutMs??h.connectTimeoutMs);function b(g){if(g.source!==a||!x(g.data)||!g.ports[0]||d&&g.origin!==d)return;window.removeEventListener("message",b),clearTimeout(c);const s=g.ports[0],u=k(s,n);u.request("session.get").then(l=>{u.session=l,n.applyTheme!==!1&&(M(l.theme,l.locale,l.dir),u.on("theme",y=>M(y)),u.on("locale",y=>{const e=y;M(void 0,e.locale,e.dir)})),f(u)}).catch(m)}window.addEventListener("message",b),a.postMessage({type:S,v:1},"*")})}function P(){try{const n=window.location.ancestorOrigins;if(n&&n.length>0&&n[0]!=="null")return n[0]}catch{}return""}function k(n,f){let m=0;const a=new Map,d=new Map;let c=null;n.onmessage=e=>{const t=e.data;if(!(!t||typeof t!="object")){if(typeof t.id=="number"){const r=a.get(t.id);if(!r)return;a.delete(t.id),t.error?r.reject(new p(t.error)):r.resolve(t.result);return}if(typeof t.hid=="number"){b(t);return}if(typeof t.event=="string")for(const r of d.get(t.event)??[])try{r(t.data)}catch(i){console.error("[filex-app-ui] listener failed",i)}}};async function b(e){if(e.request==="save"){if(!c){n.postMessage({hid:e.hid,error:{code:"unavailable",message:"this app has no save handler"}});return}try{const t=await g();n.postMessage({hid:e.hid,result:t??{saved:!0}})}catch(t){const r=t instanceof p?{code:t.code,message:t.message}:{code:"failed",message:String(t?.message??t)};n.postMessage({hid:e.hid,error:r})}return}n.postMessage({hid:e.hid,error:{code:"unknown_method"}})}async function g(){if(!c)return null;const e=await c();return e==null?null:y.save(e)}function s(e,t,r=[]){const i=++m;return new Promise((w,v)=>{a.set(i,{resolve:w,reject:v});try{n.postMessage({id:i,method:e,params:t},r)}catch(D){a.delete(i),v(D)}})}function u(e,t){s(e,t).catch(r=>console.warn("[filex-app-ui]",e,r))}async function l(e){if(typeof e=="string")return{data:e,transfer:[]};if(e instanceof ArrayBuffer)return{data:e,transfer:[e]};if(ArrayBuffer.isView(e)){const t=e.buffer.slice(e.byteOffset,e.byteOffset+e.byteLength);return{data:t,transfer:[t]}}if(typeof Blob<"u"&&e instanceof Blob){const t=await e.arrayBuffer();return{data:t,transfer:[t]}}if(typeof ReadableStream<"u"&&e instanceof ReadableStream)return{data:e,transfer:[e]};throw new p({code:"invalid",message:"save takes a string, a Blob, bytes or a ReadableStream"})}const y={session:void 0,async open(e=0){const t=y.session?.files?.[e];if(!t)throw new p({code:"not_found",message:`no file at ${e}`});const r=(i,w)=>s("file.read",{index:e,as:i}).then(v=>v[w]);return{...t,text:()=>r("text","text"),bytes:()=>r("bytes","bytes"),stream:()=>r("stream","stream"),save:(i,w)=>y.save(i,{index:e,mime:w})}},async save(e,t={}){const r=await l(e),i={index:t.index??0,data:r.data,mime:t.mime};return typeof t.through=="number"&&(i.through=t.through),s("file.save",i,r.transfer)},async saveAs(e,t,r){const i=await l(t);return s("file.saveAs",{name:e,data:i.data,mime:r},i.transfer)},async download(e,t,r){const i=await l(t);return s("ui.download",{name:e,data:i.data,mime:r},i.transfer)},async print(e,t){const r=await l(t);return s("ui.print",{name:e,data:r.data,mime:"application/pdf"},r.transfer)},coedit:{join(e=0){return s("coedit.join",{index:e})},subscribe(e){return s("coedit.subscribe",{from:e})},append(e,t){return s("coedit.append",{kind:e,body:t})},async lease(e,t){return!!(await s("coedit.lease",{op:e,changesSeen:t}))?.granted},cursor(e){u("coedit.cursor",{cursor:e})},async putBlob(e,t){const r=await l(t);if(!(r.data instanceof ArrayBuffer))throw new p({code:"invalid",message:"a blob is bytes"});await s("coedit.blob.put",{name:e,data:r.data},r.transfer)},async getBlob(e){return(await s("coedit.blob.get",{name:e})).bytes},async leave(){await s("coedit.leave")}},dirty(e){u("ui.dirty",{dirty:!!e})},title(e){u("ui.title",{text:String(e).slice(0,h.maxLineChars)})},toast(e,t){u("ui.toast",{text:String(e).slice(0,h.maxLineChars),tone:t})},confirm(e){return s("ui.confirm",typeof e=="string"?{text:e}:e)},close(){u("ui.close")},copy(e){return s("clipboard.write",{text:String(e)})},call(e,t){return s("engine.call",{method:e,params:t})},submit(e,t){return s("job.submit",{action:e,params:t})},state:{get(e){return s("state.get",{key:e})},set(e,t){return s("state.set",{key:e,value:t})}},license:{get(){return s("license.get")}},on(e,t){let r=d.get(e);return r||d.set(e,r=new Set),r.add(t),()=>r.delete(t)},onSave(e){return c=e,()=>{c===e&&(c=null)}},request:s};return f.saveShortcut!==!1&&typeof document<"u"&&document.addEventListener("keydown",e=>{(e.ctrlKey||e.metaKey)&&!e.altKey&&!e.shiftKey&&(e.key==="s"||e.key==="S")&&c&&(e.preventDefault(),g().catch(t=>y.toast(String(t?.message??t),"error")))},!0),y}function M(n,f,m){if(typeof document>"u")return;const a=document.documentElement;if(n){a.dataset.theme=n.mode,a.style.colorScheme=n.mode;for(const[d,c]of Object.entries(n.tokens??{}))/^--fe-[a-z0-9-]+$/.test(d)&&typeof c=="string"&&a.style.setProperty(d,c)}f&&(a.lang=f),m&&(a.dir=m)}return o.BRIDGE_VERSION=1,o.FilexError=p,o.HELLO=S,o.LIMITS=h,o.METHODS=O,o.PORT=B,o.connect=T,o.isHello=L,o.isKnownMethod=I,o.isPortMessage=x,o.isRequest=R,Object.defineProperty(o,Symbol.toStringTag,{value:"Module"}),o}({});
2
2
  //# sourceMappingURL=filex-app-ui.iife.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"filex-app-ui.iife.js","sources":["../src/protocol.ts","../src/index.ts"],"sourcesContent":["/**\n * The bridge between filex and an app's own interface — the ONE definition.\n *\n * An app's interface (the `ui` block of its filex-app.json) runs in a frame\n * filex draws with `sandbox=\"allow-scripts\"`: an opaque origin, no cookies, no\n * storage, no network (the server's CSP says `connect-src 'none'`). Everything\n * it may do goes through this bridge, and the host decides every call.\n *\n * Both halves import this file — the SDK the app bundles (`src/index.ts`) and\n * filex's own frame (`packages/core/src/components/plugin/AppFrame.vue`) — so a\n * method or an event added here is added on both sides at once.\n *\n * The wire, in order (docs/APP-PLUGINS-API.md → \"The interface bridge\"):\n *\n * 1. The app posts `{type: 'filex:hello', v: 1}` to its parent window.\n * 2. The host takes it ONLY when `event.source` is the frame it drew. The\n * origin says nothing here: a sandboxed frame's origin is always \"null\",\n * so any other sandboxed frame on the page would look the same (measured,\n * 2026-09-27, Chrome, Firefox and WebKit).\n * 3. The host answers with a MessagePort: `{type: 'filex:port', v: 1}` and\n * the port transferred. Once per load of the frame; a second hello is\n * ignored.\n * 4. The app takes the port ONLY when `event.source` is its parent window.\n * 5. From then on, only the port: requests `{id, method, params}`, answers\n * `{id, result}` or `{id, error}`, events `{event, data}`, and the host's\n * own requests `{hid, request, params}` answered `{hid, result|error}`.\n */\n\n/** The bridge generation both sides speak. */\nexport const BRIDGE_VERSION = 1;\n\n/** The app's first message, to `window.parent`. */\nexport const HELLO = 'filex:hello';\n/** The host's answer, carrying the port. */\nexport const PORT = 'filex:port';\n\n/** What the app may ask the host. Anything else is `unknown_method`. */\nexport type Method =\n | 'session.get'\n | 'file.read'\n | 'file.save'\n | 'file.saveAs'\n | 'ui.dirty'\n | 'ui.title'\n | 'ui.toast'\n | 'ui.confirm'\n | 'ui.close'\n | 'clipboard.write'\n | 'ui.download'\n | 'engine.call'\n | 'job.submit'\n | 'state.get'\n | 'state.set'\n /** 0.52.0: what the app's license says (a paid app from a store), `LicenseInfo`. */\n | 'license.get'\n /** 0.55.0: filex prints a PDF the app hands it (`PrintParams`, grant `ui:print`). */\n | 'ui.print'\n /**\n * 0.55.0, editing together (`coedit.*`, task #189): the session's log the\n * host seals, orders through filex's relay and opens again. Defined so an\n * app can be written against them; filex 0.55 offers no relay yet, so each\n * answers `unavailable` - edit alone then (see `CoEditHello`).\n */\n | 'coedit.join'\n | 'coedit.subscribe'\n | 'coedit.append'\n | 'coedit.lease'\n | 'coedit.cursor'\n | 'coedit.blob.put'\n | 'coedit.blob.get'\n | 'coedit.leave';\n\nexport const METHODS: readonly Method[] = [\n 'session.get',\n 'file.read',\n 'file.save',\n 'file.saveAs',\n 'ui.dirty',\n 'ui.title',\n 'ui.toast',\n 'ui.confirm',\n 'ui.close',\n 'clipboard.write',\n 'ui.download',\n 'engine.call',\n 'job.submit',\n 'state.get',\n 'state.set',\n 'license.get',\n 'ui.print',\n 'coedit.join',\n 'coedit.subscribe',\n 'coedit.append',\n 'coedit.lease',\n 'coedit.cursor',\n 'coedit.blob.put',\n 'coedit.blob.get',\n 'coedit.leave',\n];\n\n/** What the host tells the app, unasked. */\nexport type HostEvent =\n /** The interface's colours changed (light/dark, palette). `data: Theme`. */\n | 'theme'\n /** The reader's language changed. `data: {locale, dir}`. */\n | 'locale'\n /** The open file changed underneath (another tab, another person). */\n | 'file.changed'\n /** The person closed the frame's surroundings; last chance to say so. */\n | 'close.request'\n /** An administrator installed a new version of this app: reload to use it. */\n | 'app.updated'\n /** Editing together: the next entry of the session's log, opened. `data: CoEditEntry`. */\n | 'coedit.entry'\n /** Editing together: another member's cursor (not kept). `data: CoEditCursor`. */\n | 'coedit.cursor'\n /**\n * Editing together: the host stopped handing entries over (the app fell\n * behind, or the connection dropped). `data: {from}` - subscribe again\n * from the last entry the app holds.\n */\n | 'coedit.dropped';\n\n/** What the host may ask the app (and wait for). */\nexport type HostRequest =\n /** Save now (the host's Save button, a draft's \"Save to disk\"): the app\n * hands its document to `file.save` and answers when it is written. */\n 'save';\n\n/** Why a call was refused. */\nexport type ErrorCode =\n /** The app was not granted what the call needs (files:write for a save…). */\n | 'not_granted'\n /** No such file (an index past the opened files), no such key. */\n | 'not_found'\n /** The file cannot be written: a read-only storage, a view-only opening. */\n | 'read_only'\n /** The call's parameters are wrong. */\n | 'invalid'\n /** Over a limit (LIMITS). */\n | 'too_large'\n /** The server could not be reached or refused; `message` says what. */\n | 'failed'\n /** The person said no (a confirm, a save-as picker closed). */\n | 'cancelled'\n /** The host does not offer this here (no engine, no save handler). */\n | 'unavailable'\n | 'unknown_method';\n\nexport interface BridgeError {\n code: ErrorCode;\n message?: string;\n}\n\n/* ── messages ──────────────────────────────────────────────────────────── */\n\nexport interface HelloMessage {\n type: typeof HELLO;\n v: number;\n}\n\nexport interface PortMessage {\n type: typeof PORT;\n v: number;\n}\n\nexport interface RequestMessage {\n id: number;\n method: Method;\n params?: unknown;\n}\n\nexport interface ResponseMessage {\n id: number;\n result?: unknown;\n error?: BridgeError;\n}\n\nexport interface EventMessage {\n event: HostEvent;\n data?: unknown;\n}\n\nexport interface HostRequestMessage {\n hid: number;\n request: HostRequest;\n params?: unknown;\n}\n\nexport interface HostResponseMessage {\n hid: number;\n result?: unknown;\n error?: BridgeError;\n}\n\n/* ── shapes ────────────────────────────────────────────────────────────── */\n\n/** The interface's look: `mode` and filex's own `--fe-*` custom properties. */\nexport interface Theme {\n mode: 'light' | 'dark';\n /** `--fe-bg`, `--fe-text`, `--fe-accent`… — the SDK sets them on `<html>`. */\n tokens: Record<string, string>;\n}\n\n/** One file the interface was opened with. Never a storage path. */\nexport interface FileInfo {\n index: number;\n name: string;\n /** Lower-case, no dot. */\n ext: string;\n size: number;\n mime: string;\n /** It cannot be saved over: read-only storage, view-only opening, or the\n * app holds no `files:write`. */\n readOnly: boolean;\n /**\n * 0.55.0: the file is end-to-end encrypted - `folder` in an encrypted\n * folder, `vault` in a vault (where only one person edits at a time),\n * `file` for a single encrypted file (`.fxe`). As the server says it;\n * ABSENT for a file that is not encrypted.\n *\n * ⚠ filex 0.55 decrypts nothing for an app: the server holds such a file\n * only as ciphertext and has no key, so `file.read` is refused\n * (`failed` with `encrypted`), a save is refused, and `readOnly` is true.\n * The explorer offers no app an encrypted file in the first place; the\n * field is information, not a promise of plaintext.\n */\n encrypted?: 'folder' | 'vault' | 'file';\n}\n\n/** What `session.get` answers — what the app knows about where it runs. */\nexport interface Session {\n v: number;\n app: { name: string; version: string };\n view: { id: string; placement: 'modal' | 'page' | 'inspector' | 'home' | 'viewer' };\n /** The reader's language tag (`tr`, `pt-br`) and its direction. */\n locale: string;\n dir: 'ltr' | 'rtl';\n theme: Theme;\n /** The person's display name; never their e-mail, never a token. */\n user: { name: string };\n files: FileInfo[];\n /** The permissions this app was granted — so it can hide what it may not do. */\n grants: string[];\n /** The administrator's non-secret settings for this app (grant `settings`). */\n settings?: Record<string, string>;\n}\n\n/** `file.read` params. */\nexport interface ReadParams {\n index?: number;\n /** `stream` (a transferred ReadableStream<Uint8Array>), `bytes` (an\n * ArrayBuffer), or `text` (UTF-8, at most LIMITS.maxTextBytes). */\n as?: 'stream' | 'bytes' | 'text';\n}\n\nexport interface ReadResult {\n name: string;\n size: number;\n mime: string;\n stream?: ReadableStream<Uint8Array>;\n bytes?: ArrayBuffer;\n text?: string;\n}\n\n/** `file.save` params: the new content of the opened file. */\nexport interface SaveParams {\n index?: number;\n /** A transferred ReadableStream<Uint8Array>, an ArrayBuffer or a string. */\n data: ReadableStream<Uint8Array> | ArrayBuffer | string;\n mime?: string;\n /**\n * 0.55.0, editing together: the last log entry (`CoEditEntry.seq`) the\n * saved document holds, so the session knows what is saved. Ignored\n * outside a session.\n */\n through?: number;\n}\n\nexport interface SaveResult {\n saved: true;\n size: number;\n}\n\n/**\n * `ui.download`: a file for the person's own disk. filex does it (a sandboxed\n * frame cannot download), with the app's `ui:download` grant, on a gesture in\n * the frame or the person's yes, never over LIMITS.maxDownloadBytes.\n */\nexport interface DownloadParams {\n /** The file name offered — a name, not a path. */\n name: string;\n /** A transferred ReadableStream<Uint8Array>, an ArrayBuffer or a string. */\n data: ReadableStream<Uint8Array> | ArrayBuffer | string;\n mime?: string;\n}\n\nexport interface DownloadResult {\n saved: true;\n size: number;\n}\n\n/**\n * `ui.print` (filex 0.55.0): a PDF for the browser's print dialog. A\n * sandboxed frame may not open that dialog (measured: Chromium ignores\n * `print()` there and says so, Firefox ignores it), so filex prints the PDF\n * from a page of its own, with the app's `ui:print` grant, never over\n * LIMITS.maxPrintBytes. filex asks every time, above the frame, and the\n * dialog opens only on the person's click on filex's Allow. The print dialog\n * can save the PDF too, so `ui:print` is the same kind of grant as\n * `ui:download`. filex 0.54 and older answer `unknown_method`.\n */\nexport interface PrintParams {\n /** The document's name, for the question filex may ask - a name, not a path. */\n name: string;\n /** The PDF: a transferred ArrayBuffer or ReadableStream<Uint8Array>. */\n data: ReadableStream<Uint8Array> | ArrayBuffer;\n /** `application/pdf` when given; nothing else is printed. */\n mime?: string;\n}\n\nexport interface PrintResult {\n /** The browser's print dialog was opened with the PDF. */\n printed: true;\n size: number;\n}\n\n/* ── editing together (coedit.*, filex 0.55.0: defined, not offered) ────── */\n\n/** What a member appends: the editor's changes, a lock request, a release. */\nexport type CoEditKind = 'changes' | 'lock' | 'release';\n\n/** A member of the session. */\nexport interface CoEditMember {\n /** The relay's id for the connection. */\n client: string;\n /** An opaque id of the person, the same for each of their connections in\n * this session; never an account id. */\n user: string;\n /** The person's display name. */\n name: string;\n /** The editor's per-session user index: given once, never twice. */\n indexUser: number;\n /** May append changes (an editor role and the right to change the file). */\n canEdit: boolean;\n}\n\n/** `coedit.join` params: join (or start) the session of an opened file. */\nexport interface CoEditJoinParams {\n index?: number;\n}\n\n/**\n * What `coedit.join` answers: who the app is in the session and how far\n * the log has gone. The entries come as `coedit.entry` events once the app\n * subscribes (`coedit.subscribe` from 0 for the whole log). `unavailable`\n * when filex offers no editing together here (filex 0.55, a vault, a file\n * that cannot be shared): the app edits alone.\n */\nexport interface CoEditHello {\n session: string;\n me: CoEditMember;\n /** The last entry's seq (0: an empty log). */\n head: number;\n /** The last `changes` entry's seq. */\n changesHead: number;\n /** The last entry the file on the storage holds. */\n savedThrough: number;\n}\n\n/** `coedit.subscribe` params: entries after `from` arrive as `coedit.entry`. */\nexport interface CoEditSubscribeParams {\n from: number;\n}\n\n/** `coedit.append` params: the host seals it and the relay places it. */\nexport interface CoEditAppendParams {\n kind: CoEditKind;\n body: unknown;\n}\n\nexport interface CoEditAppendResult {\n /** Where it landed; it comes back as a `coedit.entry` too. */\n seq: number;\n}\n\n/** `coedit.lease`: the changes lease (one writer at a time, one that has\n * seen every change). */\nexport interface CoEditLeaseParams {\n op: 'acquire' | 'release';\n /** The seq of the last `changes` entry the app applied. */\n changesSeen: number;\n}\n\nexport interface CoEditLeaseResult {\n granted: boolean;\n}\n\n/** `coedit.cursor` params: passed to the other members, not kept. */\nexport interface CoEditCursorParams {\n cursor: unknown;\n}\n\n/** `coedit.blob.put` / `coedit.blob.get`: the session's sealed blobs (the\n * base document, images added while editing). A name, not a path. */\nexport interface CoEditBlobPutParams {\n name: string;\n data: ArrayBuffer;\n}\n\nexport interface CoEditBlobGetParams {\n name: string;\n}\n\nexport interface CoEditBlobGetResult {\n name: string;\n bytes: ArrayBuffer;\n}\n\ninterface CoEditEntryBase {\n seq: number;\n /** The relay's time, Unix ms - the same for every member. */\n at: number;\n client: string;\n}\n\n/** One entry of the session's log, opened and checked by filex. */\nexport type CoEditEntry =\n | (CoEditEntryBase & { kind: CoEditKind; body: unknown })\n | (CoEditEntryBase & { kind: 'join'; member: CoEditMember })\n | (CoEditEntryBase & { kind: 'leave' })\n | (CoEditEntryBase & { kind: 'saved'; through: number });\n\n/** `coedit.cursor` event data. */\nexport interface CoEditCursor {\n client: string;\n cursor: unknown;\n}\n\n/**\n * `file.saveAs`: a NEW file, in a folder the person picks in filex's own\n * folder dialog (the one its Move to… uses), which opens in the opened\n * file's folder. Closing the dialog answers `cancelled`.\n */\nexport interface SaveAsParams {\n /** The file name — a name, not a path. */\n name: string;\n data: ReadableStream<Uint8Array> | ArrayBuffer | string;\n mime?: string;\n}\n\nexport interface SaveAsResult {\n saved: true;\n name: string;\n size: number;\n}\n\nexport interface ToastParams {\n text: string;\n tone?: 'info' | 'success' | 'warning' | 'error';\n}\n\nexport interface ConfirmParams {\n title?: string;\n text: string;\n confirm?: string;\n cancel?: string;\n danger?: boolean;\n}\n\n/**\n * `license.get` (filex 0.52.0): what the app's license says. A paid app is\n * installed from a store, which issues its license and is asked about it every\n * day; filex keeps the key and never hands it to the app. `status`:\n *\n * - `free`: not a paid app;\n * - `valid`: the store said valid at its last check;\n * - `grace`: valid, but the store could not be asked since - it holds until\n * the grace the store signed ends;\n * - anything else (`revoked`, `expired`, `invalid`, `seats_exhausted`,\n * `wrong_app`, `grace_expired`, `missing`, `unverified`): the app is held\n * and its interface is not served, so an app rarely reads one of these.\n *\n * `valid_until` / `updates_until` are there for a valid license (dates as\n * RFC 3339). Who holds the license is not the app's to read. filex 0.51.0\n * and older answer `unknown_method`.\n */\nexport interface LicenseInfo {\n status: string;\n valid_until?: string;\n updates_until?: string;\n}\n\n/** `engine.call`: the app's own module, `ui_call` export. */\nexport interface EngineCallParams {\n method: string;\n params?: unknown;\n}\n\n/** `job.submit`: queue one of the app's actions on the opened files. */\nexport interface JobSubmitParams {\n action: string;\n params?: Record<string, unknown>;\n}\n\n/* ── limits ────────────────────────────────────────────────────────────── */\n\nexport const LIMITS = {\n /** `file.read({as: 'text'})` refuses a file larger than this; read a stream. */\n maxTextBytes: 32 << 20,\n /** One `state.set` value, as JSON. */\n maxStateBytes: 8 << 10,\n /** A `ui.toast` / `ui.title` text, in characters. */\n maxLineChars: 300,\n /** How long the SDK waits for the host's port. */\n connectTimeoutMs: 10_000,\n /** The largest file `ui.download` hands the person (streamed or not). */\n maxDownloadBytes: 256 << 20,\n /**\n * The largest PDF `ui.print` prints (filex 0.55). Lower than a download:\n * a download can stream to disk, a print is held whole in the page (one\n * Blob) until the print dialog has read it, and a document worth printing\n * is far smaller.\n */\n maxPrintBytes: 64 << 20,\n} as const;\n\n/* ── guards ────────────────────────────────────────────────────────────── */\n\nexport function isHello(d: unknown): d is HelloMessage {\n return !!d && typeof d === 'object' && (d as HelloMessage).type === HELLO && typeof (d as HelloMessage).v === 'number';\n}\n\nexport function isPortMessage(d: unknown): d is PortMessage {\n return !!d && typeof d === 'object' && (d as PortMessage).type === PORT;\n}\n\nexport function isRequest(d: unknown): d is RequestMessage {\n return (\n !!d &&\n typeof d === 'object' &&\n typeof (d as RequestMessage).id === 'number' &&\n typeof (d as RequestMessage).method === 'string'\n );\n}\n\nexport function isKnownMethod(m: string): m is Method {\n return (METHODS as readonly string[]).includes(m);\n}\n","/**\n * @brftech/filex-app-ui — the SDK an app's own interface bundles.\n *\n * ```js\n * import { connect } from '@brftech/filex-app-ui';\n *\n * const fx = await connect(); // the handshake with filex\n * const file = await fx.open(); // the file the app was opened with\n * editor.setValue(await file.text());\n * editor.onChange(() => fx.dirty(true));\n * fx.onSave(async () => editor.getValue()); // filex's Save, a draft's \"Save to disk\", Ctrl+S\n * ```\n *\n * No dependency, a few KB. The interface runs in a sandboxed frame with no\n * network and no storage of its own: every call below goes to filex over the\n * MessagePort the handshake set up, and filex decides it (docs/APP-PLUGINS.md\n * → \"An app's own interface\"). The protocol itself is `./protocol`.\n */\nimport {\n BRIDGE_VERSION,\n HELLO,\n LIMITS,\n isPortMessage,\n type BridgeError,\n type ConfirmParams,\n type ErrorCode,\n type FileInfo,\n type HostEvent,\n type HostRequestMessage,\n type Method,\n type ReadResult,\n type SaveAsResult,\n type SaveResult,\n type Session,\n type Theme,\n type ToastParams,\n type DownloadResult,\n type LicenseInfo,\n type PrintResult,\n type CoEditHello,\n type CoEditKind,\n type CoEditAppendResult,\n type CoEditLeaseResult,\n type CoEditBlobGetResult,\n} from './protocol';\n\nexport * from './protocol';\n\n/** A call filex refused, with the reason as a code. */\nexport class FilexError extends Error {\n readonly code: ErrorCode;\n constructor(err: BridgeError) {\n super(err.message || err.code);\n this.name = 'FilexError';\n this.code = err.code;\n }\n}\n\n/** Content the app hands to a save. */\nexport type SaveData = string | Blob | ArrayBuffer | ArrayBufferView | ReadableStream<Uint8Array>;\n\n/** One opened file, read the way the app needs it. */\nexport interface OpenedFile extends FileInfo {\n text(): Promise<string>;\n bytes(): Promise<ArrayBuffer>;\n stream(): Promise<ReadableStream<Uint8Array>>;\n /** Save new content over this file (a new version, or the draft it is). */\n save(data: SaveData, mime?: string): Promise<SaveResult>;\n}\n\nexport interface ConnectOptions {\n /** How long to wait for filex (default 10 s). */\n timeoutMs?: number;\n /**\n * Put filex's colours (`--fe-*`), `lang`, `dir` and `data-theme` on\n * `<html>`, and keep them current (default true).\n */\n applyTheme?: boolean;\n /** Ctrl/Cmd+S inside the interface runs the save handler (default true). */\n saveShortcut?: boolean;\n}\n\nexport interface FilexApp {\n readonly session: Session;\n /** The file at `index` (default the first) the interface was opened with. */\n open(index?: number): Promise<OpenedFile>;\n /** Save new content over an opened file. */\n save(data: SaveData, opts?: { index?: number; mime?: string }): Promise<SaveResult>;\n /** Save a NEW file; filex asks the person where (its own folder picker). */\n saveAs(name: string, data: SaveData, mime?: string): Promise<SaveAsResult>;\n /** Unsaved changes, or not: filex asks before the person leaves them. */\n dirty(on: boolean): void;\n title(text: string): void;\n toast(text: string, tone?: ToastParams['tone']): void;\n confirm(opts: ConfirmParams | string): Promise<boolean>;\n /** Ask filex to close the interface (it asks first when there are changes). */\n close(): void;\n /** Put text on the clipboard (filex does it, the same way in every browser). */\n copy(text: string): Promise<void>;\n /**\n * Hand the person a file for their own disk (the app needs `ui.download`\n * in its manifest). Call it from a click or a key press: without one,\n * filex asks the person first. `cancelled` when they say no.\n */\n download(name: string, data: SaveData, mime?: string): Promise<DownloadResult>;\n /**\n * Print a PDF (filex 0.55; the app needs `ui.print` in its manifest): a\n * sandboxed frame may not open the print dialog, so filex prints it from\n * its own page. filex asks the person every time, and the dialog opens on\n * their click on filex's Allow; at most LIMITS.maxPrintBytes. filex 0.54\n * and older answer `unknown_method`, and a host whose print page may not\n * be shown `unavailable`: hand the PDF over with `download` then.\n */\n print(name: string, pdf: Blob | ArrayBuffer | ArrayBufferView | ReadableStream<Uint8Array>): Promise<PrintResult>;\n /**\n * Editing together (filex 0.55: defined, not offered - every call answers\n * `unavailable`, and the app edits alone). filex seals what the app\n * appends, orders it through its relay and hands every member the same\n * entries in the same order (`coedit.entry` events after `subscribe`);\n * the app never holds a key.\n */\n coedit: {\n join(index?: number): Promise<CoEditHello>;\n subscribe(from: number): Promise<void>;\n append(kind: CoEditKind, body: unknown): Promise<CoEditAppendResult>;\n lease(op: 'acquire' | 'release', changesSeen: number): Promise<boolean>;\n cursor(cursor: unknown): void;\n putBlob(name: string, data: Blob | ArrayBuffer | ArrayBufferView): Promise<void>;\n getBlob(name: string): Promise<ArrayBuffer>;\n leave(): Promise<void>;\n };\n /** Call the app's own module (`ui_call` export) with the opened files. */\n call<T = unknown>(method: string, params?: unknown): Promise<T>;\n /** Queue one of the app's actions on the opened files; answers the op. */\n submit(action: string, params?: Record<string, unknown>): Promise<{ op: unknown }>;\n /** This person's small store for this app (JSON values, 8 KiB each). */\n state: {\n get<T = unknown>(key: string): Promise<T | undefined>;\n set(key: string, value: unknown): Promise<void>;\n };\n /**\n * The app's license (filex 0.52.0; a paid app installed from a store):\n * `{status: \"free\"}` for a free app, `{status: \"valid\", valid_until,\n * updates_until}` for a licensed one. Never the key, never the licensee.\n */\n license: {\n get(): Promise<LicenseInfo>;\n };\n /** Listen to filex. Returns the unsubscribe. */\n on(event: HostEvent, handler: (data: unknown) => void): () => void;\n /**\n * What to save when filex asks (its Save button, a draft's \"Save to disk\",\n * Ctrl+S): return the document and the SDK saves it over the opened file;\n * return nothing if the handler saved by itself.\n */\n onSave(handler: () => SaveData | void | Promise<SaveData | void>): () => void;\n /** Raw call, for a method this SDK has no helper for. */\n request<T = unknown>(method: Method, params?: unknown, transfer?: Transferable[]): Promise<T>;\n}\n\nlet connecting: Promise<FilexApp> | null = null;\n\n/**\n * The handshake. Resolves once filex answered with its port and the session;\n * rejects outside filex or when filex did not answer in time. Calling it again\n * answers the same connection.\n */\nexport function connect(opts: ConnectOptions = {}): Promise<FilexApp> {\n if (!connecting) connecting = open(opts);\n return connecting;\n}\n\nfunction open(opts: ConnectOptions): Promise<FilexApp> {\n return new Promise<FilexApp>((resolve, reject) => {\n if (typeof window === 'undefined' || window.parent === window) {\n reject(new FilexError({ code: 'unavailable', message: 'not running inside filex' }));\n return;\n }\n const parent = window.parent;\n // Where filex runs, when the browser says so (Chrome, Safari). A frame\n // drawn with referrerpolicy=\"no-referrer\" has no document.referrer, and\n // Firefox has no ancestorOrigins: then `source === parent` is the check,\n // which is the one that matters (only the parent window IS the parent).\n const expected = ancestorOrigin();\n const timer = setTimeout(() => {\n window.removeEventListener('message', onMessage);\n reject(new FilexError({ code: 'unavailable', message: 'filex did not answer' }));\n }, opts.timeoutMs ?? LIMITS.connectTimeoutMs);\n\n function onMessage(ev: MessageEvent) {\n if (ev.source !== parent || !isPortMessage(ev.data) || !ev.ports[0]) return;\n if (expected && ev.origin !== expected) return;\n window.removeEventListener('message', onMessage);\n clearTimeout(timer);\n const port = ev.ports[0];\n const app = bridge(port, opts);\n app\n .request<Session>('session.get')\n .then((session) => {\n (app as { session: Session }).session = session;\n if (opts.applyTheme !== false) {\n applyLook(session.theme, session.locale, session.dir);\n app.on('theme', (t) => applyLook(t as Theme));\n app.on('locale', (l) => {\n const v = l as { locale?: string; dir?: 'ltr' | 'rtl' };\n applyLook(undefined, v.locale, v.dir);\n });\n }\n resolve(app);\n })\n .catch(reject);\n }\n window.addEventListener('message', onMessage);\n parent.postMessage({ type: HELLO, v: BRIDGE_VERSION }, '*');\n });\n}\n\nfunction ancestorOrigin(): string {\n try {\n const list = (window.location as Location & { ancestorOrigins?: DOMStringList }).ancestorOrigins;\n if (list && list.length > 0 && list[0] !== 'null') return list[0];\n } catch {\n /* no such API */\n }\n return '';\n}\n\nfunction bridge(port: MessagePort, opts: ConnectOptions): FilexApp {\n let seq = 0;\n const pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: unknown) => void }>();\n const listeners = new Map<HostEvent, Set<(data: unknown) => void>>();\n let saveHandler: (() => SaveData | void | Promise<SaveData | void>) | null = null;\n\n port.onmessage = (ev: MessageEvent) => {\n const m = ev.data as Record<string, unknown> | null;\n if (!m || typeof m !== 'object') return;\n if (typeof m.id === 'number') {\n const p = pending.get(m.id);\n if (!p) return;\n pending.delete(m.id);\n if (m.error) p.reject(new FilexError(m.error as BridgeError));\n else p.resolve(m.result);\n return;\n }\n if (typeof m.hid === 'number') {\n void answerHost(m as unknown as HostRequestMessage);\n return;\n }\n if (typeof m.event === 'string') {\n for (const h of listeners.get(m.event as HostEvent) ?? []) {\n try {\n h(m.data);\n } catch (e) {\n console.error('[filex-app-ui] listener failed', e);\n }\n }\n }\n };\n\n async function answerHost(m: HostRequestMessage) {\n if (m.request === 'save') {\n if (!saveHandler) {\n port.postMessage({ hid: m.hid, error: { code: 'unavailable', message: 'this app has no save handler' } });\n return;\n }\n try {\n const res = await runSave();\n port.postMessage({ hid: m.hid, result: res ?? { saved: true } });\n } catch (e) {\n const err = e instanceof FilexError ? { code: e.code, message: e.message } : { code: 'failed', message: String((e as Error)?.message ?? e) };\n port.postMessage({ hid: m.hid, error: err });\n }\n return;\n }\n port.postMessage({ hid: m.hid, error: { code: 'unknown_method' } });\n }\n\n async function runSave(): Promise<SaveResult | null> {\n if (!saveHandler) return null;\n const data = await saveHandler();\n if (data === undefined || data === null) return null;\n return app.save(data as SaveData);\n }\n\n function request<T>(method: Method, params?: unknown, transfer: Transferable[] = []): Promise<T> {\n const id = ++seq;\n return new Promise<T>((resolve, reject) => {\n pending.set(id, { resolve: resolve as (v: unknown) => void, reject });\n try {\n port.postMessage({ id, method, params }, transfer);\n } catch (e) {\n pending.delete(id);\n reject(e);\n }\n });\n }\n\n function notify(method: Method, params?: unknown) {\n request(method, params).catch((e) => console.warn('[filex-app-ui]', method, e));\n }\n\n async function payload(data: SaveData): Promise<{ data: ReadableStream<Uint8Array> | ArrayBuffer | string; transfer: Transferable[] }> {\n if (typeof data === 'string') return { data, transfer: [] };\n if (data instanceof ArrayBuffer) return { data, transfer: [data] };\n if (ArrayBuffer.isView(data)) {\n const copy = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;\n return { data: copy, transfer: [copy] };\n }\n if (typeof Blob !== 'undefined' && data instanceof Blob) {\n const buf = await data.arrayBuffer();\n return { data: buf, transfer: [buf] };\n }\n if (typeof ReadableStream !== 'undefined' && data instanceof ReadableStream) {\n return { data, transfer: [data as unknown as Transferable] };\n }\n throw new FilexError({ code: 'invalid', message: 'save takes a string, a Blob, bytes or a ReadableStream' });\n }\n\n const app: FilexApp = {\n session: undefined as unknown as Session,\n async open(index = 0) {\n const info = app.session?.files?.[index];\n if (!info) throw new FilexError({ code: 'not_found', message: `no file at ${index}` });\n const read = <K extends keyof ReadResult>(as: 'stream' | 'bytes' | 'text', key: K) =>\n request<ReadResult>('file.read', { index, as }).then((r) => r[key] as NonNullable<ReadResult[K]>);\n return {\n ...info,\n text: () => read('text', 'text'),\n bytes: () => read('bytes', 'bytes'),\n stream: () => read('stream', 'stream'),\n save: (data: SaveData, mime?: string) => app.save(data, { index, mime }),\n };\n },\n async save(data, o = {}) {\n const p = await payload(data);\n return request<SaveResult>('file.save', { index: o.index ?? 0, data: p.data, mime: o.mime }, p.transfer);\n },\n async saveAs(name, data, mime) {\n const p = await payload(data);\n return request<SaveAsResult>('file.saveAs', { name, data: p.data, mime }, p.transfer);\n },\n async download(name, data, mime) {\n const p = await payload(data);\n return request<DownloadResult>('ui.download', { name, data: p.data, mime }, p.transfer);\n },\n async print(name, pdf) {\n const p = await payload(pdf);\n return request<PrintResult>('ui.print', { name, data: p.data, mime: 'application/pdf' }, p.transfer);\n },\n coedit: {\n join(index = 0) {\n return request<CoEditHello>('coedit.join', { index });\n },\n subscribe(from: number) {\n return request<void>('coedit.subscribe', { from });\n },\n append(kind: CoEditKind, body: unknown) {\n return request<CoEditAppendResult>('coedit.append', { kind, body });\n },\n async lease(op: 'acquire' | 'release', changesSeen: number) {\n const r = await request<CoEditLeaseResult>('coedit.lease', { op, changesSeen });\n return !!r?.granted;\n },\n cursor(cursor: unknown) {\n notify('coedit.cursor', { cursor });\n },\n async putBlob(name: string, data: Blob | ArrayBuffer | ArrayBufferView) {\n const p = await payload(data);\n if (!(p.data instanceof ArrayBuffer)) throw new FilexError({ code: 'invalid', message: 'a blob is bytes' });\n await request<void>('coedit.blob.put', { name, data: p.data }, p.transfer);\n },\n async getBlob(name: string) {\n const r = await request<CoEditBlobGetResult>('coedit.blob.get', { name });\n return r.bytes;\n },\n async leave() {\n await request<void>('coedit.leave');\n },\n },\n dirty(on) {\n notify('ui.dirty', { dirty: !!on });\n },\n title(text) {\n notify('ui.title', { text: String(text).slice(0, LIMITS.maxLineChars) });\n },\n toast(text, tone) {\n notify('ui.toast', { text: String(text).slice(0, LIMITS.maxLineChars), tone });\n },\n confirm(o) {\n const params = typeof o === 'string' ? { text: o } : o;\n return request<boolean>('ui.confirm', params);\n },\n close() {\n notify('ui.close');\n },\n copy(text) {\n return request<void>('clipboard.write', { text: String(text) });\n },\n call<T>(method: string, params?: unknown) {\n return request<T>('engine.call', { method, params });\n },\n submit(action, params) {\n return request<{ op: unknown }>('job.submit', { action, params });\n },\n state: {\n get<T>(key: string) {\n return request<T | undefined>('state.get', { key });\n },\n set(key: string, value: unknown) {\n return request<void>('state.set', { key, value });\n },\n },\n license: {\n get() {\n return request<LicenseInfo>('license.get');\n },\n },\n on(event, handler) {\n let set = listeners.get(event);\n if (!set) listeners.set(event, (set = new Set()));\n set.add(handler);\n return () => set!.delete(handler);\n },\n onSave(handler) {\n saveHandler = handler;\n return () => {\n if (saveHandler === handler) saveHandler = null;\n };\n },\n request,\n };\n\n if (opts.saveShortcut !== false && typeof document !== 'undefined') {\n // ⚠ The keystroke is handled HERE and never forwarded: a frame that could\n // send key events to filex could press filex's own shortcuts. Ctrl+S runs\n // the app's own save handler, which saves through the bridge like any call.\n document.addEventListener(\n 'keydown',\n (e) => {\n if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 's' || e.key === 'S') && saveHandler) {\n e.preventDefault();\n runSave().catch((err) => app.toast(String((err as Error)?.message ?? err), 'error'));\n }\n },\n true,\n );\n }\n return app;\n}\n\n/** Paint filex's look onto the interface's own document. */\nfunction applyLook(theme?: Theme, locale?: string, dir?: 'ltr' | 'rtl') {\n if (typeof document === 'undefined') return;\n const root = document.documentElement;\n if (theme) {\n root.dataset.theme = theme.mode;\n root.style.colorScheme = theme.mode;\n for (const [k, v] of Object.entries(theme.tokens ?? {})) {\n if (/^--fe-[a-z0-9-]+$/.test(k) && typeof v === 'string') root.style.setProperty(k, v);\n }\n }\n if (locale) root.lang = locale;\n if (dir) root.dir = dir;\n}\n"],"names":["HELLO","PORT","METHODS","LIMITS","isHello","d","isPortMessage","isRequest","isKnownMethod","m","FilexError","err","connecting","connect","opts","open","resolve","reject","parent","expected","ancestorOrigin","timer","onMessage","ev","port","app","bridge","session","applyLook","t","l","v","list","seq","pending","listeners","saveHandler","p","answerHost","h","e","res","runSave","data","request","method","params","transfer","id","notify","payload","copy","buf","index","info","read","as","key","r","mime","o","name","pdf","from","kind","body","op","changesSeen","cursor","on","text","tone","action","value","event","handler","set","theme","locale","dir","root","k"],"mappings":"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"}
1
+ {"version":3,"file":"filex-app-ui.iife.js","sources":["../src/protocol.ts","../src/index.ts"],"sourcesContent":["/**\n * The bridge between filex and an app's own interface — the ONE definition.\n *\n * An app's interface (the `ui` block of its filex-app.json) runs in a frame\n * filex draws with `sandbox=\"allow-scripts\"`: an opaque origin, no cookies, no\n * storage, no network (the server's CSP says `connect-src 'none'`). Everything\n * it may do goes through this bridge, and the host decides every call.\n *\n * Both halves import this file — the SDK the app bundles (`src/index.ts`) and\n * filex's own frame (`packages/core/src/components/plugin/AppFrame.vue`) — so a\n * method or an event added here is added on both sides at once.\n *\n * The wire, in order (docs/APP-PLUGINS-API.md → \"The interface bridge\"):\n *\n * 1. The app posts `{type: 'filex:hello', v: 1}` to its parent window.\n * 2. The host takes it ONLY when `event.source` is the frame it drew. The\n * origin says nothing here: a sandboxed frame's origin is always \"null\",\n * so any other sandboxed frame on the page would look the same (measured,\n * 2026-09-27, Chrome, Firefox and WebKit).\n * 3. The host answers with a MessagePort: `{type: 'filex:port', v: 1}` and\n * the port transferred. Once per load of the frame; a second hello is\n * ignored.\n * 4. The app takes the port ONLY when `event.source` is its parent window.\n * 5. From then on, only the port: requests `{id, method, params}`, answers\n * `{id, result}` or `{id, error}`, events `{event, data}`, and the host's\n * own requests `{hid, request, params}` answered `{hid, result|error}`.\n */\n\n/** The bridge generation both sides speak. */\nexport const BRIDGE_VERSION = 1;\n\n/** The app's first message, to `window.parent`. */\nexport const HELLO = 'filex:hello';\n/** The host's answer, carrying the port. */\nexport const PORT = 'filex:port';\n\n/** What the app may ask the host. Anything else is `unknown_method`. */\nexport type Method =\n | 'session.get'\n | 'file.read'\n | 'file.save'\n | 'file.saveAs'\n | 'ui.dirty'\n | 'ui.title'\n | 'ui.toast'\n | 'ui.confirm'\n | 'ui.close'\n | 'clipboard.write'\n | 'ui.download'\n | 'engine.call'\n | 'job.submit'\n | 'state.get'\n | 'state.set'\n /** 0.52.0: what the app's license says (a paid app from a store), `LicenseInfo`. */\n | 'license.get'\n /** 0.55.0: filex prints a PDF the app hands it (`PrintParams`, grant `ui:print`). */\n | 'ui.print'\n /**\n * Editing together (`coedit.*`, task #189): the session's log the host\n * seals, orders through filex's relay and opens again. Defined in filex\n * 0.55, which answers each `unavailable`; offered from 0.56 to an app\n * granted `files:co-edit` (see `CoEditHello`). `unavailable` = edit alone.\n */\n | 'coedit.join'\n | 'coedit.subscribe'\n | 'coedit.append'\n | 'coedit.lease'\n | 'coedit.cursor'\n | 'coedit.blob.put'\n | 'coedit.blob.get'\n | 'coedit.leave';\n\nexport const METHODS: readonly Method[] = [\n 'session.get',\n 'file.read',\n 'file.save',\n 'file.saveAs',\n 'ui.dirty',\n 'ui.title',\n 'ui.toast',\n 'ui.confirm',\n 'ui.close',\n 'clipboard.write',\n 'ui.download',\n 'engine.call',\n 'job.submit',\n 'state.get',\n 'state.set',\n 'license.get',\n 'ui.print',\n 'coedit.join',\n 'coedit.subscribe',\n 'coedit.append',\n 'coedit.lease',\n 'coedit.cursor',\n 'coedit.blob.put',\n 'coedit.blob.get',\n 'coedit.leave',\n];\n\n/** What the host tells the app, unasked. */\nexport type HostEvent =\n /** The interface's colours changed (light/dark, palette). `data: Theme`. */\n | 'theme'\n /** The reader's language changed. `data: {locale, dir}`. */\n | 'locale'\n /** The open file changed underneath (another tab, another person). */\n | 'file.changed'\n /** The person closed the frame's surroundings; last chance to say so. */\n | 'close.request'\n /** An administrator installed a new version of this app: reload to use it. */\n | 'app.updated'\n /** Editing together: the next entry of the session's log, opened. `data: CoEditEntry`. */\n | 'coedit.entry'\n /** Editing together: another member's cursor (not kept). `data: CoEditCursor`. */\n | 'coedit.cursor'\n /**\n * Editing together: the host stopped handing entries over. `data:\n * CoEditDropped` - `gone`: the membership ended (join again); `broken`: an\n * entry did not check out (stop editing together). A connection that drops\n * for a moment is the host's own: it reconnects and says nothing.\n */\n | 'coedit.dropped';\n\n/** What the host may ask the app (and wait for). */\nexport type HostRequest =\n /** Save now (the host's Save button, a draft's \"Save to disk\"): the app\n * hands its document to `file.save` and answers when it is written. */\n 'save';\n\n/** Why a call was refused. */\nexport type ErrorCode =\n /** The app was not granted what the call needs (files:write for a save…). */\n | 'not_granted'\n /** No such file (an index past the opened files), no such key. */\n | 'not_found'\n /** The file cannot be written: a read-only storage, a view-only opening. */\n | 'read_only'\n /** The call's parameters are wrong. */\n | 'invalid'\n /** Over a limit (LIMITS). */\n | 'too_large'\n /** The server could not be reached or refused; `message` says what. */\n | 'failed'\n /** The person said no (a confirm, a save-as picker closed). */\n | 'cancelled'\n /** The host does not offer this here (no engine, no save handler). */\n | 'unavailable'\n | 'unknown_method';\n\nexport interface BridgeError {\n code: ErrorCode;\n message?: string;\n}\n\n/* ── messages ──────────────────────────────────────────────────────────── */\n\nexport interface HelloMessage {\n type: typeof HELLO;\n v: number;\n}\n\nexport interface PortMessage {\n type: typeof PORT;\n v: number;\n}\n\nexport interface RequestMessage {\n id: number;\n method: Method;\n params?: unknown;\n}\n\nexport interface ResponseMessage {\n id: number;\n result?: unknown;\n error?: BridgeError;\n}\n\nexport interface EventMessage {\n event: HostEvent;\n data?: unknown;\n}\n\nexport interface HostRequestMessage {\n hid: number;\n request: HostRequest;\n params?: unknown;\n}\n\nexport interface HostResponseMessage {\n hid: number;\n result?: unknown;\n error?: BridgeError;\n}\n\n/* ── shapes ────────────────────────────────────────────────────────────── */\n\n/** The interface's look: `mode` and filex's own `--fe-*` custom properties. */\nexport interface Theme {\n mode: 'light' | 'dark';\n /** `--fe-bg`, `--fe-text`, `--fe-accent`… — the SDK sets them on `<html>`. */\n tokens: Record<string, string>;\n}\n\n/** One file the interface was opened with. Never a storage path. */\nexport interface FileInfo {\n index: number;\n name: string;\n /** Lower-case, no dot. */\n ext: string;\n size: number;\n mime: string;\n /** It cannot be saved over: read-only storage, view-only opening, or the\n * app holds no `files:write`. */\n readOnly: boolean;\n /**\n * 0.55.0: the file is end-to-end encrypted - `folder` in an encrypted\n * folder, `vault` in a vault (where only one person edits at a time),\n * `file` for a single encrypted file (`.fxe`). As the server says it;\n * ABSENT for a file that is not encrypted.\n *\n * ⚠ The server holds such a file only as ciphertext and has no key. Unless\n * `plaintext` is true, filex hands the app nothing of it: `file.read` is\n * refused (`failed` with `encrypted`), a save is refused, and `readOnly`\n * is true.\n */\n encrypted?: 'folder' | 'vault' | 'file';\n /**\n * 0.56.0: filex hands this encrypted file to the app in the clear. The\n * person's browser, which holds the folder key while the folder is\n * unlocked, decrypts it for `file.read` and encrypts what `file.save`\n * hands over before it leaves the browser; the server sees only\n * ciphertext. Only with `encrypted: 'folder'` or `'vault'`, for an app\n * whose manifest says `encrypted_folders: {\"open\": true}` (the grant\n * `files:e2e-plaintext`) - never for a `.fxe`. ABSENT otherwise.\n *\n * In a vault (`encrypted: 'vault'`) the browser reads the file from the\n * vault's packs and writes a save as the vault's next generation, under\n * the vault's write lock (one writer at a time). A save is refused -\n * `failed` with `vault_locked` - while somebody else writes the vault, and\n * with `vault_lock_lost` when this session's write lock ended before the\n * save was committed: nothing was written, save again. The vault locked\n * meanwhile (its keys left the browser): `unavailable`, and filex closes\n * the app.\n *\n * What changes for the app: a read and a save are held whole in the page\n * (at most LIMITS.maxEncryptedBytes); a save is written only over the file\n * as it was opened (`failed` with `changed` when it changed meanwhile);\n * `file.saveAs` answers `unavailable` (a copy elsewhere would leave the\n * folder decrypted); the app's module (`engine.call`, `job.submit`) is\n * never handed the file; `state.get` / `state.set` use a store of their\n * own for this folder, sealed in the person's browser (the server keeps it\n * and cannot read it; apart from the app's ordinary store, a little smaller\n * as it is measured sealed). Everything the app holds is plaintext: keep it\n * in the frame. In a folder whose names are encrypted too, `name` and\n * `ext` are the decrypted name's - the server never sees them.\n */\n plaintext?: true;\n}\n\n/** What `session.get` answers — what the app knows about where it runs. */\nexport interface Session {\n v: number;\n app: { name: string; version: string };\n view: { id: string; placement: 'modal' | 'page' | 'inspector' | 'home' | 'viewer' };\n /** The reader's language tag (`tr`, `pt-br`) and its direction. */\n locale: string;\n dir: 'ltr' | 'rtl';\n theme: Theme;\n /** The person's display name; never their e-mail, never a token. */\n user: { name: string };\n files: FileInfo[];\n /** The permissions this app was granted — so it can hide what it may not do. */\n grants: string[];\n /** The administrator's non-secret settings for this app (grant `settings`). */\n settings?: Record<string, string>;\n}\n\n/** `file.read` params. */\nexport interface ReadParams {\n index?: number;\n /** `stream` (a transferred ReadableStream<Uint8Array>), `bytes` (an\n * ArrayBuffer), or `text` (UTF-8, at most LIMITS.maxTextBytes). */\n as?: 'stream' | 'bytes' | 'text';\n}\n\nexport interface ReadResult {\n name: string;\n size: number;\n mime: string;\n stream?: ReadableStream<Uint8Array>;\n bytes?: ArrayBuffer;\n text?: string;\n}\n\n/** `file.save` params: the new content of the opened file. */\nexport interface SaveParams {\n index?: number;\n /** A transferred ReadableStream<Uint8Array>, an ArrayBuffer or a string. */\n data: ReadableStream<Uint8Array> | ArrayBuffer | string;\n mime?: string;\n /**\n * 0.55.0, editing together: the last log entry (`CoEditEntry.seq`) the\n * saved document holds, so the session knows what is saved. Ignored\n * outside a session.\n */\n through?: number;\n}\n\nexport interface SaveResult {\n saved: true;\n size: number;\n}\n\n/**\n * `ui.download`: a file for the person's own disk. filex does it (a sandboxed\n * frame cannot download), with the app's `ui:download` grant, on a gesture in\n * the frame or the person's yes, never over LIMITS.maxDownloadBytes.\n */\nexport interface DownloadParams {\n /** The file name offered — a name, not a path. */\n name: string;\n /** A transferred ReadableStream<Uint8Array>, an ArrayBuffer or a string. */\n data: ReadableStream<Uint8Array> | ArrayBuffer | string;\n mime?: string;\n}\n\nexport interface DownloadResult {\n saved: true;\n size: number;\n}\n\n/**\n * `ui.print` (filex 0.55.0): a PDF for the browser's print dialog. A\n * sandboxed frame may not open that dialog (measured: Chromium ignores\n * `print()` there and says so, Firefox ignores it), so filex prints the PDF\n * from a page of its own, with the app's `ui:print` grant, never over\n * LIMITS.maxPrintBytes. filex asks every time, above the frame, and the\n * dialog opens only on the person's click on filex's Allow. The print dialog\n * can save the PDF too, so `ui:print` is the same kind of grant as\n * `ui:download`. filex 0.54 and older answer `unknown_method`.\n */\nexport interface PrintParams {\n /** The document's name, for the question filex may ask - a name, not a path. */\n name: string;\n /** The PDF: a transferred ArrayBuffer or ReadableStream<Uint8Array>. */\n data: ReadableStream<Uint8Array> | ArrayBuffer;\n /** `application/pdf` when given; nothing else is printed. */\n mime?: string;\n}\n\nexport interface PrintResult {\n /** The browser's print dialog was opened with the PDF. */\n printed: true;\n size: number;\n}\n\n/* ── editing together (coedit.*: filex 0.55 defined, 0.56 offers) ──────── */\n\n/** What a member appends: the editor's changes, a lock request, a release. */\nexport type CoEditKind = 'changes' | 'lock' | 'release';\n\n/** A member of the session. */\nexport interface CoEditMember {\n /** The relay's id for the connection. */\n client: string;\n /** An opaque id of the person, the same for each of their connections in\n * this session; never an account id. */\n user: string;\n /** The person's display name. */\n name: string;\n /** The editor's per-session user index: given once, never twice. */\n indexUser: number;\n /** May append changes (an editor role and the right to change the file). */\n canEdit: boolean;\n}\n\n/** `coedit.join` params: join (or start) the session of an opened file. */\nexport interface CoEditJoinParams {\n index?: number;\n}\n\n/**\n * What `coedit.join` answers: who the app is in the session and how far\n * the log has gone. The entries come as `coedit.entry` events once the app\n * subscribes (`coedit.subscribe` from 0 for the whole log). `unavailable`\n * when filex offers no editing together here (filex 0.55, editing together\n * turned off, an encrypted file, a vault): the app edits alone - the message\n * says why (`not_ready` after waiting for a session's creator, `encrypted`,\n * `nothing_to_watch` for a person who may only read the file while nobody\n * edits it, or the server's code: `coedit_off`, `coedit_build`,\n * `coedit_file_changed`).\n */\nexport interface CoEditHello {\n session: string;\n me: CoEditMember;\n /**\n * The last entry's seq when filex handed the session over (0: an empty\n * log). filex 0.56: the changes this browser kept from an earlier opening\n * of the file (a lost connection, a closed tab), which filex sends before\n * it answers, are in the log up to here - an editor that starts once it\n * has read this far opens with them.\n */\n head: number;\n /** The last `changes` entry's seq, as far as `head`. */\n changesHead: number;\n /** The last entry the file on the storage holds. */\n savedThrough: number;\n /**\n * filex 0.56: this member started the session. Before anybody else can\n * join it puts the base document - EXACTLY the bytes it opened - with\n * `coedit.blob.put('base', bytes)`. A member that did not start it opens\n * `coedit.blob.get('base')`, never the file: the file may hold a save of\n * the session already, and the log applies to the base.\n */\n created: boolean;\n}\n\n/** `coedit.dropped` event data (filex 0.56). */\nexport interface CoEditDropped {\n /** The last entry the host handed over. */\n from: number;\n /** `gone`: the membership ended (the session closed, or the page was away\n * too long) - join again. `broken`: an entry did not check out (missing,\n * moved, altered, replayed) - stop editing together. */\n reason: 'gone' | 'broken';\n}\n\n/** `coedit.subscribe` params: entries after `from` arrive as `coedit.entry`. */\nexport interface CoEditSubscribeParams {\n from: number;\n}\n\n/** `coedit.append` params: the host seals it and the relay places it. */\nexport interface CoEditAppendParams {\n kind: CoEditKind;\n body: unknown;\n}\n\nexport interface CoEditAppendResult {\n /** Where it landed; it comes back as a `coedit.entry` too. */\n seq: number;\n}\n\n/** `coedit.lease`: the changes lease (one writer at a time, one that has\n * seen every change). */\nexport interface CoEditLeaseParams {\n op: 'acquire' | 'release';\n /** The seq of the last `changes` entry the app applied. */\n changesSeen: number;\n}\n\nexport interface CoEditLeaseResult {\n granted: boolean;\n}\n\n/** `coedit.cursor` params: passed to the other members, not kept. The host\n * sends a few a second at most (the last one wins). */\nexport interface CoEditCursorParams {\n cursor: unknown;\n}\n\n/** `coedit.blob.put` / `coedit.blob.get`: the session's sealed blobs (the\n * base document, images added while editing). A name, not a path. */\nexport interface CoEditBlobPutParams {\n name: string;\n data: ArrayBuffer;\n}\n\nexport interface CoEditBlobGetParams {\n name: string;\n}\n\nexport interface CoEditBlobGetResult {\n name: string;\n bytes: ArrayBuffer;\n}\n\ninterface CoEditEntryBase {\n seq: number;\n /** The relay's time, Unix ms - the same for every member. */\n at: number;\n client: string;\n}\n\n/** One entry of the session's log, opened and checked by filex. */\nexport type CoEditEntry =\n | (CoEditEntryBase & { kind: CoEditKind; body: unknown })\n | (CoEditEntryBase & { kind: 'join'; member: CoEditMember })\n | (CoEditEntryBase & { kind: 'leave' })\n | (CoEditEntryBase & { kind: 'saved'; through: number });\n\n/** `coedit.cursor` event data. */\nexport interface CoEditCursor {\n client: string;\n cursor: unknown;\n}\n\n/**\n * `file.saveAs`: a NEW file, in a folder the person picks in filex's own\n * folder dialog (the one its Move to… uses), which opens in the opened\n * file's folder. Closing the dialog answers `cancelled`.\n */\nexport interface SaveAsParams {\n /** The file name — a name, not a path. */\n name: string;\n data: ReadableStream<Uint8Array> | ArrayBuffer | string;\n mime?: string;\n}\n\nexport interface SaveAsResult {\n saved: true;\n name: string;\n size: number;\n}\n\nexport interface ToastParams {\n text: string;\n tone?: 'info' | 'success' | 'warning' | 'error';\n}\n\nexport interface ConfirmParams {\n title?: string;\n text: string;\n confirm?: string;\n cancel?: string;\n danger?: boolean;\n}\n\n/**\n * `license.get` (filex 0.52.0): what the app's license says. A paid app is\n * installed from a store, which issues its license and is asked about it every\n * day; filex keeps the key and never hands it to the app. `status`:\n *\n * - `free`: not a paid app;\n * - `valid`: the store said valid at its last check;\n * - `grace`: valid, but the store could not be asked since - it holds until\n * the grace the store signed ends;\n * - anything else (`revoked`, `expired`, `invalid`, `seats_exhausted`,\n * `wrong_app`, `grace_expired`, `missing`, `unverified`): the app is held\n * and its interface is not served, so an app rarely reads one of these.\n *\n * `valid_until` / `updates_until` are there for a valid license (dates as\n * RFC 3339). Who holds the license is not the app's to read. filex 0.51.0\n * and older answer `unknown_method`.\n */\nexport interface LicenseInfo {\n status: string;\n valid_until?: string;\n updates_until?: string;\n}\n\n/** `engine.call`: the app's own module, `ui_call` export. */\nexport interface EngineCallParams {\n method: string;\n params?: unknown;\n}\n\n/** `job.submit`: queue one of the app's actions on the opened files. */\nexport interface JobSubmitParams {\n action: string;\n params?: Record<string, unknown>;\n}\n\n/* ── limits ────────────────────────────────────────────────────────────── */\n\nexport const LIMITS = {\n /** `file.read({as: 'text'})` refuses a file larger than this; read a stream. */\n maxTextBytes: 32 << 20,\n /** One `state.set` value, as JSON. */\n maxStateBytes: 8 << 10,\n /** A `ui.toast` / `ui.title` text, in characters. */\n maxLineChars: 300,\n /** How long the SDK waits for the host's port. */\n connectTimeoutMs: 10_000,\n /** The largest file `ui.download` hands the person (streamed or not). */\n maxDownloadBytes: 256 << 20,\n /**\n * The largest PDF `ui.print` prints (filex 0.55). Lower than a download:\n * a download can stream to disk, a print is held whole in the page (one\n * Blob) until the print dialog has read it, and a document worth printing\n * is far smaller.\n */\n maxPrintBytes: 64 << 20,\n /**\n * The largest file of an encrypted folder filex hands an app in the clear\n * or takes back from it (filex 0.56, `FileInfo.plaintext`): it is\n * decrypted and encrypted whole in the page, as filex's own explorer does\n * for a file of this size.\n */\n maxEncryptedBytes: 200 << 20,\n} as const;\n\n/* ── guards ────────────────────────────────────────────────────────────── */\n\nexport function isHello(d: unknown): d is HelloMessage {\n return !!d && typeof d === 'object' && (d as HelloMessage).type === HELLO && typeof (d as HelloMessage).v === 'number';\n}\n\nexport function isPortMessage(d: unknown): d is PortMessage {\n return !!d && typeof d === 'object' && (d as PortMessage).type === PORT;\n}\n\nexport function isRequest(d: unknown): d is RequestMessage {\n return (\n !!d &&\n typeof d === 'object' &&\n typeof (d as RequestMessage).id === 'number' &&\n typeof (d as RequestMessage).method === 'string'\n );\n}\n\nexport function isKnownMethod(m: string): m is Method {\n return (METHODS as readonly string[]).includes(m);\n}\n","/**\n * @brftech/filex-app-ui — the SDK an app's own interface bundles.\n *\n * ```js\n * import { connect } from '@brftech/filex-app-ui';\n *\n * const fx = await connect(); // the handshake with filex\n * const file = await fx.open(); // the file the app was opened with\n * editor.setValue(await file.text());\n * editor.onChange(() => fx.dirty(true));\n * fx.onSave(async () => editor.getValue()); // filex's Save, a draft's \"Save to disk\", Ctrl+S\n * ```\n *\n * No dependency, a few KB. The interface runs in a sandboxed frame with no\n * network and no storage of its own: every call below goes to filex over the\n * MessagePort the handshake set up, and filex decides it (docs/APP-PLUGINS.md\n * → \"An app's own interface\"). The protocol itself is `./protocol`.\n */\nimport {\n BRIDGE_VERSION,\n HELLO,\n LIMITS,\n isPortMessage,\n type BridgeError,\n type ConfirmParams,\n type ErrorCode,\n type FileInfo,\n type HostEvent,\n type HostRequestMessage,\n type Method,\n type ReadResult,\n type SaveAsResult,\n type SaveResult,\n type Session,\n type Theme,\n type ToastParams,\n type DownloadResult,\n type LicenseInfo,\n type PrintResult,\n type CoEditHello,\n type CoEditKind,\n type CoEditAppendResult,\n type CoEditLeaseResult,\n type CoEditBlobGetResult,\n} from './protocol';\n\nexport * from './protocol';\n\n/** A call filex refused, with the reason as a code. */\nexport class FilexError extends Error {\n readonly code: ErrorCode;\n constructor(err: BridgeError) {\n super(err.message || err.code);\n this.name = 'FilexError';\n this.code = err.code;\n }\n}\n\n/** Content the app hands to a save. */\nexport type SaveData = string | Blob | ArrayBuffer | ArrayBufferView | ReadableStream<Uint8Array>;\n\n/** One opened file, read the way the app needs it. */\nexport interface OpenedFile extends FileInfo {\n text(): Promise<string>;\n bytes(): Promise<ArrayBuffer>;\n stream(): Promise<ReadableStream<Uint8Array>>;\n /** Save new content over this file (a new version, or the draft it is). */\n save(data: SaveData, mime?: string): Promise<SaveResult>;\n}\n\nexport interface ConnectOptions {\n /** How long to wait for filex (default 10 s). */\n timeoutMs?: number;\n /**\n * Put filex's colours (`--fe-*`), `lang`, `dir` and `data-theme` on\n * `<html>`, and keep them current (default true).\n */\n applyTheme?: boolean;\n /** Ctrl/Cmd+S inside the interface runs the save handler (default true). */\n saveShortcut?: boolean;\n}\n\nexport interface FilexApp {\n readonly session: Session;\n /** The file at `index` (default the first) the interface was opened with. */\n open(index?: number): Promise<OpenedFile>;\n /**\n * Save new content over an opened file. `through` (filex 0.56, editing\n * together): the last log entry the saved document holds, so the session\n * knows what is saved.\n */\n save(data: SaveData, opts?: { index?: number; mime?: string; through?: number }): Promise<SaveResult>;\n /** Save a NEW file; filex asks the person where (its own folder picker). */\n saveAs(name: string, data: SaveData, mime?: string): Promise<SaveAsResult>;\n /** Unsaved changes, or not: filex asks before the person leaves them. */\n dirty(on: boolean): void;\n title(text: string): void;\n toast(text: string, tone?: ToastParams['tone']): void;\n confirm(opts: ConfirmParams | string): Promise<boolean>;\n /** Ask filex to close the interface (it asks first when there are changes). */\n close(): void;\n /** Put text on the clipboard (filex does it, the same way in every browser). */\n copy(text: string): Promise<void>;\n /**\n * Hand the person a file for their own disk (the app needs `ui.download`\n * in its manifest). Call it from a click or a key press: without one,\n * filex asks the person first. `cancelled` when they say no.\n */\n download(name: string, data: SaveData, mime?: string): Promise<DownloadResult>;\n /**\n * Print a PDF (filex 0.55; the app needs `ui.print` in its manifest): a\n * sandboxed frame may not open the print dialog, so filex prints it from\n * its own page. filex asks the person every time, and the dialog opens on\n * their click on filex's Allow; at most LIMITS.maxPrintBytes. filex 0.54\n * and older answer `unknown_method`, and a host whose print page may not\n * be shown `unavailable`: hand the PDF over with `download` then.\n */\n print(name: string, pdf: Blob | ArrayBuffer | ArrayBufferView | ReadableStream<Uint8Array>): Promise<PrintResult>;\n /**\n * Editing together (filex 0.56; the app's manifest says `\"co_edit\":\n * {\"open\": true}`, granted as `files:co-edit`). filex seals what the app appends, orders it\n * through its relay and hands every member the same entries in the same\n * order (`coedit.entry` events after `subscribe`); the app never holds a\n * key. `join` answering `unavailable` (filex 0.55, editing together off\n * here, an encrypted file) means: edit alone. The member that started the\n * session (`created`) puts the base document before anybody else may join.\n */\n coedit: {\n join(index?: number): Promise<CoEditHello>;\n subscribe(from: number): Promise<void>;\n append(kind: CoEditKind, body: unknown): Promise<CoEditAppendResult>;\n lease(op: 'acquire' | 'release', changesSeen: number): Promise<boolean>;\n cursor(cursor: unknown): void;\n putBlob(name: string, data: Blob | ArrayBuffer | ArrayBufferView): Promise<void>;\n getBlob(name: string): Promise<ArrayBuffer>;\n leave(): Promise<void>;\n };\n /** Call the app's own module (`ui_call` export) with the opened files. */\n call<T = unknown>(method: string, params?: unknown): Promise<T>;\n /** Queue one of the app's actions on the opened files; answers the op. */\n submit(action: string, params?: Record<string, unknown>): Promise<{ op: unknown }>;\n /**\n * This person's small store for this app (JSON values, 8 KiB each). While\n * the app holds a file of an encrypted folder in the clear\n * (`FileInfo.plaintext`, filex 0.56) it is another store, sealed in the\n * browser - apart from this one, and empty while the folder is locked.\n */\n state: {\n get<T = unknown>(key: string): Promise<T | undefined>;\n set(key: string, value: unknown): Promise<void>;\n };\n /**\n * The app's license (filex 0.52.0; a paid app installed from a store):\n * `{status: \"free\"}` for a free app, `{status: \"valid\", valid_until,\n * updates_until}` for a licensed one. Never the key, never the licensee.\n */\n license: {\n get(): Promise<LicenseInfo>;\n };\n /** Listen to filex. Returns the unsubscribe. */\n on(event: HostEvent, handler: (data: unknown) => void): () => void;\n /**\n * What to save when filex asks (its Save button, a draft's \"Save to disk\",\n * Ctrl+S): return the document and the SDK saves it over the opened file;\n * return nothing if the handler saved by itself.\n */\n onSave(handler: () => SaveData | void | Promise<SaveData | void>): () => void;\n /** Raw call, for a method this SDK has no helper for. */\n request<T = unknown>(method: Method, params?: unknown, transfer?: Transferable[]): Promise<T>;\n}\n\nlet connecting: Promise<FilexApp> | null = null;\n\n/**\n * The handshake. Resolves once filex answered with its port and the session;\n * rejects outside filex or when filex did not answer in time. Calling it again\n * answers the same connection.\n */\nexport function connect(opts: ConnectOptions = {}): Promise<FilexApp> {\n if (!connecting) connecting = open(opts);\n return connecting;\n}\n\nfunction open(opts: ConnectOptions): Promise<FilexApp> {\n return new Promise<FilexApp>((resolve, reject) => {\n if (typeof window === 'undefined' || window.parent === window) {\n reject(new FilexError({ code: 'unavailable', message: 'not running inside filex' }));\n return;\n }\n const parent = window.parent;\n // Where filex runs, when the browser says so (Chrome, Safari). A frame\n // drawn with referrerpolicy=\"no-referrer\" has no document.referrer, and\n // Firefox has no ancestorOrigins: then `source === parent` is the check,\n // which is the one that matters (only the parent window IS the parent).\n const expected = ancestorOrigin();\n const timer = setTimeout(() => {\n window.removeEventListener('message', onMessage);\n reject(new FilexError({ code: 'unavailable', message: 'filex did not answer' }));\n }, opts.timeoutMs ?? LIMITS.connectTimeoutMs);\n\n function onMessage(ev: MessageEvent) {\n if (ev.source !== parent || !isPortMessage(ev.data) || !ev.ports[0]) return;\n if (expected && ev.origin !== expected) return;\n window.removeEventListener('message', onMessage);\n clearTimeout(timer);\n const port = ev.ports[0];\n const app = bridge(port, opts);\n app\n .request<Session>('session.get')\n .then((session) => {\n (app as { session: Session }).session = session;\n if (opts.applyTheme !== false) {\n applyLook(session.theme, session.locale, session.dir);\n app.on('theme', (t) => applyLook(t as Theme));\n app.on('locale', (l) => {\n const v = l as { locale?: string; dir?: 'ltr' | 'rtl' };\n applyLook(undefined, v.locale, v.dir);\n });\n }\n resolve(app);\n })\n .catch(reject);\n }\n window.addEventListener('message', onMessage);\n parent.postMessage({ type: HELLO, v: BRIDGE_VERSION }, '*');\n });\n}\n\nfunction ancestorOrigin(): string {\n try {\n const list = (window.location as Location & { ancestorOrigins?: DOMStringList }).ancestorOrigins;\n if (list && list.length > 0 && list[0] !== 'null') return list[0];\n } catch {\n /* no such API */\n }\n return '';\n}\n\nfunction bridge(port: MessagePort, opts: ConnectOptions): FilexApp {\n let seq = 0;\n const pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: unknown) => void }>();\n const listeners = new Map<HostEvent, Set<(data: unknown) => void>>();\n let saveHandler: (() => SaveData | void | Promise<SaveData | void>) | null = null;\n\n port.onmessage = (ev: MessageEvent) => {\n const m = ev.data as Record<string, unknown> | null;\n if (!m || typeof m !== 'object') return;\n if (typeof m.id === 'number') {\n const p = pending.get(m.id);\n if (!p) return;\n pending.delete(m.id);\n if (m.error) p.reject(new FilexError(m.error as BridgeError));\n else p.resolve(m.result);\n return;\n }\n if (typeof m.hid === 'number') {\n void answerHost(m as unknown as HostRequestMessage);\n return;\n }\n if (typeof m.event === 'string') {\n for (const h of listeners.get(m.event as HostEvent) ?? []) {\n try {\n h(m.data);\n } catch (e) {\n console.error('[filex-app-ui] listener failed', e);\n }\n }\n }\n };\n\n async function answerHost(m: HostRequestMessage) {\n if (m.request === 'save') {\n if (!saveHandler) {\n port.postMessage({ hid: m.hid, error: { code: 'unavailable', message: 'this app has no save handler' } });\n return;\n }\n try {\n const res = await runSave();\n port.postMessage({ hid: m.hid, result: res ?? { saved: true } });\n } catch (e) {\n const err = e instanceof FilexError ? { code: e.code, message: e.message } : { code: 'failed', message: String((e as Error)?.message ?? e) };\n port.postMessage({ hid: m.hid, error: err });\n }\n return;\n }\n port.postMessage({ hid: m.hid, error: { code: 'unknown_method' } });\n }\n\n async function runSave(): Promise<SaveResult | null> {\n if (!saveHandler) return null;\n const data = await saveHandler();\n if (data === undefined || data === null) return null;\n return app.save(data as SaveData);\n }\n\n function request<T>(method: Method, params?: unknown, transfer: Transferable[] = []): Promise<T> {\n const id = ++seq;\n return new Promise<T>((resolve, reject) => {\n pending.set(id, { resolve: resolve as (v: unknown) => void, reject });\n try {\n port.postMessage({ id, method, params }, transfer);\n } catch (e) {\n pending.delete(id);\n reject(e);\n }\n });\n }\n\n function notify(method: Method, params?: unknown) {\n request(method, params).catch((e) => console.warn('[filex-app-ui]', method, e));\n }\n\n async function payload(data: SaveData): Promise<{ data: ReadableStream<Uint8Array> | ArrayBuffer | string; transfer: Transferable[] }> {\n if (typeof data === 'string') return { data, transfer: [] };\n if (data instanceof ArrayBuffer) return { data, transfer: [data] };\n if (ArrayBuffer.isView(data)) {\n const copy = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;\n return { data: copy, transfer: [copy] };\n }\n if (typeof Blob !== 'undefined' && data instanceof Blob) {\n const buf = await data.arrayBuffer();\n return { data: buf, transfer: [buf] };\n }\n if (typeof ReadableStream !== 'undefined' && data instanceof ReadableStream) {\n return { data, transfer: [data as unknown as Transferable] };\n }\n throw new FilexError({ code: 'invalid', message: 'save takes a string, a Blob, bytes or a ReadableStream' });\n }\n\n const app: FilexApp = {\n session: undefined as unknown as Session,\n async open(index = 0) {\n const info = app.session?.files?.[index];\n if (!info) throw new FilexError({ code: 'not_found', message: `no file at ${index}` });\n const read = <K extends keyof ReadResult>(as: 'stream' | 'bytes' | 'text', key: K) =>\n request<ReadResult>('file.read', { index, as }).then((r) => r[key] as NonNullable<ReadResult[K]>);\n return {\n ...info,\n text: () => read('text', 'text'),\n bytes: () => read('bytes', 'bytes'),\n stream: () => read('stream', 'stream'),\n save: (data: SaveData, mime?: string) => app.save(data, { index, mime }),\n };\n },\n async save(data, o = {}) {\n const p = await payload(data);\n const params: Record<string, unknown> = { index: o.index ?? 0, data: p.data, mime: o.mime };\n if (typeof o.through === 'number') params.through = o.through;\n return request<SaveResult>('file.save', params, p.transfer);\n },\n async saveAs(name, data, mime) {\n const p = await payload(data);\n return request<SaveAsResult>('file.saveAs', { name, data: p.data, mime }, p.transfer);\n },\n async download(name, data, mime) {\n const p = await payload(data);\n return request<DownloadResult>('ui.download', { name, data: p.data, mime }, p.transfer);\n },\n async print(name, pdf) {\n const p = await payload(pdf);\n return request<PrintResult>('ui.print', { name, data: p.data, mime: 'application/pdf' }, p.transfer);\n },\n coedit: {\n join(index = 0) {\n return request<CoEditHello>('coedit.join', { index });\n },\n subscribe(from: number) {\n return request<void>('coedit.subscribe', { from });\n },\n append(kind: CoEditKind, body: unknown) {\n return request<CoEditAppendResult>('coedit.append', { kind, body });\n },\n async lease(op: 'acquire' | 'release', changesSeen: number) {\n const r = await request<CoEditLeaseResult>('coedit.lease', { op, changesSeen });\n return !!r?.granted;\n },\n cursor(cursor: unknown) {\n notify('coedit.cursor', { cursor });\n },\n async putBlob(name: string, data: Blob | ArrayBuffer | ArrayBufferView) {\n const p = await payload(data);\n if (!(p.data instanceof ArrayBuffer)) throw new FilexError({ code: 'invalid', message: 'a blob is bytes' });\n await request<void>('coedit.blob.put', { name, data: p.data }, p.transfer);\n },\n async getBlob(name: string) {\n const r = await request<CoEditBlobGetResult>('coedit.blob.get', { name });\n return r.bytes;\n },\n async leave() {\n await request<void>('coedit.leave');\n },\n },\n dirty(on) {\n notify('ui.dirty', { dirty: !!on });\n },\n title(text) {\n notify('ui.title', { text: String(text).slice(0, LIMITS.maxLineChars) });\n },\n toast(text, tone) {\n notify('ui.toast', { text: String(text).slice(0, LIMITS.maxLineChars), tone });\n },\n confirm(o) {\n const params = typeof o === 'string' ? { text: o } : o;\n return request<boolean>('ui.confirm', params);\n },\n close() {\n notify('ui.close');\n },\n copy(text) {\n return request<void>('clipboard.write', { text: String(text) });\n },\n call<T>(method: string, params?: unknown) {\n return request<T>('engine.call', { method, params });\n },\n submit(action, params) {\n return request<{ op: unknown }>('job.submit', { action, params });\n },\n state: {\n get<T>(key: string) {\n return request<T | undefined>('state.get', { key });\n },\n set(key: string, value: unknown) {\n return request<void>('state.set', { key, value });\n },\n },\n license: {\n get() {\n return request<LicenseInfo>('license.get');\n },\n },\n on(event, handler) {\n let set = listeners.get(event);\n if (!set) listeners.set(event, (set = new Set()));\n set.add(handler);\n return () => set!.delete(handler);\n },\n onSave(handler) {\n saveHandler = handler;\n return () => {\n if (saveHandler === handler) saveHandler = null;\n };\n },\n request,\n };\n\n if (opts.saveShortcut !== false && typeof document !== 'undefined') {\n // ⚠ The keystroke is handled HERE and never forwarded: a frame that could\n // send key events to filex could press filex's own shortcuts. Ctrl+S runs\n // the app's own save handler, which saves through the bridge like any call.\n document.addEventListener(\n 'keydown',\n (e) => {\n if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 's' || e.key === 'S') && saveHandler) {\n e.preventDefault();\n runSave().catch((err) => app.toast(String((err as Error)?.message ?? err), 'error'));\n }\n },\n true,\n );\n }\n return app;\n}\n\n/** Paint filex's look onto the interface's own document. */\nfunction applyLook(theme?: Theme, locale?: string, dir?: 'ltr' | 'rtl') {\n if (typeof document === 'undefined') return;\n const root = document.documentElement;\n if (theme) {\n root.dataset.theme = theme.mode;\n root.style.colorScheme = theme.mode;\n for (const [k, v] of Object.entries(theme.tokens ?? {})) {\n if (/^--fe-[a-z0-9-]+$/.test(k) && typeof v === 'string') root.style.setProperty(k, v);\n }\n }\n if (locale) root.lang = locale;\n if (dir) root.dir = dir;\n}\n"],"names":["HELLO","PORT","METHODS","LIMITS","isHello","d","isPortMessage","isRequest","isKnownMethod","m","FilexError","err","connecting","connect","opts","open","resolve","reject","parent","expected","ancestorOrigin","timer","onMessage","ev","port","app","bridge","session","applyLook","t","l","v","list","seq","pending","listeners","saveHandler","p","answerHost","h","e","res","runSave","data","request","method","params","transfer","id","notify","payload","copy","buf","index","info","read","as","key","r","mime","o","name","pdf","from","kind","body","op","changesSeen","cursor","on","text","tone","action","value","event","handler","set","theme","locale","dir","root","k"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- const j = 1, S = "filex:hello", B = "filex:port", E = [
1
+ const j = 1, S = "filex:hello", M = "filex:port", E = [
2
2
  "session.get",
3
3
  "file.read",
4
4
  "file.save",
@@ -41,13 +41,20 @@ const j = 1, S = "filex:hello", B = "filex:port", E = [
41
41
  * Blob) until the print dialog has read it, and a document worth printing
42
42
  * is far smaller.
43
43
  */
44
- maxPrintBytes: 67108864
44
+ maxPrintBytes: 67108864,
45
+ /**
46
+ * The largest file of an encrypted folder filex hands an app in the clear
47
+ * or takes back from it (filex 0.56, `FileInfo.plaintext`): it is
48
+ * decrypted and encrypted whole in the page, as filex's own explorer does
49
+ * for a file of this size.
50
+ */
51
+ maxEncryptedBytes: 209715200
45
52
  };
46
53
  function k(n) {
47
54
  return !!n && typeof n == "object" && n.type === S && typeof n.v == "number";
48
55
  }
49
56
  function L(n) {
50
- return !!n && typeof n == "object" && n.type === B;
57
+ return !!n && typeof n == "object" && n.type === M;
51
58
  }
52
59
  function A(n) {
53
60
  return !!n && typeof n == "object" && typeof n.id == "number" && typeof n.method == "string";
@@ -60,9 +67,9 @@ class m extends Error {
60
67
  super(u.message || u.code), this.name = "FilexError", this.code = u.code;
61
68
  }
62
69
  }
63
- let v = null;
70
+ let h = null;
64
71
  function P(n = {}) {
65
- return v || (v = O(n)), v;
72
+ return h || (h = O(n)), h;
66
73
  }
67
74
  function O(n) {
68
75
  return new Promise((u, p) => {
@@ -78,9 +85,9 @@ function O(n) {
78
85
  window.removeEventListener("message", g), clearTimeout(a);
79
86
  const s = y.ports[0], c = T(s, n);
80
87
  c.request("session.get").then((f) => {
81
- c.session = f, n.applyTheme !== !1 && (h(f.theme, f.locale, f.dir), c.on("theme", (d) => h(d)), c.on("locale", (d) => {
88
+ c.session = f, n.applyTheme !== !1 && (v(f.theme, f.locale, f.dir), c.on("theme", (d) => v(d)), c.on("locale", (d) => {
82
89
  const e = d;
83
- h(void 0, e.locale, e.dir);
90
+ v(void 0, e.locale, e.dir);
84
91
  })), u(c);
85
92
  }).catch(p);
86
93
  }
@@ -149,8 +156,8 @@ function T(n, u) {
149
156
  o.set(i, { resolve: b, reject: w });
150
157
  try {
151
158
  n.postMessage({ id: i, method: e, params: t }, r);
152
- } catch (M) {
153
- o.delete(i), w(M);
159
+ } catch (B) {
160
+ o.delete(i), w(B);
154
161
  }
155
162
  });
156
163
  }
@@ -187,8 +194,8 @@ function T(n, u) {
187
194
  };
188
195
  },
189
196
  async save(e, t = {}) {
190
- const r = await f(e);
191
- return s("file.save", { index: t.index ?? 0, data: r.data, mime: t.mime }, r.transfer);
197
+ const r = await f(e), i = { index: t.index ?? 0, data: r.data, mime: t.mime };
198
+ return typeof t.through == "number" && (i.through = t.through), s("file.save", i, r.transfer);
192
199
  },
193
200
  async saveAs(e, t, r) {
194
201
  const i = await f(t);
@@ -286,7 +293,7 @@ function T(n, u) {
286
293
  !0
287
294
  ), d;
288
295
  }
289
- function h(n, u, p) {
296
+ function v(n, u, p) {
290
297
  if (typeof document > "u") return;
291
298
  const o = document.documentElement;
292
299
  if (n) {
@@ -302,7 +309,7 @@ export {
302
309
  S as HELLO,
303
310
  x as LIMITS,
304
311
  E as METHODS,
305
- B as PORT,
312
+ M as PORT,
306
313
  P as connect,
307
314
  k as isHello,
308
315
  I as isKnownMethod,
@@ -1 +1 @@
1
- {"version":3,"file":"filex-app-ui.js","sources":["../src/protocol.ts","../src/index.ts"],"sourcesContent":["/**\n * The bridge between filex and an app's own interface — the ONE definition.\n *\n * An app's interface (the `ui` block of its filex-app.json) runs in a frame\n * filex draws with `sandbox=\"allow-scripts\"`: an opaque origin, no cookies, no\n * storage, no network (the server's CSP says `connect-src 'none'`). Everything\n * it may do goes through this bridge, and the host decides every call.\n *\n * Both halves import this file — the SDK the app bundles (`src/index.ts`) and\n * filex's own frame (`packages/core/src/components/plugin/AppFrame.vue`) — so a\n * method or an event added here is added on both sides at once.\n *\n * The wire, in order (docs/APP-PLUGINS-API.md → \"The interface bridge\"):\n *\n * 1. The app posts `{type: 'filex:hello', v: 1}` to its parent window.\n * 2. The host takes it ONLY when `event.source` is the frame it drew. The\n * origin says nothing here: a sandboxed frame's origin is always \"null\",\n * so any other sandboxed frame on the page would look the same (measured,\n * 2026-09-27, Chrome, Firefox and WebKit).\n * 3. The host answers with a MessagePort: `{type: 'filex:port', v: 1}` and\n * the port transferred. Once per load of the frame; a second hello is\n * ignored.\n * 4. The app takes the port ONLY when `event.source` is its parent window.\n * 5. From then on, only the port: requests `{id, method, params}`, answers\n * `{id, result}` or `{id, error}`, events `{event, data}`, and the host's\n * own requests `{hid, request, params}` answered `{hid, result|error}`.\n */\n\n/** The bridge generation both sides speak. */\nexport const BRIDGE_VERSION = 1;\n\n/** The app's first message, to `window.parent`. */\nexport const HELLO = 'filex:hello';\n/** The host's answer, carrying the port. */\nexport const PORT = 'filex:port';\n\n/** What the app may ask the host. Anything else is `unknown_method`. */\nexport type Method =\n | 'session.get'\n | 'file.read'\n | 'file.save'\n | 'file.saveAs'\n | 'ui.dirty'\n | 'ui.title'\n | 'ui.toast'\n | 'ui.confirm'\n | 'ui.close'\n | 'clipboard.write'\n | 'ui.download'\n | 'engine.call'\n | 'job.submit'\n | 'state.get'\n | 'state.set'\n /** 0.52.0: what the app's license says (a paid app from a store), `LicenseInfo`. */\n | 'license.get'\n /** 0.55.0: filex prints a PDF the app hands it (`PrintParams`, grant `ui:print`). */\n | 'ui.print'\n /**\n * 0.55.0, editing together (`coedit.*`, task #189): the session's log the\n * host seals, orders through filex's relay and opens again. Defined so an\n * app can be written against them; filex 0.55 offers no relay yet, so each\n * answers `unavailable` - edit alone then (see `CoEditHello`).\n */\n | 'coedit.join'\n | 'coedit.subscribe'\n | 'coedit.append'\n | 'coedit.lease'\n | 'coedit.cursor'\n | 'coedit.blob.put'\n | 'coedit.blob.get'\n | 'coedit.leave';\n\nexport const METHODS: readonly Method[] = [\n 'session.get',\n 'file.read',\n 'file.save',\n 'file.saveAs',\n 'ui.dirty',\n 'ui.title',\n 'ui.toast',\n 'ui.confirm',\n 'ui.close',\n 'clipboard.write',\n 'ui.download',\n 'engine.call',\n 'job.submit',\n 'state.get',\n 'state.set',\n 'license.get',\n 'ui.print',\n 'coedit.join',\n 'coedit.subscribe',\n 'coedit.append',\n 'coedit.lease',\n 'coedit.cursor',\n 'coedit.blob.put',\n 'coedit.blob.get',\n 'coedit.leave',\n];\n\n/** What the host tells the app, unasked. */\nexport type HostEvent =\n /** The interface's colours changed (light/dark, palette). `data: Theme`. */\n | 'theme'\n /** The reader's language changed. `data: {locale, dir}`. */\n | 'locale'\n /** The open file changed underneath (another tab, another person). */\n | 'file.changed'\n /** The person closed the frame's surroundings; last chance to say so. */\n | 'close.request'\n /** An administrator installed a new version of this app: reload to use it. */\n | 'app.updated'\n /** Editing together: the next entry of the session's log, opened. `data: CoEditEntry`. */\n | 'coedit.entry'\n /** Editing together: another member's cursor (not kept). `data: CoEditCursor`. */\n | 'coedit.cursor'\n /**\n * Editing together: the host stopped handing entries over (the app fell\n * behind, or the connection dropped). `data: {from}` - subscribe again\n * from the last entry the app holds.\n */\n | 'coedit.dropped';\n\n/** What the host may ask the app (and wait for). */\nexport type HostRequest =\n /** Save now (the host's Save button, a draft's \"Save to disk\"): the app\n * hands its document to `file.save` and answers when it is written. */\n 'save';\n\n/** Why a call was refused. */\nexport type ErrorCode =\n /** The app was not granted what the call needs (files:write for a save…). */\n | 'not_granted'\n /** No such file (an index past the opened files), no such key. */\n | 'not_found'\n /** The file cannot be written: a read-only storage, a view-only opening. */\n | 'read_only'\n /** The call's parameters are wrong. */\n | 'invalid'\n /** Over a limit (LIMITS). */\n | 'too_large'\n /** The server could not be reached or refused; `message` says what. */\n | 'failed'\n /** The person said no (a confirm, a save-as picker closed). */\n | 'cancelled'\n /** The host does not offer this here (no engine, no save handler). */\n | 'unavailable'\n | 'unknown_method';\n\nexport interface BridgeError {\n code: ErrorCode;\n message?: string;\n}\n\n/* ── messages ──────────────────────────────────────────────────────────── */\n\nexport interface HelloMessage {\n type: typeof HELLO;\n v: number;\n}\n\nexport interface PortMessage {\n type: typeof PORT;\n v: number;\n}\n\nexport interface RequestMessage {\n id: number;\n method: Method;\n params?: unknown;\n}\n\nexport interface ResponseMessage {\n id: number;\n result?: unknown;\n error?: BridgeError;\n}\n\nexport interface EventMessage {\n event: HostEvent;\n data?: unknown;\n}\n\nexport interface HostRequestMessage {\n hid: number;\n request: HostRequest;\n params?: unknown;\n}\n\nexport interface HostResponseMessage {\n hid: number;\n result?: unknown;\n error?: BridgeError;\n}\n\n/* ── shapes ────────────────────────────────────────────────────────────── */\n\n/** The interface's look: `mode` and filex's own `--fe-*` custom properties. */\nexport interface Theme {\n mode: 'light' | 'dark';\n /** `--fe-bg`, `--fe-text`, `--fe-accent`… — the SDK sets them on `<html>`. */\n tokens: Record<string, string>;\n}\n\n/** One file the interface was opened with. Never a storage path. */\nexport interface FileInfo {\n index: number;\n name: string;\n /** Lower-case, no dot. */\n ext: string;\n size: number;\n mime: string;\n /** It cannot be saved over: read-only storage, view-only opening, or the\n * app holds no `files:write`. */\n readOnly: boolean;\n /**\n * 0.55.0: the file is end-to-end encrypted - `folder` in an encrypted\n * folder, `vault` in a vault (where only one person edits at a time),\n * `file` for a single encrypted file (`.fxe`). As the server says it;\n * ABSENT for a file that is not encrypted.\n *\n * ⚠ filex 0.55 decrypts nothing for an app: the server holds such a file\n * only as ciphertext and has no key, so `file.read` is refused\n * (`failed` with `encrypted`), a save is refused, and `readOnly` is true.\n * The explorer offers no app an encrypted file in the first place; the\n * field is information, not a promise of plaintext.\n */\n encrypted?: 'folder' | 'vault' | 'file';\n}\n\n/** What `session.get` answers — what the app knows about where it runs. */\nexport interface Session {\n v: number;\n app: { name: string; version: string };\n view: { id: string; placement: 'modal' | 'page' | 'inspector' | 'home' | 'viewer' };\n /** The reader's language tag (`tr`, `pt-br`) and its direction. */\n locale: string;\n dir: 'ltr' | 'rtl';\n theme: Theme;\n /** The person's display name; never their e-mail, never a token. */\n user: { name: string };\n files: FileInfo[];\n /** The permissions this app was granted — so it can hide what it may not do. */\n grants: string[];\n /** The administrator's non-secret settings for this app (grant `settings`). */\n settings?: Record<string, string>;\n}\n\n/** `file.read` params. */\nexport interface ReadParams {\n index?: number;\n /** `stream` (a transferred ReadableStream<Uint8Array>), `bytes` (an\n * ArrayBuffer), or `text` (UTF-8, at most LIMITS.maxTextBytes). */\n as?: 'stream' | 'bytes' | 'text';\n}\n\nexport interface ReadResult {\n name: string;\n size: number;\n mime: string;\n stream?: ReadableStream<Uint8Array>;\n bytes?: ArrayBuffer;\n text?: string;\n}\n\n/** `file.save` params: the new content of the opened file. */\nexport interface SaveParams {\n index?: number;\n /** A transferred ReadableStream<Uint8Array>, an ArrayBuffer or a string. */\n data: ReadableStream<Uint8Array> | ArrayBuffer | string;\n mime?: string;\n /**\n * 0.55.0, editing together: the last log entry (`CoEditEntry.seq`) the\n * saved document holds, so the session knows what is saved. Ignored\n * outside a session.\n */\n through?: number;\n}\n\nexport interface SaveResult {\n saved: true;\n size: number;\n}\n\n/**\n * `ui.download`: a file for the person's own disk. filex does it (a sandboxed\n * frame cannot download), with the app's `ui:download` grant, on a gesture in\n * the frame or the person's yes, never over LIMITS.maxDownloadBytes.\n */\nexport interface DownloadParams {\n /** The file name offered — a name, not a path. */\n name: string;\n /** A transferred ReadableStream<Uint8Array>, an ArrayBuffer or a string. */\n data: ReadableStream<Uint8Array> | ArrayBuffer | string;\n mime?: string;\n}\n\nexport interface DownloadResult {\n saved: true;\n size: number;\n}\n\n/**\n * `ui.print` (filex 0.55.0): a PDF for the browser's print dialog. A\n * sandboxed frame may not open that dialog (measured: Chromium ignores\n * `print()` there and says so, Firefox ignores it), so filex prints the PDF\n * from a page of its own, with the app's `ui:print` grant, never over\n * LIMITS.maxPrintBytes. filex asks every time, above the frame, and the\n * dialog opens only on the person's click on filex's Allow. The print dialog\n * can save the PDF too, so `ui:print` is the same kind of grant as\n * `ui:download`. filex 0.54 and older answer `unknown_method`.\n */\nexport interface PrintParams {\n /** The document's name, for the question filex may ask - a name, not a path. */\n name: string;\n /** The PDF: a transferred ArrayBuffer or ReadableStream<Uint8Array>. */\n data: ReadableStream<Uint8Array> | ArrayBuffer;\n /** `application/pdf` when given; nothing else is printed. */\n mime?: string;\n}\n\nexport interface PrintResult {\n /** The browser's print dialog was opened with the PDF. */\n printed: true;\n size: number;\n}\n\n/* ── editing together (coedit.*, filex 0.55.0: defined, not offered) ────── */\n\n/** What a member appends: the editor's changes, a lock request, a release. */\nexport type CoEditKind = 'changes' | 'lock' | 'release';\n\n/** A member of the session. */\nexport interface CoEditMember {\n /** The relay's id for the connection. */\n client: string;\n /** An opaque id of the person, the same for each of their connections in\n * this session; never an account id. */\n user: string;\n /** The person's display name. */\n name: string;\n /** The editor's per-session user index: given once, never twice. */\n indexUser: number;\n /** May append changes (an editor role and the right to change the file). */\n canEdit: boolean;\n}\n\n/** `coedit.join` params: join (or start) the session of an opened file. */\nexport interface CoEditJoinParams {\n index?: number;\n}\n\n/**\n * What `coedit.join` answers: who the app is in the session and how far\n * the log has gone. The entries come as `coedit.entry` events once the app\n * subscribes (`coedit.subscribe` from 0 for the whole log). `unavailable`\n * when filex offers no editing together here (filex 0.55, a vault, a file\n * that cannot be shared): the app edits alone.\n */\nexport interface CoEditHello {\n session: string;\n me: CoEditMember;\n /** The last entry's seq (0: an empty log). */\n head: number;\n /** The last `changes` entry's seq. */\n changesHead: number;\n /** The last entry the file on the storage holds. */\n savedThrough: number;\n}\n\n/** `coedit.subscribe` params: entries after `from` arrive as `coedit.entry`. */\nexport interface CoEditSubscribeParams {\n from: number;\n}\n\n/** `coedit.append` params: the host seals it and the relay places it. */\nexport interface CoEditAppendParams {\n kind: CoEditKind;\n body: unknown;\n}\n\nexport interface CoEditAppendResult {\n /** Where it landed; it comes back as a `coedit.entry` too. */\n seq: number;\n}\n\n/** `coedit.lease`: the changes lease (one writer at a time, one that has\n * seen every change). */\nexport interface CoEditLeaseParams {\n op: 'acquire' | 'release';\n /** The seq of the last `changes` entry the app applied. */\n changesSeen: number;\n}\n\nexport interface CoEditLeaseResult {\n granted: boolean;\n}\n\n/** `coedit.cursor` params: passed to the other members, not kept. */\nexport interface CoEditCursorParams {\n cursor: unknown;\n}\n\n/** `coedit.blob.put` / `coedit.blob.get`: the session's sealed blobs (the\n * base document, images added while editing). A name, not a path. */\nexport interface CoEditBlobPutParams {\n name: string;\n data: ArrayBuffer;\n}\n\nexport interface CoEditBlobGetParams {\n name: string;\n}\n\nexport interface CoEditBlobGetResult {\n name: string;\n bytes: ArrayBuffer;\n}\n\ninterface CoEditEntryBase {\n seq: number;\n /** The relay's time, Unix ms - the same for every member. */\n at: number;\n client: string;\n}\n\n/** One entry of the session's log, opened and checked by filex. */\nexport type CoEditEntry =\n | (CoEditEntryBase & { kind: CoEditKind; body: unknown })\n | (CoEditEntryBase & { kind: 'join'; member: CoEditMember })\n | (CoEditEntryBase & { kind: 'leave' })\n | (CoEditEntryBase & { kind: 'saved'; through: number });\n\n/** `coedit.cursor` event data. */\nexport interface CoEditCursor {\n client: string;\n cursor: unknown;\n}\n\n/**\n * `file.saveAs`: a NEW file, in a folder the person picks in filex's own\n * folder dialog (the one its Move to… uses), which opens in the opened\n * file's folder. Closing the dialog answers `cancelled`.\n */\nexport interface SaveAsParams {\n /** The file name — a name, not a path. */\n name: string;\n data: ReadableStream<Uint8Array> | ArrayBuffer | string;\n mime?: string;\n}\n\nexport interface SaveAsResult {\n saved: true;\n name: string;\n size: number;\n}\n\nexport interface ToastParams {\n text: string;\n tone?: 'info' | 'success' | 'warning' | 'error';\n}\n\nexport interface ConfirmParams {\n title?: string;\n text: string;\n confirm?: string;\n cancel?: string;\n danger?: boolean;\n}\n\n/**\n * `license.get` (filex 0.52.0): what the app's license says. A paid app is\n * installed from a store, which issues its license and is asked about it every\n * day; filex keeps the key and never hands it to the app. `status`:\n *\n * - `free`: not a paid app;\n * - `valid`: the store said valid at its last check;\n * - `grace`: valid, but the store could not be asked since - it holds until\n * the grace the store signed ends;\n * - anything else (`revoked`, `expired`, `invalid`, `seats_exhausted`,\n * `wrong_app`, `grace_expired`, `missing`, `unverified`): the app is held\n * and its interface is not served, so an app rarely reads one of these.\n *\n * `valid_until` / `updates_until` are there for a valid license (dates as\n * RFC 3339). Who holds the license is not the app's to read. filex 0.51.0\n * and older answer `unknown_method`.\n */\nexport interface LicenseInfo {\n status: string;\n valid_until?: string;\n updates_until?: string;\n}\n\n/** `engine.call`: the app's own module, `ui_call` export. */\nexport interface EngineCallParams {\n method: string;\n params?: unknown;\n}\n\n/** `job.submit`: queue one of the app's actions on the opened files. */\nexport interface JobSubmitParams {\n action: string;\n params?: Record<string, unknown>;\n}\n\n/* ── limits ────────────────────────────────────────────────────────────── */\n\nexport const LIMITS = {\n /** `file.read({as: 'text'})` refuses a file larger than this; read a stream. */\n maxTextBytes: 32 << 20,\n /** One `state.set` value, as JSON. */\n maxStateBytes: 8 << 10,\n /** A `ui.toast` / `ui.title` text, in characters. */\n maxLineChars: 300,\n /** How long the SDK waits for the host's port. */\n connectTimeoutMs: 10_000,\n /** The largest file `ui.download` hands the person (streamed or not). */\n maxDownloadBytes: 256 << 20,\n /**\n * The largest PDF `ui.print` prints (filex 0.55). Lower than a download:\n * a download can stream to disk, a print is held whole in the page (one\n * Blob) until the print dialog has read it, and a document worth printing\n * is far smaller.\n */\n maxPrintBytes: 64 << 20,\n} as const;\n\n/* ── guards ────────────────────────────────────────────────────────────── */\n\nexport function isHello(d: unknown): d is HelloMessage {\n return !!d && typeof d === 'object' && (d as HelloMessage).type === HELLO && typeof (d as HelloMessage).v === 'number';\n}\n\nexport function isPortMessage(d: unknown): d is PortMessage {\n return !!d && typeof d === 'object' && (d as PortMessage).type === PORT;\n}\n\nexport function isRequest(d: unknown): d is RequestMessage {\n return (\n !!d &&\n typeof d === 'object' &&\n typeof (d as RequestMessage).id === 'number' &&\n typeof (d as RequestMessage).method === 'string'\n );\n}\n\nexport function isKnownMethod(m: string): m is Method {\n return (METHODS as readonly string[]).includes(m);\n}\n","/**\n * @brftech/filex-app-ui — the SDK an app's own interface bundles.\n *\n * ```js\n * import { connect } from '@brftech/filex-app-ui';\n *\n * const fx = await connect(); // the handshake with filex\n * const file = await fx.open(); // the file the app was opened with\n * editor.setValue(await file.text());\n * editor.onChange(() => fx.dirty(true));\n * fx.onSave(async () => editor.getValue()); // filex's Save, a draft's \"Save to disk\", Ctrl+S\n * ```\n *\n * No dependency, a few KB. The interface runs in a sandboxed frame with no\n * network and no storage of its own: every call below goes to filex over the\n * MessagePort the handshake set up, and filex decides it (docs/APP-PLUGINS.md\n * → \"An app's own interface\"). The protocol itself is `./protocol`.\n */\nimport {\n BRIDGE_VERSION,\n HELLO,\n LIMITS,\n isPortMessage,\n type BridgeError,\n type ConfirmParams,\n type ErrorCode,\n type FileInfo,\n type HostEvent,\n type HostRequestMessage,\n type Method,\n type ReadResult,\n type SaveAsResult,\n type SaveResult,\n type Session,\n type Theme,\n type ToastParams,\n type DownloadResult,\n type LicenseInfo,\n type PrintResult,\n type CoEditHello,\n type CoEditKind,\n type CoEditAppendResult,\n type CoEditLeaseResult,\n type CoEditBlobGetResult,\n} from './protocol';\n\nexport * from './protocol';\n\n/** A call filex refused, with the reason as a code. */\nexport class FilexError extends Error {\n readonly code: ErrorCode;\n constructor(err: BridgeError) {\n super(err.message || err.code);\n this.name = 'FilexError';\n this.code = err.code;\n }\n}\n\n/** Content the app hands to a save. */\nexport type SaveData = string | Blob | ArrayBuffer | ArrayBufferView | ReadableStream<Uint8Array>;\n\n/** One opened file, read the way the app needs it. */\nexport interface OpenedFile extends FileInfo {\n text(): Promise<string>;\n bytes(): Promise<ArrayBuffer>;\n stream(): Promise<ReadableStream<Uint8Array>>;\n /** Save new content over this file (a new version, or the draft it is). */\n save(data: SaveData, mime?: string): Promise<SaveResult>;\n}\n\nexport interface ConnectOptions {\n /** How long to wait for filex (default 10 s). */\n timeoutMs?: number;\n /**\n * Put filex's colours (`--fe-*`), `lang`, `dir` and `data-theme` on\n * `<html>`, and keep them current (default true).\n */\n applyTheme?: boolean;\n /** Ctrl/Cmd+S inside the interface runs the save handler (default true). */\n saveShortcut?: boolean;\n}\n\nexport interface FilexApp {\n readonly session: Session;\n /** The file at `index` (default the first) the interface was opened with. */\n open(index?: number): Promise<OpenedFile>;\n /** Save new content over an opened file. */\n save(data: SaveData, opts?: { index?: number; mime?: string }): Promise<SaveResult>;\n /** Save a NEW file; filex asks the person where (its own folder picker). */\n saveAs(name: string, data: SaveData, mime?: string): Promise<SaveAsResult>;\n /** Unsaved changes, or not: filex asks before the person leaves them. */\n dirty(on: boolean): void;\n title(text: string): void;\n toast(text: string, tone?: ToastParams['tone']): void;\n confirm(opts: ConfirmParams | string): Promise<boolean>;\n /** Ask filex to close the interface (it asks first when there are changes). */\n close(): void;\n /** Put text on the clipboard (filex does it, the same way in every browser). */\n copy(text: string): Promise<void>;\n /**\n * Hand the person a file for their own disk (the app needs `ui.download`\n * in its manifest). Call it from a click or a key press: without one,\n * filex asks the person first. `cancelled` when they say no.\n */\n download(name: string, data: SaveData, mime?: string): Promise<DownloadResult>;\n /**\n * Print a PDF (filex 0.55; the app needs `ui.print` in its manifest): a\n * sandboxed frame may not open the print dialog, so filex prints it from\n * its own page. filex asks the person every time, and the dialog opens on\n * their click on filex's Allow; at most LIMITS.maxPrintBytes. filex 0.54\n * and older answer `unknown_method`, and a host whose print page may not\n * be shown `unavailable`: hand the PDF over with `download` then.\n */\n print(name: string, pdf: Blob | ArrayBuffer | ArrayBufferView | ReadableStream<Uint8Array>): Promise<PrintResult>;\n /**\n * Editing together (filex 0.55: defined, not offered - every call answers\n * `unavailable`, and the app edits alone). filex seals what the app\n * appends, orders it through its relay and hands every member the same\n * entries in the same order (`coedit.entry` events after `subscribe`);\n * the app never holds a key.\n */\n coedit: {\n join(index?: number): Promise<CoEditHello>;\n subscribe(from: number): Promise<void>;\n append(kind: CoEditKind, body: unknown): Promise<CoEditAppendResult>;\n lease(op: 'acquire' | 'release', changesSeen: number): Promise<boolean>;\n cursor(cursor: unknown): void;\n putBlob(name: string, data: Blob | ArrayBuffer | ArrayBufferView): Promise<void>;\n getBlob(name: string): Promise<ArrayBuffer>;\n leave(): Promise<void>;\n };\n /** Call the app's own module (`ui_call` export) with the opened files. */\n call<T = unknown>(method: string, params?: unknown): Promise<T>;\n /** Queue one of the app's actions on the opened files; answers the op. */\n submit(action: string, params?: Record<string, unknown>): Promise<{ op: unknown }>;\n /** This person's small store for this app (JSON values, 8 KiB each). */\n state: {\n get<T = unknown>(key: string): Promise<T | undefined>;\n set(key: string, value: unknown): Promise<void>;\n };\n /**\n * The app's license (filex 0.52.0; a paid app installed from a store):\n * `{status: \"free\"}` for a free app, `{status: \"valid\", valid_until,\n * updates_until}` for a licensed one. Never the key, never the licensee.\n */\n license: {\n get(): Promise<LicenseInfo>;\n };\n /** Listen to filex. Returns the unsubscribe. */\n on(event: HostEvent, handler: (data: unknown) => void): () => void;\n /**\n * What to save when filex asks (its Save button, a draft's \"Save to disk\",\n * Ctrl+S): return the document and the SDK saves it over the opened file;\n * return nothing if the handler saved by itself.\n */\n onSave(handler: () => SaveData | void | Promise<SaveData | void>): () => void;\n /** Raw call, for a method this SDK has no helper for. */\n request<T = unknown>(method: Method, params?: unknown, transfer?: Transferable[]): Promise<T>;\n}\n\nlet connecting: Promise<FilexApp> | null = null;\n\n/**\n * The handshake. Resolves once filex answered with its port and the session;\n * rejects outside filex or when filex did not answer in time. Calling it again\n * answers the same connection.\n */\nexport function connect(opts: ConnectOptions = {}): Promise<FilexApp> {\n if (!connecting) connecting = open(opts);\n return connecting;\n}\n\nfunction open(opts: ConnectOptions): Promise<FilexApp> {\n return new Promise<FilexApp>((resolve, reject) => {\n if (typeof window === 'undefined' || window.parent === window) {\n reject(new FilexError({ code: 'unavailable', message: 'not running inside filex' }));\n return;\n }\n const parent = window.parent;\n // Where filex runs, when the browser says so (Chrome, Safari). A frame\n // drawn with referrerpolicy=\"no-referrer\" has no document.referrer, and\n // Firefox has no ancestorOrigins: then `source === parent` is the check,\n // which is the one that matters (only the parent window IS the parent).\n const expected = ancestorOrigin();\n const timer = setTimeout(() => {\n window.removeEventListener('message', onMessage);\n reject(new FilexError({ code: 'unavailable', message: 'filex did not answer' }));\n }, opts.timeoutMs ?? LIMITS.connectTimeoutMs);\n\n function onMessage(ev: MessageEvent) {\n if (ev.source !== parent || !isPortMessage(ev.data) || !ev.ports[0]) return;\n if (expected && ev.origin !== expected) return;\n window.removeEventListener('message', onMessage);\n clearTimeout(timer);\n const port = ev.ports[0];\n const app = bridge(port, opts);\n app\n .request<Session>('session.get')\n .then((session) => {\n (app as { session: Session }).session = session;\n if (opts.applyTheme !== false) {\n applyLook(session.theme, session.locale, session.dir);\n app.on('theme', (t) => applyLook(t as Theme));\n app.on('locale', (l) => {\n const v = l as { locale?: string; dir?: 'ltr' | 'rtl' };\n applyLook(undefined, v.locale, v.dir);\n });\n }\n resolve(app);\n })\n .catch(reject);\n }\n window.addEventListener('message', onMessage);\n parent.postMessage({ type: HELLO, v: BRIDGE_VERSION }, '*');\n });\n}\n\nfunction ancestorOrigin(): string {\n try {\n const list = (window.location as Location & { ancestorOrigins?: DOMStringList }).ancestorOrigins;\n if (list && list.length > 0 && list[0] !== 'null') return list[0];\n } catch {\n /* no such API */\n }\n return '';\n}\n\nfunction bridge(port: MessagePort, opts: ConnectOptions): FilexApp {\n let seq = 0;\n const pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: unknown) => void }>();\n const listeners = new Map<HostEvent, Set<(data: unknown) => void>>();\n let saveHandler: (() => SaveData | void | Promise<SaveData | void>) | null = null;\n\n port.onmessage = (ev: MessageEvent) => {\n const m = ev.data as Record<string, unknown> | null;\n if (!m || typeof m !== 'object') return;\n if (typeof m.id === 'number') {\n const p = pending.get(m.id);\n if (!p) return;\n pending.delete(m.id);\n if (m.error) p.reject(new FilexError(m.error as BridgeError));\n else p.resolve(m.result);\n return;\n }\n if (typeof m.hid === 'number') {\n void answerHost(m as unknown as HostRequestMessage);\n return;\n }\n if (typeof m.event === 'string') {\n for (const h of listeners.get(m.event as HostEvent) ?? []) {\n try {\n h(m.data);\n } catch (e) {\n console.error('[filex-app-ui] listener failed', e);\n }\n }\n }\n };\n\n async function answerHost(m: HostRequestMessage) {\n if (m.request === 'save') {\n if (!saveHandler) {\n port.postMessage({ hid: m.hid, error: { code: 'unavailable', message: 'this app has no save handler' } });\n return;\n }\n try {\n const res = await runSave();\n port.postMessage({ hid: m.hid, result: res ?? { saved: true } });\n } catch (e) {\n const err = e instanceof FilexError ? { code: e.code, message: e.message } : { code: 'failed', message: String((e as Error)?.message ?? e) };\n port.postMessage({ hid: m.hid, error: err });\n }\n return;\n }\n port.postMessage({ hid: m.hid, error: { code: 'unknown_method' } });\n }\n\n async function runSave(): Promise<SaveResult | null> {\n if (!saveHandler) return null;\n const data = await saveHandler();\n if (data === undefined || data === null) return null;\n return app.save(data as SaveData);\n }\n\n function request<T>(method: Method, params?: unknown, transfer: Transferable[] = []): Promise<T> {\n const id = ++seq;\n return new Promise<T>((resolve, reject) => {\n pending.set(id, { resolve: resolve as (v: unknown) => void, reject });\n try {\n port.postMessage({ id, method, params }, transfer);\n } catch (e) {\n pending.delete(id);\n reject(e);\n }\n });\n }\n\n function notify(method: Method, params?: unknown) {\n request(method, params).catch((e) => console.warn('[filex-app-ui]', method, e));\n }\n\n async function payload(data: SaveData): Promise<{ data: ReadableStream<Uint8Array> | ArrayBuffer | string; transfer: Transferable[] }> {\n if (typeof data === 'string') return { data, transfer: [] };\n if (data instanceof ArrayBuffer) return { data, transfer: [data] };\n if (ArrayBuffer.isView(data)) {\n const copy = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;\n return { data: copy, transfer: [copy] };\n }\n if (typeof Blob !== 'undefined' && data instanceof Blob) {\n const buf = await data.arrayBuffer();\n return { data: buf, transfer: [buf] };\n }\n if (typeof ReadableStream !== 'undefined' && data instanceof ReadableStream) {\n return { data, transfer: [data as unknown as Transferable] };\n }\n throw new FilexError({ code: 'invalid', message: 'save takes a string, a Blob, bytes or a ReadableStream' });\n }\n\n const app: FilexApp = {\n session: undefined as unknown as Session,\n async open(index = 0) {\n const info = app.session?.files?.[index];\n if (!info) throw new FilexError({ code: 'not_found', message: `no file at ${index}` });\n const read = <K extends keyof ReadResult>(as: 'stream' | 'bytes' | 'text', key: K) =>\n request<ReadResult>('file.read', { index, as }).then((r) => r[key] as NonNullable<ReadResult[K]>);\n return {\n ...info,\n text: () => read('text', 'text'),\n bytes: () => read('bytes', 'bytes'),\n stream: () => read('stream', 'stream'),\n save: (data: SaveData, mime?: string) => app.save(data, { index, mime }),\n };\n },\n async save(data, o = {}) {\n const p = await payload(data);\n return request<SaveResult>('file.save', { index: o.index ?? 0, data: p.data, mime: o.mime }, p.transfer);\n },\n async saveAs(name, data, mime) {\n const p = await payload(data);\n return request<SaveAsResult>('file.saveAs', { name, data: p.data, mime }, p.transfer);\n },\n async download(name, data, mime) {\n const p = await payload(data);\n return request<DownloadResult>('ui.download', { name, data: p.data, mime }, p.transfer);\n },\n async print(name, pdf) {\n const p = await payload(pdf);\n return request<PrintResult>('ui.print', { name, data: p.data, mime: 'application/pdf' }, p.transfer);\n },\n coedit: {\n join(index = 0) {\n return request<CoEditHello>('coedit.join', { index });\n },\n subscribe(from: number) {\n return request<void>('coedit.subscribe', { from });\n },\n append(kind: CoEditKind, body: unknown) {\n return request<CoEditAppendResult>('coedit.append', { kind, body });\n },\n async lease(op: 'acquire' | 'release', changesSeen: number) {\n const r = await request<CoEditLeaseResult>('coedit.lease', { op, changesSeen });\n return !!r?.granted;\n },\n cursor(cursor: unknown) {\n notify('coedit.cursor', { cursor });\n },\n async putBlob(name: string, data: Blob | ArrayBuffer | ArrayBufferView) {\n const p = await payload(data);\n if (!(p.data instanceof ArrayBuffer)) throw new FilexError({ code: 'invalid', message: 'a blob is bytes' });\n await request<void>('coedit.blob.put', { name, data: p.data }, p.transfer);\n },\n async getBlob(name: string) {\n const r = await request<CoEditBlobGetResult>('coedit.blob.get', { name });\n return r.bytes;\n },\n async leave() {\n await request<void>('coedit.leave');\n },\n },\n dirty(on) {\n notify('ui.dirty', { dirty: !!on });\n },\n title(text) {\n notify('ui.title', { text: String(text).slice(0, LIMITS.maxLineChars) });\n },\n toast(text, tone) {\n notify('ui.toast', { text: String(text).slice(0, LIMITS.maxLineChars), tone });\n },\n confirm(o) {\n const params = typeof o === 'string' ? { text: o } : o;\n return request<boolean>('ui.confirm', params);\n },\n close() {\n notify('ui.close');\n },\n copy(text) {\n return request<void>('clipboard.write', { text: String(text) });\n },\n call<T>(method: string, params?: unknown) {\n return request<T>('engine.call', { method, params });\n },\n submit(action, params) {\n return request<{ op: unknown }>('job.submit', { action, params });\n },\n state: {\n get<T>(key: string) {\n return request<T | undefined>('state.get', { key });\n },\n set(key: string, value: unknown) {\n return request<void>('state.set', { key, value });\n },\n },\n license: {\n get() {\n return request<LicenseInfo>('license.get');\n },\n },\n on(event, handler) {\n let set = listeners.get(event);\n if (!set) listeners.set(event, (set = new Set()));\n set.add(handler);\n return () => set!.delete(handler);\n },\n onSave(handler) {\n saveHandler = handler;\n return () => {\n if (saveHandler === handler) saveHandler = null;\n };\n },\n request,\n };\n\n if (opts.saveShortcut !== false && typeof document !== 'undefined') {\n // ⚠ The keystroke is handled HERE and never forwarded: a frame that could\n // send key events to filex could press filex's own shortcuts. Ctrl+S runs\n // the app's own save handler, which saves through the bridge like any call.\n document.addEventListener(\n 'keydown',\n (e) => {\n if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 's' || e.key === 'S') && saveHandler) {\n e.preventDefault();\n runSave().catch((err) => app.toast(String((err as Error)?.message ?? err), 'error'));\n }\n },\n true,\n );\n }\n return app;\n}\n\n/** Paint filex's look onto the interface's own document. */\nfunction applyLook(theme?: Theme, locale?: string, dir?: 'ltr' | 'rtl') {\n if (typeof document === 'undefined') return;\n const root = document.documentElement;\n if (theme) {\n root.dataset.theme = theme.mode;\n root.style.colorScheme = theme.mode;\n for (const [k, v] of Object.entries(theme.tokens ?? {})) {\n if (/^--fe-[a-z0-9-]+$/.test(k) && typeof v === 'string') root.style.setProperty(k, v);\n }\n }\n if (locale) root.lang = locale;\n if (dir) root.dir = dir;\n}\n"],"names":["BRIDGE_VERSION","HELLO","PORT","METHODS","LIMITS","isHello","d","isPortMessage","isRequest","isKnownMethod","m","FilexError","err","connecting","connect","opts","open","resolve","reject","parent","expected","ancestorOrigin","timer","onMessage","ev","port","app","bridge","session","applyLook","t","l","v","list","seq","pending","listeners","saveHandler","p","answerHost","h","e","res","runSave","data","request","method","params","transfer","id","notify","payload","copy","buf","index","info","read","as","key","r","mime","o","name","pdf","from","kind","body","op","changesSeen","cursor","on","text","tone","action","value","event","handler","set","theme","locale","dir","root","k"],"mappings":"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"}
1
+ {"version":3,"file":"filex-app-ui.js","sources":["../src/protocol.ts","../src/index.ts"],"sourcesContent":["/**\n * The bridge between filex and an app's own interface — the ONE definition.\n *\n * An app's interface (the `ui` block of its filex-app.json) runs in a frame\n * filex draws with `sandbox=\"allow-scripts\"`: an opaque origin, no cookies, no\n * storage, no network (the server's CSP says `connect-src 'none'`). Everything\n * it may do goes through this bridge, and the host decides every call.\n *\n * Both halves import this file — the SDK the app bundles (`src/index.ts`) and\n * filex's own frame (`packages/core/src/components/plugin/AppFrame.vue`) — so a\n * method or an event added here is added on both sides at once.\n *\n * The wire, in order (docs/APP-PLUGINS-API.md → \"The interface bridge\"):\n *\n * 1. The app posts `{type: 'filex:hello', v: 1}` to its parent window.\n * 2. The host takes it ONLY when `event.source` is the frame it drew. The\n * origin says nothing here: a sandboxed frame's origin is always \"null\",\n * so any other sandboxed frame on the page would look the same (measured,\n * 2026-09-27, Chrome, Firefox and WebKit).\n * 3. The host answers with a MessagePort: `{type: 'filex:port', v: 1}` and\n * the port transferred. Once per load of the frame; a second hello is\n * ignored.\n * 4. The app takes the port ONLY when `event.source` is its parent window.\n * 5. From then on, only the port: requests `{id, method, params}`, answers\n * `{id, result}` or `{id, error}`, events `{event, data}`, and the host's\n * own requests `{hid, request, params}` answered `{hid, result|error}`.\n */\n\n/** The bridge generation both sides speak. */\nexport const BRIDGE_VERSION = 1;\n\n/** The app's first message, to `window.parent`. */\nexport const HELLO = 'filex:hello';\n/** The host's answer, carrying the port. */\nexport const PORT = 'filex:port';\n\n/** What the app may ask the host. Anything else is `unknown_method`. */\nexport type Method =\n | 'session.get'\n | 'file.read'\n | 'file.save'\n | 'file.saveAs'\n | 'ui.dirty'\n | 'ui.title'\n | 'ui.toast'\n | 'ui.confirm'\n | 'ui.close'\n | 'clipboard.write'\n | 'ui.download'\n | 'engine.call'\n | 'job.submit'\n | 'state.get'\n | 'state.set'\n /** 0.52.0: what the app's license says (a paid app from a store), `LicenseInfo`. */\n | 'license.get'\n /** 0.55.0: filex prints a PDF the app hands it (`PrintParams`, grant `ui:print`). */\n | 'ui.print'\n /**\n * Editing together (`coedit.*`, task #189): the session's log the host\n * seals, orders through filex's relay and opens again. Defined in filex\n * 0.55, which answers each `unavailable`; offered from 0.56 to an app\n * granted `files:co-edit` (see `CoEditHello`). `unavailable` = edit alone.\n */\n | 'coedit.join'\n | 'coedit.subscribe'\n | 'coedit.append'\n | 'coedit.lease'\n | 'coedit.cursor'\n | 'coedit.blob.put'\n | 'coedit.blob.get'\n | 'coedit.leave';\n\nexport const METHODS: readonly Method[] = [\n 'session.get',\n 'file.read',\n 'file.save',\n 'file.saveAs',\n 'ui.dirty',\n 'ui.title',\n 'ui.toast',\n 'ui.confirm',\n 'ui.close',\n 'clipboard.write',\n 'ui.download',\n 'engine.call',\n 'job.submit',\n 'state.get',\n 'state.set',\n 'license.get',\n 'ui.print',\n 'coedit.join',\n 'coedit.subscribe',\n 'coedit.append',\n 'coedit.lease',\n 'coedit.cursor',\n 'coedit.blob.put',\n 'coedit.blob.get',\n 'coedit.leave',\n];\n\n/** What the host tells the app, unasked. */\nexport type HostEvent =\n /** The interface's colours changed (light/dark, palette). `data: Theme`. */\n | 'theme'\n /** The reader's language changed. `data: {locale, dir}`. */\n | 'locale'\n /** The open file changed underneath (another tab, another person). */\n | 'file.changed'\n /** The person closed the frame's surroundings; last chance to say so. */\n | 'close.request'\n /** An administrator installed a new version of this app: reload to use it. */\n | 'app.updated'\n /** Editing together: the next entry of the session's log, opened. `data: CoEditEntry`. */\n | 'coedit.entry'\n /** Editing together: another member's cursor (not kept). `data: CoEditCursor`. */\n | 'coedit.cursor'\n /**\n * Editing together: the host stopped handing entries over. `data:\n * CoEditDropped` - `gone`: the membership ended (join again); `broken`: an\n * entry did not check out (stop editing together). A connection that drops\n * for a moment is the host's own: it reconnects and says nothing.\n */\n | 'coedit.dropped';\n\n/** What the host may ask the app (and wait for). */\nexport type HostRequest =\n /** Save now (the host's Save button, a draft's \"Save to disk\"): the app\n * hands its document to `file.save` and answers when it is written. */\n 'save';\n\n/** Why a call was refused. */\nexport type ErrorCode =\n /** The app was not granted what the call needs (files:write for a save…). */\n | 'not_granted'\n /** No such file (an index past the opened files), no such key. */\n | 'not_found'\n /** The file cannot be written: a read-only storage, a view-only opening. */\n | 'read_only'\n /** The call's parameters are wrong. */\n | 'invalid'\n /** Over a limit (LIMITS). */\n | 'too_large'\n /** The server could not be reached or refused; `message` says what. */\n | 'failed'\n /** The person said no (a confirm, a save-as picker closed). */\n | 'cancelled'\n /** The host does not offer this here (no engine, no save handler). */\n | 'unavailable'\n | 'unknown_method';\n\nexport interface BridgeError {\n code: ErrorCode;\n message?: string;\n}\n\n/* ── messages ──────────────────────────────────────────────────────────── */\n\nexport interface HelloMessage {\n type: typeof HELLO;\n v: number;\n}\n\nexport interface PortMessage {\n type: typeof PORT;\n v: number;\n}\n\nexport interface RequestMessage {\n id: number;\n method: Method;\n params?: unknown;\n}\n\nexport interface ResponseMessage {\n id: number;\n result?: unknown;\n error?: BridgeError;\n}\n\nexport interface EventMessage {\n event: HostEvent;\n data?: unknown;\n}\n\nexport interface HostRequestMessage {\n hid: number;\n request: HostRequest;\n params?: unknown;\n}\n\nexport interface HostResponseMessage {\n hid: number;\n result?: unknown;\n error?: BridgeError;\n}\n\n/* ── shapes ────────────────────────────────────────────────────────────── */\n\n/** The interface's look: `mode` and filex's own `--fe-*` custom properties. */\nexport interface Theme {\n mode: 'light' | 'dark';\n /** `--fe-bg`, `--fe-text`, `--fe-accent`… — the SDK sets them on `<html>`. */\n tokens: Record<string, string>;\n}\n\n/** One file the interface was opened with. Never a storage path. */\nexport interface FileInfo {\n index: number;\n name: string;\n /** Lower-case, no dot. */\n ext: string;\n size: number;\n mime: string;\n /** It cannot be saved over: read-only storage, view-only opening, or the\n * app holds no `files:write`. */\n readOnly: boolean;\n /**\n * 0.55.0: the file is end-to-end encrypted - `folder` in an encrypted\n * folder, `vault` in a vault (where only one person edits at a time),\n * `file` for a single encrypted file (`.fxe`). As the server says it;\n * ABSENT for a file that is not encrypted.\n *\n * ⚠ The server holds such a file only as ciphertext and has no key. Unless\n * `plaintext` is true, filex hands the app nothing of it: `file.read` is\n * refused (`failed` with `encrypted`), a save is refused, and `readOnly`\n * is true.\n */\n encrypted?: 'folder' | 'vault' | 'file';\n /**\n * 0.56.0: filex hands this encrypted file to the app in the clear. The\n * person's browser, which holds the folder key while the folder is\n * unlocked, decrypts it for `file.read` and encrypts what `file.save`\n * hands over before it leaves the browser; the server sees only\n * ciphertext. Only with `encrypted: 'folder'` or `'vault'`, for an app\n * whose manifest says `encrypted_folders: {\"open\": true}` (the grant\n * `files:e2e-plaintext`) - never for a `.fxe`. ABSENT otherwise.\n *\n * In a vault (`encrypted: 'vault'`) the browser reads the file from the\n * vault's packs and writes a save as the vault's next generation, under\n * the vault's write lock (one writer at a time). A save is refused -\n * `failed` with `vault_locked` - while somebody else writes the vault, and\n * with `vault_lock_lost` when this session's write lock ended before the\n * save was committed: nothing was written, save again. The vault locked\n * meanwhile (its keys left the browser): `unavailable`, and filex closes\n * the app.\n *\n * What changes for the app: a read and a save are held whole in the page\n * (at most LIMITS.maxEncryptedBytes); a save is written only over the file\n * as it was opened (`failed` with `changed` when it changed meanwhile);\n * `file.saveAs` answers `unavailable` (a copy elsewhere would leave the\n * folder decrypted); the app's module (`engine.call`, `job.submit`) is\n * never handed the file; `state.get` / `state.set` use a store of their\n * own for this folder, sealed in the person's browser (the server keeps it\n * and cannot read it; apart from the app's ordinary store, a little smaller\n * as it is measured sealed). Everything the app holds is plaintext: keep it\n * in the frame. In a folder whose names are encrypted too, `name` and\n * `ext` are the decrypted name's - the server never sees them.\n */\n plaintext?: true;\n}\n\n/** What `session.get` answers — what the app knows about where it runs. */\nexport interface Session {\n v: number;\n app: { name: string; version: string };\n view: { id: string; placement: 'modal' | 'page' | 'inspector' | 'home' | 'viewer' };\n /** The reader's language tag (`tr`, `pt-br`) and its direction. */\n locale: string;\n dir: 'ltr' | 'rtl';\n theme: Theme;\n /** The person's display name; never their e-mail, never a token. */\n user: { name: string };\n files: FileInfo[];\n /** The permissions this app was granted — so it can hide what it may not do. */\n grants: string[];\n /** The administrator's non-secret settings for this app (grant `settings`). */\n settings?: Record<string, string>;\n}\n\n/** `file.read` params. */\nexport interface ReadParams {\n index?: number;\n /** `stream` (a transferred ReadableStream<Uint8Array>), `bytes` (an\n * ArrayBuffer), or `text` (UTF-8, at most LIMITS.maxTextBytes). */\n as?: 'stream' | 'bytes' | 'text';\n}\n\nexport interface ReadResult {\n name: string;\n size: number;\n mime: string;\n stream?: ReadableStream<Uint8Array>;\n bytes?: ArrayBuffer;\n text?: string;\n}\n\n/** `file.save` params: the new content of the opened file. */\nexport interface SaveParams {\n index?: number;\n /** A transferred ReadableStream<Uint8Array>, an ArrayBuffer or a string. */\n data: ReadableStream<Uint8Array> | ArrayBuffer | string;\n mime?: string;\n /**\n * 0.55.0, editing together: the last log entry (`CoEditEntry.seq`) the\n * saved document holds, so the session knows what is saved. Ignored\n * outside a session.\n */\n through?: number;\n}\n\nexport interface SaveResult {\n saved: true;\n size: number;\n}\n\n/**\n * `ui.download`: a file for the person's own disk. filex does it (a sandboxed\n * frame cannot download), with the app's `ui:download` grant, on a gesture in\n * the frame or the person's yes, never over LIMITS.maxDownloadBytes.\n */\nexport interface DownloadParams {\n /** The file name offered — a name, not a path. */\n name: string;\n /** A transferred ReadableStream<Uint8Array>, an ArrayBuffer or a string. */\n data: ReadableStream<Uint8Array> | ArrayBuffer | string;\n mime?: string;\n}\n\nexport interface DownloadResult {\n saved: true;\n size: number;\n}\n\n/**\n * `ui.print` (filex 0.55.0): a PDF for the browser's print dialog. A\n * sandboxed frame may not open that dialog (measured: Chromium ignores\n * `print()` there and says so, Firefox ignores it), so filex prints the PDF\n * from a page of its own, with the app's `ui:print` grant, never over\n * LIMITS.maxPrintBytes. filex asks every time, above the frame, and the\n * dialog opens only on the person's click on filex's Allow. The print dialog\n * can save the PDF too, so `ui:print` is the same kind of grant as\n * `ui:download`. filex 0.54 and older answer `unknown_method`.\n */\nexport interface PrintParams {\n /** The document's name, for the question filex may ask - a name, not a path. */\n name: string;\n /** The PDF: a transferred ArrayBuffer or ReadableStream<Uint8Array>. */\n data: ReadableStream<Uint8Array> | ArrayBuffer;\n /** `application/pdf` when given; nothing else is printed. */\n mime?: string;\n}\n\nexport interface PrintResult {\n /** The browser's print dialog was opened with the PDF. */\n printed: true;\n size: number;\n}\n\n/* ── editing together (coedit.*: filex 0.55 defined, 0.56 offers) ──────── */\n\n/** What a member appends: the editor's changes, a lock request, a release. */\nexport type CoEditKind = 'changes' | 'lock' | 'release';\n\n/** A member of the session. */\nexport interface CoEditMember {\n /** The relay's id for the connection. */\n client: string;\n /** An opaque id of the person, the same for each of their connections in\n * this session; never an account id. */\n user: string;\n /** The person's display name. */\n name: string;\n /** The editor's per-session user index: given once, never twice. */\n indexUser: number;\n /** May append changes (an editor role and the right to change the file). */\n canEdit: boolean;\n}\n\n/** `coedit.join` params: join (or start) the session of an opened file. */\nexport interface CoEditJoinParams {\n index?: number;\n}\n\n/**\n * What `coedit.join` answers: who the app is in the session and how far\n * the log has gone. The entries come as `coedit.entry` events once the app\n * subscribes (`coedit.subscribe` from 0 for the whole log). `unavailable`\n * when filex offers no editing together here (filex 0.55, editing together\n * turned off, an encrypted file, a vault): the app edits alone - the message\n * says why (`not_ready` after waiting for a session's creator, `encrypted`,\n * `nothing_to_watch` for a person who may only read the file while nobody\n * edits it, or the server's code: `coedit_off`, `coedit_build`,\n * `coedit_file_changed`).\n */\nexport interface CoEditHello {\n session: string;\n me: CoEditMember;\n /**\n * The last entry's seq when filex handed the session over (0: an empty\n * log). filex 0.56: the changes this browser kept from an earlier opening\n * of the file (a lost connection, a closed tab), which filex sends before\n * it answers, are in the log up to here - an editor that starts once it\n * has read this far opens with them.\n */\n head: number;\n /** The last `changes` entry's seq, as far as `head`. */\n changesHead: number;\n /** The last entry the file on the storage holds. */\n savedThrough: number;\n /**\n * filex 0.56: this member started the session. Before anybody else can\n * join it puts the base document - EXACTLY the bytes it opened - with\n * `coedit.blob.put('base', bytes)`. A member that did not start it opens\n * `coedit.blob.get('base')`, never the file: the file may hold a save of\n * the session already, and the log applies to the base.\n */\n created: boolean;\n}\n\n/** `coedit.dropped` event data (filex 0.56). */\nexport interface CoEditDropped {\n /** The last entry the host handed over. */\n from: number;\n /** `gone`: the membership ended (the session closed, or the page was away\n * too long) - join again. `broken`: an entry did not check out (missing,\n * moved, altered, replayed) - stop editing together. */\n reason: 'gone' | 'broken';\n}\n\n/** `coedit.subscribe` params: entries after `from` arrive as `coedit.entry`. */\nexport interface CoEditSubscribeParams {\n from: number;\n}\n\n/** `coedit.append` params: the host seals it and the relay places it. */\nexport interface CoEditAppendParams {\n kind: CoEditKind;\n body: unknown;\n}\n\nexport interface CoEditAppendResult {\n /** Where it landed; it comes back as a `coedit.entry` too. */\n seq: number;\n}\n\n/** `coedit.lease`: the changes lease (one writer at a time, one that has\n * seen every change). */\nexport interface CoEditLeaseParams {\n op: 'acquire' | 'release';\n /** The seq of the last `changes` entry the app applied. */\n changesSeen: number;\n}\n\nexport interface CoEditLeaseResult {\n granted: boolean;\n}\n\n/** `coedit.cursor` params: passed to the other members, not kept. The host\n * sends a few a second at most (the last one wins). */\nexport interface CoEditCursorParams {\n cursor: unknown;\n}\n\n/** `coedit.blob.put` / `coedit.blob.get`: the session's sealed blobs (the\n * base document, images added while editing). A name, not a path. */\nexport interface CoEditBlobPutParams {\n name: string;\n data: ArrayBuffer;\n}\n\nexport interface CoEditBlobGetParams {\n name: string;\n}\n\nexport interface CoEditBlobGetResult {\n name: string;\n bytes: ArrayBuffer;\n}\n\ninterface CoEditEntryBase {\n seq: number;\n /** The relay's time, Unix ms - the same for every member. */\n at: number;\n client: string;\n}\n\n/** One entry of the session's log, opened and checked by filex. */\nexport type CoEditEntry =\n | (CoEditEntryBase & { kind: CoEditKind; body: unknown })\n | (CoEditEntryBase & { kind: 'join'; member: CoEditMember })\n | (CoEditEntryBase & { kind: 'leave' })\n | (CoEditEntryBase & { kind: 'saved'; through: number });\n\n/** `coedit.cursor` event data. */\nexport interface CoEditCursor {\n client: string;\n cursor: unknown;\n}\n\n/**\n * `file.saveAs`: a NEW file, in a folder the person picks in filex's own\n * folder dialog (the one its Move to… uses), which opens in the opened\n * file's folder. Closing the dialog answers `cancelled`.\n */\nexport interface SaveAsParams {\n /** The file name — a name, not a path. */\n name: string;\n data: ReadableStream<Uint8Array> | ArrayBuffer | string;\n mime?: string;\n}\n\nexport interface SaveAsResult {\n saved: true;\n name: string;\n size: number;\n}\n\nexport interface ToastParams {\n text: string;\n tone?: 'info' | 'success' | 'warning' | 'error';\n}\n\nexport interface ConfirmParams {\n title?: string;\n text: string;\n confirm?: string;\n cancel?: string;\n danger?: boolean;\n}\n\n/**\n * `license.get` (filex 0.52.0): what the app's license says. A paid app is\n * installed from a store, which issues its license and is asked about it every\n * day; filex keeps the key and never hands it to the app. `status`:\n *\n * - `free`: not a paid app;\n * - `valid`: the store said valid at its last check;\n * - `grace`: valid, but the store could not be asked since - it holds until\n * the grace the store signed ends;\n * - anything else (`revoked`, `expired`, `invalid`, `seats_exhausted`,\n * `wrong_app`, `grace_expired`, `missing`, `unverified`): the app is held\n * and its interface is not served, so an app rarely reads one of these.\n *\n * `valid_until` / `updates_until` are there for a valid license (dates as\n * RFC 3339). Who holds the license is not the app's to read. filex 0.51.0\n * and older answer `unknown_method`.\n */\nexport interface LicenseInfo {\n status: string;\n valid_until?: string;\n updates_until?: string;\n}\n\n/** `engine.call`: the app's own module, `ui_call` export. */\nexport interface EngineCallParams {\n method: string;\n params?: unknown;\n}\n\n/** `job.submit`: queue one of the app's actions on the opened files. */\nexport interface JobSubmitParams {\n action: string;\n params?: Record<string, unknown>;\n}\n\n/* ── limits ────────────────────────────────────────────────────────────── */\n\nexport const LIMITS = {\n /** `file.read({as: 'text'})` refuses a file larger than this; read a stream. */\n maxTextBytes: 32 << 20,\n /** One `state.set` value, as JSON. */\n maxStateBytes: 8 << 10,\n /** A `ui.toast` / `ui.title` text, in characters. */\n maxLineChars: 300,\n /** How long the SDK waits for the host's port. */\n connectTimeoutMs: 10_000,\n /** The largest file `ui.download` hands the person (streamed or not). */\n maxDownloadBytes: 256 << 20,\n /**\n * The largest PDF `ui.print` prints (filex 0.55). Lower than a download:\n * a download can stream to disk, a print is held whole in the page (one\n * Blob) until the print dialog has read it, and a document worth printing\n * is far smaller.\n */\n maxPrintBytes: 64 << 20,\n /**\n * The largest file of an encrypted folder filex hands an app in the clear\n * or takes back from it (filex 0.56, `FileInfo.plaintext`): it is\n * decrypted and encrypted whole in the page, as filex's own explorer does\n * for a file of this size.\n */\n maxEncryptedBytes: 200 << 20,\n} as const;\n\n/* ── guards ────────────────────────────────────────────────────────────── */\n\nexport function isHello(d: unknown): d is HelloMessage {\n return !!d && typeof d === 'object' && (d as HelloMessage).type === HELLO && typeof (d as HelloMessage).v === 'number';\n}\n\nexport function isPortMessage(d: unknown): d is PortMessage {\n return !!d && typeof d === 'object' && (d as PortMessage).type === PORT;\n}\n\nexport function isRequest(d: unknown): d is RequestMessage {\n return (\n !!d &&\n typeof d === 'object' &&\n typeof (d as RequestMessage).id === 'number' &&\n typeof (d as RequestMessage).method === 'string'\n );\n}\n\nexport function isKnownMethod(m: string): m is Method {\n return (METHODS as readonly string[]).includes(m);\n}\n","/**\n * @brftech/filex-app-ui — the SDK an app's own interface bundles.\n *\n * ```js\n * import { connect } from '@brftech/filex-app-ui';\n *\n * const fx = await connect(); // the handshake with filex\n * const file = await fx.open(); // the file the app was opened with\n * editor.setValue(await file.text());\n * editor.onChange(() => fx.dirty(true));\n * fx.onSave(async () => editor.getValue()); // filex's Save, a draft's \"Save to disk\", Ctrl+S\n * ```\n *\n * No dependency, a few KB. The interface runs in a sandboxed frame with no\n * network and no storage of its own: every call below goes to filex over the\n * MessagePort the handshake set up, and filex decides it (docs/APP-PLUGINS.md\n * → \"An app's own interface\"). The protocol itself is `./protocol`.\n */\nimport {\n BRIDGE_VERSION,\n HELLO,\n LIMITS,\n isPortMessage,\n type BridgeError,\n type ConfirmParams,\n type ErrorCode,\n type FileInfo,\n type HostEvent,\n type HostRequestMessage,\n type Method,\n type ReadResult,\n type SaveAsResult,\n type SaveResult,\n type Session,\n type Theme,\n type ToastParams,\n type DownloadResult,\n type LicenseInfo,\n type PrintResult,\n type CoEditHello,\n type CoEditKind,\n type CoEditAppendResult,\n type CoEditLeaseResult,\n type CoEditBlobGetResult,\n} from './protocol';\n\nexport * from './protocol';\n\n/** A call filex refused, with the reason as a code. */\nexport class FilexError extends Error {\n readonly code: ErrorCode;\n constructor(err: BridgeError) {\n super(err.message || err.code);\n this.name = 'FilexError';\n this.code = err.code;\n }\n}\n\n/** Content the app hands to a save. */\nexport type SaveData = string | Blob | ArrayBuffer | ArrayBufferView | ReadableStream<Uint8Array>;\n\n/** One opened file, read the way the app needs it. */\nexport interface OpenedFile extends FileInfo {\n text(): Promise<string>;\n bytes(): Promise<ArrayBuffer>;\n stream(): Promise<ReadableStream<Uint8Array>>;\n /** Save new content over this file (a new version, or the draft it is). */\n save(data: SaveData, mime?: string): Promise<SaveResult>;\n}\n\nexport interface ConnectOptions {\n /** How long to wait for filex (default 10 s). */\n timeoutMs?: number;\n /**\n * Put filex's colours (`--fe-*`), `lang`, `dir` and `data-theme` on\n * `<html>`, and keep them current (default true).\n */\n applyTheme?: boolean;\n /** Ctrl/Cmd+S inside the interface runs the save handler (default true). */\n saveShortcut?: boolean;\n}\n\nexport interface FilexApp {\n readonly session: Session;\n /** The file at `index` (default the first) the interface was opened with. */\n open(index?: number): Promise<OpenedFile>;\n /**\n * Save new content over an opened file. `through` (filex 0.56, editing\n * together): the last log entry the saved document holds, so the session\n * knows what is saved.\n */\n save(data: SaveData, opts?: { index?: number; mime?: string; through?: number }): Promise<SaveResult>;\n /** Save a NEW file; filex asks the person where (its own folder picker). */\n saveAs(name: string, data: SaveData, mime?: string): Promise<SaveAsResult>;\n /** Unsaved changes, or not: filex asks before the person leaves them. */\n dirty(on: boolean): void;\n title(text: string): void;\n toast(text: string, tone?: ToastParams['tone']): void;\n confirm(opts: ConfirmParams | string): Promise<boolean>;\n /** Ask filex to close the interface (it asks first when there are changes). */\n close(): void;\n /** Put text on the clipboard (filex does it, the same way in every browser). */\n copy(text: string): Promise<void>;\n /**\n * Hand the person a file for their own disk (the app needs `ui.download`\n * in its manifest). Call it from a click or a key press: without one,\n * filex asks the person first. `cancelled` when they say no.\n */\n download(name: string, data: SaveData, mime?: string): Promise<DownloadResult>;\n /**\n * Print a PDF (filex 0.55; the app needs `ui.print` in its manifest): a\n * sandboxed frame may not open the print dialog, so filex prints it from\n * its own page. filex asks the person every time, and the dialog opens on\n * their click on filex's Allow; at most LIMITS.maxPrintBytes. filex 0.54\n * and older answer `unknown_method`, and a host whose print page may not\n * be shown `unavailable`: hand the PDF over with `download` then.\n */\n print(name: string, pdf: Blob | ArrayBuffer | ArrayBufferView | ReadableStream<Uint8Array>): Promise<PrintResult>;\n /**\n * Editing together (filex 0.56; the app's manifest says `\"co_edit\":\n * {\"open\": true}`, granted as `files:co-edit`). filex seals what the app appends, orders it\n * through its relay and hands every member the same entries in the same\n * order (`coedit.entry` events after `subscribe`); the app never holds a\n * key. `join` answering `unavailable` (filex 0.55, editing together off\n * here, an encrypted file) means: edit alone. The member that started the\n * session (`created`) puts the base document before anybody else may join.\n */\n coedit: {\n join(index?: number): Promise<CoEditHello>;\n subscribe(from: number): Promise<void>;\n append(kind: CoEditKind, body: unknown): Promise<CoEditAppendResult>;\n lease(op: 'acquire' | 'release', changesSeen: number): Promise<boolean>;\n cursor(cursor: unknown): void;\n putBlob(name: string, data: Blob | ArrayBuffer | ArrayBufferView): Promise<void>;\n getBlob(name: string): Promise<ArrayBuffer>;\n leave(): Promise<void>;\n };\n /** Call the app's own module (`ui_call` export) with the opened files. */\n call<T = unknown>(method: string, params?: unknown): Promise<T>;\n /** Queue one of the app's actions on the opened files; answers the op. */\n submit(action: string, params?: Record<string, unknown>): Promise<{ op: unknown }>;\n /**\n * This person's small store for this app (JSON values, 8 KiB each). While\n * the app holds a file of an encrypted folder in the clear\n * (`FileInfo.plaintext`, filex 0.56) it is another store, sealed in the\n * browser - apart from this one, and empty while the folder is locked.\n */\n state: {\n get<T = unknown>(key: string): Promise<T | undefined>;\n set(key: string, value: unknown): Promise<void>;\n };\n /**\n * The app's license (filex 0.52.0; a paid app installed from a store):\n * `{status: \"free\"}` for a free app, `{status: \"valid\", valid_until,\n * updates_until}` for a licensed one. Never the key, never the licensee.\n */\n license: {\n get(): Promise<LicenseInfo>;\n };\n /** Listen to filex. Returns the unsubscribe. */\n on(event: HostEvent, handler: (data: unknown) => void): () => void;\n /**\n * What to save when filex asks (its Save button, a draft's \"Save to disk\",\n * Ctrl+S): return the document and the SDK saves it over the opened file;\n * return nothing if the handler saved by itself.\n */\n onSave(handler: () => SaveData | void | Promise<SaveData | void>): () => void;\n /** Raw call, for a method this SDK has no helper for. */\n request<T = unknown>(method: Method, params?: unknown, transfer?: Transferable[]): Promise<T>;\n}\n\nlet connecting: Promise<FilexApp> | null = null;\n\n/**\n * The handshake. Resolves once filex answered with its port and the session;\n * rejects outside filex or when filex did not answer in time. Calling it again\n * answers the same connection.\n */\nexport function connect(opts: ConnectOptions = {}): Promise<FilexApp> {\n if (!connecting) connecting = open(opts);\n return connecting;\n}\n\nfunction open(opts: ConnectOptions): Promise<FilexApp> {\n return new Promise<FilexApp>((resolve, reject) => {\n if (typeof window === 'undefined' || window.parent === window) {\n reject(new FilexError({ code: 'unavailable', message: 'not running inside filex' }));\n return;\n }\n const parent = window.parent;\n // Where filex runs, when the browser says so (Chrome, Safari). A frame\n // drawn with referrerpolicy=\"no-referrer\" has no document.referrer, and\n // Firefox has no ancestorOrigins: then `source === parent` is the check,\n // which is the one that matters (only the parent window IS the parent).\n const expected = ancestorOrigin();\n const timer = setTimeout(() => {\n window.removeEventListener('message', onMessage);\n reject(new FilexError({ code: 'unavailable', message: 'filex did not answer' }));\n }, opts.timeoutMs ?? LIMITS.connectTimeoutMs);\n\n function onMessage(ev: MessageEvent) {\n if (ev.source !== parent || !isPortMessage(ev.data) || !ev.ports[0]) return;\n if (expected && ev.origin !== expected) return;\n window.removeEventListener('message', onMessage);\n clearTimeout(timer);\n const port = ev.ports[0];\n const app = bridge(port, opts);\n app\n .request<Session>('session.get')\n .then((session) => {\n (app as { session: Session }).session = session;\n if (opts.applyTheme !== false) {\n applyLook(session.theme, session.locale, session.dir);\n app.on('theme', (t) => applyLook(t as Theme));\n app.on('locale', (l) => {\n const v = l as { locale?: string; dir?: 'ltr' | 'rtl' };\n applyLook(undefined, v.locale, v.dir);\n });\n }\n resolve(app);\n })\n .catch(reject);\n }\n window.addEventListener('message', onMessage);\n parent.postMessage({ type: HELLO, v: BRIDGE_VERSION }, '*');\n });\n}\n\nfunction ancestorOrigin(): string {\n try {\n const list = (window.location as Location & { ancestorOrigins?: DOMStringList }).ancestorOrigins;\n if (list && list.length > 0 && list[0] !== 'null') return list[0];\n } catch {\n /* no such API */\n }\n return '';\n}\n\nfunction bridge(port: MessagePort, opts: ConnectOptions): FilexApp {\n let seq = 0;\n const pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: unknown) => void }>();\n const listeners = new Map<HostEvent, Set<(data: unknown) => void>>();\n let saveHandler: (() => SaveData | void | Promise<SaveData | void>) | null = null;\n\n port.onmessage = (ev: MessageEvent) => {\n const m = ev.data as Record<string, unknown> | null;\n if (!m || typeof m !== 'object') return;\n if (typeof m.id === 'number') {\n const p = pending.get(m.id);\n if (!p) return;\n pending.delete(m.id);\n if (m.error) p.reject(new FilexError(m.error as BridgeError));\n else p.resolve(m.result);\n return;\n }\n if (typeof m.hid === 'number') {\n void answerHost(m as unknown as HostRequestMessage);\n return;\n }\n if (typeof m.event === 'string') {\n for (const h of listeners.get(m.event as HostEvent) ?? []) {\n try {\n h(m.data);\n } catch (e) {\n console.error('[filex-app-ui] listener failed', e);\n }\n }\n }\n };\n\n async function answerHost(m: HostRequestMessage) {\n if (m.request === 'save') {\n if (!saveHandler) {\n port.postMessage({ hid: m.hid, error: { code: 'unavailable', message: 'this app has no save handler' } });\n return;\n }\n try {\n const res = await runSave();\n port.postMessage({ hid: m.hid, result: res ?? { saved: true } });\n } catch (e) {\n const err = e instanceof FilexError ? { code: e.code, message: e.message } : { code: 'failed', message: String((e as Error)?.message ?? e) };\n port.postMessage({ hid: m.hid, error: err });\n }\n return;\n }\n port.postMessage({ hid: m.hid, error: { code: 'unknown_method' } });\n }\n\n async function runSave(): Promise<SaveResult | null> {\n if (!saveHandler) return null;\n const data = await saveHandler();\n if (data === undefined || data === null) return null;\n return app.save(data as SaveData);\n }\n\n function request<T>(method: Method, params?: unknown, transfer: Transferable[] = []): Promise<T> {\n const id = ++seq;\n return new Promise<T>((resolve, reject) => {\n pending.set(id, { resolve: resolve as (v: unknown) => void, reject });\n try {\n port.postMessage({ id, method, params }, transfer);\n } catch (e) {\n pending.delete(id);\n reject(e);\n }\n });\n }\n\n function notify(method: Method, params?: unknown) {\n request(method, params).catch((e) => console.warn('[filex-app-ui]', method, e));\n }\n\n async function payload(data: SaveData): Promise<{ data: ReadableStream<Uint8Array> | ArrayBuffer | string; transfer: Transferable[] }> {\n if (typeof data === 'string') return { data, transfer: [] };\n if (data instanceof ArrayBuffer) return { data, transfer: [data] };\n if (ArrayBuffer.isView(data)) {\n const copy = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;\n return { data: copy, transfer: [copy] };\n }\n if (typeof Blob !== 'undefined' && data instanceof Blob) {\n const buf = await data.arrayBuffer();\n return { data: buf, transfer: [buf] };\n }\n if (typeof ReadableStream !== 'undefined' && data instanceof ReadableStream) {\n return { data, transfer: [data as unknown as Transferable] };\n }\n throw new FilexError({ code: 'invalid', message: 'save takes a string, a Blob, bytes or a ReadableStream' });\n }\n\n const app: FilexApp = {\n session: undefined as unknown as Session,\n async open(index = 0) {\n const info = app.session?.files?.[index];\n if (!info) throw new FilexError({ code: 'not_found', message: `no file at ${index}` });\n const read = <K extends keyof ReadResult>(as: 'stream' | 'bytes' | 'text', key: K) =>\n request<ReadResult>('file.read', { index, as }).then((r) => r[key] as NonNullable<ReadResult[K]>);\n return {\n ...info,\n text: () => read('text', 'text'),\n bytes: () => read('bytes', 'bytes'),\n stream: () => read('stream', 'stream'),\n save: (data: SaveData, mime?: string) => app.save(data, { index, mime }),\n };\n },\n async save(data, o = {}) {\n const p = await payload(data);\n const params: Record<string, unknown> = { index: o.index ?? 0, data: p.data, mime: o.mime };\n if (typeof o.through === 'number') params.through = o.through;\n return request<SaveResult>('file.save', params, p.transfer);\n },\n async saveAs(name, data, mime) {\n const p = await payload(data);\n return request<SaveAsResult>('file.saveAs', { name, data: p.data, mime }, p.transfer);\n },\n async download(name, data, mime) {\n const p = await payload(data);\n return request<DownloadResult>('ui.download', { name, data: p.data, mime }, p.transfer);\n },\n async print(name, pdf) {\n const p = await payload(pdf);\n return request<PrintResult>('ui.print', { name, data: p.data, mime: 'application/pdf' }, p.transfer);\n },\n coedit: {\n join(index = 0) {\n return request<CoEditHello>('coedit.join', { index });\n },\n subscribe(from: number) {\n return request<void>('coedit.subscribe', { from });\n },\n append(kind: CoEditKind, body: unknown) {\n return request<CoEditAppendResult>('coedit.append', { kind, body });\n },\n async lease(op: 'acquire' | 'release', changesSeen: number) {\n const r = await request<CoEditLeaseResult>('coedit.lease', { op, changesSeen });\n return !!r?.granted;\n },\n cursor(cursor: unknown) {\n notify('coedit.cursor', { cursor });\n },\n async putBlob(name: string, data: Blob | ArrayBuffer | ArrayBufferView) {\n const p = await payload(data);\n if (!(p.data instanceof ArrayBuffer)) throw new FilexError({ code: 'invalid', message: 'a blob is bytes' });\n await request<void>('coedit.blob.put', { name, data: p.data }, p.transfer);\n },\n async getBlob(name: string) {\n const r = await request<CoEditBlobGetResult>('coedit.blob.get', { name });\n return r.bytes;\n },\n async leave() {\n await request<void>('coedit.leave');\n },\n },\n dirty(on) {\n notify('ui.dirty', { dirty: !!on });\n },\n title(text) {\n notify('ui.title', { text: String(text).slice(0, LIMITS.maxLineChars) });\n },\n toast(text, tone) {\n notify('ui.toast', { text: String(text).slice(0, LIMITS.maxLineChars), tone });\n },\n confirm(o) {\n const params = typeof o === 'string' ? { text: o } : o;\n return request<boolean>('ui.confirm', params);\n },\n close() {\n notify('ui.close');\n },\n copy(text) {\n return request<void>('clipboard.write', { text: String(text) });\n },\n call<T>(method: string, params?: unknown) {\n return request<T>('engine.call', { method, params });\n },\n submit(action, params) {\n return request<{ op: unknown }>('job.submit', { action, params });\n },\n state: {\n get<T>(key: string) {\n return request<T | undefined>('state.get', { key });\n },\n set(key: string, value: unknown) {\n return request<void>('state.set', { key, value });\n },\n },\n license: {\n get() {\n return request<LicenseInfo>('license.get');\n },\n },\n on(event, handler) {\n let set = listeners.get(event);\n if (!set) listeners.set(event, (set = new Set()));\n set.add(handler);\n return () => set!.delete(handler);\n },\n onSave(handler) {\n saveHandler = handler;\n return () => {\n if (saveHandler === handler) saveHandler = null;\n };\n },\n request,\n };\n\n if (opts.saveShortcut !== false && typeof document !== 'undefined') {\n // ⚠ The keystroke is handled HERE and never forwarded: a frame that could\n // send key events to filex could press filex's own shortcuts. Ctrl+S runs\n // the app's own save handler, which saves through the bridge like any call.\n document.addEventListener(\n 'keydown',\n (e) => {\n if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 's' || e.key === 'S') && saveHandler) {\n e.preventDefault();\n runSave().catch((err) => app.toast(String((err as Error)?.message ?? err), 'error'));\n }\n },\n true,\n );\n }\n return app;\n}\n\n/** Paint filex's look onto the interface's own document. */\nfunction applyLook(theme?: Theme, locale?: string, dir?: 'ltr' | 'rtl') {\n if (typeof document === 'undefined') return;\n const root = document.documentElement;\n if (theme) {\n root.dataset.theme = theme.mode;\n root.style.colorScheme = theme.mode;\n for (const [k, v] of Object.entries(theme.tokens ?? {})) {\n if (/^--fe-[a-z0-9-]+$/.test(k) && typeof v === 'string') root.style.setProperty(k, v);\n }\n }\n if (locale) root.lang = locale;\n if (dir) root.dir = dir;\n}\n"],"names":["BRIDGE_VERSION","HELLO","PORT","METHODS","LIMITS","isHello","d","isPortMessage","isRequest","isKnownMethod","m","FilexError","err","connecting","connect","opts","open","resolve","reject","parent","expected","ancestorOrigin","timer","onMessage","ev","port","app","bridge","session","applyLook","t","l","v","list","seq","pending","listeners","saveHandler","p","answerHost","h","e","res","runSave","data","request","method","params","transfer","id","notify","payload","copy","buf","index","info","read","as","key","r","mime","o","name","pdf","from","kind","body","op","changesSeen","cursor","on","text","tone","action","value","event","handler","set","theme","locale","dir","root","k"],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -39,11 +39,22 @@ export declare interface CoEditCursor {
39
39
  cursor: unknown;
40
40
  }
41
41
 
42
- /** `coedit.cursor` params: passed to the other members, not kept. */
42
+ /** `coedit.cursor` params: passed to the other members, not kept. The host
43
+ * sends a few a second at most (the last one wins). */
43
44
  export declare interface CoEditCursorParams {
44
45
  cursor: unknown;
45
46
  }
46
47
 
48
+ /** `coedit.dropped` event data (filex 0.56). */
49
+ export declare interface CoEditDropped {
50
+ /** The last entry the host handed over. */
51
+ from: number;
52
+ /** `gone`: the membership ended (the session closed, or the page was away
53
+ * too long) - join again. `broken`: an entry did not check out (missing,
54
+ * moved, altered, replayed) - stop editing together. */
55
+ reason: 'gone' | 'broken';
56
+ }
57
+
47
58
  /** One entry of the session's log, opened and checked by filex. */
48
59
  export declare type CoEditEntry = (CoEditEntryBase & {
49
60
  kind: CoEditKind;
@@ -69,18 +80,36 @@ declare interface CoEditEntryBase {
69
80
  * What `coedit.join` answers: who the app is in the session and how far
70
81
  * the log has gone. The entries come as `coedit.entry` events once the app
71
82
  * subscribes (`coedit.subscribe` from 0 for the whole log). `unavailable`
72
- * when filex offers no editing together here (filex 0.55, a vault, a file
73
- * that cannot be shared): the app edits alone.
83
+ * when filex offers no editing together here (filex 0.55, editing together
84
+ * turned off, an encrypted file, a vault): the app edits alone - the message
85
+ * says why (`not_ready` after waiting for a session's creator, `encrypted`,
86
+ * `nothing_to_watch` for a person who may only read the file while nobody
87
+ * edits it, or the server's code: `coedit_off`, `coedit_build`,
88
+ * `coedit_file_changed`).
74
89
  */
75
90
  export declare interface CoEditHello {
76
91
  session: string;
77
92
  me: CoEditMember;
78
- /** The last entry's seq (0: an empty log). */
93
+ /**
94
+ * The last entry's seq when filex handed the session over (0: an empty
95
+ * log). filex 0.56: the changes this browser kept from an earlier opening
96
+ * of the file (a lost connection, a closed tab), which filex sends before
97
+ * it answers, are in the log up to here - an editor that starts once it
98
+ * has read this far opens with them.
99
+ */
79
100
  head: number;
80
- /** The last `changes` entry's seq. */
101
+ /** The last `changes` entry's seq, as far as `head`. */
81
102
  changesHead: number;
82
103
  /** The last entry the file on the storage holds. */
83
104
  savedThrough: number;
105
+ /**
106
+ * filex 0.56: this member started the session. Before anybody else can
107
+ * join it puts the base document - EXACTLY the bytes it opened - with
108
+ * `coedit.blob.put('base', bytes)`. A member that did not start it opens
109
+ * `coedit.blob.get('base')`, never the file: the file may hold a save of
110
+ * the session already, and the log applies to the base.
111
+ */
112
+ created: boolean;
84
113
  }
85
114
 
86
115
  /** `coedit.join` params: join (or start) the session of an opened file. */
@@ -215,23 +244,58 @@ export declare interface FileInfo {
215
244
  * `file` for a single encrypted file (`.fxe`). As the server says it;
216
245
  * ABSENT for a file that is not encrypted.
217
246
  *
218
- * ⚠ filex 0.55 decrypts nothing for an app: the server holds such a file
219
- * only as ciphertext and has no key, so `file.read` is refused
220
- * (`failed` with `encrypted`), a save is refused, and `readOnly` is true.
221
- * The explorer offers no app an encrypted file in the first place; the
222
- * field is information, not a promise of plaintext.
247
+ * ⚠ The server holds such a file only as ciphertext and has no key. Unless
248
+ * `plaintext` is true, filex hands the app nothing of it: `file.read` is
249
+ * refused (`failed` with `encrypted`), a save is refused, and `readOnly`
250
+ * is true.
223
251
  */
224
252
  encrypted?: 'folder' | 'vault' | 'file';
253
+ /**
254
+ * 0.56.0: filex hands this encrypted file to the app in the clear. The
255
+ * person's browser, which holds the folder key while the folder is
256
+ * unlocked, decrypts it for `file.read` and encrypts what `file.save`
257
+ * hands over before it leaves the browser; the server sees only
258
+ * ciphertext. Only with `encrypted: 'folder'` or `'vault'`, for an app
259
+ * whose manifest says `encrypted_folders: {"open": true}` (the grant
260
+ * `files:e2e-plaintext`) - never for a `.fxe`. ABSENT otherwise.
261
+ *
262
+ * In a vault (`encrypted: 'vault'`) the browser reads the file from the
263
+ * vault's packs and writes a save as the vault's next generation, under
264
+ * the vault's write lock (one writer at a time). A save is refused -
265
+ * `failed` with `vault_locked` - while somebody else writes the vault, and
266
+ * with `vault_lock_lost` when this session's write lock ended before the
267
+ * save was committed: nothing was written, save again. The vault locked
268
+ * meanwhile (its keys left the browser): `unavailable`, and filex closes
269
+ * the app.
270
+ *
271
+ * What changes for the app: a read and a save are held whole in the page
272
+ * (at most LIMITS.maxEncryptedBytes); a save is written only over the file
273
+ * as it was opened (`failed` with `changed` when it changed meanwhile);
274
+ * `file.saveAs` answers `unavailable` (a copy elsewhere would leave the
275
+ * folder decrypted); the app's module (`engine.call`, `job.submit`) is
276
+ * never handed the file; `state.get` / `state.set` use a store of their
277
+ * own for this folder, sealed in the person's browser (the server keeps it
278
+ * and cannot read it; apart from the app's ordinary store, a little smaller
279
+ * as it is measured sealed). Everything the app holds is plaintext: keep it
280
+ * in the frame. In a folder whose names are encrypted too, `name` and
281
+ * `ext` are the decrypted name's - the server never sees them.
282
+ */
283
+ plaintext?: true;
225
284
  }
226
285
 
227
286
  export declare interface FilexApp {
228
287
  readonly session: Session;
229
288
  /** The file at `index` (default the first) the interface was opened with. */
230
289
  open(index?: number): Promise<OpenedFile>;
231
- /** Save new content over an opened file. */
290
+ /**
291
+ * Save new content over an opened file. `through` (filex 0.56, editing
292
+ * together): the last log entry the saved document holds, so the session
293
+ * knows what is saved.
294
+ */
232
295
  save(data: SaveData, opts?: {
233
296
  index?: number;
234
297
  mime?: string;
298
+ through?: number;
235
299
  }): Promise<SaveResult>;
236
300
  /** Save a NEW file; filex asks the person where (its own folder picker). */
237
301
  saveAs(name: string, data: SaveData, mime?: string): Promise<SaveAsResult>;
@@ -260,11 +324,13 @@ export declare interface FilexApp {
260
324
  */
261
325
  print(name: string, pdf: Blob | ArrayBuffer | ArrayBufferView | ReadableStream<Uint8Array>): Promise<PrintResult>;
262
326
  /**
263
- * Editing together (filex 0.55: defined, not offered - every call answers
264
- * `unavailable`, and the app edits alone). filex seals what the app
265
- * appends, orders it through its relay and hands every member the same
266
- * entries in the same order (`coedit.entry` events after `subscribe`);
267
- * the app never holds a key.
327
+ * Editing together (filex 0.56; the app's manifest says `"co_edit":
328
+ * {"open": true}`, granted as `files:co-edit`). filex seals what the app appends, orders it
329
+ * through its relay and hands every member the same entries in the same
330
+ * order (`coedit.entry` events after `subscribe`); the app never holds a
331
+ * key. `join` answering `unavailable` (filex 0.55, editing together off
332
+ * here, an encrypted file) means: edit alone. The member that started the
333
+ * session (`created`) puts the base document before anybody else may join.
268
334
  */
269
335
  coedit: {
270
336
  join(index?: number): Promise<CoEditHello>;
@@ -282,7 +348,12 @@ export declare interface FilexApp {
282
348
  submit(action: string, params?: Record<string, unknown>): Promise<{
283
349
  op: unknown;
284
350
  }>;
285
- /** This person's small store for this app (JSON values, 8 KiB each). */
351
+ /**
352
+ * This person's small store for this app (JSON values, 8 KiB each). While
353
+ * the app holds a file of an encrypted folder in the clear
354
+ * (`FileInfo.plaintext`, filex 0.56) it is another store, sealed in the
355
+ * browser - apart from this one, and empty while the folder is locked.
356
+ */
286
357
  state: {
287
358
  get<T = unknown>(key: string): Promise<T | undefined>;
288
359
  set(key: string, value: unknown): Promise<void>;
@@ -338,9 +409,10 @@ export declare type HostEvent =
338
409
  /** Editing together: another member's cursor (not kept). `data: CoEditCursor`. */
339
410
  | 'coedit.cursor'
340
411
  /**
341
- * Editing together: the host stopped handing entries over (the app fell
342
- * behind, or the connection dropped). `data: {from}` - subscribe again
343
- * from the last entry the app holds.
412
+ * Editing together: the host stopped handing entries over. `data:
413
+ * CoEditDropped` - `gone`: the membership ended (join again); `broken`: an
414
+ * entry did not check out (stop editing together). A connection that drops
415
+ * for a moment is the host's own: it reconnects and says nothing.
344
416
  */
345
417
  | 'coedit.dropped';
346
418
 
@@ -417,6 +489,13 @@ export declare const LIMITS: {
417
489
  * is far smaller.
418
490
  */
419
491
  readonly maxPrintBytes: number;
492
+ /**
493
+ * The largest file of an encrypted folder filex hands an app in the clear
494
+ * or takes back from it (filex 0.56, `FileInfo.plaintext`): it is
495
+ * decrypted and encrypted whole in the page, as filex's own explorer does
496
+ * for a file of this size.
497
+ */
498
+ readonly maxEncryptedBytes: number;
420
499
  };
421
500
 
422
501
  /** What the app may ask the host. Anything else is `unknown_method`. */
@@ -426,10 +505,10 @@ export declare type Method = 'session.get' | 'file.read' | 'file.save' | 'file.s
426
505
  /** 0.55.0: filex prints a PDF the app hands it (`PrintParams`, grant `ui:print`). */
427
506
  | 'ui.print'
428
507
  /**
429
- * 0.55.0, editing together (`coedit.*`, task #189): the session's log the
430
- * host seals, orders through filex's relay and opens again. Defined so an
431
- * app can be written against them; filex 0.55 offers no relay yet, so each
432
- * answers `unavailable` - edit alone then (see `CoEditHello`).
508
+ * Editing together (`coedit.*`, task #189): the session's log the host
509
+ * seals, orders through filex's relay and opens again. Defined in filex
510
+ * 0.55, which answers each `unavailable`; offered from 0.56 to an app
511
+ * granted `files:co-edit` (see `CoEditHello`). `unavailable` = edit alone.
433
512
  */
434
513
  | 'coedit.join' | 'coedit.subscribe' | 'coedit.append' | 'coedit.lease' | 'coedit.cursor' | 'coedit.blob.put' | 'coedit.blob.get' | 'coedit.leave';
435
514
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brftech/filex-app-ui",
3
- "version": "0.55.0",
3
+ "version": "0.56.0",
4
4
  "description": "filex app interface SDK: the bridge between an app's own sandboxed interface and filex (open, save, dirty, call the app's module)",
5
5
  "type": "module",
6
6
  "main": "./dist/filex-app-ui.js",
package/src/index.ts CHANGED
@@ -84,8 +84,12 @@ export interface FilexApp {
84
84
  readonly session: Session;
85
85
  /** The file at `index` (default the first) the interface was opened with. */
86
86
  open(index?: number): Promise<OpenedFile>;
87
- /** Save new content over an opened file. */
88
- save(data: SaveData, opts?: { index?: number; mime?: string }): Promise<SaveResult>;
87
+ /**
88
+ * Save new content over an opened file. `through` (filex 0.56, editing
89
+ * together): the last log entry the saved document holds, so the session
90
+ * knows what is saved.
91
+ */
92
+ save(data: SaveData, opts?: { index?: number; mime?: string; through?: number }): Promise<SaveResult>;
89
93
  /** Save a NEW file; filex asks the person where (its own folder picker). */
90
94
  saveAs(name: string, data: SaveData, mime?: string): Promise<SaveAsResult>;
91
95
  /** Unsaved changes, or not: filex asks before the person leaves them. */
@@ -113,11 +117,13 @@ export interface FilexApp {
113
117
  */
114
118
  print(name: string, pdf: Blob | ArrayBuffer | ArrayBufferView | ReadableStream<Uint8Array>): Promise<PrintResult>;
115
119
  /**
116
- * Editing together (filex 0.55: defined, not offered - every call answers
117
- * `unavailable`, and the app edits alone). filex seals what the app
118
- * appends, orders it through its relay and hands every member the same
119
- * entries in the same order (`coedit.entry` events after `subscribe`);
120
- * the app never holds a key.
120
+ * Editing together (filex 0.56; the app's manifest says `"co_edit":
121
+ * {"open": true}`, granted as `files:co-edit`). filex seals what the app appends, orders it
122
+ * through its relay and hands every member the same entries in the same
123
+ * order (`coedit.entry` events after `subscribe`); the app never holds a
124
+ * key. `join` answering `unavailable` (filex 0.55, editing together off
125
+ * here, an encrypted file) means: edit alone. The member that started the
126
+ * session (`created`) puts the base document before anybody else may join.
121
127
  */
122
128
  coedit: {
123
129
  join(index?: number): Promise<CoEditHello>;
@@ -133,7 +139,12 @@ export interface FilexApp {
133
139
  call<T = unknown>(method: string, params?: unknown): Promise<T>;
134
140
  /** Queue one of the app's actions on the opened files; answers the op. */
135
141
  submit(action: string, params?: Record<string, unknown>): Promise<{ op: unknown }>;
136
- /** This person's small store for this app (JSON values, 8 KiB each). */
142
+ /**
143
+ * This person's small store for this app (JSON values, 8 KiB each). While
144
+ * the app holds a file of an encrypted folder in the clear
145
+ * (`FileInfo.plaintext`, filex 0.56) it is another store, sealed in the
146
+ * browser - apart from this one, and empty while the folder is locked.
147
+ */
137
148
  state: {
138
149
  get<T = unknown>(key: string): Promise<T | undefined>;
139
150
  set(key: string, value: unknown): Promise<void>;
@@ -333,7 +344,9 @@ function bridge(port: MessagePort, opts: ConnectOptions): FilexApp {
333
344
  },
334
345
  async save(data, o = {}) {
335
346
  const p = await payload(data);
336
- return request<SaveResult>('file.save', { index: o.index ?? 0, data: p.data, mime: o.mime }, p.transfer);
347
+ const params: Record<string, unknown> = { index: o.index ?? 0, data: p.data, mime: o.mime };
348
+ if (typeof o.through === 'number') params.through = o.through;
349
+ return request<SaveResult>('file.save', params, p.transfer);
337
350
  },
338
351
  async saveAs(name, data, mime) {
339
352
  const p = await payload(data);
package/src/protocol.ts CHANGED
@@ -56,10 +56,10 @@ export type Method =
56
56
  /** 0.55.0: filex prints a PDF the app hands it (`PrintParams`, grant `ui:print`). */
57
57
  | 'ui.print'
58
58
  /**
59
- * 0.55.0, editing together (`coedit.*`, task #189): the session's log the
60
- * host seals, orders through filex's relay and opens again. Defined so an
61
- * app can be written against them; filex 0.55 offers no relay yet, so each
62
- * answers `unavailable` - edit alone then (see `CoEditHello`).
59
+ * Editing together (`coedit.*`, task #189): the session's log the host
60
+ * seals, orders through filex's relay and opens again. Defined in filex
61
+ * 0.55, which answers each `unavailable`; offered from 0.56 to an app
62
+ * granted `files:co-edit` (see `CoEditHello`). `unavailable` = edit alone.
63
63
  */
64
64
  | 'coedit.join'
65
65
  | 'coedit.subscribe'
@@ -115,9 +115,10 @@ export type HostEvent =
115
115
  /** Editing together: another member's cursor (not kept). `data: CoEditCursor`. */
116
116
  | 'coedit.cursor'
117
117
  /**
118
- * Editing together: the host stopped handing entries over (the app fell
119
- * behind, or the connection dropped). `data: {from}` - subscribe again
120
- * from the last entry the app holds.
118
+ * Editing together: the host stopped handing entries over. `data:
119
+ * CoEditDropped` - `gone`: the membership ended (join again); `broken`: an
120
+ * entry did not check out (stop editing together). A connection that drops
121
+ * for a moment is the host's own: it reconnects and says nothing.
121
122
  */
122
123
  | 'coedit.dropped';
123
124
 
@@ -219,13 +220,43 @@ export interface FileInfo {
219
220
  * `file` for a single encrypted file (`.fxe`). As the server says it;
220
221
  * ABSENT for a file that is not encrypted.
221
222
  *
222
- * ⚠ filex 0.55 decrypts nothing for an app: the server holds such a file
223
- * only as ciphertext and has no key, so `file.read` is refused
224
- * (`failed` with `encrypted`), a save is refused, and `readOnly` is true.
225
- * The explorer offers no app an encrypted file in the first place; the
226
- * field is information, not a promise of plaintext.
223
+ * ⚠ The server holds such a file only as ciphertext and has no key. Unless
224
+ * `plaintext` is true, filex hands the app nothing of it: `file.read` is
225
+ * refused (`failed` with `encrypted`), a save is refused, and `readOnly`
226
+ * is true.
227
227
  */
228
228
  encrypted?: 'folder' | 'vault' | 'file';
229
+ /**
230
+ * 0.56.0: filex hands this encrypted file to the app in the clear. The
231
+ * person's browser, which holds the folder key while the folder is
232
+ * unlocked, decrypts it for `file.read` and encrypts what `file.save`
233
+ * hands over before it leaves the browser; the server sees only
234
+ * ciphertext. Only with `encrypted: 'folder'` or `'vault'`, for an app
235
+ * whose manifest says `encrypted_folders: {"open": true}` (the grant
236
+ * `files:e2e-plaintext`) - never for a `.fxe`. ABSENT otherwise.
237
+ *
238
+ * In a vault (`encrypted: 'vault'`) the browser reads the file from the
239
+ * vault's packs and writes a save as the vault's next generation, under
240
+ * the vault's write lock (one writer at a time). A save is refused -
241
+ * `failed` with `vault_locked` - while somebody else writes the vault, and
242
+ * with `vault_lock_lost` when this session's write lock ended before the
243
+ * save was committed: nothing was written, save again. The vault locked
244
+ * meanwhile (its keys left the browser): `unavailable`, and filex closes
245
+ * the app.
246
+ *
247
+ * What changes for the app: a read and a save are held whole in the page
248
+ * (at most LIMITS.maxEncryptedBytes); a save is written only over the file
249
+ * as it was opened (`failed` with `changed` when it changed meanwhile);
250
+ * `file.saveAs` answers `unavailable` (a copy elsewhere would leave the
251
+ * folder decrypted); the app's module (`engine.call`, `job.submit`) is
252
+ * never handed the file; `state.get` / `state.set` use a store of their
253
+ * own for this folder, sealed in the person's browser (the server keeps it
254
+ * and cannot read it; apart from the app's ordinary store, a little smaller
255
+ * as it is measured sealed). Everything the app holds is plaintext: keep it
256
+ * in the frame. In a folder whose names are encrypted too, `name` and
257
+ * `ext` are the decrypted name's - the server never sees them.
258
+ */
259
+ plaintext?: true;
229
260
  }
230
261
 
231
262
  /** What `session.get` answers — what the app knows about where it runs. */
@@ -325,7 +356,7 @@ export interface PrintResult {
325
356
  size: number;
326
357
  }
327
358
 
328
- /* ── editing together (coedit.*, filex 0.55.0: defined, not offered) ────── */
359
+ /* ── editing together (coedit.*: filex 0.55 defined, 0.56 offers) ──────── */
329
360
 
330
361
  /** What a member appends: the editor's changes, a lock request, a release. */
331
362
  export type CoEditKind = 'changes' | 'lock' | 'release';
@@ -354,18 +385,46 @@ export interface CoEditJoinParams {
354
385
  * What `coedit.join` answers: who the app is in the session and how far
355
386
  * the log has gone. The entries come as `coedit.entry` events once the app
356
387
  * subscribes (`coedit.subscribe` from 0 for the whole log). `unavailable`
357
- * when filex offers no editing together here (filex 0.55, a vault, a file
358
- * that cannot be shared): the app edits alone.
388
+ * when filex offers no editing together here (filex 0.55, editing together
389
+ * turned off, an encrypted file, a vault): the app edits alone - the message
390
+ * says why (`not_ready` after waiting for a session's creator, `encrypted`,
391
+ * `nothing_to_watch` for a person who may only read the file while nobody
392
+ * edits it, or the server's code: `coedit_off`, `coedit_build`,
393
+ * `coedit_file_changed`).
359
394
  */
360
395
  export interface CoEditHello {
361
396
  session: string;
362
397
  me: CoEditMember;
363
- /** The last entry's seq (0: an empty log). */
398
+ /**
399
+ * The last entry's seq when filex handed the session over (0: an empty
400
+ * log). filex 0.56: the changes this browser kept from an earlier opening
401
+ * of the file (a lost connection, a closed tab), which filex sends before
402
+ * it answers, are in the log up to here - an editor that starts once it
403
+ * has read this far opens with them.
404
+ */
364
405
  head: number;
365
- /** The last `changes` entry's seq. */
406
+ /** The last `changes` entry's seq, as far as `head`. */
366
407
  changesHead: number;
367
408
  /** The last entry the file on the storage holds. */
368
409
  savedThrough: number;
410
+ /**
411
+ * filex 0.56: this member started the session. Before anybody else can
412
+ * join it puts the base document - EXACTLY the bytes it opened - with
413
+ * `coedit.blob.put('base', bytes)`. A member that did not start it opens
414
+ * `coedit.blob.get('base')`, never the file: the file may hold a save of
415
+ * the session already, and the log applies to the base.
416
+ */
417
+ created: boolean;
418
+ }
419
+
420
+ /** `coedit.dropped` event data (filex 0.56). */
421
+ export interface CoEditDropped {
422
+ /** The last entry the host handed over. */
423
+ from: number;
424
+ /** `gone`: the membership ended (the session closed, or the page was away
425
+ * too long) - join again. `broken`: an entry did not check out (missing,
426
+ * moved, altered, replayed) - stop editing together. */
427
+ reason: 'gone' | 'broken';
369
428
  }
370
429
 
371
430
  /** `coedit.subscribe` params: entries after `from` arrive as `coedit.entry`. */
@@ -396,7 +455,8 @@ export interface CoEditLeaseResult {
396
455
  granted: boolean;
397
456
  }
398
457
 
399
- /** `coedit.cursor` params: passed to the other members, not kept. */
458
+ /** `coedit.cursor` params: passed to the other members, not kept. The host
459
+ * sends a few a second at most (the last one wins). */
400
460
  export interface CoEditCursorParams {
401
461
  cursor: unknown;
402
462
  }
@@ -523,6 +583,13 @@ export const LIMITS = {
523
583
  * is far smaller.
524
584
  */
525
585
  maxPrintBytes: 64 << 20,
586
+ /**
587
+ * The largest file of an encrypted folder filex hands an app in the clear
588
+ * or takes back from it (filex 0.56, `FileInfo.plaintext`): it is
589
+ * decrypted and encrypted whole in the page, as filex's own explorer does
590
+ * for a file of this size.
591
+ */
592
+ maxEncryptedBytes: 200 << 20,
526
593
  } as const;
527
594
 
528
595
  /* ── guards ────────────────────────────────────────────────────────────── */