├── .gitignore ├── LICENSE ├── README.md ├── create-xdc.sh ├── icon.png ├── index.html ├── manifest.toml └── webxdc.js /.gitignore: -------------------------------------------------------------------------------- 1 | *.xdc 2 | 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hello 2 | 3 | Sample project with a simple implementation of the webxdc read and write APIs. 4 | 5 | 6 | ## Demo (no server or installation required) 7 | 8 | 1. Open `index.html` in your web browser 9 | 2. Click 'Add Peer' to open as many peers as you like 10 | 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.) 11 | 12 | 13 | ## Developing webxdc apps 14 | 15 | Simply copy `webxdc.js` from this repo beside your `index.html` and you are ready to go 16 | to **develop and test your app in most browsers.** 17 | 18 | Bundle your app using `./create-xdc.sh your-app-name` 19 | and **send it to your friends** 🙂 20 | 21 | Screenshot 2023-02-10 at 20 40 22 22 | 23 | 24 | ## Further Hints and Troubleshooting 25 | 26 | 27 | ### Limitations 28 | 29 | Due to the nature of most browsers and how they scope `localStorage`, 30 | each emulated peer will get the same `localStorage`. 31 | 32 | To really test the storage usage of your Webxdc, 33 | bundle the app and test it in Delta Chat directly 34 | where all peers get their own `localStorage`. 35 | Alternatively, use the more advanced [webxdc-dev](https://github.com/webxdc/webxdc-dev) tool. 36 | 37 | 38 | ### Type-checking and completion 39 | 40 | If you want to have type-checking and autocompletion you can use [@webxdc/types](https://github.com/webxdc/webxdc-types/) package. 41 | Refer to https://webxdc.org/docs/faq/typing.html and https://github.com/webxdc/webxdc-types/ README for the documentation on setting it up. 42 | 43 | 44 | ### Developing in Safari 45 | 46 | To use the devtool in safari you need to disable the local file restrictions 47 | under `Develop` -> `Disable Local File Restrictions`. 48 | 49 | After doing this you can use the dev tool simulator. 50 | 51 | Make sure to reload (`Cmd + R`) all simulator tabs/windows to apply this setting. 52 | Without this option `Add Peer` seems to work (it opens a new instance), but **the instances will not be able to communicate**. 53 | 54 | 55 | ### Developing on Android 56 | 57 | - install Termux 58 | - install Python and Git in Termux 59 | - `git clone` the devtool repo or your fork of it 60 | - use `python -m http.server` to serve it for development using nano/vim 61 | - when you are done, use `./create-xdc.sh` for bundling 62 | - copy the created `.xdc` file to a location from where you can access and send it via Delta Chat 63 | - pro tip: you can create symbolic link to a folder in the external storage 64 | -------------------------------------------------------------------------------- /create-xdc.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | case "$1" in 4 | "-h" | "--help") 5 | echo "usage: ${0##*/} [PACKAGE_NAME]" 6 | exit 7 | ;; 8 | "") 9 | PACKAGE_NAME=${PWD##*/} # '##*/' removes everything before the last slash and the last slash 10 | ;; 11 | *) 12 | PACKAGE_NAME=${1%.xdc} # '%.xdc' removes the extension and allows PACKAGE_NAME to be given with or without extension 13 | ;; 14 | esac 15 | 16 | rm "$PACKAGE_NAME.xdc" 2> /dev/null 17 | zip -9 --recurse-paths "$PACKAGE_NAME.xdc" --exclude LICENSE README.md webxdc.js webxdc.d.ts "./*.sh" "./*.xdc" -- * 18 | 19 | echo "success, archive contents:" 20 | unzip -l "$PACKAGE_NAME.xdc" 21 | 22 | # check package size 23 | MAXSIZE=655360 24 | size=$(wc -c < "$PACKAGE_NAME.xdc") 25 | if [ "$size" -ge $MAXSIZE ]; then 26 | echo "WARNING: package size exceeded the limit ($size > $MAXSIZE)" 27 | fi 28 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webxdc/hello/52e688b2975d80614e77db61c0ad97f480507d8b/icon.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Hello 5 | 6 | 7 | 8 | 13 | 14 | 15 |

Hello

16 |
17 | 18 | 19 |
20 |

21 |

