From fc10b32b64d1a8824c221d97b93d570fd63d2b61 Mon Sep 17 00:00:00 2001 From: Claas Date: Sat, 6 Dec 2025 22:35:26 +0100 Subject: [PATCH] Port invite --- app/src/routes/index.tsx | 46 ++----------------- app/src/routes/invite.jsx | 12 ++--- app/src/service-worker/serviceWorker.ts | 61 ++++++++++++++++--------- app/src/useCrackle.ts | 18 ++++++++ core/src/lib.rs | 5 +- core/src/v2/serializable.rs | 53 +++++++++++++++++++-- 6 files changed, 115 insertions(+), 80 deletions(-) create mode 100644 app/src/useCrackle.ts diff --git a/app/src/routes/index.tsx b/app/src/routes/index.tsx index 73ff69f..6806d18 100644 --- a/app/src/routes/index.tsx +++ b/app/src/routes/index.tsx @@ -2,8 +2,7 @@ import { createEffect, createResource, For, Show } from "solid-js"; import Onboarding from "../components/Onboarding"; import { useAppContext } from "../components/AppContext"; import TopAppBar from "../components/TopAppBar"; -import { proxy } from "../crackle"; -import { type Handler } from "../service-worker/serviceWorker"; +import { setupCrackle } from "../useCrackle"; // https://devblogs.microsoft.com/typescript/announcing-typescript-5-5/#the-jsdoc-@import-tag /** @import { Signal, JSX, Accessor, ParentProps } from "solid-js" */ /** @import { Message } from "../components/AppContext" */ @@ -142,54 +141,15 @@ function ChatList() { ); } -/** - * @import { ComlinkExposed } from "../service-worker/serviceWorker" - */ -// async function initializeComlink() { -// const { port1, port2 } = new MessageChannel(); -// const message = { -// type: "initializeComlink", -// }; - -// const worker = await navigator.serviceWorker.ready; -// if (worker.active === null) throw new Error("No active service worker"); -// worker.active.postMessage(message, [port1]); -// const comlink = /** @type {Comlink.Remote} */ ( -// Comlink.wrap(port2) -// ); -// console.debug("Comlink", comlink.getIsOnboarded); -// const isOnboarded = await comlink.getIsOnboarded(); -// console.debug("Is onboarded?", isOnboarded); -// } - -async function initializeCrackle() { - const { port1, port2 } = new MessageChannel(); - const message = { - type: "initializeCrackle", - }; - - const worker = await navigator.serviceWorker.ready; - // Should not happen as we just awaited the ready promise - if (worker.active === null) throw new Error("No active service worker"); - worker.active.postMessage(message, [port1]); - const wrapper = await proxy(port2); - return wrapper; -} - -const crackled = initializeCrackle(); - export default function Index() { const [isOnboarded, { mutate: setIsOnboarded }] = createResource(async () => { - const crackle = await crackled; + const crackle = await setupCrackle; return await crackle.getIsOnboarded(); }); async function setName(name: string) { - console.debug("Setting name", name, crackled); - const crackle = await crackled; - console.debug("Setting name 2"); + const crackle = await setupCrackle; await crackle.completeOnboarding(name); - console.debug("Name set"); setIsOnboarded(true); } diff --git a/app/src/routes/invite.jsx b/app/src/routes/invite.jsx index d1fc5a5..d448021 100644 --- a/app/src/routes/invite.jsx +++ b/app/src/routes/invite.jsx @@ -3,7 +3,7 @@ import { useAppContext } from "../components/AppContext"; import { createEffect, createResource, createSignal, Show } from "solid-js"; //TODO replace with SVG QR-Code solution. Maybe with something custom import QRCode from "qrcode"; -import { sendRequest } from "../sendMessage"; +import { setupCrackle } from "../useCrackle"; /** @import { JSX, EffectFunction, VoidProps, Signal } from "solid-js" */ const FormElement = /** @type {const} */ ({ @@ -100,14 +100,8 @@ export default function Invite() { const [qrCodeFile, setQrCodeFile] = createSignal(); const [inviteUrl] = createResource(async () => { - /** @type {ServiceWorkerResponse} */ - const response = await sendRequest({ - type: "createInvite", - }); - if (response.type !== "inviteUrl") - throw new Error("Expected inviteUrl response"); - - return response.inviteUrl; + const crackle = await setupCrackle; + return await crackle.createInvite(); }); const shareData = () => { diff --git a/app/src/service-worker/serviceWorker.ts b/app/src/service-worker/serviceWorker.ts index 6ea7e5a..7fabb16 100644 --- a/app/src/service-worker/serviceWorker.ts +++ b/app/src/service-worker/serviceWorker.ts @@ -1,6 +1,6 @@ import { precacheAndRoute } from "workbox-precaching"; import { openDB } from "idb"; -import init, { create_client } from "meal-core"; +import init, { create_client, create_invite } from "meal-core"; import { expose } from "../crackle"; import { Schema } from "./schema"; @@ -45,22 +45,13 @@ async function getConfiguration(): Promise { return configuration; } -/** - * - * @returns {Promise} - */ -async function initializeClient() { - const configuration = await getConfiguration(); - // Have to use wasm-pack --target web to build the wasm package to get the init function because with the bundler target - // it is included as a top level await which is not supported by service workers according to the web spec - await init(); - +async function persistClient(client: Uint8Array) { // Persist client state const directory = await navigator.storage.getDirectory(); const FILE_NAME = "client.meal"; - /** @type {FileSystemFileHandle | undefined} */ - let fileHandle; + + let fileHandle: FileSystemFileHandle | undefined; try { fileHandle = await directory.getFileHandle(FILE_NAME); } catch (error) { @@ -73,14 +64,9 @@ async function initializeClient() { if (fileHandle !== undefined) { const file = await fileHandle.getFile(); - return await file.arrayBuffer(); + return new Uint8Array(await file.arrayBuffer()); } - const client = create_client( - configuration.clientId, - configuration.user?.name - ); - fileHandle = await directory.getFileHandle(FILE_NAME, { create: true, }); @@ -95,12 +81,29 @@ async function initializeClient() { writeStream.close(); } - console.debug("Created new client", client); + return client; +} + +async function initializeClient(): Promise { + const configuration = await getConfiguration(); + // Have to use wasm-pack --target web to build the wasm package to get the init function because with the bundler target + // it is included as a top level await which is not supported by service workers according to the web spec + await init(); - return client.buffer; + const client = create_client( + configuration.clientId, + configuration.user?.name + ); + + return await persistClient(client); } -const setupClient = initializeClient(); +let setupClient = initializeClient(); + +async function updateClient(client: Uint8Array) { + setupClient = persistClient(client); + await setupClient; +} /** The url for messages endpoint. Don't forget the trailing slash. */ const messagesUrl = new URL("/messages/", self.location.origin); @@ -120,6 +123,20 @@ const handler = { const cursor = await store.openCursor(); cursor?.update({ isOnboarded: true, name }); }, + + async createInvite() { + const client = await setupClient; + // The getter should clone the data so we can free the memory + const result = create_invite(client); + const { invite_payload, client: newClient } = result; + result.free(); + + // Persisting the client does not block us from responding + void updateClient(newClient); + + const inviteUrl = new URL(`/join/${invite_payload}`, location.origin); + return inviteUrl.href; + }, }; export type Handler = typeof handler; diff --git a/app/src/useCrackle.ts b/app/src/useCrackle.ts new file mode 100644 index 0000000..22e0be0 --- /dev/null +++ b/app/src/useCrackle.ts @@ -0,0 +1,18 @@ +import { proxy } from "./crackle"; +import { Handler } from "./service-worker/serviceWorker"; + +async function initializeCrackle() { + const { port1, port2 } = new MessageChannel(); + const message = { + type: "initializeCrackle", + }; + + const worker = await navigator.serviceWorker.ready; + // Should not happen as we just awaited the ready promise + if (worker.active === null) throw new Error("No active service worker"); + worker.active.postMessage(message, [port1]); + const wrapper = await proxy(port2); + return wrapper; +} + +export const setupCrackle = initializeCrackle(); diff --git a/core/src/lib.rs b/core/src/lib.rs index 78bb53e..1b08ea7 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -72,7 +72,8 @@ enum Message { }, } -fn encode_application_id(mut id: String, name: &Option) -> Extensions { +fn encode_application_id(id: &str, name: &Option) -> Extensions { + let mut id = id.to_owned(); if let Some(name) = name { id.push_str(name); } @@ -158,7 +159,7 @@ impl Client { //TODO and remove things that do not change or where we use a default //TODO adding postcard as dependency yields 9-10% smaller serialized + base64 encoded key packages - let extensions = encode_application_id(self.id.clone(), &name); + let extensions = encode_application_id(&self.id, &name); // Add identifier to help users identify the origin of the key package / invitation // Details: https://www.rfc-editor.org/rfc/rfc9420.html#section-5.3.3 diff --git a/core/src/v2/serializable.rs b/core/src/v2/serializable.rs index ddfbfe6..56b15c5 100644 --- a/core/src/v2/serializable.rs +++ b/core/src/v2/serializable.rs @@ -7,13 +7,14 @@ use std::{collections::HashSet, rc::Rc}; +use base64::prelude::*; use nanoid::nanoid; use openmls::prelude::*; use openmls_basic_credential::SignatureKeyPair; use serde::{Deserialize, Serialize}; use wasm_bindgen::{JsError, prelude::wasm_bindgen}; -use crate::{CIPHERSUITE, ID_LENGTH, v2::provider::Provider}; +use crate::{CIPHERSUITE, ID_LENGTH, encode_application_id, v2::provider::Provider}; #[derive(Serialize, Deserialize)] struct User { @@ -29,6 +30,9 @@ struct Client { /// We only store the group ids because the groups themselves are not serializable. /// The group state can be retrieved from the storage provider using the group id. groups: HashSet, + + /// Need to be kept for later reference + key_packages: Vec, provider: Provider, } @@ -41,8 +45,8 @@ pub fn create_client(id: Option, name: Option) -> Result //TODO Basic credentials only for tests and demo let credential: Credential = BasicCredential::new(client_id.clone().into_bytes()).into(); - let signature_keys = SignatureKeyPair::new(CIPHERSUITE.signature_algorithm()).unwrap(); - signature_keys.store(provider.storage()).unwrap(); + let signature_keys = SignatureKeyPair::new(CIPHERSUITE.signature_algorithm())?; + signature_keys.store(provider.storage())?; let credential = CredentialWithKey { credential, @@ -59,8 +63,49 @@ pub fn create_client(id: Option, name: Option) -> Result id: client_id.into(), user, groups: HashSet::new(), + key_packages: Vec::new(), provider, }; - postcard::to_allocvec(&client).map_err(JsError::from) + Ok(postcard::to_allocvec(&client)?) +} + +#[wasm_bindgen(getter_with_clone)] +pub struct InviteResult { + pub client: Box<[u8]>, + pub invite_payload: String, +} + +#[wasm_bindgen] +pub fn create_invite(client: &[u8], user_name: Option) -> Result { + let mut client: Client = postcard::from_bytes(client)?; + + //TODO think about ways to reduce size of key package to generate smaller invite links + //TODO like using a non self describing serialization format and remove + //TODO and remove things that do not change or where we use a default + //TODO adding postcard as dependency yields 9-10% smaller serialized + base64 encoded key packages + + let extensions = encode_application_id(&client.id, &user_name); + + // Add identifier to help users identify the origin of the key package / invitation + // Details: https://www.rfc-editor.org/rfc/rfc9420.html#section-5.3.3 + + let bundle = KeyPackage::builder() + .key_package_extensions(extensions) + .build( + CIPHERSUITE, + &client.provider, + &client.user.signature_key, + client.user.credential.clone(), + )?; + + client.key_packages.push(bundle.key_package().clone()); + let client = postcard::to_allocvec(&client)?.into(); + // Using postcard reduces the size by around 40 bytes or 9-10% + // This might not be worth the dependency but we are using it for application messages anyways + let data = postcard::to_allocvec(bundle.key_package())?; + Ok(InviteResult { + client, + invite_payload: BASE64_URL_SAFE_NO_PAD.encode(data), + }) } -- 2.51.2