diff --git a/example/webxdc/.gitignore b/example/webxdc/.gitignore new file mode 100644 index 0000000..97f3520 --- /dev/null +++ b/example/webxdc/.gitignore @@ -0,0 +1,2 @@ +*.xdc + diff --git a/example/webxdc/LICENSE b/example/webxdc/LICENSE new file mode 100644 index 0000000..fdddb29 --- /dev/null +++ b/example/webxdc/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/example/webxdc/README.md b/example/webxdc/README.md new file mode 100644 index 0000000..793abfe --- /dev/null +++ b/example/webxdc/README.md @@ -0,0 +1,74 @@ +# Hello + +Sample project with a simple implementation of the webxdc read and write APIs. + + +## Demo (no server or installation required) + +1. Open `index.html` in your web browser +2. Click 'Add Peer' to open as many peers as you like +3. Type a message and press 'Send' to see the update in each peer. (For Safari you might need to check the setting under Develop > Disable Local File Restrictions.) + + +## Developing webxdc apps + +Simply copy `webxdc.js` from this repo beside your `index.html` and you are ready to go +to **develop and test your app in most browsers.** + +Bundle your app using `./create-xdc.sh your-app-name` +and **send it to your friends** 🙂 + +Screenshot 2023-02-10 at 20 40 22 + + +## Further Hints and Troubleshooting + + +### Limitations + +Due to the nature of most browsers and how they scope `localStorage`, +each emulated peer will get the same `localStorage`. + +To really test the storage usage of your Webxdc, +bundle the app and test it in Delta Chat directly +where all peers get their own `localStorage`. +Alternatively, use the more advanced [webxdc-dev](https://github.com/webxdc/webxdc-dev) tool. + + +### Type-checking and completion + +If you are using VSCode you can have autocompletion and type-checking even without using TypeScript by adding these two lines to your JavaScript source files: + +```js +//@ts-check +/** @typedef {import('./webxdc').Webxdc} Webxdc */ +``` + +Without VSCode you need to install TypeScript and then run the check manually. + +```sh +npm -g typescript +tsc --noEmit --allowJs --lib es2016,dom webxdc.js # to check if types and simulator are in sync +tsc --noEmit --allowJs --lib es2016,dom your_js_file.js +``` + +### Developing in Safari + +To use the devtool in safari you need to disable the local file restrictions +under `Develop` -> `Disable Local File Restrictions`. + +After doing this you can use the dev tool simulator. + +Make sure to reload (`Cmd + R`) all simulator tabs/windows to apply this setting. +Without this option `Add Peer` seems to work (it opens a new instance), but **the instances will not be able to communicate**. + + +### Developing on Android + +- install Termux +- install Python and Git in Termux +- `git clone` the devtool repo or your fork of it +- use `python -m http.server` to serve it for development using nano/vim +- when you are done, use `./create-xdc.sh` for bundling +- copy the created `.xdc` file to a location from where you can access and send it via Delta Chat +- pro tip: you can create symbolic link to a folder in the external storage diff --git a/example/webxdc/create-xdc.sh b/example/webxdc/create-xdc.sh new file mode 100755 index 0000000..eeb26c8 --- /dev/null +++ b/example/webxdc/create-xdc.sh @@ -0,0 +1,27 @@ +#!/bin/sh + +case "$1" in + "-h" | "--help") + echo "usage: ${0##*/} [PACKAGE_NAME]" + exit + ;; + "") + PACKAGE_NAME=${PWD##*/} # '##*/' removes everything before the last slash and the last slash + ;; + *) + PACKAGE_NAME=${1%.xdc} # '%.xdc' removes the extension and allows PACKAGE_NAME to be given with or without extension + ;; +esac + +rm "$PACKAGE_NAME.xdc" 2> /dev/null +zip -9 --recurse-paths "$PACKAGE_NAME.xdc" --exclude LICENSE README.md webxdc.js "./*.sh" "./*.xdc" -- * + +echo "success, archive contents:" +unzip -l "$PACKAGE_NAME.xdc" + +# check package size +MAXSIZE=655360 +size=$(wc -c < "$PACKAGE_NAME.xdc") +if [ "$size" -ge $MAXSIZE ]; then + echo "WARNING: package size exceeded the limit ($size > $MAXSIZE)" +fi diff --git a/example/webxdc/icon.png b/example/webxdc/icon.png new file mode 100644 index 0000000..0a5ed8d Binary files /dev/null and b/example/webxdc/icon.png differ diff --git a/example/webxdc/index.html b/example/webxdc/index.html new file mode 100644 index 0000000..2db6a44 --- /dev/null +++ b/example/webxdc/index.html @@ -0,0 +1,67 @@ + + + + Hello + + + + + + +

Hello

+
+ + +
+

+

