cozy-interapp¶
cozy-interapp implements Cozy intents. It lets an app (the client) ask another app (the service) to perform an action it does not have permissions for. The two apps communicate through an <iframe> or a WebView and postMessage and follow a postMessage intent protocol described below. This library helps build a client and a service. You can also implement the postMessage intent protocol yourself.
See cozy-stack for more details on Cozy intents.
See cozy-drive for an example intent.
Usage¶
Install¶
yarn add cozy-interapp
cozy-interapp does not depend on React. It needs a way to talk to the
stack: either a cozy-client instance or a fetch function managing authentication.
Initialization¶
With cozy-client:
import CozyClient from 'cozy-client'
import Intents from 'cozy-interapp'
const client = new CozyClient({ uri, token })
const intents = new Intents({ client })
Without cozy-client, pass a fetch function with the following signature:
import Intents from 'cozy-interapp'
const fetchJSON = async (method, path, body) => {
const res = await fetch(`${WORKSPACE_URL}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${TOKEN}`
},
body: body ? JSON.stringify(body) : undefined
})
if (!res.ok) throw new Error(await res.text())
return res.json()
}
const intents = new Intents({ fetch: fetchJSON })
See cozy-ui-plus for ready for use React components using cozy-interapp.
postMessage intent protocol overview¶
Roles¶
| Role | Where it runs | What it does |
|---|---|---|
| Client | The calling app (top window) | Creates the intent via the stack, inserts the iframe, listens for messages, resolves a promise with the result. |
| Service | The serving app (inside the iframe) | Receives intent data, renders UI, sends back done / cancel / error / resize / … messages. |
| Stack | cozy-stack |
POST /intents creates the intent, returns the list of matching services and availableApps. GET /intents/:id is used by the service to retrieve the intent. |
The stack returns the list of services that can handle the intent. The client picks one and loads its href in an iframe. The service page runs inside the iframe and talks back to its parent via postMessage.
Sequence diagram¶
sequenceDiagram
participant Client as Client (parent window)
participant Stack as cozy-stack
participant Service as Service (iframe)
Client->>Stack: POST /intents { action, type, data, permissions }
Stack-->>Client: intent { id, attributes.services[] }
Client->>Client: insert iframe service.href
Service->>Client: postMessage { type: "intent-{id}:ready" }
Client->>Service: postMessage(data) (raw intent data, no envelope)
Service->>Client: postMessage { type: "intent-{id}:readyToUse" }
Note over Client,Service: Intent is usable (optional messages can happen here)
Service->>Client: postMessage { type: "intent-{id}:done", document } OR
Service->>Client: postMessage { type: "intent-{id}:exposeFrameRemoval", document } OR
Service->>Client: postMessage { type: "intent-{id}:cancel" } OR
Service->>Client: postMessage { type: "intent-{id}:error", error }
Client->>Client: use and the result and remove iframe
Messages¶
All messages from service to client are objects with a type field of the form intent-{intentId}:{subtype}.
The service sends all those messages via serviceWindow.parent.postMessage(message, intent.attributes.client). The client must listen via window.addEventListener('message', ...) and filter by event.origin === serviceOrigin.
Errors are serialized: the service sends a plain object with message and name and the client can reconstruct a new Error(message) with the original properties copied back onto it.
Core messages¶
| Direction | type (or payload shape) |
Fields | Meaning |
|---|---|---|---|
| Service → Client | intent-{id}:ready |
— | Handshake. Client responds by posting the raw intent data back to the service. |
| Client → Service | (raw data, no envelope) |
whatever was passed to intents.create(action, type, data, ...) |
Sent immediately after ready. The service reads it via service.getData(). |
| Service → Client | intent-{id}:readyToUse |
— | The service’s UI is rendered and its initial data has loaded. Fires the client’s onReadyToUse callback. Sent at most once. |
| Client → Service | (raw doc, no envelope) |
the resulting document of the composed intent | Sent back after the composed intent resolves. The service’s compose(...) promise resolves with it. |
| Service → Client | intent-{id}:done |
document |
Successful termination. Client resolves the intent promise with document, removes the iframe. |
| Service → Client | intent-{id}:exposeFrameRemoval |
document |
Like done, but the client keeps the iframe in the DOM and returns { document, removeIntentIframe } so the caller can animate closing before removing the iframe. Only sent when the client passed exposeIntentFrameRemoval: true in the intent data. |
| Service → Client | intent-{id}:cancel |
— | The service cancels. Client resolves the intent promise with null, removes the iframe. Also sent automatically on the service’s unload event if the service was never terminated. |
| Service → Client | intent-{id}:error |
error |
Failure. Client rejects the intent promise with a deserialized Error. |
Optional messages¶
| Direction | type (or payload shape) |
Fields | Meaning |
|---|---|---|---|
| Service → Client | intent-{id}:resize |
dimensions: { width?, height?, maxWidth?, maxHeight?, element? }, transition?: string |
Resizes the element that holds the iframe. element is measured client-side by the service and converted to maxWidth / maxHeight. transition is applied as a CSS transition property. |
| Service → Client | intent-{id}:hideCross |
— | Ask the client to hide its close button. Fires onHideCross. |
| Service → Client | intent-{id}:showCross |
— | Ask the client to show its close button. Fires onShowCross. |
| Service → Client | intent-{id}:compose |
action, doctype, data |
Ask the client to start a nested intent. The client creates it, hides the current service iframe, runs the nested intent, then posts the resulting document back to the service via postMessage(doc, origin). |
API reference¶
intents.create()¶
intents.create(action, doctype [, data, permissions]) create an intent. It returns a modified Promise for the intent document, having a custom start(element) method. This method interacts with the DOM to append an iframe to the given HTML element. This iframe will provide an access to an app, which will serve a service page able to manage the intent action for the intent doctype. The start(element) method returns a promise for the result document provided by intent service.
startoptions: Thestartmethod takes an optional second argument, an options object:start(element, options). Supported keys:
onReady— called when the intent iframe has finished loading (iframeonload). Useful for running client code once the iframe is ready.onHideCross/onShowCross— called when the service asks the client to hide/show the close button.onReadyToUse— called when the service signals it is truly ready (UI rendered and initial data loaded), viaservice.notifyReadyToUse(). Distinct fromonReady, which only signals the iframe loaded. Seeintents.createService()below.
An intent has to be created everytime an app need to perform an action over a doctype for wich it does not have permission. For example, the Cozy Drive app should create an intent to pick a io.cozy.contacts document. The cozy-stack will determines which app can offer a service to resolve the intent. It’s this service’s URL that will be passed to the iframe src property.
Once the intent process is terminated by service, the iframe is removed from DOM.
You can also use .then to run some code after the intents is terminated like following:
Example¶
intents.create('CREATE', 'io.cozy.apps')
.start()
.then(intent => {
// do something with the result of the intent
// intent = {
// id: '1337',
// attributes: {...}
// }
})
Example to use removeIntentFrame() method (by passing the flag exposeIntentFrameRemoval flag):
intents.create('EDIT', 'io.cozy.apps')
.start(document.getElementById('intent-service-wrapper'))
.then({removeIntentFrame, doc} => { // after service.terminate(doc)
// Code to be run before removing the terminated intent iframe
removeIntentFrame()
// Other code, use doc
})
intents.createService()¶
intents.createService([intentId, window]) has to be used in the intent service page. It initializes communication with the parent window (remember: the service is supposed to be in an iframe).
If intentId and window parameters are not provided the method will try to retrieve them automatically.
It returns a service object, which provides the following methods :
compose(action, doctype, data): request the client to make a second intent. This returns a promise fulfilled with the second intent result.
// ...
const app = await service.compose('INSTALL', 'io.cozy.apps', { slug: 'drive' })
getData(): returns the data passed to the service by the client.getIntent(): returns the intentresizeClient(doc, transitionProperty): forces the size of the intent modale to a given width, maxWidth, height, maxHeight, or dimensions of a given element. The second optional argumenttransitionPropertycan be used to add a CSS transition property on the intent in order to ‘animate’ the resizing.
// resize the client ot 300 pixels max height
service.resizeClient({
maxHeight: 300
}, '.2s linear') // will be in css -> transition: .2s linear;
// or
service.resizeClient({
element: document.querySelector('.class')
})
On intent size: If an intent is used by multiple applications, we don’t use resizeClient(), since each application can have his own layout. You have to define the size of the intent in your application
-
terminate(doc): ends the intent process by passing to the client the resulting documentdoc. An intent service may only be terminated once.If a boolean
exposeIntentFrameRemovalis found astruein the data sent by the client, theterminate()method will return an object with as properties a function namedremoveIntentFrameto remove the iframe DOM node (in order to be run by the client later on) and the resulting documentdoc. This could be useful to animate an intent closing and remove the iframe node at the animation ending. -
cancel(): ends the intent process by passing anullvalue to the client. This method terminate the intent service the same way thatterminate(). -
throw(error): throw an error to client and causes the intent promise rejection. -
notifyReadyToUse(): tells the client the service UI is rendered and its initial data has loaded. A service may call this only once; a second call is a no-op with a warning. Throws if called afterterminate(). The client receives it via theonReadyToUseoption ofstart().
Example¶
intents.createService('77bcc42c-0fd8-11e7-ac95-8f605f6e8338', window)
.then(service => {
const data = service.getData()
// [...]
// Do stuff with data
// [...]
const resultingDoc = {
type: 'io.cozy.photos',
width: 100,
height: 100
}
service.terminate(resultingDoc)
})
intents.getRedirectionURL()¶
intents.getRedirectionURL(doctype, data) retrieves a redirection URL for a given doctype, with specified data. It relies internally on a regular intent mechanism, which creates an intent for the REDIRECT action. It then build the redirection URL from URL sent by the stack and returns it. This URL can be used as link href for example, to show the doctype or the document in an application able to handle it.
Example¶
const myFolder = {
folder: '4bce4649-e7b7-4226-d82e-6b87dbb684e7'
}
const url = await intents.getRedirectionURL('io.cozy.files', myFolder)
// url is http://domain-app.cozy.rocks/#/files?folder=4bce4649-e7b7-4226-d82e-6b87dbb684e7
intents.redirect()¶
intents.redirect(doctype, data) is based on intents.getRedirectionURL() and it redirects the browser to the retrieved URL.