mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-18 09:35:20 +02:00
167 lines
7.4 KiB
Markdown
167 lines
7.4 KiB
Markdown
|
# Web app
|
||
|
|
||
|
:::note
|
||
|
|
||
|
This document explains how the Joplin Web App works from a technical perspective.
|
||
|
|
||
|
:::
|
||
|
|
||
|
The Joplin Web App is a version of the mobile application that targets web using [`react-native-web`](https://www.npmjs.com/package/react-native-web). See [BUILD.md](../BUILD.md#web) for information about building thee web app.
|
||
|
|
||
|
|
||
|
## File system
|
||
|
|
||
|
On web, `shim.fsDriver` wraps [the Origin Private File System (OPFS)](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system). As of July 2024, some major browsers (Safari) only provide the synchronous versions of certain operations (e.g. `createSyncAccessHandle`). These synchronous operations can only be accessed in a web `Worker`.
|
||
|
|
||
|
|
||
|
```mermaid
|
||
|
flowchart LR
|
||
|
app["App logic"]
|
||
|
fsDriver["fsDriver.web"]
|
||
|
Worker["Worker"]
|
||
|
OPFS["OPFS and virtual files"]
|
||
|
|
||
|
app <--> fsDriver
|
||
|
fsDriver <--> Worker
|
||
|
Worker <--> OPFS
|
||
|
```
|
||
|
|
||
|
In addition to files stored persistently through the OPFS API, the web `fsDriver` also supports non-persistent virtual files and access to local user-specified directories.
|
||
|
|
||
|
|
||
|
### Virtual files
|
||
|
|
||
|
In some cases, it doesn't make sense to write temporary files to persistent storage. To support this, `fsDriver.web` supports creating read-only virtual files with `createReadOnlyVirtualFile`. These files are stored in local memory and can be accessed using most `fsDriver` methods.
|
||
|
|
||
|
|
||
|
### Local file access
|
||
|
|
||
|
In some browsers, it's possible to mount local directories using `fsDriver.mountExternalDirectory`. This method takes a handle returned by [`showDirectoryPicker`](https://developer.mozilla.org/en-US/docs/Web/API/Window/showDirectoryPicker), which, as of 2024, has [limited browser support](https://developer.mozilla.org/en-US/docs/Web/API/Window/showDirectoryPicker#browser_compatibility).
|
||
|
|
||
|
To persist access to external directories, file system handles are stored in [an `indexedDB` database](https://developer.mozilla.org/en-US/docs/Web/API/Window/indexedDB), which supports storing and loading file system handles.
|
||
|
|
||
|
External files are mounted as subdirectories of the virtual `/external/` directory.
|
||
|
|
||
|
|
||
|
## Database and cross-origin isolation
|
||
|
|
||
|
The web app uses [`@sqlite.org/sqlite-wasm`](https://github.com/sqlite/sqlite-wasm) for database access.
|
||
|
|
||
|
To function properly (likely due to use of `SharedArrayBuffer`), `@sqlite.org/sqlite-wasm` requires [cross origin isolation to be enabled](https://web.dev/articles/coop-coep). This is done adding certain HTTP headers to responses from the server.
|
||
|
|
||
|
As of July 2024, the official deployment of the Web App is hosted on GitHub pages, [which doesn't support serving with these headers](https://github.com/orgs/community/discussions/13309). The Web App works around this by providing these headers in a `ServiceWorker`.
|
||
|
|
||
|
The Web App's `ServiceWorker` is a heavily-modified fork of [the coi-serviceworker project](https://github.com/gzuidhof/coi-serviceworker), which enables cross-origin isolation.
|
||
|
|
||
|
|
||
|
## Single-instance lock
|
||
|
|
||
|
To prevent data corruption due to out-of-sync state between tabs, the Web App currently only allows one copy of the app to be open at a time.
|
||
|
|
||
|
This single-instance lock is enforced using a `ServiceWorker`. If a user attempts to load the Web App in a new tab, the `ServiceWorker`,
|
||
|
1. determines whether the request is for the Web App (see `handleRedirects` in `serviceWorker.ts`),
|
||
|
2. if so, intercepts the request,
|
||
|
3. checks for an already-running copy of the app, and
|
||
|
4. if a copy of the app is already running, returns an error page.
|
||
|
|
||
|
If the `ServiceWorker` fails to register (and cross-origin isolation is enabled by the server), it may be possible to bypass the `ServiceWorker`'s single-instance lock. As such, a secondary single-instance lock is also present. This lock attempts to communicate with other open Web Apps using a `BroadcastChannel`:
|
||
|
- This might succeed even if the original `ServiceWorker` failed to register.
|
||
|
- This check might incorrectly report that only one app is open if other apps are in a different tab and haven't been used recently.
|
||
|
|
||
|
|
||
|
## Offline support
|
||
|
|
||
|
The `ServiceWorker` intercepts and caches responses to requests for certain file types **from the same domain as the web client**. In the future, when equivalent requests fail, the `ServiceWorker` responds with a cached response.
|
||
|
|
||
|
|
||
|
## WebViews
|
||
|
|
||
|
On all platforms, WebViews that load a local file should use `ExtendedWebView`. This component uses a different WebView implementation on different platforms:
|
||
|
- Android and iOS use `react-native-webview`,
|
||
|
- Jest uses `JSDOM`-based mock, and
|
||
|
- Web uses a sandboxed `iframe`.
|
||
|
- As of July 2024, `react-native-webview` does not support web.
|
||
|
|
||
|
This section summarizes how `iframe`-based WebViews work on web.
|
||
|
|
||
|
|
||
|
### IPC
|
||
|
|
||
|
High-level communication can be done with `ExtendedWebView` using an `RNToWebViewMessenger` paired with a `WebViewToRNMessenger`. These `RemoteMessenger`s expose methods on JavaScript objects to content inside or outside a `WebView`.
|
||
|
|
||
|
Although `RNToWebViewMessenger` and `WebViewToRNMessenger` wrap `.postMessage` and `.onmessage` with a higher-level API, it's still possible to use the lower-level messaging API.
|
||
|
|
||
|
|
||
|
#### `.postMessage`
|
||
|
|
||
|
For compatibility with `react-native-webview`, `ExtendedWebView` exposes a `ReactNativeWebView` global object to content running within the `WebView`.
|
||
|
|
||
|
For example, the following syntax can be used to send some `message` to the `ExtendedWebView`'s `onMessage` handler.
|
||
|
```js
|
||
|
ReactNativeWebView.postMessage(message) // Calls onMessage
|
||
|
```
|
||
|
|
||
|
|
||
|
#### `window.onmessage`
|
||
|
|
||
|
Message sent to the WebView using `webviewRef.postMessage` are received by the global `onmessage` event. For example,
|
||
|
```tsx
|
||
|
// ...within some component
|
||
|
const webViewRef = useRef<WebViewControl>();
|
||
|
|
||
|
return (
|
||
|
<ExtendedWebView
|
||
|
webviewInstanceId='test-webview'
|
||
|
html={'some html here'}
|
||
|
injectedJavaScript={`
|
||
|
window.addEventListener('message', event => {
|
||
|
if (event.origin === 'react-native') {
|
||
|
const messageData = event.data;
|
||
|
// ...use event.data...
|
||
|
}
|
||
|
});
|
||
|
`}
|
||
|
ref={webViewRef}
|
||
|
onLoadEnd={() => webViewRef.current.postMessage('test')}
|
||
|
/>
|
||
|
)
|
||
|
```
|
||
|
|
||
|
|
||
|
## Note viewer
|
||
|
|
||
|
As on Android and iOS, the `NoteBodyViewer` uses an `ExtendedWebView` to render and display notes. However, because the Web App uses a virtual file system, extra processing is needed to transfer resources to the WebView.
|
||
|
|
||
|
For resources, this process might look like this:
|
||
|
```mermaid
|
||
|
flowchart TD
|
||
|
subgraph app
|
||
|
resources["Attached resource IDs"]
|
||
|
loadFiles("Load from fsDriver")
|
||
|
renderCall("Rerender")
|
||
|
|
||
|
resources-->loadFiles
|
||
|
end
|
||
|
|
||
|
subgraph NoteViewer
|
||
|
toBlobUrl("Convert to blob URL")
|
||
|
renderer("Renderer")
|
||
|
|
||
|
loadFiles--"setResourceFile(id, file)"-->toBlobUrl
|
||
|
resourcePathOverrides
|
||
|
toBlobUrl--"store in"-->resourcePathOverrides
|
||
|
|
||
|
resourcePathOverrides-->renderer
|
||
|
renderCall-->renderer
|
||
|
end
|
||
|
```
|
||
|
|
||
|
A similar process loads plugin assets (e.g. CSS and fonts used by rendered math).
|
||
|
|
||
|
|
||
|
## Incompatible libraries
|
||
|
|
||
|
Some libraries are incompatible with `react-native-web`. There are two or more ways to handle this:
|
||
|
1. Use the library only in an Android and iOS-only file. For example, if the library is used in `shareImage.ts`, create `shareImage.web.ts` with a web-only implementation. On web, the `.web.ts` extension is preferred to the `.ts` extension. On other platforms, this is not the case. As such, on web `shareImage.web.ts` will be imported, while on other platforms `shareImage.ts` will be.
|
||
|
2. Replace the incompatible library with an empty mock (see `web/webpack.config.js`). This can be useful if the library is `imported` by a code that is known to be unreachable on web.
|