+ + + diff --git a/example/webxdc/manifest.toml b/example/webxdc/manifest.toml new file mode 100644 index 0000000..9f0f2ce --- /dev/null +++ b/example/webxdc/manifest.toml @@ -0,0 +1,2 @@ +name = "Hello" +source_code_url = "https://github.com/webxdc/hello" diff --git a/example/webxdc/webxdc.js b/example/webxdc/webxdc.js new file mode 100644 index 0000000..8fb5ee7 --- /dev/null +++ b/example/webxdc/webxdc.js @@ -0,0 +1,324 @@ +// This file originates from +// https://github.com/webxdc/hello/blob/master/webxdc.js +// It's a stub `webxdc.js` that adds a webxdc API stub for easy testing in +// browsers. In an actual webxdc environment (e.g. Delta Chat messenger) this +// file is not used and will automatically be replaced with a real one. +// See https://docs.webxdc.org/spec.html#webxdc-api +let ephemeralUpdateKey = "__xdcEphemeralUpdateKey__"; + +/** + * @typedef {import('./webxdc.d.ts').RealtimeListener} RT + * @type {RT} + */ +class RealtimeListener { + constructor() { + this.listener = null; + this.trashed = false; + } + + is_trashed() { + return this.trashed; + } + + receive(data) { + if (this.trashed) { + throw new Error("realtime listener is trashed and can no longer be used"); + } + if (this.listener) { + this.listener(data); + } + } + + setListener(listener) { + this.listener = listener; + } + + send(data) { + if (!data instanceof Uint8Array) { + throw new Error("realtime listener data must be a Uint8Array"); + } + window.localStorage.setItem( + ephemeralUpdateKey, + JSON.stringify([window.webxdc.selfAddr, Array.from(data), Date.now()]) // Date.now() is needed to trigger the event + ); + } + + leave() { + this.trashed = true; + } +} + +// debug friend: document.writeln(JSON.stringify(value)); +//@ts-check +/** @type {import('./webxdc').Webxdc} */ +window.webxdc = (() => { + var updateListener = (_) => {}; + /** + * @type {RT | null} + */ + var realtimeListener = null; + var updatesKey = "__xdcUpdatesKey__"; + window.addEventListener("storage", (event) => { + if (event.key == null) { + window.location.reload(); + } else if (event.key === updatesKey) { + var updates = JSON.parse(event.newValue); + var update = updates[updates.length - 1]; + update.max_serial = updates.length; + console.log("[Webxdc] " + JSON.stringify(update)); + updateListener(update); + } else if (event.key === ephemeralUpdateKey) { + var [sender, update] = JSON.parse(event.newValue); + if (window.webxdc.selfAddr !== sender && realtimeListener && !realtimeListener.is_trashed()) { + realtimeListener.receive( Uint8Array.from(update)); + } + } + }); + + function getUpdates() { + var updatesJSON = window.localStorage.getItem(updatesKey); + return updatesJSON ? JSON.parse(updatesJSON) : []; + } + + var params = new URLSearchParams(window.location.hash.substr(1)); + return { + selfAddr: params.get("addr") || "device0@local.host", + selfName: params.get("name") || "device0", + setUpdateListener: (cb, serial = 0) => { + var updates = getUpdates(); + var maxSerial = updates.length; + updates.forEach((update) => { + if (update.serial > serial) { + update.max_serial = maxSerial; + cb(update); + } + }); + updateListener = cb; + return Promise.resolve(); + }, + joinRealtimeChannel: (cb) => { + if (realtimeListener && realtimeListener.is_trashed()) { + return; + } + rt = new RealtimeListener(); + // mimic connection establishment time + setTimeout(() => realtimeListener = rt, 500); + return rt; + }, + getAllUpdates: () => { + console.log("[Webxdc] WARNING: getAllUpdates() is deprecated."); + return Promise.resolve([]); + }, + sendUpdate: (update, description) => { + var updates = getUpdates(); + var serial = updates.length + 1; + var _update = { + payload: update.payload, + summary: update.summary, + info: update.info, + document: update.document, + serial: serial, + }; + updates.push(_update); + window.localStorage.setItem(updatesKey, JSON.stringify(updates)); + _update.max_serial = serial; + console.log( + '[Webxdc] description="' + description + '", ' + JSON.stringify(_update) + ); + updateListener(_update); + }, + sendToChat: async (content) => { + if (!content.file && !content.text) { + alert("🚨 Error: either file or text need to be set. (or both)"); + return Promise.reject( + "Error from sendToChat: either file or text need to be set" + ); + } + + /** @type {(file: Blob) => Promise} */ + const blob_to_base64 = (file) => { + const data_start = ";base64,"; + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => { + /** @type {string} */ + //@ts-ignore + let data = reader.result; + resolve(data.slice(data.indexOf(data_start) + data_start.length)); + }; + reader.onerror = () => reject(reader.error); + }); + }; + + let base64Content; + if (content.file) { + if (!content.file.name) { + return Promise.reject("file name is missing"); + } + if ( + Object.keys(content.file).filter((key) => + ["blob", "base64", "plainText"].includes(key) + ).length > 1 + ) { + return Promise.reject( + "you can only set one of `blob`, `base64` or `plainText`, not multiple ones" + ); + } + + // @ts-ignore - needed because typescript imagines that blob would not exist + if (content.file.blob instanceof Blob) { + // @ts-ignore - needed because typescript imagines that blob would not exist + base64Content = await blob_to_base64(content.file.blob); + // @ts-ignore - needed because typescript imagines that base64 would not exist + } else if (typeof content.file.base64 === "string") { + // @ts-ignore - needed because typescript imagines that base64 would not exist + base64Content = content.file.base64; + // @ts-ignore - needed because typescript imagines that plainText would not exist + } else if (typeof content.file.plainText === "string") { + base64Content = await blob_to_base64( + // @ts-ignore - needed because typescript imagines that plainText would not exist + new Blob([content.file.plainText]) + ); + } else { + return Promise.reject( + "data is not set or wrong format, set one of `blob`, `base64` or `plainText`, see webxdc documentation for sendToChat" + ); + } + } + const msg = `The app would now close and the user would select a chat to send this message:\nText: ${ + content.text ? `"${content.text}"` : "No Text" + }\nFile: ${ + content.file + ? `${content.file.name} - ${base64Content.length} bytes` + : "No File" + }`; + if (content.file) { + const confirmed = confirm( + msg + "\n\nDownload the file in the browser instead?" + ); + if (confirmed) { + var element = document.createElement("a"); + element.setAttribute( + "href", + "data:application/octet-stream;base64," + base64Content + ); + element.setAttribute("download", content.file.name); + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); + } + } else { + alert(msg); + } + }, + importFiles: (filters) => { + var element = document.createElement("input"); + element.type = "file"; + element.accept = [ + ...(filters.extensions || []), + ...(filters.mimeTypes || []), + ].join(","); + element.multiple = filters.multiple || false; + const promise = new Promise((resolve, _reject) => { + element.onchange = (_ev) => { + console.log("element.files", element.files); + const files = Array.from(element.files || []); + document.body.removeChild(element); + resolve(files); + }; + }); + element.style.display = "none"; + document.body.appendChild(element); + element.click(); + console.log(element); + return promise; + }, + }; +})(); + +window.addXdcPeer = () => { + var loc = window.location; + // get next peer ID + var params = new URLSearchParams(loc.hash.substr(1)); + var peerId = Number(params.get("next_peer")) || 1; + + // open a new window + var peerName = "device" + peerId; + var url = + loc.protocol + + "//" + + loc.host + + loc.pathname + + "#name=" + + peerName + + "&addr=" + + peerName + + "@local.host"; + window.open(url); + + // update next peer ID + params.set("next_peer", String(peerId + 1)); + window.location.hash = "#" + params.toString(); +}; + +window.clearXdcStorage = () => { + window.localStorage.clear(); + window.location.reload(); +}; + +window.alterXdcApp = () => { + var styleControlPanel = + "position: fixed; bottom:1em; left:1em; background-color: #000; opacity:0.8; padding:.5em; font-size:16px; font-family: sans-serif; color:#fff; z-index: 9999"; + var styleMenuLink = + "color:#fff; text-decoration: none; vertical-align: middle"; + var styleAppIcon = + "height: 1.5em; width: 1.5em; margin-right: 0.5em; border-radius:10%; vertical-align: middle"; + var title = document.getElementsByTagName("title")[0]; + if (typeof title == "undefined") { + title = document.createElement("title"); + document.getElementsByTagName("head")[0].append(title); + } + title.innerText = window.webxdc.selfAddr; + + if (window.webxdc.selfName === "device0") { + var root = document.createElement("section"); + root.innerHTML = + '
' + + '
webxdc dev tools
' + + 'Add Peer' + + ' | ' + + 'Reset' + + "
"; + var controlPanel = root.firstChild; + + function loadIcon(name) { + var tester = new Image(); + tester.onload = () => { + root.innerHTML = + ''; + controlPanel.insertBefore(root.firstChild, controlPanel.childNodes[1]); + + var pageIcon = document.createElement("link"); + pageIcon.rel = "icon"; + pageIcon.href = name; + document.head.append(pageIcon); + }; + tester.src = name; + } + loadIcon("icon.png"); + loadIcon("icon.jpg"); + + document.getElementsByTagName("body")[0].append(controlPanel); + } +}; + +window.addEventListener("load", window.alterXdcApp);