22 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /manifest.toml: -------------------------------------------------------------------------------- 1 | name = "Hello" 2 | source_code_url = "https://github.com/webxdc/hello" 3 | -------------------------------------------------------------------------------- /webxdc.js: -------------------------------------------------------------------------------- 1 | // This file originates from 2 | // https://github.com/webxdc/vite-plugins/blob/main/src/webxdc.js 3 | // It's a stub `webxdc.js` that adds a webxdc API stub for easy testing in 4 | // browsers. In an actual webxdc environment (e.g. Delta Chat messenger) this 5 | // file is not used and will automatically be replaced with a real one. 6 | // See https://docs.webxdc.org/spec.html#webxdc-api 7 | 8 | // @ts-check 9 | /** @typedef {import('@webxdc/types/global')} */ 10 | 11 | /** @type {import('@webxdc/types').Webxdc} */ 12 | window.webxdc = (() => { 13 | function h(tag, attributes, ...children) { 14 | const element = document.createElement(tag); 15 | if (attributes) { 16 | Object.entries(attributes).forEach((entry) => { 17 | element.setAttribute(entry[0], entry[1]); 18 | }); 19 | } 20 | element.append(...children); 21 | return element; 22 | } 23 | 24 | let appIcon = undefined; 25 | async function getIcon() { 26 | if (appIcon) { 27 | return appIcon; 28 | } 29 | const img = new Image(); 30 | try { 31 | img.src = "icon.png"; 32 | await img.decode(); 33 | appIcon = "icon.png"; 34 | } catch (e) { 35 | img.src = "icon.jpg"; 36 | try { 37 | await img.decode(); 38 | appIcon = "icon.jpg"; 39 | } catch (e) {} 40 | } 41 | return appIcon; 42 | } 43 | getIcon(); 44 | 45 | let ephemeralUpdateKey = "__xdcEphemeralUpdateKey__"; 46 | 47 | /** 48 | * @typedef {import('@webxdc/types').RealtimeListener} RT 49 | * @type {RT} 50 | */ 51 | class RealtimeListener { 52 | constructor() { 53 | /** @private */ 54 | this.listener = null; 55 | /** @private */ 56 | this.trashed = false; 57 | } 58 | 59 | is_trashed() { 60 | return this.trashed; 61 | } 62 | 63 | receive(data) { 64 | if (this.trashed) { 65 | throw new Error( 66 | "realtime listener is trashed and can no longer be used", 67 | ); 68 | } 69 | if (this.listener) { 70 | this.listener(data); 71 | } 72 | } 73 | 74 | setListener(listener) { 75 | this.listener = listener; 76 | } 77 | 78 | send(data) { 79 | if (!(data instanceof Uint8Array)) { 80 | throw new Error("realtime listener data must be a Uint8Array"); 81 | } 82 | window.localStorage.setItem( 83 | ephemeralUpdateKey, 84 | JSON.stringify([window.webxdc.selfAddr, Array.from(data), Date.now()]), // Date.now() is needed to trigger the event 85 | ); 86 | } 87 | 88 | leave() { 89 | this.trashed = true; 90 | } 91 | } 92 | 93 | let updateListener = (_) => {}; 94 | /** 95 | * @type {RT | null} 96 | */ 97 | let realtimeListener = null; 98 | const updatesKey = "__xdcUpdatesKey__"; 99 | window.addEventListener("storage", (event) => { 100 | if (event.key == null) { 101 | window.location.reload(); 102 | } else if (event.key === updatesKey) { 103 | const updates = JSON.parse(event.newValue); 104 | const update = updates[updates.length - 1]; 105 | update.max_serial = updates.length; 106 | console.log("[Webxdc] " + JSON.stringify(update)); 107 | if (update.notify && update._sender !== window.webxdc.selfAddr) { 108 | if (update.notify[window.webxdc.selfAddr]) { 109 | sendNotification(update.notify[window.webxdc.selfAddr]); 110 | } else if (update.notify["*"]) { 111 | sendNotification(update.notify["*"]); 112 | } 113 | } 114 | updateListener(update); 115 | } else if (event.key === ephemeralUpdateKey) { 116 | const [sender, update] = JSON.parse(event.newValue); 117 | // @ts-ignore: is_trashed() is private 118 | if ( 119 | window.webxdc.selfAddr !== sender && 120 | realtimeListener && 121 | // @ts-ignore: is_trashed() is private 122 | !realtimeListener.is_trashed() 123 | ) { 124 | // @ts-ignore: receive() is private 125 | realtimeListener.receive(Uint8Array.from(update)); 126 | } 127 | } 128 | }); 129 | 130 | function getUpdates() { 131 | const updatesJSON = window.localStorage.getItem(updatesKey); 132 | return updatesJSON ? JSON.parse(updatesJSON) : []; 133 | } 134 | 135 | async function sendNotification(text) { 136 | console.log("[NOTIFICATION] " + text); 137 | 138 | const opts = { body: text, icon: await getIcon() }; 139 | const title = "To: " + window.webxdc.selfName; 140 | if (Notification.permission === "granted") { 141 | new Notification(title, opts); 142 | } else { 143 | Notification.requestPermission((permission) => { 144 | if (Notification.permission === "granted") { 145 | new Notification(title, opts); 146 | } 147 | }); 148 | } 149 | } 150 | 151 | function addXdcPeer() { 152 | const loc = window.location; 153 | // get next peer ID 154 | const params = new URLSearchParams(loc.hash.substr(1)); 155 | const peerId = Number(params.get("next_peer")) || 1; 156 | 157 | // open a new window 158 | const peerName = "device" + peerId; 159 | const url = 160 | loc.protocol + 161 | "//" + 162 | loc.host + 163 | loc.pathname + 164 | "#name=" + 165 | peerName + 166 | "&addr=" + 167 | peerName + 168 | "@local.host"; 169 | window.open(url); 170 | 171 | // update next peer ID 172 | params.set("next_peer", String(peerId + 1)); 173 | window.location.hash = "#" + params.toString(); 174 | } 175 | 176 | window.addEventListener("load", async () => { 177 | const styleControlPanel = 178 | "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"; 179 | const styleMenuLink = 180 | "color:#fff; text-decoration: none; vertical-align: middle"; 181 | const styleAppIcon = 182 | "height: 1.5em; width: 1.5em; margin-right: 0.5em; border-radius:10%; vertical-align: middle"; 183 | let title = document.getElementsByTagName("title")[0]; 184 | if (typeof title == "undefined") { 185 | title = h("title"); 186 | document.getElementsByTagName("head")[0].append(title); 187 | } 188 | title.innerText = window.webxdc.selfAddr; 189 | 190 | if (window.webxdc.selfName === "device0") { 191 | const addPeerBtn = h( 192 | "a", 193 | { href: "javascript:void(0);", style: styleMenuLink }, 194 | "Add Peer", 195 | ); 196 | addPeerBtn.onclick = () => addXdcPeer(); 197 | const resetBtn = h( 198 | "a", 199 | { href: "javascript:void(0);", style: styleMenuLink }, 200 | "Reset", 201 | ); 202 | resetBtn.onclick = () => { 203 | window.localStorage.clear(); 204 | window.location.reload(); 205 | }; 206 | const controlPanel = h( 207 | "div", 208 | { style: styleControlPanel }, 209 | h( 210 | "header", 211 | { style: "margin-bottom: 0.5em; font-size:12px;" }, 212 | "webxdc dev tools", 213 | ), 214 | addPeerBtn, 215 | h("span", { style: styleMenuLink }, " | "), 216 | resetBtn, 217 | ); 218 | 219 | const icon = await getIcon(); 220 | if (icon) { 221 | controlPanel.insertBefore( 222 | h("img", { src: icon, style: styleAppIcon }), 223 | controlPanel.childNodes[1], 224 | ); 225 | document.head.append(h("link", { rel: "icon", href: icon })); 226 | } 227 | 228 | document.getElementsByTagName("body")[0].append(controlPanel); 229 | } 230 | }); 231 | 232 | const params = new URLSearchParams(window.location.hash.substr(1)); 233 | return { 234 | sendUpdateInterval: 1000, 235 | sendUpdateMaxSize: 999999, 236 | selfAddr: params.get("addr") || "device0@local.host", 237 | selfName: params.get("name") || "device0", 238 | setUpdateListener: (cb, serial = 0) => { 239 | const updates = getUpdates(); 240 | const maxSerial = updates.length; 241 | updates.forEach((update) => { 242 | if (update.serial > serial) { 243 | update.max_serial = maxSerial; 244 | cb(update); 245 | } 246 | }); 247 | updateListener = cb; 248 | return Promise.resolve(); 249 | }, 250 | joinRealtimeChannel: (cb) => { 251 | // @ts-ignore: is_trashed() is private 252 | if (realtimeListener && realtimeListener.is_trashed()) { 253 | return; 254 | } 255 | const rt = new RealtimeListener(); 256 | // mimic connection establishment time 257 | setTimeout(() => (realtimeListener = rt), 500); 258 | return rt; 259 | }, 260 | getAllUpdates: () => { 261 | console.log("[Webxdc] WARNING: getAllUpdates() is deprecated."); 262 | return Promise.resolve([]); 263 | }, 264 | sendUpdate: (update) => { 265 | const updates = getUpdates(); 266 | const serial = updates.length + 1; 267 | const _update = { 268 | payload: update.payload, 269 | summary: update.summary, 270 | info: update.info, 271 | notify: update.notify, 272 | href: update.href, 273 | document: update.document, 274 | serial: serial, 275 | }; 276 | console.log(`[Webxdc] ${JSON.stringify(_update)}`); 277 | _update._sender = window.webxdc.selfAddr; 278 | updates.push(_update); 279 | window.localStorage.setItem(updatesKey, JSON.stringify(updates)); 280 | _update.max_serial = serial; 281 | updateListener(_update); 282 | }, 283 | sendToChat: async (content) => { 284 | if (!content.file && !content.text) { 285 | alert("🚨 Error: either file or text need to be set. (or both)"); 286 | return Promise.reject( 287 | "Error from sendToChat: either file or text need to be set", 288 | ); 289 | } 290 | 291 | /** @type {(file: Blob) => Promise} */ 292 | const blob_to_base64 = (file) => { 293 | const data_start = ";base64,"; 294 | return new Promise((resolve, reject) => { 295 | const reader = new FileReader(); 296 | reader.readAsDataURL(file); 297 | reader.onload = () => { 298 | /** @type {string} */ 299 | //@ts-ignore 300 | let data = reader.result; 301 | resolve(data.slice(data.indexOf(data_start) + data_start.length)); 302 | }; 303 | reader.onerror = () => reject(reader.error); 304 | }); 305 | }; 306 | 307 | let base64Content; 308 | if (content.file) { 309 | if (!content.file.name) { 310 | return Promise.reject("file name is missing"); 311 | } 312 | if ( 313 | Object.keys(content.file).filter((key) => 314 | ["blob", "base64", "plainText"].includes(key), 315 | ).length > 1 316 | ) { 317 | return Promise.reject( 318 | "you can only set one of `blob`, `base64` or `plainText`, not multiple ones", 319 | ); 320 | } 321 | 322 | // @ts-ignore - needed because typescript imagines that blob would not exist 323 | if (content.file.blob instanceof Blob) { 324 | // @ts-ignore - needed because typescript imagines that blob would not exist 325 | base64Content = await blob_to_base64(content.file.blob); 326 | // @ts-ignore - needed because typescript imagines that base64 would not exist 327 | } else if (typeof content.file.base64 === "string") { 328 | // @ts-ignore - needed because typescript imagines that base64 would not exist 329 | base64Content = content.file.base64; 330 | // @ts-ignore - needed because typescript imagines that plainText would not exist 331 | } else if (typeof content.file.plainText === "string") { 332 | base64Content = await blob_to_base64( 333 | // @ts-ignore - needed because typescript imagines that plainText would not exist 334 | new Blob([content.file.plainText]), 335 | ); 336 | } else { 337 | return Promise.reject( 338 | "data is not set or wrong format, set one of `blob`, `base64` or `plainText`, see webxdc documentation for sendToChat", 339 | ); 340 | } 341 | } 342 | const msg = `The app would now close and the user would select a chat to send this message:\nText: ${ 343 | content.text ? `"${content.text}"` : "No Text" 344 | }\nFile: ${ 345 | content.file 346 | ? `${content.file.name} - ${base64Content.length} bytes` 347 | : "No File" 348 | }`; 349 | if (content.file) { 350 | const confirmed = confirm( 351 | msg + "\n\nDownload the file in the browser instead?", 352 | ); 353 | if (confirmed) { 354 | const dataURL = 355 | "data:application/octet-stream;base64," + base64Content; 356 | const element = h("a", { 357 | href: dataURL, 358 | download: content.file.name, 359 | }); 360 | document.body.appendChild(element); 361 | element.click(); 362 | document.body.removeChild(element); 363 | } 364 | } else { 365 | alert(msg); 366 | } 367 | }, 368 | importFiles: (filters) => { 369 | const accept = [ 370 | ...(filters.extensions || []), 371 | ...(filters.mimeTypes || []), 372 | ].join(","); 373 | const element = h("input", { 374 | type: "file", 375 | accept, 376 | multiple: filters.multiple || false, 377 | }); 378 | const promise = new Promise((resolve, _reject) => { 379 | element.onchange = (_ev) => { 380 | console.log("element.files", element.files); 381 | const files = Array.from(element.files || []); 382 | document.body.removeChild(element); 383 | resolve(files); 384 | }; 385 | }); 386 | element.style.display = "none"; 387 | document.body.appendChild(element); 388 | element.click(); 389 | console.log(element); 390 | return promise; 391 | }, 392 | }; 393 | })(); 394 | --------------------------------------------------------------------------------