commit 95c80d04c9ee0f6bf27461173ec9150535e7c105 Author: Andy Kopra Date: Tue Oct 6 15:58:02 2026 +0200 Kokkoja: first public assembly Assembled from the development repository by tools/make_release.sh. Co-Authored-By: Claude Fable 5.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7046770 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Editor backups +*~ + +# PDFs are renderings of the Markdown in notes/ +*.pdf + +# Python +.venv/ +__pycache__/ + +# Development tools installed by npm (see package.json) +node_modules/ + +# Local Claude settings +.claude/settings.local.json +tests/downloads_* diff --git a/README.md b/README.md new file mode 100644 index 0000000..f237bfe --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Kokkoja + +Kokkoja lets people at different computers write together on a shared +page, by hand or by typing, with every page saved automatically to each +person's own notebook. It runs in a web browser beside a video call and +replaces the call's screen sharing and annotation. It was made first for +teaching a foreign language over a video call, and it serves any work +that two or more people would otherwise do over one sheet of paper. + +Kokkoja is free and open-source software. + +## Documentation + +* [An introduction](docs/introduction.md): what Kokkoja does and whom it + is for. +* [A guest's Kokkoja](docs/guest.md): joining a session from an + invitation, and the screen. +* [A host's Kokkoja](docs/host.md): installing the Kokkoja program, + starting a session, inviting guests, driving the session. +* [How Kokkoja works](docs/implementation.md): the parts, the encryption, + the page file, running a server. + +## In short + +A guest needs nothing but a web browser: open the invitation link, enter +a name, join. + +A host needs Python 3 and this directory: + + python3 kokkoja.py --relay wss://kokkoja.andykopra.com/relay + +opens Kokkoja in the browser; *Start a session* gives an invitation to +send to the guests. Notebooks are directories in `Kokkoja` in the +Documents directory. + +## What is here + +* `kokkoja.py`: the local program, which serves the client to your + browser and keeps your notebooks as files. Python standard library + only. +* `client/`: the client, plain JavaScript modules run by the browser as + written. +* `relay/`: the relay server, Python with the `websockets` library. +* `deploy/`: how a Kokkoja server is set up (relay plus web site). +* `docs/`: the documentation above. +* `tests/`: the tests (`python3 -m unittest discover tests`; the + end-to-end test needs Firefox and geckodriver). diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..dbd96d5 --- /dev/null +++ b/client/index.html @@ -0,0 +1,151 @@ + + + + + +Kokkoja + + + + + +
+

Kokkoja

+ + + + + + +

+ +
+

Start a session

+

You will be given an invitation to send to the others.

+ +
+ +
+

Join a session

+ + + +

+
+ + +
+ + + + +
+

Notebooks

+ +
+
+ +
+
+

+
+ + +
+

Image

+ +
+
+ + +
+
+ +
+
+ + +

+
+ + + diff --git a/client/js/browser_files.js b/client/js/browser_files.js new file mode 100644 index 0000000..5e93c86 --- /dev/null +++ b/client/js/browser_files.js @@ -0,0 +1,38 @@ +// @ts-check +/** + * Files without the local program: when Kokkoja is opened from a web + * site, an image file comes from the browser's own file chooser, and + * anything to be saved is a download. + */ + +/** + * Let the user choose an image file. + * @returns {Promise} Null if none was chosen. + */ +export function pickImageFile() { + return new Promise((resolve) => { + const picker = document.createElement('input'); + picker.type = 'file'; + picker.accept = 'image/*'; + picker.addEventListener('change', () => resolve(picker.files?.[0] ?? null)); + picker.addEventListener('cancel', () => resolve(null)); + picker.click(); + }); +} + +/** + * Give the user a file, through the browser's downloads. + * @param {string} name + * @param {Blob} content + */ +export function download(name, content) { + const address = URL.createObjectURL(content); + const link = document.createElement('a'); + link.href = address; + link.download = name; + document.body.append(link); + link.click(); + link.remove(); + // The browser has begun the download; the address can go a moment later. + setTimeout(() => URL.revokeObjectURL(address), 10000); +} diff --git a/client/js/browser_notebook.js b/client/js/browser_notebook.js new file mode 100644 index 0000000..2e5cf74 --- /dev/null +++ b/client/js/browser_notebook.js @@ -0,0 +1,154 @@ +// @ts-check +/** + * Notebooks kept in the browser, for Kokkoja opened from a web site with + * no local program on the computer. + * + * The notebooks follow the same rules as the directories the local program + * keeps (`notebook.js`): a notebook holds `Current.svg`, the page being + * written, and dated kept pages such as `2026_10_02_01.svg`. They live in + * the browser's IndexedDB for this site, which survives closing the + * browser but belongs to this browser on this computer alone; the way to + * take a page out is to download it. The identity is kept in the + * browser's localStorage in the same way. + */ + +const DATABASE = 'kokkoja'; +const PAGES = 'pages'; +const IDENTITY_KEY = 'kokkoja_identity'; +const CURRENT_PAGE = 'Current.svg'; +const KEPT_PAGE = /^(\d{4}_\d{2}_\d{2})_(\d{2,})\.svg$/; + +/** + * @typedef {object} StoredPage + * @property {string} notebook + * @property {string} name + * @property {string} text + * @property {number} changed When the page was last written, in milliseconds. + */ + +/** @type {Promise | null} */ +let opening = null; + +/** @returns {Promise} */ +function database() { + opening ??= new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE, 1); + request.onupgradeneeded = () => { + const store = request.result.createObjectStore(PAGES, { keyPath: ['notebook', 'name'] }); + store.createIndex('notebook', 'notebook'); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('The browser’s storage cannot be opened.')); + }); + return opening; +} + +/** + * @template T + * @param {IDBRequest} request + * @returns {Promise} + */ +function result(request) { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('The browser’s storage failed.')); + }); +} + +/** + * Run work in one transaction on the pages. + * @template T + * @param {IDBTransactionMode} mode + * @param {(pages: IDBObjectStore) => Promise} work + * @returns {Promise} + */ +async function transaction(mode, work) { + const db = await database(); + const tx = db.transaction(PAGES, mode); + const done = new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(undefined); + tx.onerror = () => reject(tx.error ?? new Error('The browser’s storage failed.')); + tx.onabort = () => reject(tx.error ?? new Error('The browser’s storage failed.')); + }); + const value = await work(tx.objectStore(PAGES)); + await done; + return value; +} + +/** + * @param {IDBObjectStore} pages + * @param {string} notebook + * @returns {Promise} The notebook's pages, by name. + */ +async function pagesOf(pages, notebook) { + const found = /** @type {StoredPage[]} */ (await result(pages.index('notebook').getAll(notebook))); + return found.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); +} + +/** + * @param {number} time Milliseconds. + * @returns {string} The day, as 2026_10_02. + */ +function dayOf(time) { + const date = new Date(time); + return [date.getFullYear(), date.getMonth() + 1, date.getDate()] + .map((part) => String(part).padStart(2, '0')).join('_'); +} + +/** @type {import('./notebook.js').Backend} */ +export const backend = { + async readIdentity() { + return localStorage.getItem(IDENTITY_KEY); + }, + + async keepIdentity(text) { + if (localStorage.getItem(IDENTITY_KEY) !== null) return false; + localStorage.setItem(IDENTITY_KEY, text); + return true; + }, + + async listNotebooks() { + const names = await transaction('readonly', async (pages) => { + const keys = /** @type {[string, string][]} */ (await result(pages.getAllKeys())); + return [...new Set(keys.map(([notebook]) => notebook))].sort(); + }); + return { directory: '', notebooks: names }; + }, + + async listPages(notebook) { + return transaction('readonly', async (pages) => (await pagesOf(pages, notebook)).map((page) => page.name)); + }, + + async readPage(notebook, pageName) { + const page = /** @type {StoredPage | undefined} */ (await transaction( + 'readonly', (pages) => result(pages.get([notebook, pageName])))); + return page ? page.text : null; + }, + + async writePage(notebook, pageName, text) { + await transaction('readwrite', async (pages) => { + await result(pages.put({ notebook, name: pageName, text, changed: Date.now() })); + }); + }, + + // The same rules as keep_page() in kokkoja.py: nothing is kept of a + // blank page or of one already kept; the name is the day the page was + // last changed and a serial number. + async keepPage(notebook) { + return transaction('readwrite', async (pages) => { + const current = /** @type {StoredPage | undefined} */ (await result(pages.get([notebook, CURRENT_PAGE]))); + if (!current || !(current.text.includes('data-stroke=') || current.text.includes(' KEPT_PAGE.test(page.name)); + const same = kept.find((page) => page.text === current.text); + if (same) return { kept: same.name, new: false }; + const day = dayOf(current.changed); + const serials = kept.map((page) => /** @type {RegExpExecArray} */ (KEPT_PAGE.exec(page.name))) + .filter((match) => match[1] === day).map((match) => Number(match[2])); + const name = `${day}_${String(Math.max(0, ...serials) + 1).padStart(2, '0')}.svg`; + await result(pages.put({ notebook, name, text: current.text, changed: current.changed })); + return { kept: name, new: true }; + }); + }, +}; diff --git a/client/js/chooser.js b/client/js/chooser.js new file mode 100644 index 0000000..f09e167 --- /dev/null +++ b/client/js/chooser.js @@ -0,0 +1,132 @@ +// @ts-check +/** + * The notebook chooser: the "Notebook" button's dialog, which shows the + * notebooks and the pages kept in them, and loads the chosen page. + */ + +import { listKeptPages, listNotebooks, readPage } from './notebook.js'; + +/** + * @param {string} id + * @returns {HTMLElement} + */ +function element(id) { + return /** @type {HTMLElement} */ (document.getElementById(id)); +} + +/** + * @param {string} pageName + * @returns {string} "2026_10_02_01.svg" shown as "2026-10-02 (1)". + */ +function pageTitle(pageName) { + const match = /^(\d{4})_(\d{2})_(\d{2})_(\d+)\.svg$/.exec(pageName); + return match ? `${match[1]}-${match[2]}-${match[3]} (${Number(match[4])})` : pageName; +} + +export class NotebookChooser { + /** + * @param {(notebook: string, pageName: string, text: string) => void} onChosen + * Called with the chosen page file. + */ + constructor(onChosen) { + this.onChosen = onChosen; + this.dialog = /** @type {HTMLDialogElement} */ (element('notebook_chooser')); + this.notebook = ''; + /** Counts openings, so that pictures fetched for an earlier one are dropped. */ + this.opening = 0; + /** @type {string[]} */ + this.pictureAddresses = []; + + element('chooser_close').addEventListener('click', () => this.dialog.close()); + this.dialog.addEventListener('close', () => this.forgetPictures()); + } + + /** + * Show the notebooks, or the pages of the only notebook. + * @param {string} [notebook] Open on this notebook. + */ + async open(notebook) { + this.opening += 1; + this.dialog.showModal(); + element('chooser_message').textContent = ''; + element('chooser_pages').replaceChildren(); // Nothing stale while the lists load. + try { + const { notebooks } = await listNotebooks(); + this.showNotebooks(notebooks); + const chosen = notebook && notebooks.includes(notebook) ? notebook + : notebooks.length === 1 ? notebooks[0] : ''; + await this.showPages(chosen); + } catch (error) { + element('chooser_message').textContent = error instanceof Error ? error.message : String(error); + } + } + + /** @param {string[]} notebooks */ + showNotebooks(notebooks) { + const list = element('chooser_notebooks'); + list.replaceChildren(); + list.hidden = notebooks.length <= 1; + for (const notebook of notebooks) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = notebook; + button.addEventListener('click', () => { this.showPages(notebook); }); + list.append(button); + } + } + + /** @param {string} notebook */ + async showPages(notebook) { + this.notebook = notebook; + for (const button of element('chooser_notebooks').querySelectorAll('button')) { + button.setAttribute('aria-pressed', String(button.textContent === notebook)); + } + const grid = element('chooser_pages'); + grid.replaceChildren(); + this.forgetPictures(); + if (!notebook) { + element('chooser_message').textContent = 'Choose a notebook.'; + return; + } + const opening = this.opening += 1; + const pages = await listKeptPages(notebook); + if (opening !== this.opening) return; + element('chooser_message').textContent = pages.length ? '' : 'This notebook has no kept pages.'; + + /** @type {Map} */ + const pictures = new Map(); + for (const pageName of pages) { + const card = document.createElement('button'); + card.type = 'button'; + card.className = 'page_card'; + const picture = document.createElement('img'); + picture.alt = ''; + pictures.set(pageName, picture); + const title = document.createElement('span'); + title.textContent = pageTitle(pageName); + card.append(picture, title); + card.addEventListener('click', async () => { + const text = await readPage(notebook, pageName); + if (text === null) return; + this.dialog.close(); + this.onChosen(notebook, pageName, text); + }); + grid.append(card); + } + + // The pictures are the page files themselves, fetched one by one. + for (const [pageName, picture] of pictures) { + const text = await readPage(notebook, pageName); + if (opening !== this.opening) return; + if (text === null) continue; + const address = URL.createObjectURL(new Blob([text], { type: 'image/svg+xml' })); + this.pictureAddresses.push(address); + picture.src = address; + } + } + + forgetPictures() { + for (const address of this.pictureAddresses) URL.revokeObjectURL(address); + this.pictureAddresses = []; + } +} diff --git a/client/js/crypto.js b/client/js/crypto.js new file mode 100644 index 0000000..5681579 --- /dev/null +++ b/client/js/crypto.js @@ -0,0 +1,278 @@ +// @ts-check +/** + * Cryptography for a session, using only the browser's Web Crypto API. + * + * An invitation carries a random *secret*. From it every participant + * derives the *room id* (which the relay sees) and the *message key* (which + * the relay never sees). Each participant also has a signing key pair; the + * participant's id is a hash of the public key, so nobody else can claim it. + * + * See notes/protocol.md. + */ + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +const SECRET_PATTERN = /^[A-Za-z0-9_-]{22}$/; +const SIGNATURE = { name: 'ECDSA', hash: 'SHA-256' }; +const SIGNING_KEY = { name: 'ECDSA', namedCurve: 'P-256' }; + +/** + * @param {Uint8Array} bytes + * @returns {string} + */ +export function toBase64Url(bytes) { + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** + * @param {string} text + * @returns {Uint8Array} + */ +export function fromBase64Url(text) { + const binary = atob(text.replace(/-/g, '+').replace(/_/g, '/')); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +/** + * Ordinary base64, as used in a `data:` address. Works on large arrays. + * @param {Uint8Array} bytes + * @returns {string} + */ +export function toBase64(bytes) { + let binary = ''; + for (let i = 0; i < bytes.length; i += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + } + return btoa(binary); +} + +/** + * @param {string} text Ordinary base64. + * @returns {Uint8Array} + */ +export function fromBase64(text) { + const binary = atob(text); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +/** + * @param {Uint8Array} bytes + * @returns {Promise} SHA-256 of the bytes, base64url. + */ +export async function hashOf(bytes) { + return toBase64Url(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))); +} + +/** + * A random identifier, for strokes and the like. + * @param {number} [byteCount] + * @returns {string} + */ +export function randomId(byteCount = 9) { + return toBase64Url(crypto.getRandomValues(new Uint8Array(byteCount))); +} + +/** + * @typedef {object} Invitation + * @property {string} relay Address of the relay, ws:// or wss://. + * @property {string} secret The session secret. + * @property {string} hostId Participant id of the host. + */ + +/** + * @param {Invitation} invitation + * @returns {string} + */ +export function formatInvitation({ relay, secret, hostId }) { + return `${relay}#${secret}.${hostId}`; +} + +/** + * An invitation as a link to a Kokkoja web site, which a guest can open + * and be in the session with nothing installed. The invitation itself is + * after the '#', which browsers never send to the site. + * @param {string} site The site's address, such as https://kokkoja.example/. + * @param {string} invitation From formatInvitation(). + * @returns {string} + */ +export function formatInvitationLink(site, invitation) { + // Only the '#' needs hiding; the rest stays readable. + return `${site.replace(/\/*$/, '/')}#join=${invitation.replace('#', '%23')}`; +} + +/** + * The invitation in a piece of text: the invitation itself, or a link + * that carries one. + * @param {string} text + * @returns {string} The invitation, or the text as it was. + */ +export function unwrapInvitation(text) { + text = text.trim(); + const link = /^https?:\/\/[^#\s]+#(.*)$/.exec(text); + if (!link) return text; + return new URLSearchParams(link[1]).get('join')?.trim() ?? text; +} + +/** + * @param {string} text An invitation, or a link that carries one. + * @returns {Invitation | null} Null if the text is not an invitation. + */ +export function parseInvitation(text) { + const match = /^(wss?:\/\/[^#\s]+)#([A-Za-z0-9_-]{22})\.([A-Za-z0-9_-]{22})$/.exec(unwrapInvitation(text)); + if (!match) return null; + return { relay: match[1].replace(/\/+$/, ''), secret: match[2], hostId: match[3] }; +} + +/** @returns {string} */ +export function newSecret() { + return toBase64Url(crypto.getRandomValues(new Uint8Array(16))); +} + +/** + * Derive the room id and the message key from the session secret. + * @param {string} secret + * @returns {Promise<{roomId: string, key: CryptoKey}>} + */ +export async function deriveSession(secret) { + if (!SECRET_PATTERN.test(secret)) throw new Error('bad session secret'); + const material = await crypto.subtle.importKey( + 'raw', fromBase64Url(secret), 'HKDF', false, ['deriveBits', 'deriveKey']); + const salt = new Uint8Array(32); + const roomBits = await crypto.subtle.deriveBits( + { name: 'HKDF', hash: 'SHA-256', salt, info: encoder.encode('kokkoja 1 room') }, + material, 128); + const key = await crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt, info: encoder.encode('kokkoja 1 key') }, + material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + return { roomId: toBase64Url(new Uint8Array(roomBits)), key }; +} + +/** + * Encrypt a message. The result is a 12-byte nonce followed by the + * ciphertext. + * @param {CryptoKey} key + * @param {object} message + * @returns {Promise} + */ +export async function encrypt(key, message) { + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: nonce }, key, encoder.encode(JSON.stringify(message))); + const result = new Uint8Array(12 + ciphertext.byteLength); + result.set(nonce); + result.set(new Uint8Array(ciphertext), 12); + return result; +} + +/** + * Decrypt a message. Rejects if the message was not made with this key. + * @param {CryptoKey} key + * @param {Uint8Array} bytes + * @returns {Promise} + */ +export async function decrypt(key, bytes) { + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: bytes.subarray(0, 12) }, key, bytes.subarray(12)); + return JSON.parse(decoder.decode(plaintext)); +} + +/** + * A participant's identity: a signing key pair and the id derived from it. + * @typedef {object} Identity + * @property {string} id First 16 bytes of SHA-256 of the public key. + * @property {string} publicKey Raw public key, base64url. + * @property {CryptoKey} privateKey + */ + +/** + * @param {Uint8Array} publicKeyBytes + * @returns {Promise} + */ +async function idOfPublicKey(publicKeyBytes) { + const digest = await crypto.subtle.digest('SHA-256', publicKeyBytes); + return toBase64Url(new Uint8Array(digest, 0, 16)); +} + +/** @returns {Promise} */ +export async function newIdentity() { + const pair = await crypto.subtle.generateKey(SIGNING_KEY, true, ['sign', 'verify']); + const publicKeyBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)); + return { + id: await idOfPublicKey(publicKeyBytes), + publicKey: toBase64Url(publicKeyBytes), + privateKey: pair.privateKey, + }; +} + +/** + * @param {Identity} identity + * @returns {Promise} Text that loadIdentity() accepts. + */ +export async function saveIdentity(identity) { + const jwk = await crypto.subtle.exportKey('jwk', identity.privateKey); + return JSON.stringify({ publicKey: identity.publicKey, privateKey: jwk }); +} + +/** + * @param {string} text + * @returns {Promise} + */ +export async function loadIdentity(text) { + const stored = JSON.parse(text); + const privateKey = await crypto.subtle.importKey( + 'jwk', stored.privateKey, SIGNING_KEY, true, ['sign']); + return { + id: await idOfPublicKey(fromBase64Url(stored.publicKey)), + publicKey: stored.publicKey, + privateKey, + }; +} + +/** + * The statement a participant signs to claim a relay connection: "the + * holder of this key is peer number `peer` in room `roomId`". + * @param {string} roomId + * @param {number} peer + */ +function claimText(roomId, peer) { + return encoder.encode(`kokkoja 1 hello ${roomId} ${peer}`); +} + +/** + * @param {Identity} identity + * @param {string} roomId + * @param {number} peer + * @returns {Promise} The signature, base64url. + */ +export async function signClaim(identity, roomId, peer) { + const signature = await crypto.subtle.sign(SIGNATURE, identity.privateKey, claimText(roomId, peer)); + return toBase64Url(new Uint8Array(signature)); +} + +/** + * Check a claim made by another participant. + * @param {string} publicKey Raw public key, base64url. + * @param {string} signature From signClaim(), base64url. + * @param {string} roomId + * @param {number} peer + * @returns {Promise} The participant id, or null if the + * claim is not valid. + */ +export async function verifyClaim(publicKey, signature, roomId, peer) { + try { + const publicKeyBytes = fromBase64Url(publicKey); + const key = await crypto.subtle.importKey('raw', publicKeyBytes, SIGNING_KEY, false, ['verify']); + const valid = await crypto.subtle.verify( + SIGNATURE, key, fromBase64Url(signature), claimText(roomId, peer)); + return valid ? await idOfPublicKey(publicKeyBytes) : null; + } catch { + return null; + } +} diff --git a/client/js/file_chooser.js b/client/js/file_chooser.js new file mode 100644 index 0000000..116b918 --- /dev/null +++ b/client/js/file_chooser.js @@ -0,0 +1,312 @@ +// @ts-check +/** + * The file chooser: Kokkoja's own dialog for choosing an image file + * ("Image") and for choosing where to export a picture of the page + * ("Export"). The browser's file chooser cannot be told where to begin + * and cannot choose a place to write to; this one begins where the + * notebooks are, remembers where it was last used, and reaches the whole + * file system through the local program. + */ + +import { listFiles, readFile } from './files.js'; + +/** Where the chooser was last used, for each of its two purposes. */ +const DIRECTORY_KEYS = { image: 'kokkoja_image_directory', export: 'kokkoja_export_directory' }; + +/** Whether an image wider than the page is shrunk to fit it: 'no' if not. */ +const SHRINK_KEY = 'kokkoja_image_shrink'; + +/** The longer side of the small pictures of image files, in pixels. */ +const SMALL_PICTURE_SIDE = 240; +/** No small picture is made of a file larger than this. */ +const SMALL_PICTURE_BYTES = 32 << 20; + +/** + * @param {string} id + * @returns {HTMLElement} + */ +function element(id) { + return /** @type {HTMLElement} */ (document.getElementById(id)); +} + +export class FileChooser { + constructor() { + this.dialog = /** @type {HTMLDialogElement} */ (element('file_chooser')); + this.directoryField = /** @type {HTMLInputElement} */ (element('files_directory')); + this.nameField = /** @type {HTMLInputElement} */ (element('files_name')); + this.shrinkBox = /** @type {HTMLInputElement} */ (element('files_shrink')); + this.shrinkBox.checked = localStorage.getItem(SHRINK_KEY) !== 'no'; + this.shrinkBox.addEventListener('change', () => { + localStorage.setItem(SHRINK_KEY, this.shrinkBox.checked ? 'yes' : 'no'); + }); + /** @type {'image' | 'export'} */ + this.purpose = 'image'; + /** @type {import('./files.js').DirectoryListing | null} */ + this.listing = null; + /** Counts listings, so that work begun for an earlier one is dropped. */ + this.showing = 0; + /** @type {((result: any) => void) | null} Ends the present use. */ + this.finish = null; + /** @type {((directory: string, name: string, replace: boolean) => Promise) | null} */ + this.save = null; + /** The name that a second press of the button may replace. */ + this.mayReplace = ''; + + element('files_close').addEventListener('click', () => this.done(null)); + // Escape closes the dialog by itself. The browser reports a close a + // moment after it happens; if the chooser is open again by then, the + // report is about the earlier use and must not end this one. + this.dialog.addEventListener('close', () => { + if (!this.dialog.open) this.done(null); + }); + element('files_up').addEventListener('click', () => { + if (this.listing?.parent) this.show(this.listing.parent); + }); + this.directoryField.addEventListener('keydown', (event) => { + if (event.key !== 'Enter') return; + event.preventDefault(); + this.show(this.directoryField.value.trim()); + }); + this.nameField.addEventListener('input', () => this.forgetReplacing()); + this.nameField.addEventListener('keydown', (event) => { + if (event.key !== 'Enter') return; + event.preventDefault(); + this.saveHere(); + }); + element('files_save_here').addEventListener('click', () => this.saveHere()); + } + + /** + * Let the user choose an image file. + * @returns {Promise} The file, or null if none was chosen. + */ + chooseImage() { + return this.open('image', 'Image'); + } + + /** + * @returns {boolean} Whether the user wants an image wider than the page + * shrunk to fit it (the other choice keeps its size and widens the page). + */ + shrinksWideImages() { + return this.shrinkBox.checked; + } + + /** + * Let the user choose a directory and a name, and save there. + * @param {string} title + * @param {string} name The name to suggest. + * @param {(directory: string, name: string, replace: boolean) => Promise} save + * Writes the file and returns its full path, or null if a file of + * that name exists and `replace` is false. + * @returns {Promise} The full path written, or null. + */ + chooseWhereToSave(title, name, save) { + const result = this.open('export', title); + this.save = save; + this.nameField.value = name; + return result; + } + + /** + * @param {'image' | 'export'} purpose + * @param {string} title + * @returns {Promise} + */ + open(purpose, title) { + if (this.dialog.open) return Promise.resolve(null); + this.finish?.(null); + this.purpose = purpose; + element('files_title').textContent = title; + element('files_save').hidden = purpose !== 'export'; + element('files_shrink_line').hidden = purpose !== 'image'; + element('files_directories').replaceChildren(); + element('files_entries').replaceChildren(); + this.dialog.showModal(); + const result = new Promise((resolve) => { this.finish = resolve; }); + // Where it was last used; if that is gone, where the notebooks are. + this.show(localStorage.getItem(DIRECTORY_KEYS[purpose]) ?? '').then((shown) => { + if (!shown && this.finish) this.show(''); + }); + return result; + } + + /** @param {any} result */ + done(result) { + const finish = this.finish; + this.finish = null; + this.save = null; + this.showing += 1; + if (this.dialog.open) this.dialog.close(); + finish?.(result); + } + + /** @param {string} message */ + say(message) { + element('files_message').textContent = message; + } + + forgetReplacing() { + this.mayReplace = ''; + element('files_save_here').textContent = 'Export'; + } + + /** + * Show a directory. + * @param {string} directory A full path, or '' for where the notebooks are. + * @returns {Promise} Whether it could be shown. + */ + async show(directory) { + const showing = this.showing += 1; + let listing; + try { + listing = await listFiles(directory); + } catch (error) { + if (showing === this.showing) this.say(error instanceof Error ? error.message : String(error)); + return false; + } + if (showing !== this.showing) return true; + this.listing = listing; + this.directoryField.value = listing.directory; + /** @type {HTMLButtonElement} */ (element('files_up')).disabled = listing.parent === null; + localStorage.setItem(DIRECTORY_KEYS[this.purpose], listing.directory); + this.forgetReplacing(); + + // The directories go down the left, one to a line, and the files + // beside them (Andy, 2026-10-05), so that neither pushes the other out + // of view. + const entries = element('files_entries'); + entries.replaceChildren(); + const directories = element('files_directories'); + directories.replaceChildren(); + for (const one of listing.directories) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = one.name; + button.addEventListener('click', () => this.show(one.path)); + directories.append(button); + } + + const files = this.purpose === 'image' ? listing.files + : listing.files.filter((file) => file.name.toLowerCase().endsWith('.png')); + if (this.purpose === 'image') this.showImages(files, entries, showing); + else this.showExisting(files, entries); + this.say(this.purpose === 'image' && files.length === 0 ? 'No image files here.' : ''); + return true; + } + + /** + * The image files of a directory, each with a small picture, made one + * after another as the files come into view. + * @param {import('./files.js').DirectoryListing['files']} files + * @param {HTMLElement} entries + * @param {number} showing + */ + showImages(files, entries, showing) { + const grid = document.createElement('div'); + grid.className = 'files_images'; + /** @type {Map} */ + const waiting = new Map(); + /** @type {Element[]} Cards in view, awaiting their pictures. */ + const queue = []; + let drawing = false; + + const drawNext = async () => { + if (drawing) return; + drawing = true; + for (let card = queue.shift(); card && showing === this.showing; card = queue.shift()) { + const entry = waiting.get(card); + waiting.delete(card); + if (!entry) continue; + try { + const bitmap = await createImageBitmap(await readFile(entry.path)); + const scale = Math.min(1, SMALL_PICTURE_SIDE / Math.max(bitmap.width, bitmap.height)); + entry.picture.width = Math.max(1, Math.round(bitmap.width * scale)); + entry.picture.height = Math.max(1, Math.round(bitmap.height * scale)); + entry.picture.getContext('2d')?.drawImage(bitmap, 0, 0, entry.picture.width, entry.picture.height); + bitmap.close(); + } catch { + // Not an image the browser can read: the card keeps its name only. + } + } + drawing = false; + }; + const inView = new IntersectionObserver((changes) => { + for (const change of changes) { + if (!change.isIntersecting || !waiting.has(change.target)) continue; + inView.unobserve(change.target); + queue.push(change.target); + } + drawNext(); + }, { root: entries }); + + for (const file of files) { + const card = document.createElement('button'); + card.type = 'button'; + card.className = 'file_card'; + const picture = document.createElement('canvas'); + picture.width = picture.height = 1; + const name = document.createElement('span'); + name.textContent = file.name; + card.append(picture, name); + card.addEventListener('click', async () => { + try { + this.done(await readFile(file.path)); + } catch (error) { + this.say(error instanceof Error ? error.message : String(error)); + } + }); + grid.append(card); + if (file.size <= SMALL_PICTURE_BYTES) { + waiting.set(card, { path: file.path, picture }); + inView.observe(card); + } + } + if (files.length) entries.append(grid); + } + + /** + * The PNG files already in a directory, by name: choosing one takes its + * name, to replace it. + * @param {import('./files.js').DirectoryListing['files']} files + * @param {HTMLElement} entries + */ + showExisting(files, entries) { + const list = document.createElement('div'); + list.className = 'files_existing'; + for (const file of files) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = file.name; + button.addEventListener('click', () => { + this.nameField.value = file.name; + this.forgetReplacing(); + }); + list.append(button); + } + if (files.length) entries.append(list); + } + + /** Save into the directory shown, under the name in the name field. */ + async saveHere() { + const save = this.save; + const directory = this.listing?.directory; + let name = this.nameField.value.trim(); + if (!save || !directory || !name) return; + if (!name.toLowerCase().endsWith('.png')) name += '.png'; + this.nameField.value = name; + try { + const saved = await save(directory, name, this.mayReplace === name); + if (saved !== null) { + this.done(saved); + return; + } + // No system dialog: a second press of the button replaces the file. + this.mayReplace = name; + element('files_save_here').textContent = 'Replace'; + this.say(`${name} exists here.`); + } catch (error) { + this.say(error instanceof Error ? error.message : String(error)); + } + } +} diff --git a/client/js/files.js b/client/js/files.js new file mode 100644 index 0000000..8a1b73f --- /dev/null +++ b/client/js/files.js @@ -0,0 +1,67 @@ +// @ts-check +/** + * Files outside the notebooks: image files to put on a page, and the place + * a picture of a page is exported to. A web page cannot look through the + * user's directories or write where the user chooses, so it asks the local + * program (kokkoja.py), as it does for the notebooks. + */ + +import { askLocalProgram } from './notebook.js'; + +/** + * @typedef {object} DirectoryListing + * @property {string} directory Its full path. + * @property {string | null} parent Null at the top of the file system. + * @property {{name: string, path: string}[]} directories + * @property {{name: string, path: string, size: number}[]} files Image files only. + */ + +/** + * @param {Response} response + * @returns {Promise} What the local program says went wrong. + */ +async function reason(response) { + try { + return String((await response.json()).error); + } catch { + return `error ${response.status}`; + } +} + +/** + * @param {string} [directory] A full path; without one, where the notebooks are. + * @returns {Promise} + */ +export async function listFiles(directory = '') { + const response = await askLocalProgram(`api/files?directory=${encodeURIComponent(directory)}`); + if (!response.ok) throw new Error(`${directory}: ${await reason(response)}`); + return response.json(); +} + +/** + * @param {string} path The full path of an image file, from a listing. + * @returns {Promise} + */ +export async function readFile(path) { + const response = await askLocalProgram(`api/file?path=${encodeURIComponent(path)}`); + if (!response.ok) throw new Error(`${path}: ${await reason(response)}`); + return response.blob(); +} + +/** + * Write a PNG picture into a directory. + * @param {string} directory Its full path. + * @param {string} name Ending in .png. + * @param {Blob} picture + * @param {boolean} replace Whether a file of that name may be replaced. + * @returns {Promise} The full path written, or null if a + * file of that name exists and may not be replaced. + */ +export async function writePicture(directory, name, picture, replace) { + const query = `directory=${encodeURIComponent(directory)}&name=${encodeURIComponent(name)}` + + (replace ? '&replace=1' : ''); + const response = await askLocalProgram(`api/file?${query}`, 'PUT', picture); + if (response.status === 409) return null; + if (!response.ok) throw new Error(await reason(response)); + return (await response.json()).saved; +} diff --git a/client/js/image_info.js b/client/js/image_info.js new file mode 100644 index 0000000..5ad5d27 --- /dev/null +++ b/client/js/image_info.js @@ -0,0 +1,121 @@ +// @ts-check +/** + * Reads the resolution an image file records about itself, which gives the + * image its natural size on the page. Scanners write it; most other + * programs do not. + * + * Understood: the PNG `pHYs` chunk, the JPEG JFIF header, and the JPEG EXIF + * resolution tags. Anything else gives null. + */ + +/** Resolutions outside this range are mistakes, not information. */ +const LEAST_DPI = 30; +const MOST_DPI = 2400; + +/** + * @param {number | null} dpi + * @returns {number | null} + */ +function plausible(dpi) { + return dpi !== null && dpi >= LEAST_DPI && dpi <= MOST_DPI ? dpi : null; +} + +/** + * @param {DataView} view + * @returns {number | null} Dots per inch from the pHYs chunk, if any. + */ +function pngResolution(view) { + let at = 8; // After the signature. + while (at + 12 <= view.byteLength) { + const length = view.getUint32(at); + const type = String.fromCharCode( + view.getUint8(at + 4), view.getUint8(at + 5), view.getUint8(at + 6), view.getUint8(at + 7)); + if (type === 'pHYs' && length === 9 && at + 8 + 9 <= view.byteLength) { + const perMetre = view.getUint32(at + 8); + const unitIsMetre = view.getUint8(at + 16) === 1; + return unitIsMetre ? perMetre * 0.0254 : null; + } + if (type === 'IDAT' || type === 'IEND') return null; + at += 12 + length; + } + return null; +} + +/** + * @param {DataView} view + * @param {number} start Where the TIFF header of the EXIF data begins. + * @returns {number | null} Dots per inch from XResolution and ResolutionUnit. + */ +function exifResolution(view, start) { + const little = view.getUint16(start) === 0x4949; + /** @param {number} at */ + const u16 = (at) => view.getUint16(at, little); + /** @param {number} at */ + const u32 = (at) => view.getUint32(at, little); + if (u16(start + 2) !== 42) return null; + const ifd = start + u32(start + 4); + if (ifd + 2 > view.byteLength) return null; + const count = u16(ifd); + let resolution = null; + let unit = 2; // Inches unless said otherwise. + for (let i = 0; i < count; i++) { + const entry = ifd + 2 + i * 12; + if (entry + 12 > view.byteLength) return null; + const tag = u16(entry); + if (tag === 0x011a) { // XResolution, a rational at the given offset. + const at = start + u32(entry + 8); + if (at + 8 > view.byteLength) return null; + const denominator = u32(at + 4); + resolution = denominator ? u32(at) / denominator : null; + } else if (tag === 0x0128) { // ResolutionUnit: 2 inches, 3 centimetres. + unit = u16(entry + 8); + } + } + if (resolution === null) return null; + return unit === 3 ? resolution * 2.54 : resolution; +} + +/** + * @param {DataView} view + * @returns {number | null} Dots per inch from the JFIF or EXIF header, if any. + */ +function jpegResolution(view) { + let at = 2; // After the SOI marker. + let fromJfif = null; + while (at + 4 <= view.byteLength && view.getUint8(at) === 0xff) { + const marker = view.getUint8(at + 1); + if (marker === 0xda) break; // Start of scan: no more headers. + const length = view.getUint16(at + 2); + const body = at + 4; + if (marker === 0xe0 && length >= 16 && body + 12 <= view.byteLength && + view.getUint32(body) === 0x4a464946) { // "JFIF" + const units = view.getUint8(body + 7); + const density = view.getUint16(body + 8); + if (units === 1) fromJfif = density; + else if (units === 2) fromJfif = density * 2.54; + } + if (marker === 0xe1 && body + 6 <= view.byteLength && + view.getUint32(body) === 0x45786966) { // "Exif" + const fromExif = plausible(exifResolution(view, body + 6)); + if (fromExif !== null) return fromExif; + } + at = body + length - 2; + } + return fromJfif; +} + +/** + * @param {Uint8Array} bytes An image file. + * @returns {number | null} The resolution the file records, in dots per + * inch, or null if it records none that can be believed. + */ +export function imageResolution(bytes) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + try { + if (bytes.length > 8 && view.getUint32(0) === 0x89504e47) return plausible(pngResolution(view)); + if (bytes.length > 4 && view.getUint16(0) === 0xffd8) return plausible(jpegResolution(view)); + } catch { + // A truncated or malformed header: no resolution. + } + return null; +} diff --git a/client/js/main.js b/client/js/main.js new file mode 100644 index 0000000..a3341c0 --- /dev/null +++ b/client/js/main.js @@ -0,0 +1,958 @@ +// @ts-check +/** + * The Kokkoja client: the start screen, and the session screen that + * connects the pen, the page and the other participants. + */ + +import { + deriveSession, formatInvitation, formatInvitationLink, loadIdentity, newIdentity, newSecret, + parseInvitation, saveIdentity, +} from './crypto.js'; +import { + NotebookWriter, keepCurrentPage, keepIdentity, listKeptPages, listNotebooks, notebooksInBrowser, + readCurrentPage, readIdentity, readPage, rememberToken, useBrowserStorage, +} from './notebook.js'; +import { download, pickImageFile } from './browser_files.js'; +import { pageFromSvg, pageToSvg } from './page_file.js'; +import { hashOf } from './crypto.js'; +import { imageResolution } from './image_info.js'; +import { + LEAST_TEXT_POINTS, MAX_IMAGE_BYTES, MOST_TEXT_POINTS, TEXT_FONTS, TEXT_SIZE, + UNITS_PER_POINT, strokesTouched, textAt, textsTouched, +} from './page_model.js'; +import { attachPenInput } from './pen_input.js'; +import { Renderer, ZOOM_STEP } from './renderer.js'; +import { Session } from './session.js'; +import { NotebookChooser } from './chooser.js'; +import { FileChooser } from './file_chooser.js'; +import { writePicture } from './files.js'; +import { pageToPng } from './page_picture.js'; +import { TextEditor } from './text_editor.js'; +import { enableTooltips } from './tooltip.js'; + +const COLORS = ['#1c1c1e', '#1f4fd8', '#d42a2a', '#1c8a3c']; +/** Everyone begins in black (Andy, 2026-10-03; the host began in red before). */ +const DEFAULT_COLOR = COLORS[0]; + +/** Nominal line widths in page units (0.1 mm). */ +const WIDTHS = [4, 7, 12]; + +/** Radius of the eraser on the screen, in CSS pixels. */ +const ERASER_RADIUS = 10; + +/** With the text tool or the eraser, a press that moves less than this (3 mm) is a tap. */ +const TAP_DISTANCE = 30; + +/** + * An image file is shared as it is if it is no larger than this, in bytes + * and in pixels along its longer side. Otherwise it is made smaller and + * stored as JPEG, so that it travels quickly and the page file stays small. + */ +const IMAGE_AS_IS_BYTES = 1500000; +const IMAGE_LONGEST_SIDE = 2400; + +const NAME_KEY = 'kokkoja_name'; +/** Colours the user added from the palette, remembered in this browser. */ +const COLORS_KEY = 'kokkoja_colors'; + +/** + * The palette behind "+": eight hues in three shades, and greys. Our own, + * not the browser's colour dialog, which crashes Firefox on Linux. + */ +const PALETTE = [ + '#e53935', '#fb8c00', '#fdd835', '#43a047', '#00acc1', '#1e88e5', '#8e24aa', '#d81b60', + '#b71c1c', '#e65100', '#f9a825', '#1b5e20', '#006064', '#0d47a1', '#4a148c', '#880e4f', + '#ffcdd2', '#ffe0b2', '#fff9c4', '#c8e6c9', '#b2ebf2', '#bbdefb', '#e1bee7', '#f8bbd0', + '#ffffff', '#bdbdbd', '#757575', '#424242', '#8d6e63', '#5d4037', '#3e2723', '#000000', +]; +const NOTEBOOK_KEY = 'kokkoja_notebook'; +const DEFAULT_NOTEBOOK = 'Notebook'; + +/** + * Where the installed Kokkoja program serves the client, for a guest who + * opened an invitation link on a web site but has the program too. + */ +const INSTALLED_ADDRESS = 'http://localhost:8760/'; + +/** + * How this client was served (`config.json`): by the local program, which + * keeps the notebooks as files ("files"), or by a web site, where the + * notebooks are kept in the browser ("browser"). The relay is the one for + * sessions started here; the site, if any, is a Kokkoja web site that an + * invitation can be a link to. + * @typedef {object} Config + * @property {string} relay + * @property {'files' | 'browser'} storage + * @property {string} site + */ +/** @type {Config} */ +let config = { relay: '', storage: 'files', site: '' }; + +/** + * @param {string} id + * @returns {HTMLElement} + */ +function element(id) { + return /** @type {HTMLElement} */ (document.getElementById(id)); +} + +/** + * @param {string} id + * @returns {HTMLInputElement} + */ +function input(id) { + return /** @type {HTMLInputElement} */ (document.getElementById(id)); +} + +/** + * Our identity: the signing keys that make us the same participant in + * every session. The local program keeps them, so that they survive the + * browser and are the same from any browser on this computer; the first + * time, they are made here and given to it. + * @returns {Promise} + */ +async function ownIdentity() { + const kept = await readIdentity(); + if (kept !== null) return loadIdentity(kept); + const identity = await newIdentity(); + if (await keepIdentity(await saveIdentity(identity))) return identity; + // Another tab got there first: use what it kept. + const other = await readIdentity(); + return other !== null ? loadIdentity(other) : identity; +} + +// Start screen. + +/** @param {string} [message] */ +function showStart(message = '') { + element('session').hidden = true; + element('start').hidden = false; + element('start_message').textContent = message; + input('name').value = localStorage.getItem(NAME_KEY) ?? ''; + input('notebook').value = localStorage.getItem(NOTEBOOK_KEY) ?? DEFAULT_NOTEBOOK; + const pending = invitationInAddress(); + if (pending) input('invitation_entry').value = pending; + // From a web site, a guest who has the Kokkoja program installed may + // prefer it, for a notebook of real files. + const installed = /** @type {HTMLAnchorElement} */ (element('open_installed')); + installed.hidden = !(pending && notebooksInBrowser()); + installed.href = `${INSTALLED_ADDRESS}#join=${encodeURIComponent(pending)}`; + showNotebooks().catch((error) => { + element('notebook_note').textContent = String(error.message ?? error); + element('notebook_note').classList.add('problem'); + }); +} + +/** Offer the existing notebooks and say where notebooks are kept. */ +async function showNotebooks() { + const { directory, notebooks } = await listNotebooks(); + const choices = element('notebook_choices'); + choices.replaceChildren(); + for (const notebook of notebooks) { + const option = document.createElement('option'); + option.value = notebook; + choices.append(option); + } + for (const note of document.querySelectorAll('.notebook_directory')) { + note.textContent = directory ? `Notebooks are kept in ${directory}` + : 'Notebooks are kept in this browser, on this computer. “Keep page” also downloads the page.'; + note.classList.remove('problem'); + } +} + +/** + * The part of the page address after '#', as named values. The local + * program puts the token there; a join link puts the invitation there. + * Browsers do not send this part of an address to any server. + */ +function addressValues() { + return new URLSearchParams(location.hash.slice(1)); +} + +/** @returns {string} The invitation in the page address, or ''. */ +function invitationInAddress() { + return addressValues().get('join') ?? ''; +} + +/** + * Set the page address for a session, or for none. + * @param {string} [invitation] + */ +function setAddress(invitation) { + history.replaceState(null, '', invitation + ? `#join=${encodeURIComponent(invitation)}` : location.pathname); +} + +/** @returns {string | null} The name entered, or null after complaining. */ +function enteredName() { + const name = input('name').value.trim(); + if (!name) { + element('start_message').textContent = 'Please enter your name.'; + input('name').focus(); + return null; + } + localStorage.setItem(NAME_KEY, name); + return name; +} + +/** @returns {string} The notebook entered. */ +function enteredNotebook() { + const notebook = input('notebook').value.trim() || DEFAULT_NOTEBOOK; + localStorage.setItem(NOTEBOOK_KEY, notebook); + return notebook; +} + +async function startSession() { + const name = enteredName(); + if (!name) return; + const identity = await ownIdentity(); + const secret = newSecret(); + const invitation = formatInvitation({ relay: config.relay, secret, hostId: identity.id }); + await enterSession(invitation, name, enteredNotebook()); +} + +async function joinSession() { + const name = enteredName(); + if (!name) return; + const invitation = input('invitation_entry').value.trim(); + if (!parseInvitation(invitation)) { + element('start_message').textContent = 'That is not a Kokkoja invitation.'; + return; + } + await enterSession(invitation, name, enteredNotebook()); +} + +// Session screen. + +/** + * Prepare an image file to be put on the page. The image's natural size + * comes from the resolution the file records, if any, and otherwise from + * taking a pixel as 0.1 mm (Andy's decision, 2026-10-03); where it goes on + * the page is decided when it is added. An image of more pixels or bytes + * than is useful is made smaller here, which does not change its natural + * size. + * @param {Blob} file + * @returns {Promise[0]>} + */ +async function prepareImage(file) { + const original = new Uint8Array(await file.arrayBuffer()); + const bitmap = await createImageBitmap(file); + const dpi = imageResolution(original); + const unitsPerPixel = dpi === null ? 1 : 254 / dpi; + let blob = file; + const longest = Math.max(bitmap.width, bitmap.height); + const asIs = (file.type === 'image/png' || file.type === 'image/jpeg') && + file.size <= IMAGE_AS_IS_BYTES && longest <= IMAGE_LONGEST_SIDE; + if (!asIs) { + const scale = Math.min(1, IMAGE_LONGEST_SIDE / longest); + const canvas = document.createElement('canvas'); + canvas.width = Math.round(bitmap.width * scale); + canvas.height = Math.round(bitmap.height * scale); + const context = /** @type {CanvasRenderingContext2D} */ (canvas.getContext('2d')); + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, canvas.width, canvas.height); + context.drawImage(bitmap, 0, 0, canvas.width, canvas.height); + for (const quality of [0.9, 0.75, 0.5]) { + blob = await new Promise((resolve, reject) => canvas.toBlob( + (result) => (result ? resolve(result) : reject(new Error('The image could not be prepared.'))), + 'image/jpeg', quality)); + if (blob.size <= MAX_IMAGE_BYTES) break; + } + } + if (blob.size > MAX_IMAGE_BYTES) throw new Error('The image is too large.'); + const data = blob === file ? original : new Uint8Array(await blob.arrayBuffer()); + const type = blob.type === 'image/png' ? 'image/png' : 'image/jpeg'; + return { + type, data, hash: await hashOf(data), + naturalWidth: bitmap.width * unitsPerPixel, naturalHeight: bitmap.height * unitsPerPixel, + }; +} + +/** + * The page to begin a session with, from the notebook's current page file. + * If that file holds a page of this very session (the browser page was + * reloaded, or we left and came back), the session continues from it. If + * it holds a page of another session, that page is kept first, because the + * file is about to be given over to this session. + * @param {string} notebook + * @param {string} roomId + * @returns {Promise} + */ +async function pageToContinue(notebook, roomId) { + const text = await readCurrentPage(notebook); + if (text === null) return undefined; + const saved = pageFromSvg(text); + if (saved && saved.session === roomId) return saved.page; + await keepCurrentPage(notebook); + return undefined; +} + +/** True once this browser page has entered a session. */ +let entered = false; + +/** + * Where a browser tab remembers the notebook it chose for a session. + * @param {string} roomId + */ +function notebookKeyFor(roomId) { + return `kokkoja_notebook_${roomId}`; +} + +/** + * @param {string} invitationText + * @param {string} name + * @param {string} notebook + */ +async function enterSession(invitationText, name, notebook) { + const invitation = parseInvitation(invitationText); + if (!invitation) { + showStart('That is not a Kokkoja invitation.'); + return; + } + if (entered) return; + entered = true; + const { roomId, key } = await deriveSession(invitation.secret); + const identity = await ownIdentity(); + // A browser tab stays with the notebook it chose for a session. + const notebookKey = notebookKeyFor(roomId); + notebook = sessionStorage.getItem(notebookKey) ?? notebook; + sessionStorage.setItem(notebookKey, notebook); + const savedPage = await pageToContinue(notebook, roomId); + setAddress(invitationText); + + element('start').hidden = true; + element('session').hidden = false; + // The invitation is shown as a link to the web site when there is one, + // so that a guest can open it and be in the session with nothing + // installed. The link carries the invitation itself. + const plainInvitation = formatInvitation(invitation); + input('invitation').value = config.site ? formatInvitationLink(config.site, plainInvitation) : plainInvitation; + + const isHost = identity.id === invitation.hostId; + let tool = /** @type {'pen' | 'eraser' | 'text'} */ ('pen'); + let color = DEFAULT_COLOR; + let width = WIDTHS[1]; + let textSize = TEXT_SIZE; + let textFont = /** @type {import('./page_model.js').TextFont} */ ('sans-serif'); + + /** @type {Renderer} */ + let renderer; + let layerOrder = ''; + + function syncLayers() { + const order = session.layerOrder(); + if (order.join(' ') === layerOrder) return; + layerOrder = order.join(' '); + renderer.setLayerOrder(order); + } + + /** + * @param {import('./page_model.js').Page} page + * @returns {string} The page file. + */ + const fileOf = (page) => pageToSvg(page, { session: roomId, hostId: invitation.hostId }); + + function showPageNumber() { + element('page_number').textContent = session.page.number > 0 + ? `Page ${session.page.number}` : 'Waiting for the host’s page'; + } + + const session = new Session( + { + relay: invitation.relay, roomId, key, identity, hostId: invitation.hostId, name, + page: savedPage, + }, + { + onLayerReplaced: (layerId) => { + syncLayers(); + renderer.redrawLayer(layerId); + writer.changed(); + }, + onStrokeProgress: (layerId, stroke) => { + syncLayers(); + renderer.strokeProgress(layerId, stroke); + if (stroke.done) writer.changed(); + }, + onTextChanged: (layerId) => { + syncLayers(); + renderer.redrawLayer(layerId); + writer.changed(); + }, + onPageReplaced: (oldPage, newPage) => { + textEditor?.stop(); + // Nothing is lost: the page being left is kept in the notebook. + writer.pageReplaced(oldPage.number > 0 ? fileOf(oldPage) : null); + renderer.setPage(newPage); + layerOrder = ''; + syncLayers(); + showParticipants(session, renderer); + showPageNumber(); + }, + onImagesChanged: () => { + renderer.showImages(); + writer.writeNow(); + }, + onPageResized: (byImages) => { + // An image added or taken back does not rescale what is in view. + if (byImages) renderer.holdScale(); + renderer.resize(); + textEditor?.layout(); + writer.changed(); + }, + onPermissionsChanged: () => { + showHostTools(); + showParticipants(session, renderer); + }, + onParticipantsChanged: () => { + syncLayers(); + showParticipants(session, renderer); + }, + onStatus: (status, detail) => { + const text = { connecting: 'Connecting…', connected: 'Connected', failed: detail ?? 'Disconnected' }; + element('status').textContent = text[status]; + element('status').classList.toggle('problem', status !== 'connected'); + }, + }); + + const writer = new NotebookWriter( + notebook, + () => (session.page.number > 0 ? fileOf(session.page) : null), + (message, problem) => { + element('save_status').textContent = message; + element('save_status').classList.toggle('problem', problem); + }); + element('save_status').textContent = savedPage ? 'Continued from the notebook' : ''; + + const pageElement = element('page'); + renderer = new Renderer(pageElement, session.page); + /** @type {TextEditor | null} */ + let textEditor = null; + syncLayers(); + for (const layerId of session.page.layers.keys()) renderer.redrawLayer(layerId); + showParticipants(session, renderer); + showPageNumber(); + new ResizeObserver(() => { + renderer.resize(); + textEditor?.layout(); + }).observe(/** @type {Element} */ (pageElement.parentElement)); + + textEditor = new TextEditor(pageElement, renderer, { + setText: (block) => session.setText(block), + removeText: (id) => session.removeTexts([id]), + }); + + attachPenInput(pageElement, { + toPage: (clientX, clientY) => renderer.pageFromClient(clientX, clientY), + tool: () => tool, + canWrite: () => session.page.number > 0, + beginStroke: () => { + textEditor?.stop(); + renderer.holdFit = true; + session.beginStroke(color, width); + }, + addPoints: (points) => session.addPoints(points), + endStroke: () => { + session.endStroke(); + renderer.holdFit = false; + if (renderer.zoom === 'fit') renderer.resize(); + }, + eraseAlong: (x0, y0, x1, y1) => { + textEditor?.stop(); + const radius = renderer.pageLength(ERASER_RADIUS); + session.erase(strokesTouched(session.ownLayer, x0, y0, x1, y1, radius)); + session.removeTexts(textsTouched(session.ownLayer, x0, y0, x1, y1, radius)); + }, + eraseEnded: (x, y, reach) => { + // A tap of the eraser on the image we have just added takes it back. + const image = session.removableImage; + if (image && reach < TAP_DISTANCE && x >= image.x && x <= image.x + image.width && + y >= image.y && y <= image.y + image.height) { + session.removeAddedImage(); + } + }, + previewText: (x0, y0, x1) => { + if (Math.abs(x1 - x0) >= TAP_DISTANCE) textEditor?.showPreview(x0, y0, x1); + else textEditor?.hidePreview(); + }, + placeText: (x0, y0, x1, y1) => { + if (!textEditor) return; + textEditor.hidePreview(); + const editing = textEditor.block; + textEditor.stop(); + const own = textAt(session.ownLayer, x0, y0); + if (Math.hypot(x1 - x0, y1 - y0) < TAP_DISTANCE) { + // A tap: into our own text to change it, or a new block here. + if (own && own !== editing) textEditor.edit(own); + else if (!own) textEditor.begin(x0, y0, null, { color, size: textSize, font: textFont }); + } else { + // A drag along the top edge: a new block that wraps at its width. + textEditor.begin(Math.min(x0, x1), y0, Math.max(100, Math.round(Math.abs(x1 - x0))), + { color, size: textSize, font: textFont }); + } + }, + onReport: (report) => { + element('pen_report').textContent = + `Input: ${report.pointerType}, pressure ${report.pressure.toFixed(2)}, ` + + `${report.pointsPerSecond} points per second`; + }, + }); + + buildToolbar({ + color, width, textSize, textFont, + setTool: (value) => { + if (value !== 'text') textEditor?.stop(); + tool = value; + // Only the chosen tool's settings show: the pen's colours and line + // widths, the text's colours, size and font, nothing for the eraser. + element('colors').hidden = value === 'eraser'; + element('widths').hidden = value !== 'pen'; + element('text_style').hidden = value !== 'text'; + }, + setColor: (value) => { + color = value; + textEditor?.restyle({ color }); + }, + setWidth: (value) => { width = value; }, + setTextSize: (value) => { + textSize = value; + textEditor?.restyle({ size: value }); + }, + setTextFont: (value) => { + textFont = value; + textEditor?.restyle({ font: value }); + }, + }); + + // Zoom is each person's own; it changes nothing on the page. + const showZoom = () => { + element('zoom_fit').setAttribute('aria-pressed', String(renderer.zoom === 'fit')); + }; + // A reloaded tab shows the page as it was last seen in it (Andy, + // 2026-10-05: the least surprise): the zoom and the place scrolled to + // are remembered in the tab, for the page they belong to. + const viewKey = `kokkoja_view_${roomId}`; + const saveView = () => { + if (session.page.number === 0) return; + sessionStorage.setItem(viewKey, JSON.stringify({ page: session.page.id, ...renderer.view() })); + }; + let viewTimer = 0; + const viewChanged = () => { + clearTimeout(viewTimer); + viewTimer = setTimeout(saveView, 300); + }; + renderer.onZoomChanged = () => { + showZoom(); + textEditor?.layout(); + viewChanged(); + }; + element('zoom_fit').addEventListener('click', () => renderer.setZoom('fit')); + element('zoom_in').addEventListener('click', () => renderer.zoomBy(ZOOM_STEP)); + element('zoom_out').addEventListener('click', () => renderer.zoomBy(1 / ZOOM_STEP)); + showZoom(); + try { + const seen = JSON.parse(sessionStorage.getItem(viewKey) ?? 'null'); + if (seen && seen.page === session.page.id && (seen.zoom === 'fit' || seen.zoom > 0) && + Number.isFinite(seen.x) && Number.isFinite(seen.y)) { + renderer.showView(seen); + } + } catch { + // Nothing usable was remembered: the page is shown as a new one is. + } + element('desk').addEventListener('scroll', viewChanged, { passive: true }); + window.addEventListener('pagehide', saveView); + + // The host drives: the host starts new pages, and may let a guest do so. + function showHostTools() { + element('host_tools').hidden = !session.mayMakePages(); + } + showHostTools(); + element('new_page').addEventListener('click', () => session.newPage()); + + /** + * Add an image file to the page, below the images already on it. + * @param {Blob} file + * @param {boolean} [shrink] Whether an image wider than the sheet is + * scaled down to it. + */ + async function loadImage(file, shrink = true) { + if (!session.mayMakePages() || session.page.number === 0) return; + try { + if (!session.addImage(await prepareImage(file), shrink)) { + throw new Error('The page has no room for another image.'); + } + } catch (error) { + element('save_status').textContent = + error instanceof Error && error.message ? error.message : 'That file is not an image.'; + element('save_status').classList.add('problem'); + } + } + const chooser = new NotebookChooser((chosenNotebook, pageName, text) => { + const kept = pageFromSvg(text); + if (!kept) { + element('save_status').textContent = `${pageName} is not a Kokkoja page.`; + element('save_status').classList.add('problem'); + return; + } + session.loadPage(kept.page); + }); + element('load_notebook_page').addEventListener('click', () => { chooser.open(writer.notebook); }); + + // Image files are chosen, and pictures of the page exported, through a + // chooser of our own, which begins where the notebooks are. + const fileChooser = new FileChooser(); + element('load_image').addEventListener('click', async () => { + if (notebooksInBrowser()) { + // No local program to look through the files: the browser's own chooser. + const file = await pickImageFile(); + if (file) loadImage(file); + return; + } + const file = await fileChooser.chooseImage(); + if (file) loadImage(file, fileChooser.shrinksWideImages()); + }); + + /** + * Save a picture of the page as it is shown here (layers hidden on this + * screen are left out) where the user chooses. + */ + async function exportPage() { + const page = session.page; + if (page.number === 0) return; + textEditor?.stop(); + // Anything still waiting to be saved is saved first, so that its + // "Saved" does not take the place of "Exported" a moment later. + await writer.writeNow(); + const now = new Date(); + const day = [now.getFullYear(), now.getMonth() + 1, now.getDate()] + .map((part) => String(part).padStart(2, '0')).join('_'); + if (notebooksInBrowser()) { + download(`${day}_page_${page.number}.png`, + await pageToPng(page, { session: roomId, hostId: session.hostId, hidden: renderer.hidden })); + element('save_status').textContent = 'Exported to your downloads'; + element('save_status').classList.remove('problem'); + return; + } + const saved = await fileChooser.chooseWhereToSave( + 'Export', `${day}_page_${page.number}.png`, + async (directory, name, replace) => writePicture( + directory, name, + await pageToPng(page, { session: roomId, hostId: session.hostId, hidden: renderer.hidden }), + replace)); + if (saved === null) return; + element('save_status').textContent = `Exported to ${saved}`; + element('save_status').classList.remove('problem'); + } + element('export_page').addEventListener('click', () => { exportPage(); }); + + // The notebook: its name, and keeping the page. + const notebookField = input('session_notebook'); + notebookField.value = notebook; + showNotebooks().catch(() => {}); + notebookField.addEventListener('change', () => { + const chosen = notebookField.value.trim(); + if (!chosen || chosen === writer.notebook) { + notebookField.value = writer.notebook; + return; + } + sessionStorage.setItem(notebookKey, chosen); + localStorage.setItem(NOTEBOOK_KEY, chosen); + writer.changeNotebook(chosen, (text) => { + const saved = pageFromSvg(text); + return saved !== null && saved.session === roomId && saved.page.id === session.page.id; + }).then(() => showNotebooks()).catch(() => {}); + }); + element('keep_page').addEventListener('click', async () => { + const kept = await writer.keepPage(); + // In the browser the notebook is out of reach of the user's own + // programs: a kept page is also downloaded, which is how a page gets out. + if (kept && notebooksInBrowser()) { + const text = await readPage(writer.notebook, kept); + if (text !== null) download(kept, new Blob([text], { type: 'image/svg+xml' })); + } + }); + + element('copy_invitation').addEventListener('click', async () => { + const field = input('invitation'); + field.select(); + try { + await navigator.clipboard.writeText(field.value); + element('copy_invitation').textContent = 'Copied'; + setTimeout(() => { element('copy_invitation').textContent = 'Copy'; }, 1500); + } catch { + // The field is selected; the user can copy it with the keyboard. + } + }); + + if (notebooksInBrowser()) { + element('keep_page').setAttribute('data-help', + 'Keep a dated copy of the page as it is now in this browser’s notebook, and download it. ' + + 'The page is also kept by itself whenever it is about to be replaced.'); + element('export_page').setAttribute('data-help', + 'Download a picture of the page, as it is shown here, as a PNG file.'); + } + + session.start(); + // For looking at the session from the browser's console during development. + Object.assign(window, { kokkoja: { + session, renderer, writer, loadImage, textEditor, + notebook: { listNotebooks, listKeptPages, readPage, readCurrentPage, notebooksInBrowser }, + } }); +} + +/** + * Mark one button of a group as chosen. + * @param {Element} group + * @param {Element} chosen + */ +function press(group, chosen) { + for (const button of group.querySelectorAll('button')) { + button.setAttribute('aria-pressed', String(button === chosen)); + } +} + +/** + * @param {object} options + * @param {string} options.color Initial color. + * @param {number} options.width Initial width. + * @param {number} options.textSize + * @param {import('./page_model.js').TextFont} options.textFont + * @param {(tool: 'pen' | 'eraser' | 'text') => void} options.setTool + * @param {(color: string) => void} options.setColor + * @param {(width: number) => void} options.setWidth + * @param {(size: number) => void} options.setTextSize + * @param {(font: import('./page_model.js').TextFont) => void} options.setTextFont + */ +function buildToolbar(options) { + for (const button of document.querySelectorAll('[data-tool]')) { + button.addEventListener('click', () => { + press(/** @type {Element} */ (button.parentElement), button); + const chosen = button.getAttribute('data-tool'); + options.setTool(chosen === 'eraser' ? 'eraser' : chosen === 'text' ? 'text' : 'pen'); + }); + } + + const colors = element('colors'); + const palette = element('color_palette'); + const more = element('more_colors'); + + /** @param {string} color */ + const addSwatch = (color) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'swatch'; + button.style.setProperty('--swatch', color); + button.setAttribute('aria-label', `Color ${color}`); + button.setAttribute('data-help', 'Write in this color.'); + button.setAttribute('aria-pressed', String(color === options.color)); + button.addEventListener('click', () => { + press(colors, button); + options.setColor(color); + }); + colors.insertBefore(button, more); + return button; + }; + /** @type {string[]} */ + let added = []; + try { + added = JSON.parse(localStorage.getItem(COLORS_KEY) ?? '[]'); + } catch { + added = []; + } + for (const color of COLORS) addSwatch(color); + for (const color of added) { + if (/^#[0-9a-f]{6}$/.test(color)) addSwatch(color); + } + + // "+" opens a palette below it; a chosen colour becomes a swatch before + // the "+" and is the colour from then on. + /** @param {string} color */ + const choose = (color) => { + palette.hidden = true; + const existing = [...colors.querySelectorAll('.swatch')] + .find((swatch) => swatch.getAttribute('aria-label') === `Color ${color}`); + const button = existing ?? addSwatch(color); + if (!existing) { + added.push(color); + localStorage.setItem(COLORS_KEY, JSON.stringify(added)); + } + press(colors, button); + options.setColor(color); + }; + for (const color of PALETTE) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'swatch'; + button.style.setProperty('--swatch', color); + button.setAttribute('aria-label', `Color ${color}`); + button.addEventListener('click', () => choose(color)); + palette.append(button); + } + more.addEventListener('click', () => { + if (!palette.hidden) { + palette.hidden = true; + return; + } + const box = more.getBoundingClientRect(); + palette.style.left = `${box.left}px`; + palette.style.top = `${box.bottom + 6}px`; + palette.hidden = false; + }); + document.addEventListener('pointerdown', (event) => { + if (!palette.hidden && !palette.contains(/** @type {Node} */ (event.target)) && event.target !== more) { + palette.hidden = true; + } + }); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') palette.hidden = true; + }); + + const widths = element('widths'); + for (const width of WIDTHS) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'width_choice'; + button.setAttribute('aria-label', `Line width ${width / 10} mm`); + button.setAttribute('data-help', `Write with a ${width / 10} mm line. Pressing harder widens it.`); + button.setAttribute('aria-pressed', String(width === options.width)); + const line = document.createElement('span'); + line.style.height = `${Math.max(1, Math.round(width / 2.5))}px`; + button.append(line); + button.addEventListener('click', () => { + press(widths, button); + options.setWidth(width); + }); + widths.append(button); + } + + // The text size is a number of points; any size works, since the fonts + // scale. The font is one of the three generic families. + const size = input('text_size'); + size.value = String(Math.round(options.textSize / UNITS_PER_POINT)); + const applySize = () => { + const points = Math.max(LEAST_TEXT_POINTS, Math.min(MOST_TEXT_POINTS, Number(size.value) || 0)); + size.value = String(points); + options.setTextSize(Math.round(points * UNITS_PER_POINT * 10) / 10); + }; + size.addEventListener('change', applySize); + size.addEventListener('input', () => { + if (Number(size.value) >= LEAST_TEXT_POINTS) applySize(); + }); + + const font = /** @type {HTMLSelectElement} */ (document.getElementById('text_font')); + font.value = options.textFont; + font.style.fontFamily = options.textFont; + font.addEventListener('change', () => { + const chosen = TEXT_FONTS.find((family) => family === font.value) ?? 'sans-serif'; + font.style.fontFamily = chosen; + options.setTextFont(chosen); + }); +} + +/** + * @param {Session} session + * @param {Renderer} renderer + */ +function showParticipants(session, renderer) { + const list = element('participants'); + list.replaceChildren(); + for (const participant of session.participantList()) { + const item = document.createElement('li'); + const present = participant.self || participant.peer !== null; + item.classList.toggle('absent', !present); + + if (session.isHost && !participant.self) { + const allow = document.createElement('button'); + allow.type = 'button'; + allow.className = 'allow'; + allow.textContent = 'pages'; + allow.setAttribute('data-help', 'Let this participant start new pages and load images.'); + const allowed = session.permitted.has(participant.id); + allow.setAttribute('aria-pressed', String(allowed)); + allow.addEventListener('click', () => session.setPermitted(participant.id, !allowed)); + item.append(allow); + } + + const label = document.createElement('label'); + const visible = document.createElement('input'); + visible.type = 'checkbox'; + visible.checked = !renderer.hidden.has(participant.id); + visible.setAttribute('data-help', 'Show or hide this participant’s writing on your screen only.'); + visible.addEventListener('change', () => renderer.setLayerVisible(participant.id, visible.checked)); + label.append(visible, ` ${participant.name}`); + item.append(label); + + const roles = []; + if (participant.self) roles.push('you'); + if (participant.host) roles.push('host'); + if (!present) roles.push('away'); + if (roles.length) { + const role = document.createElement('span'); + role.className = 'role'; + role.textContent = roles.join(', '); + item.append(role); + } + list.append(item); + } +} + +// Startup. + +/** @param {any} error */ +function failedToEnter(error) { + entered = false; + showStart(String(error?.message ?? error)); +} + +element('start_session').addEventListener('click', () => { + startSession().catch(failedToEnter); +}); +element('join_session').addEventListener('click', () => { + joinSession().catch(failedToEnter); +}); + +/** + * Begin where this browser tab left off. A tab that was in a session and + * is reloaded goes straight back into it. A tab that arrives with an + * invitation in its address shows the start screen with the invitation + * filled in, so that the name and the notebook can be chosen. + */ +async function resume() { + const invitationText = invitationInAddress(); + const invitation = parseInvitation(invitationText); + const name = localStorage.getItem(NAME_KEY); + if (invitation && name) { + const { roomId } = await deriveSession(invitation.secret); + const notebook = sessionStorage.getItem(notebookKeyFor(roomId)); + if (notebook) { + await enterSession(invitationText, name, notebook); + return; + } + } + showStart(); +} + +enableTooltips(); + +const token = addressValues().get('token'); +if (token) { + rememberToken(token); + setAddress(invitationInAddress()); +} + +/** Learn how this client was served, then begin. */ +async function startUp() { + const served = await (await fetch('config.json')).json(); + config = { + relay: String(served.relay ?? ''), + storage: served.storage === 'browser' ? 'browser' : 'files', + site: String(served.site ?? ''), + }; + if (config.storage === 'browser') useBrowserStorage(); + await resume(); +} + +if (!window.isSecureContext) { + showStart('Kokkoja must be opened from this computer (http://localhost) or over HTTPS.'); +} else { + startUp().catch(failedToEnter); +} diff --git a/client/js/notebook.js b/client/js/notebook.js new file mode 100644 index 0000000..6f37271 --- /dev/null +++ b/client/js/notebook.js @@ -0,0 +1,333 @@ +// @ts-check +/** + * Notebooks: reading and writing page files. + * + * A notebook is a directory on the user's computer. The page being written + * is the file `Current.svg` in it, rewritten as the page changes. A page is + * *kept* by copying `Current.svg` to a dated file such as + * `2026_10_02_01.svg`. That happens when the user asks, and by itself + * whenever `Current.svg` is about to be given over to a different page, so + * that no page is lost by forgetting to keep it. + * + * The browser cannot reach those directories itself, so it asks the local + * program (kokkoja.py), proving with a token that it is the user's own + * browser. When Kokkoja is opened from a web site instead, with no local + * program, the notebooks are kept in the browser's own storage + * (`browser_notebook.js`) under the same names and rules; the user gets + * pages out of them as downloads. + */ + +import * as inBrowser from './browser_notebook.js'; + +const TOKEN_KEY = 'kokkoja_token'; +export const CURRENT_PAGE = 'Current.svg'; + +/** Write this long after the latest change... */ +const QUIET_MS = 400; +/** ...but never leave a change unwritten for longer than this. */ +const LONGEST_WAIT_MS = 2000; +/** After a write fails, try again this much later. */ +const RETRY_MS = 3000; + +/** + * What keeps the notebooks: the local program's files, or the browser. + * @typedef {object} Backend + * @property {() => Promise} readIdentity + * @property {(text: string) => Promise} keepIdentity + * @property {() => Promise<{directory: string, notebooks: string[]}>} listNotebooks + * The directory is '' when the notebooks are in the browser. + * @property {(notebook: string) => Promise} listPages Every page file, Current.svg too. + * @property {(notebook: string, pageName: string) => Promise} readPage + * @property {(notebook: string, pageName: string, text: string) => Promise} writePage + * @property {(notebook: string) => Promise<{kept: string | null, new: boolean}>} keepPage + */ + +/** + * Keep the token for later visits. The local program gives it in the + * address it opens. + * @param {string} token + */ +export function rememberToken(token) { + localStorage.setItem(TOKEN_KEY, token); +} + +/** + * Ask the local program for something, with the token that proves we are + * the user's own browser. + * @param {string} path + * @param {string} [method] + * @param {BodyInit} [body] + * @returns {Promise} + */ +export async function askLocalProgram(path, method = 'GET', body = undefined) { + const response = await fetch(path, { + method, body, + headers: { Authorization: `Bearer ${localStorage.getItem(TOKEN_KEY) ?? ''}` }, + }); + if (response.status === 401) { + throw new Error('Notebooks are locked: open Kokkoja from the address that ' + + 'the Kokkoja program shows when it starts.'); + } + return response; +} + +/** + * @param {string} method + * @param {string[]} names Notebook name, then page name; either may be absent. + * @param {string} [body] + * @returns {Promise} + */ +function request(method, names, body) { + return askLocalProgram(['api', 'notebooks', ...names.map(encodeURIComponent)].join('/'), method, body); +} + +/** The notebooks as the local program keeps them: directories of files. */ +const inFiles = /** @type {Backend} */ ({ + async readIdentity() { + const response = await askLocalProgram('api/identity'); + return response.ok ? response.text() : null; + }, + async keepIdentity(text) { + return (await askLocalProgram('api/identity', 'PUT', text)).ok; + }, + async listNotebooks() { + const response = await request('GET', []); + if (!response.ok) throw new Error('Notebooks are not available.'); + return response.json(); + }, + async listPages(notebook) { + const response = await request('GET', [notebook]); + if (!response.ok) throw new Error(`The notebook “${notebook}” cannot be read.`); + return (await response.json()).pages; + }, + async readPage(notebook, pageName) { + const response = await request('GET', [notebook, pageName]); + return response.ok ? response.text() : null; + }, + async writePage(notebook, pageName, text) { + const response = await request('PUT', [notebook, pageName], text); + if (!response.ok) throw new Error(`The page could not be saved (${response.status}).`); + }, + async keepPage(notebook) { + const response = await request('POST', [notebook, 'keep']); + if (!response.ok) throw new Error(`The page could not be kept (${response.status}).`); + return response.json(); + }, +}); + +let backend = inFiles; + +/** Keep the notebooks in this browser: there is no local program. */ +export function useBrowserStorage() { + backend = inBrowser.backend; +} + +/** @returns {boolean} Whether the notebooks are in this browser. */ +export function notebooksInBrowser() { + return backend === inBrowser.backend; +} + +/** + * The user's identity as it is kept, or null if none is kept yet. + * @returns {Promise} Text that loadIdentity() accepts. + */ +export function readIdentity() { + return backend.readIdentity(); +} + +/** + * Give the user's identity to be kept. Only the first identity is kept; + * the result says whether this one was. + * @param {string} text From saveIdentity(). + * @returns {Promise} + */ +export function keepIdentity(text) { + return backend.keepIdentity(text); +} + +/** + * @returns {Promise<{directory: string, notebooks: string[]}>} + */ +export function listNotebooks() { + return backend.listNotebooks(); +} + +/** + * @param {string} notebook + * @returns {Promise} The names of the pages kept in the + * notebook, in order; not the page being written. + */ +export async function listKeptPages(notebook) { + const pages = await backend.listPages(notebook); + return pages.filter((name) => name !== CURRENT_PAGE); +} + +/** + * @param {string} notebook + * @param {string} pageName + * @returns {Promise} The page file, or null if there is none. + */ +export function readPage(notebook, pageName) { + return backend.readPage(notebook, pageName); +} + +/** + * @param {string} notebook + * @returns {Promise} The notebook's current page file, or + * null if it has none. + */ +export function readCurrentPage(notebook) { + return backend.readPage(notebook, CURRENT_PAGE); +} + +/** + * Keep a copy of the notebook's current page, unless it is blank or an + * identical page is already kept. + * @param {string} notebook + * @returns {Promise<{kept: string | null, new: boolean}>} The name of the + * kept page, or null if there was nothing to keep, and whether the copy + * was made just now. + */ +export function keepCurrentPage(notebook) { + return backend.keepPage(notebook); +} + +/** + * Writes the session's page to the current notebook. There is no save + * command: the page is written whenever it changes. + * + * Everything that touches the notebook's files goes through one queue, so + * that writing and keeping happen in the sequence in which they were asked + * for. + */ +export class NotebookWriter { + /** + * @param {string} notebook + * @param {() => string | null} content Produces the file of the present + * page, or null if there is no page to save yet. + * @param {(message: string, problem: boolean) => void} onStatus + */ + constructor(notebook, content, onStatus) { + this.notebook = notebook; + this.content = content; + this.onStatus = onStatus; + /** @type {number | null} */ + this.timer = null; + /** Time of the oldest change not yet written, or 0. */ + this.waitingSince = 0; + /** Time of the latest write. */ + this.lastWrite = 0; + this.queue = Promise.resolve(); + // A change that was waiting is written when the tab is hidden or shown. + document.addEventListener('visibilitychange', () => { + if (this.waitingSince) this.writeNow(); + }); + } + + /** + * Add work to the queue. A failure is reported and does not stop later + * work. + * @param {() => Promise} work + * @returns {Promise} + */ + enqueue(work) { + this.queue = this.queue.then(work).catch((error) => { + this.onStatus(error instanceof Error ? error.message : String(error), true); + // The page is still in memory: try to write it again shortly. + if (this.timer === null) this.timer = setTimeout(() => this.writeNow(), RETRY_MS); + }); + return this.queue; + } + + /** @param {string} text */ + put(text) { + return backend.writePage(this.notebook, CURRENT_PAGE, text); + } + + /** The page changed: write it soon. */ + changed() { + const now = performance.now(); + if (!this.waitingSince) this.waitingSince = now; + if (this.timer !== null) clearTimeout(this.timer); + // Browsers delay the timers of a tab that is not in view, perhaps for a + // long time. There, write at once instead of waiting for a pause. + if (document.visibilityState === 'hidden' && now - this.lastWrite >= QUIET_MS) { + this.writeNow(); + return; + } + const delay = Math.max(0, Math.min(QUIET_MS, this.waitingSince + LONGEST_WAIT_MS - now)); + this.timer = setTimeout(() => this.writeNow(), delay); + } + + /** + * Write the present page without waiting. + * @returns {Promise} + */ + writeNow() { + if (this.timer !== null) clearTimeout(this.timer); + this.timer = null; + this.waitingSince = 0; + this.lastWrite = performance.now(); + return this.enqueue(async () => { + const text = this.content(); + if (text === null) return; + await this.put(text); + this.onStatus(`Saved at ${new Date().toLocaleTimeString()}`, false); + }); + } + + /** + * The user asked to keep the page as it is now. + * @returns {Promise} The name of the kept page, or null + * if there was nothing to keep. + */ + keepPage() { + this.writeNow(); + /** @type {string | null} */ + let kept = null; + return this.enqueue(async () => { + const result = await keepCurrentPage(this.notebook); + kept = result.kept; + this.onStatus( + result.kept === null ? 'The page is blank: nothing to keep' + : result.new ? `Kept as ${result.kept}` : `Already kept as ${result.kept}`, + false); + }).then(() => kept); + } + + /** + * The session moved to another page. Keep the old one, then begin + * writing the new one. + * @param {string | null} oldText The file of the old page, or null if it + * was never a page of ours to save. + * @returns {Promise} + */ + pageReplaced(oldText) { + if (this.timer !== null) clearTimeout(this.timer); + this.timer = null; + this.waitingSince = 0; + this.enqueue(async () => { + if (oldText !== null) await this.put(oldText); + await keepCurrentPage(this.notebook); + }); + return this.writeNow(); + } + + /** + * Change to another notebook. Whatever page that notebook was left with + * is kept, unless it is this very page. + * @param {string} notebook + * @param {(text: string) => boolean} isPresentPage + * Whether a page file holds the page we are on. + * @returns {Promise} + */ + changeNotebook(notebook, isPresentPage) { + this.writeNow(); + this.enqueue(async () => { + this.notebook = notebook; + const text = await readCurrentPage(notebook); + if (text !== null && !isPresentPage(text)) await keepCurrentPage(notebook); + }); + return this.writeNow(); + } +} diff --git a/client/js/page_file.js b/client/js/page_file.js new file mode 100644 index 0000000..0d1fa12 --- /dev/null +++ b/client/js/page_file.js @@ -0,0 +1,262 @@ +// @ts-check +/** + * The page file: one self-contained SVG file per page. + * + * The file serves two readers. Any web browser, or Inkscape, shows the page + * from the ordinary SVG shapes. Kokkoja reads the page back exactly from + * `data-` attributes on the same elements, so removing a layer or a stroke + * from the file removes both what is shown and what Kokkoja reads. + * + * + * the paper + * one for each image + * + * + * what is shown + * + * + * the text as typed + * one line as shown... + * + * + * + * + * This is an experiment (see CLAUDE.md, "Not yet verified"). + */ + +import { fromBase64, toBase64 } from './crypto.js'; +import { + Page, POINT_SIZE, cleanImage, cleanName, cleanPageDescriptor, cleanStroke, cleanText, + pointCount, validId, +} from './page_model.js'; +import { partAt } from './stroke_geometry.js'; +import { LEFT_GAP, LINE_HEIGHT, TOP_GAP } from './text_layout.js'; + +/** @typedef {import('./page_model.js').Stroke} Stroke */ + +const FORMAT_VERSION = '1'; +const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; + +/** + * The base64 form of each image, so that it is computed once and not at + * every save. + * @type {WeakMap} + */ +const imageText = new WeakMap(); + +/** + * @param {import('./page_model.js').PageImage} image + * @returns {string} The element that shows the image, or '' if its bytes + * have not arrived. + */ +function imageElement(image) { + if (!image.data) return ''; + let text = imageText.get(image.data); + if (text === undefined) { + text = toBase64(image.data); + imageText.set(image.data, text); + } + return ``; +} + +/** + * @param {string} text + * @returns {string} The text, safe inside a double-quoted XML attribute. + */ +function escaped(text) { + return text.replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"'); +} + +/** + * @param {string} text + * @returns {string} The text, safe as XML content. + */ +function escapedContent(text) { + return text.replace(/&/g, '&').replace(//g, '>'); +} + +/** + * The elements that show and record a text block. + * @param {import('./page_model.js').TextBlock} block + * @returns {string} + */ +function textElements(block) { + const lines = block.lines.map((line, i) => + `${escapedContent(line)}`); + return `` + + `${escapedContent(block.text)}` + + `${lines.join('')}`; +} + +/** + * @param {number} value + * @returns {string} The number with at most two decimals. + */ +function number(value) { + return String(Math.round(value * 100) / 100); +} + +/** + * The shapes that show a stroke. Consecutive parts of the same width (to + * 0.05 mm, far finer than the eye or a printer resolves) are joined into + * one path, since each part begins where the one before it ended. The + * exact pressures are in `data-points`, so nothing is lost by this. + * @param {Stroke} stroke + * @returns {string} + */ +function strokeShapes(stroke) { + const count = pointCount(stroke); + if (count === 0) return ''; + if (count === 1) { + const dot = partAt(stroke, 0); + return ``; + } + let shapes = ''; + let path = ''; + let pathWidth = ''; + for (let k = 0; k < count; k++) { + const part = partAt(stroke, k); + const width = (Math.round(part.width * 2) / 2).toString(); + if (width !== pathWidth) { + if (path) shapes += ``; + path = `M${number(part.fromX)} ${number(part.fromY)}`; + pathWidth = width; + } + path += `Q${number(part.x)} ${number(part.y)} ${number(part.toX)} ${number(part.toY)}`; + } + return shapes + ``; +} + +/** + * @param {Page} page + * @param {object} options + * @param {string} options.session The session the page belongs to (its + * room id), so that returning to the session continues the page. + * @param {string} options.hostId + * @param {Set} [options.hidden] Layers to leave out: for a picture + * of the page as someone shows it, never for the page file itself. + * @returns {string} The page file. + */ +export function pageToSvg(page, { session, hostId, hidden }) { + const lines = [ + '', + ``, + `Kokkoja page ${page.number}`, + `', + ]; + for (const image of page.images) { + const shown = imageElement(image); + if (shown) lines.push(shown); + } + for (const id of page.layerOrder(hostId)) { + const layer = page.layers.get(id); + if (!layer || layer.isEmpty() || hidden?.has(id)) continue; + lines.push(``); + for (const stroke of layer.strokes.values()) { + if (pointCount(stroke) === 0) continue; + lines.push( + `${strokeShapes(stroke)}`); + } + for (const block of layer.texts.values()) lines.push(textElements(block)); + lines.push(''); + } + lines.push('', ''); + return lines.join('\n'); +} + +/** + * Read a page file. + * @param {string} text + * @returns {{page: Page, hostId: string, session: string} | null} Null if + * the text is not a Kokkoja page. + */ +export function pageFromSvg(text) { + const root = new DOMParser().parseFromString(text, 'image/svg+xml').documentElement; + if (root.localName !== 'svg' || root.getAttribute('data-kokkoja') !== FORMAT_VERSION) return null; + const box = (root.getAttribute('viewBox') ?? '').split(/\s+/).map(Number); + if (box.length !== 4 || !(box[2] > 0) || !(box[3] > 0)) return null; + const base = (root.getAttribute('data-base') ?? '').split(/\s+/).map(Number); + + const descriptor = cleanPageDescriptor({ + id: root.getAttribute('data-page'), number: Number(root.getAttribute('data-number')), + width: base.length === 2 ? base[0] : box[2], height: base.length === 2 ? base[1] : box[3], + revision: Number(root.getAttribute('data-revision') ?? 0), + }); + if (!descriptor) return null; + + const page = new Page(descriptor.width, descriptor.height, descriptor.id, descriptor.number); + page.imagesRevision = descriptor.revision; + /** @type {Map>} An image shown twice is held once. */ + const held = new Map(); + for (const shown of root.querySelectorAll('image[data-hash]')) { + const address = /^data:(image\/png|image\/jpeg);base64,(.*)$/s.exec(shown.getAttribute('href') ?? ''); + if (!address) continue; + const hash = shown.getAttribute('data-hash') ?? ''; + const data = held.get(hash) ?? fromBase64(address[2]); + const image = cleanImage({ + type: address[1], size: data.length, hash, + x: Number(shown.getAttribute('x')), y: Number(shown.getAttribute('y')), + width: Number(shown.getAttribute('width')), height: Number(shown.getAttribute('height')), + }); + if (!image) continue; + held.set(hash, data); + page.images.push({ ...image, data }); + } + for (const group of root.querySelectorAll('g[data-layer]')) { + const layerId = group.getAttribute('data-layer'); + if (!validId(layerId)) continue; + const layer = page.layer(layerId, cleanName(group.getAttribute('data-name'))); + for (const element of group.querySelectorAll('g[data-stroke]')) { + const points = (element.getAttribute('data-points') ?? '').split(/\s+/).map(Number); + const stroke = cleanStroke({ + id: element.getAttribute('data-stroke'), + color: element.getAttribute('stroke'), + width: Number(element.getAttribute('data-width')), + points, + done: true, + }); + if (stroke && points.length >= POINT_SIZE) layer.strokes.set(stroke.id, stroke); + } + for (const element of group.querySelectorAll('g[data-text]')) { + const widthText = element.getAttribute('data-width') ?? ''; + const box = (element.getAttribute('data-box') ?? '').split(' ').map(Number); + const block = cleanText({ + id: element.getAttribute('data-text'), + x: Number(element.getAttribute('data-x')), y: Number(element.getAttribute('data-y')), + width: widthText === '' ? null : Number(widthText), + size: Number(element.getAttribute('data-size')), + font: element.getAttribute('data-font') ?? 'sans-serif', + color: element.querySelector('text')?.getAttribute('fill'), + text: element.querySelector('desc')?.textContent ?? '', + lines: [...element.querySelectorAll('tspan')].map((span) => span.textContent ?? ''), + boxWidth: box[0], boxHeight: box[1], + }); + if (block) layer.texts.set(block.id, block); + } + } + page.recomputeStrokeBounds(); + page.updateExtent(); + return { + page, + hostId: root.getAttribute('data-host') ?? '', + session: root.getAttribute('data-session') ?? '', + }; +} diff --git a/client/js/page_model.js b/client/js/page_model.js new file mode 100644 index 0000000..cc3c5f0 --- /dev/null +++ b/client/js/page_model.js @@ -0,0 +1,629 @@ +// @ts-check +/** + * The page: a stack of layers, one per participant, each holding that + * participant's strokes. Coordinates are in page units of 0.1 mm. + * + * This module holds data only. It does not draw and does not talk to the + * network. + */ + +/** A blank page is A4. */ +export const BLANK_PAGE_WIDTH = 2100; +export const BLANK_PAGE_HEIGHT = 2970; + +/** + * The page grows to keep this much room around everything written on it, + * in steps of the same amount so that it does not shift with every pen + * point. 8 mm, Andy's choice (2026-10-03; 15 mm before). + */ +export const WRITING_MARGIN = 80; + +/** + * An image is placed this far inside the sheet's edge. 8 mm, Andy's + * choice; it is deliberately a separate setting from WRITING_MARGIN. + */ +export const IMAGE_MARGIN = 80; + +/** + * @typedef {object} Stroke + * @property {string} id + * @property {string} color CSS color. + * @property {number} width Nominal width in page units. + * @property {number[]} points x, y, pressure for each point, in sequence. + * Pressure is 0 to 1. + * @property {boolean} done False while the stroke is being drawn. + */ + +/** Numbers per point in Stroke.points. */ +export const POINT_SIZE = 3; + +/** + * Typed text on a layer. Its top-left corner is (x, y); with a width it + * wraps there, without one it breaks lines only at the writer's Enter. + * @typedef {object} TextBlock + * @property {string} id + * @property {number} x + * @property {number} y + * @property {number | null} width Page units, or null for no wrapping. + * @property {string} text As typed, with the writer's line breaks. + * @property {string[]} lines As shown, laid out by the writer's client. + * @property {number} size Font size in page units. + * @property {TextFont} font One of the families every computer has. + * @property {string} color + * @property {number} boxWidth The rectangle the lines take up. + * @property {number} boxHeight + */ + +/** + * The font families a block may use: the generic ones, so that the page + * file looks right on any computer. + * @typedef {'sans-serif' | 'serif' | 'monospace'} TextFont + */ +export const TEXT_FONTS = /** @type {const} */ (['sans-serif', 'serif', 'monospace']); + +/** Page units (0.1 mm) in a typographic point. */ +export const UNITS_PER_POINT = 254 / 72; +/** Text sizes in points that may be chosen. */ +export const LEAST_TEXT_POINTS = 6; +export const MOST_TEXT_POINTS = 100; +/** The size to begin with: 14 points (Andy, 2026-10-03). */ +export const TEXT_SIZE = Math.round(14 * UNITS_PER_POINT * 10) / 10; +const MAX_TEXT_CHARACTERS = 20000; +const MAX_TEXT_LINES = 2000; + +/** + * @param {Stroke} stroke + * @returns {number} + */ +export function pointCount(stroke) { + return stroke.points.length / POINT_SIZE; +} + +/** + * Add points to a stroke. `at` is the number of points that preceded these + * when they were sent, so that points received twice are added only once. + * @param {Stroke} stroke + * @param {number} at + * @param {number[]} points + */ +export function appendPoints(stroke, at, points) { + const overlap = Math.max(0, pointCount(stroke) - at); + for (let i = overlap * POINT_SIZE; i < points.length; i++) stroke.points.push(points[i]); +} + +/** + * Round a coordinate or a pressure for storage and sending. + * @param {number} value + * @param {number} scale 10 for coordinates (0.01 mm), 1000 for pressure. + */ +export function rounded(value, scale) { + return Math.round(value * scale) / scale; +} + +// Checking data that comes from outside: other participants and page files. + +export const MAX_NAME_LENGTH = 40; +const MAX_ID_LENGTH = 40; +const MAX_STROKE_WIDTH = 200; +const COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/; + +/** A longer stroke is split, so that one stroke always fits in a message. */ +export const MAX_STROKE_POINTS = 20000; + +/** + * @param {unknown} name + * @returns {string} + */ +export function cleanName(name) { + return typeof name === 'string' ? name.trim().slice(0, MAX_NAME_LENGTH) : ''; +} + +/** + * @param {unknown} id + * @returns {id is string} + */ +export function validId(id) { + return typeof id === 'string' && id.length > 0 && id.length <= MAX_ID_LENGTH; +} + +/** + * @param {unknown} points + * @returns {points is number[]} + */ +export function validPoints(points) { + return Array.isArray(points) && points.length % POINT_SIZE === 0 && + points.length <= MAX_STROKE_POINTS * POINT_SIZE && points.every(Number.isFinite); +} + +/** + * Check a stroke that came from outside. + * @param {any} received + * @returns {Stroke | null} A stroke that is safe to keep, or null. + */ +export function cleanStroke(received) { + if (!received || !validId(received.id) || !COLOR_PATTERN.test(received.color) || + !(received.width > 0 && received.width <= MAX_STROKE_WIDTH)) return null; + const points = received.points ?? []; + if (!validPoints(points)) return null; + return { + id: received.id, color: received.color, width: received.width, + points: points.slice(), done: received.done !== false, + }; +} + +/** + * Check a text block that came from outside. + * @param {any} received + * @returns {TextBlock | null} + */ +export function cleanText(received) { + if (!received || !validId(received.id) || !COLOR_PATTERN.test(received.color) || + typeof received.text !== 'string' || received.text.length > MAX_TEXT_CHARACTERS || + !Array.isArray(received.lines) || received.lines.length > MAX_TEXT_LINES || + !received.lines.every((/** @type {unknown} */ line) => typeof line === 'string') || + ![received.x, received.y].every((v) => Number.isFinite(v) && Math.abs(v) <= 10 * MAX_PAGE_SIZE) || + (received.width !== null && !(received.width >= 10 && received.width <= MAX_PAGE_SIZE)) || + !(received.size >= 10 && received.size <= 400) || + !TEXT_FONTS.includes(received.font) || + !(received.boxWidth >= 0 && received.boxWidth <= MAX_PAGE_SIZE) || + !(received.boxHeight >= 0 && received.boxHeight <= 10 * MAX_PAGE_SIZE)) return null; + return { + id: received.id, x: received.x, y: received.y, width: received.width, + text: received.text, lines: received.lines.slice(), size: received.size, font: received.font, + color: received.color, boxWidth: received.boxWidth, boxHeight: received.boxHeight, + }; +} + +export class Layer { + /** + * @param {string} id The participant's id. + * @param {string} name The participant's display name. + */ + constructor(id, name) { + this.id = id; + this.name = name; + /** @type {Map} In drawing sequence. */ + this.strokes = new Map(); + /** @type {Map} In the sequence of making. */ + this.texts = new Map(); + } + + /** @returns {boolean} */ + isEmpty() { + return this.strokes.size === 0 && this.texts.size === 0; + } +} + +/** + * An image on a page, usually a scanned exercise, and where it lies on the + * page. A page may hold several, one below the other. + * @typedef {object} PageImage + * @property {'image/png' | 'image/jpeg'} type + * @property {number} size Bytes. + * @property {string} hash SHA-256 of the bytes, base64url. + * @property {number} x Its rectangle on the page, in page units. + * @property {number} y + * @property {number} width + * @property {number} height + * @property {Uint8Array | null} data Null until the bytes + * have arrived from another participant. + */ + +/** + * What a descriptor says about an image: everything but the bytes. + * @param {PageImage} image + * @returns {Omit} + */ +function withoutData(image) { + const { data, ...rest } = image; + return rest; +} + +/** + * What identifies a page and gives its size. The host sends it to the + * others when the page changes. + * @typedef {object} PageDescriptor + * @property {string} id + * @property {number} number Counts the pages of a session from 1. + * @property {number} width + * @property {number} height + * @property {Omit[]} images From the top of the page down. + * @property {number} revision Counts the changes made to `images`, so + * that of two lists the later one can be told. + */ + +/** + * A rectangle in page units. + * @typedef {{left: number, top: number, width: number, height: number}} Rectangle + */ + +const MAX_PAGE_SIZE = 20000; + +/** An image larger than this is made smaller before it is shared. */ +export const MAX_IMAGE_BYTES = 4 << 20; + +/** + * A page holds at most this many images, and this many bytes of them, + * because the page file holds them all and is rewritten at every save. + */ +export const MAX_PAGE_IMAGES = 12; +export const MAX_PAGE_IMAGE_BYTES = 24 << 20; + +/** + * Check the description of a page's image that came from outside. + * @param {any} received + * @returns {PageImage | null} + */ +export function cleanImage(received) { + if (!received || (received.type !== 'image/png' && received.type !== 'image/jpeg') || + !Number.isInteger(received.size) || received.size < 1 || received.size > MAX_IMAGE_BYTES || + typeof received.hash !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(received.hash) || + ![received.x, received.y].every((v) => Number.isFinite(v) && Math.abs(v) <= MAX_PAGE_SIZE) || + ![received.width, received.height].every((v) => v >= 1 && v <= MAX_PAGE_SIZE)) return null; + return { + type: received.type, size: received.size, hash: received.hash, + x: received.x, y: received.y, width: received.width, height: received.height, data: null, + }; +} + +/** + * Where an image of a given natural size goes on a page: centred across + * the A4 sheet, inside the margin (Andy, 2026-10-03: his teacher's scans + * are often partial and wider than they are tall). The first image lies + * at the top of the sheet; each later one lies below the lowest image + * already there, the margin apart, so that a page can be made of a series + * of images (Andy, 2026-10-05). + * + * An image wider than the sheet's width inside the margin is scaled down + * to it, or, if `shrink` is false, keeps its natural size: it then begins + * at the left margin and the sheet is widened to hold it (Andy, + * 2026-10-05, as a choice to try beside the scaling). + * @param {number} naturalWidth In page units. + * @param {number} naturalHeight + * @param {number} [below] The bottom edge of the lowest image already on + * the page; 0 if there is none. + * @param {boolean} [shrink] + * @returns {{x: number, y: number, width: number, height: number, + * pageWidth: number, pageHeight: number}} + * The image's rectangle, and how large the sheet must be to hold it. + */ +export function placeImage(naturalWidth, naturalHeight, below = 0, shrink = true) { + const room = BLANK_PAGE_WIDTH - 2 * IMAGE_MARGIN; + // At its natural size an image may still be no wider than a page can be. + const scale = Math.min(1, (shrink ? room : MAX_PAGE_SIZE - 2 * IMAGE_MARGIN) / naturalWidth); + const width = Math.round(naturalWidth * scale); + const height = Math.round(naturalHeight * scale); + const x = width <= room ? Math.round((BLANK_PAGE_WIDTH - width) / 2) : IMAGE_MARGIN; + const y = Math.round(below) + IMAGE_MARGIN; + return { + x, y, width, height, + pageWidth: Math.max(BLANK_PAGE_WIDTH, x + width + IMAGE_MARGIN), + pageHeight: Math.max(BLANK_PAGE_HEIGHT, y + height + IMAGE_MARGIN), + }; +} + +/** + * Check a page descriptor that came from outside. + * @param {any} received + * @returns {PageDescriptor | null} + */ +export function cleanPageDescriptor(received) { + if (!received || !validId(received.id) || !Number.isInteger(received.number) || + received.number < 1 || + !(received.width >= 100 && received.width <= MAX_PAGE_SIZE) || + !(received.height >= 100 && received.height <= MAX_PAGE_SIZE)) return null; + const listed = received.images ?? []; + if (!Array.isArray(listed) || listed.length > MAX_PAGE_IMAGES) return null; + const images = []; + for (const one of listed) { + const image = cleanImage(one); + if (!image) return null; + images.push(withoutData(image)); + } + return { + id: received.id, number: received.number, width: received.width, height: received.height, + images, + revision: Number.isInteger(received.revision) && received.revision >= 0 ? received.revision : 0, + }; +} + +export class Page { + /** + * @param {number} [width] + * @param {number} [height] + * @param {string} [id] Empty for the placeholder a guest shows + * before the host's page arrives. + * @param {number} [number] 0 for the placeholder. + */ + constructor(width = BLANK_PAGE_WIDTH, height = BLANK_PAGE_HEIGHT, id = '', number = 0) { + /** + * The size of the sheet before anything is written on it: A4, or taller + * if its images need the room. + */ + this.baseWidth = width; + this.baseHeight = height; + this.id = id; + this.number = number; + /** @type {PageImage[]} From the top of the page down. */ + this.images = []; + /** Counts the changes made to `images`. */ + this.imagesRevision = 0; + /** The page's present rectangle. See updateExtent(). */ + this.left = 0; + this.top = 0; + this.width = width; + this.height = height; + /** @type {Rectangle | null} Around every stroke, or null if none. */ + this.strokeBounds = null; + /** @type {Map} */ + this.layers = new Map(); + } + + /** @returns {PageDescriptor} */ + descriptor() { + return { + id: this.id, number: this.number, width: this.baseWidth, height: this.baseHeight, + images: this.images.map(withoutData), + revision: this.imagesRevision, + }; + } + + /** @returns {number} The bottom edge of the lowest image; 0 if there is none. */ + imagesBottom() { + return Math.max(0, ...this.images.map((image) => image.y + image.height)); + } + + /** @returns {number} How tall the sheet must be to hold its images: A4 at least. */ + sheetHeight() { + return Math.max(BLANK_PAGE_HEIGHT, this.imagesBottom() + IMAGE_MARGIN); + } + + /** @returns {number} How wide the sheet must be to hold its images: A4 at least. */ + sheetWidth() { + return Math.max(BLANK_PAGE_WIDTH, + ...this.images.map((image) => image.x + image.width + IMAGE_MARGIN)); + } + + /** @returns {boolean} Whether its images have made the sheet larger than A4. */ + isLargerThanSheet() { + return this.baseWidth > BLANK_PAGE_WIDTH || this.baseHeight > BLANK_PAGE_HEIGHT; + } + + /** + * Whether one more image may be added, given what the page holds. + * @param {number} size Bytes of the image. + * @param {number} bottom Where its bottom edge would lie. + * @returns {boolean} + */ + hasRoomForImage(size, bottom) { + const bytes = this.images.reduce((sum, image) => sum + image.size, 0); + return this.images.length < MAX_PAGE_IMAGES && bytes + size <= MAX_PAGE_IMAGE_BYTES && + bottom + IMAGE_MARGIN <= MAX_PAGE_SIZE; + } + + /** + * The rectangle the page has before anything is written on it: the + * sheet, which holds the images if there are any. + * @returns {Rectangle} + */ + baseExtent() { + return { left: 0, top: 0, width: this.baseWidth, height: this.baseHeight }; + } + + /** + * Widen the record of where strokes lie to include these points. + * @param {Stroke} stroke + * @param {number} [from] Index of the first point not yet included. + */ + includeStroke(stroke, from = 0) { + const p = stroke.points; + const reach = stroke.width * 2; // The drawn width at full pressure, and a little more. + let b = this.strokeBounds; + for (let i = from * POINT_SIZE; i < p.length; i += POINT_SIZE) { + const x = p[i]; + const y = p[i + 1]; + if (!b) { + b = this.strokeBounds = { left: x - reach, top: y - reach, width: 2 * reach, height: 2 * reach }; + continue; + } + const right = Math.max(b.left + b.width, x + reach); + const bottom = Math.max(b.top + b.height, y + reach); + b.left = Math.min(b.left, x - reach); + b.top = Math.min(b.top, y - reach); + b.width = right - b.left; + b.height = bottom - b.top; + } + } + + /** + * Widen the record of where things lie to include a text block. + * @param {TextBlock} block + */ + includeText(block) { + const corners = [block.x, block.y, 0, block.x + block.boxWidth, block.y + block.boxHeight, 0]; + this.includeStroke({ id: '', color: '', width: 0, points: corners, done: true }); + } + + /** Find where strokes and text lie again, after some were removed or replaced. */ + recomputeStrokeBounds() { + this.strokeBounds = null; + for (const layer of this.layers.values()) { + for (const stroke of layer.strokes.values()) this.includeStroke(stroke); + for (const block of layer.texts.values()) this.includeText(block); + } + } + + /** + * Set the page's rectangle from what is on it: the base rectangle, + * widened to hold every stroke with the margin around it, in steps of + * the margin. The same contents give the same rectangle on every + * participant's computer. + * @returns {boolean} Whether the rectangle changed. + */ + updateExtent() { + const base = this.baseExtent(); + let { left, top } = base; + let right = base.left + base.width; + let bottom = base.top + base.height; + const b = this.strokeBounds; + if (b) { + const step = WRITING_MARGIN; + left = Math.min(left, Math.floor((b.left - WRITING_MARGIN) / step) * step); + top = Math.min(top, Math.floor((b.top - WRITING_MARGIN) / step) * step); + right = Math.max(right, Math.ceil((b.left + b.width + WRITING_MARGIN) / step) * step); + bottom = Math.max(bottom, Math.ceil((b.top + b.height + WRITING_MARGIN) / step) * step); + } + const changed = left !== this.left || top !== this.top || + right - left !== this.width || bottom - top !== this.height; + this.left = left; + this.top = top; + this.width = right - left; + this.height = bottom - top; + return changed; + } + + /** + * An empty page from its descriptor. + * @param {PageDescriptor} descriptor + * @returns {Page} + */ + static from(descriptor) { + const page = new Page(descriptor.width, descriptor.height, descriptor.id, descriptor.number); + page.images = descriptor.images.map((image) => ({ ...image, data: null })); + page.imagesRevision = descriptor.revision; + page.updateExtent(); + return page; + } + + /** @returns {boolean} Nothing is written on the page. */ + isBlank() { + for (const layer of this.layers.values()) { + if (!layer.isEmpty()) return false; + } + return true; + } + + /** + * Layer ids from bottom to top. The host's layer is on top so that + * corrections lie over the writing they correct. + * @param {string} hostId + * @returns {string[]} + */ + layerOrder(hostId) { + return [...this.layers.keys()].sort((a, b) => + Number(a === hostId) - Number(b === hostId) || a.localeCompare(b)); + } + + /** + * The layer of a participant, created if it does not exist. + * @param {string} id + * @param {string} [name] + * @returns {Layer} + */ + layer(id, name) { + let layer = this.layers.get(id); + if (!layer) { + layer = new Layer(id, name ?? ''); + this.layers.set(id, layer); + } else if (name) { + layer.name = name; + } + return layer; + } +} + +/** + * Squared distance from point (px, py) to the segment (ax, ay)-(bx, by). + * @param {number} px + * @param {number} py + * @param {number} ax + * @param {number} ay + * @param {number} bx + * @param {number} by + */ +function distanceSquaredToSegment(px, py, ax, ay, bx, by) { + const dx = bx - ax; + const dy = by - ay; + const lengthSquared = dx * dx + dy * dy; + let t = lengthSquared === 0 ? 0 : ((px - ax) * dx + (py - ay) * dy) / lengthSquared; + t = Math.max(0, Math.min(1, t)); + const cx = ax + t * dx - px; + const cy = ay + t * dy - py; + return cx * cx + cy * cy; +} + +/** + * The text blocks of a layer whose rectangle the segment from (x0, y0) to + * (x1, y1) passes through, widened by `radius`. + * @param {Layer} layer + * @param {number} x0 + * @param {number} y0 + * @param {number} x1 + * @param {number} y1 + * @param {number} radius + * @returns {string[]} Text block ids. + */ +export function textsTouched(layer, x0, y0, x1, y1, radius) { + const touched = []; + const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / radius)); + for (const block of layer.texts.values()) { + for (let s = 0; s <= steps; s++) { + const x = x0 + (x1 - x0) * s / steps; + const y = y0 + (y1 - y0) * s / steps; + if (x >= block.x - radius && x <= block.x + block.boxWidth + radius && + y >= block.y - radius && y <= block.y + block.boxHeight + radius) { + touched.push(block.id); + break; + } + } + } + return touched; +} + +/** + * @param {Layer} layer + * @param {number} x + * @param {number} y + * @returns {TextBlock | null} The block of the layer at the point, if any. + */ +export function textAt(layer, x, y) { + for (const block of [...layer.texts.values()].reverse()) { + if (x >= block.x && x <= block.x + block.boxWidth && y >= block.y && y <= block.y + block.boxHeight) { + return block; + } + } + return null; +} + +/** + * The strokes of a layer that pass within `radius` of the segment from + * (x0, y0) to (x1, y1): what an eraser moved along that segment touches. + * @param {Layer} layer + * @param {number} x0 + * @param {number} y0 + * @param {number} x1 + * @param {number} y1 + * @param {number} radius + * @returns {string[]} Stroke ids. + */ +export function strokesTouched(layer, x0, y0, x1, y1, radius) { + const touched = []; + const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / radius)); + for (const stroke of layer.strokes.values()) { + const points = stroke.points; + const reach = radius + stroke.width; + const reachSquared = reach * reach; + let hit = false; + for (let s = 0; s <= steps && !hit; s++) { + const ex = x0 + (x1 - x0) * s / steps; + const ey = y0 + (y1 - y0) * s / steps; + for (let i = 0; i < points.length && !hit; i += POINT_SIZE) { + const j = Math.min(i + POINT_SIZE, points.length - POINT_SIZE); + hit = distanceSquaredToSegment( + ex, ey, points[i], points[i + 1], points[j], points[j + 1]) <= reachSquared; + } + } + if (hit) touched.push(stroke.id); + } + return touched; +} diff --git a/client/js/page_picture.js b/client/js/page_picture.js new file mode 100644 index 0000000..e7f6515 --- /dev/null +++ b/client/js/page_picture.js @@ -0,0 +1,88 @@ +// @ts-check +/** + * A picture of a page: the page file drawn into a PNG, for use outside + * Kokkoja. + */ + +import { pageToSvg } from './page_file.js'; + +/** + * The resolution of an exported picture, in dots per inch: enough to print + * handwriting cleanly, and an A4 page is then 1654 by 2339 pixels. + */ +export const EXPORT_DPI = 200; + +/** A very large page is exported at a lower resolution, to stay within this. */ +const MOST_PIXELS_ON_A_SIDE = 10000; + +const CRC_TABLE = new Uint32Array(256).map((_, n) => { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c; +}); + +/** + * @param {Uint8Array} bytes + * @returns {number} The checksum that ends a PNG chunk. + */ +function crc32(bytes) { + let c = 0xffffffff; + for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +/** + * Record a PNG's resolution in it (a pHYs chunk, placed after the header + * chunk), so that other programs print the page at its true size, and so + * that Kokkoja places it at that size if it is loaded as an image. + * @param {Uint8Array} png + * @param {number} dpi + * @returns {Uint8Array} + */ +function withResolution(png, dpi) { + const HEADER_END = 8 + 25; // The signature, then the IHDR chunk. + const chunk = new Uint8Array(21); + const view = new DataView(chunk.buffer); + const perMetre = Math.round(dpi / 0.0254); + view.setUint32(0, 9); + chunk.set([0x70, 0x48, 0x59, 0x73], 4); // "pHYs" + view.setUint32(8, perMetre); + view.setUint32(12, perMetre); + chunk[16] = 1; // The unit is the metre. + view.setUint32(17, crc32(chunk.subarray(4, 17))); + const out = new Uint8Array(png.length + chunk.length); + out.set(png.subarray(0, HEADER_END)); + out.set(chunk, HEADER_END); + out.set(png.subarray(HEADER_END), HEADER_END + chunk.length); + return out; +} + +/** + * @param {import('./page_model.js').Page} page + * @param {Parameters[1]} options As for the page file; + * layers named in `hidden` are left out. + * @returns {Promise} A PNG picture of the page. + */ +export async function pageToPng(page, options) { + const scale = Math.min(EXPORT_DPI / 254, MOST_PIXELS_ON_A_SIDE / Math.max(page.width, page.height)); + const address = URL.createObjectURL( + new Blob([pageToSvg(page, options)], { type: 'image/svg+xml' })); + try { + const drawing = new Image(); + drawing.src = address; + await drawing.decode(); + const canvas = document.createElement('canvas'); + canvas.width = Math.round(page.width * scale); + canvas.height = Math.round(page.height * scale); + const context = /** @type {CanvasRenderingContext2D} */ (canvas.getContext('2d')); + context.drawImage(drawing, 0, 0, canvas.width, canvas.height); + /** @type {Blob} */ + const blob = await new Promise((resolve, reject) => canvas.toBlob( + (result) => (result ? resolve(result) : reject(new Error('The picture could not be made.'))), + 'image/png')); + const png = withResolution(new Uint8Array(await blob.arrayBuffer()), scale * 254); + return new Blob([png], { type: 'image/png' }); + } finally { + URL.revokeObjectURL(address); + } +} diff --git a/client/js/pen_input.js b/client/js/pen_input.js new file mode 100644 index 0000000..a185f0e --- /dev/null +++ b/client/js/pen_input.js @@ -0,0 +1,170 @@ +// @ts-check +/** + * Turns pointer events (pen, mouse or finger) on the page into strokes and + * erasures. + */ + +import { rounded } from './page_model.js'; + +/** Bit in PointerEvent.buttons for the eraser end of a pen. */ +const ERASER_BUTTON = 32; + +/** + * Pressure recorded for a mouse or a finger, which report none. It is the + * pressure at which a stroke is drawn at its nominal width. + */ +const NOMINAL_PRESSURE = 0.4; + +/** + * @typedef {object} PenTarget + * @property {(clientX: number, clientY: number) => [number, number]} toPage + * Convert a window position to page units. + * @property {() => 'pen' | 'eraser' | 'text'} tool The tool selected in the toolbar. + * @property {() => boolean} canWrite False while there is no page to write on. + * @property {() => void} beginStroke + * @property {(points: number[]) => void} addPoints + * @property {() => void} endStroke + * @property {(x0: number, y0: number, x1: number, y1: number) => void} eraseAlong + * @property {(x: number, y: number, reach: number) => void} [eraseEnded] + * The eraser went down at (x, y) and has come up again, having moved + * no farther than `reach` from there. + * @property {(x0: number, y0: number, x1: number, y1: number) => void} placeText + * With the text tool, the pointer went down at (x0, y0) and up at (x1, y1). + * @property {(x0: number, y0: number, x1: number) => void} [previewText] + * With the text tool, the pointer is down at (x0, y0) and now at x1. + + * @property {(report: PenReport) => void} [onReport] + */ + +/** + * What the input device is delivering, for judging pen support. + * @typedef {object} PenReport + * @property {string} pointerType 'pen', 'mouse' or 'touch'. + * @property {number} pressure Latest pressure. + * @property {number} pointsPerSecond + */ + +/** + * @param {HTMLElement} surface The element that receives the pointer. + * @param {PenTarget} target + */ +export function attachPenInput(surface, target) { + /** @type {number | null} */ + let activePointer = null; + let erasing = false; + let placingText = false; + let textStartX = 0; + let textStartY = 0; + let eraseStartX = 0; + let eraseStartY = 0; + let eraseReach = 0; + let penSeen = false; + let lastX = NaN; + let lastY = NaN; + let strokeStart = 0; + let strokePoints = 0; + + /** + * @param {PointerEvent} event + * @returns {PointerEvent[]} Every position the pointer reported since + * the previous event, not only the latest. + */ + function allEvents(event) { + const coalesced = event.getCoalescedEvents ? event.getCoalescedEvents() : []; + return coalesced.length ? coalesced : [event]; + } + + /** @param {PointerEvent} event */ + function draw(event) { + /** @type {number[]} */ + const points = []; + let pressure = 0; + for (const e of allEvents(event)) { + const [x, y] = target.toPage(e.clientX, e.clientY); + const px = rounded(x, 10); + const py = rounded(y, 10); + if (px === lastX && py === lastY) continue; + pressure = e.pointerType === 'pen' ? rounded(e.pressure, 1000) : NOMINAL_PRESSURE; + points.push(px, py, pressure); + lastX = px; + lastY = py; + } + if (points.length === 0) return; + target.addPoints(points); + strokePoints += points.length / 3; + const seconds = (performance.now() - strokeStart) / 1000; + target.onReport?.({ + pointerType: event.pointerType, pressure, + pointsPerSecond: seconds > 0.2 ? Math.round(strokePoints / seconds) : 0, + }); + } + + /** @param {PointerEvent} event */ + function erase(event) { + for (const e of allEvents(event)) { + const [x, y] = target.toPage(e.clientX, e.clientY); + target.eraseAlong(Number.isNaN(lastX) ? x : lastX, Number.isNaN(lastY) ? y : lastY, x, y); + eraseReach = Math.max(eraseReach, Math.hypot(x - eraseStartX, y - eraseStartY)); + lastX = x; + lastY = y; + } + } + + surface.addEventListener('pointerdown', (event) => { + if (activePointer !== null || !target.canWrite() || event.target !== surface) return; + if (event.pointerType === 'pen') penSeen = true; + // Once a pen has been used, a touch is taken to be the resting hand. + if (event.pointerType === 'touch' && penSeen) return; + if (event.pointerType === 'mouse' && event.button !== 0) return; + event.preventDefault(); + surface.setPointerCapture(event.pointerId); + activePointer = event.pointerId; + const tool = target.tool(); + erasing = tool === 'eraser' || (event.buttons & ERASER_BUTTON) !== 0; + placingText = tool === 'text' && !erasing; + lastX = NaN; + lastY = NaN; + strokeStart = performance.now(); + strokePoints = 0; + if (placingText) { + [textStartX, textStartY] = target.toPage(event.clientX, event.clientY); + } else if (erasing) { + [eraseStartX, eraseStartY] = target.toPage(event.clientX, event.clientY); + eraseReach = 0; + erase(event); + } else { + target.beginStroke(); + draw(event); + } + }); + + surface.addEventListener('pointermove', (event) => { + if (event.pointerId !== activePointer) return; + if (placingText) { + const [x] = target.toPage(event.clientX, event.clientY); + target.previewText?.(textStartX, textStartY, x); + return; + } + if (erasing) erase(event); + else draw(event); + }); + + /** @param {PointerEvent} event */ + function finish(event) { + if (event.pointerId !== activePointer) return; + activePointer = null; + if (placingText) { + const [x, y] = target.toPage(event.clientX, event.clientY); + if (event.type === 'pointerup') target.placeText(textStartX, textStartY, x, y); + } else if (erasing) { + if (event.type === 'pointerup') target.eraseEnded?.(eraseStartX, eraseStartY, eraseReach); + } else { + target.endStroke(); + } + } + surface.addEventListener('pointerup', finish); + surface.addEventListener('pointercancel', finish); + + // The side button of a pen asks for a context menu. + surface.addEventListener('contextmenu', (event) => event.preventDefault()); +} diff --git a/client/js/relay_connection.js b/client/js/relay_connection.js new file mode 100644 index 0000000..fcd0446 --- /dev/null +++ b/client/js/relay_connection.js @@ -0,0 +1,107 @@ +// @ts-check +/** + * The connection to the relay. It knows nothing about pages or encryption: + * it delivers bytes to peers and reports who is present. It reconnects by + * itself when the connection drops. + */ + +const RETRY_DELAYS_MS = [500, 1000, 2000, 5000]; + +/** Close codes after which retrying is pointless (see the relay). */ +const FINAL_CLOSE_CODES = new Map([ + [4001, 'The session is full.'], + [4002, 'The relay is full.'], +]); + +/** + * @typedef {object} RelayHandlers + * @property {(peer: number, peers: number[]) => void} onWelcome + * Connected (or reconnected): our peer number and the peers present. + * @property {(peer: number) => void} onJoin + * @property {(peer: number) => void} onLeave + * @property {(peer: number, body: Uint8Array) => void} onMessage + * @property {(status: 'connecting' | 'connected' | 'failed', detail?: string) => void} onStatus + */ + +export class RelayConnection { + /** + * @param {string} relay Address of the relay, ws:// or wss://. + * @param {string} roomId + * @param {RelayHandlers} handlers + */ + constructor(relay, roomId, handlers) { + this.url = `${relay}/room/${roomId}`; + this.handlers = handlers; + /** @type {WebSocket | null} */ + this.socket = null; + this.attempt = 0; + this.closed = false; + } + + connect() { + if (this.closed) return; + this.handlers.onStatus('connecting'); + const socket = new WebSocket(this.url); + socket.binaryType = 'arraybuffer'; + this.socket = socket; + + socket.onmessage = (event) => { + if (socket !== this.socket) return; + if (typeof event.data === 'string') { + const control = JSON.parse(event.data); + if (control.type === 'welcome') { + this.attempt = 0; + this.handlers.onStatus('connected'); + this.handlers.onWelcome(control.peer, control.peers); + } else if (control.type === 'join') { + this.handlers.onJoin(control.peer); + } else if (control.type === 'leave') { + this.handlers.onLeave(control.peer); + } + } else { + const bytes = new Uint8Array(event.data); + if (bytes.length < 4) return; + const peer = new DataView(event.data).getUint32(0); + this.handlers.onMessage(peer, bytes.subarray(4)); + } + }; + + socket.onclose = (event) => { + if (socket !== this.socket) return; + this.socket = null; + if (this.closed) return; + const final = FINAL_CLOSE_CODES.get(event.code); + if (final) { + this.closed = true; + this.handlers.onStatus('failed', final); + return; + } + this.handlers.onStatus('connecting'); + const delay = RETRY_DELAYS_MS[Math.min(this.attempt, RETRY_DELAYS_MS.length - 1)]; + this.attempt += 1; + setTimeout(() => this.connect(), delay); + }; + } + + /** @returns {boolean} */ + get connected() { + return this.socket !== null && this.socket.readyState === WebSocket.OPEN; + } + + /** + * @param {number} destination A peer number, or 0 for everyone else. + * @param {Uint8Array} body + */ + send(destination, body) { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return; + const message = new Uint8Array(4 + body.length); + new DataView(message.buffer).setUint32(0, destination); + message.set(body, 4); + this.socket.send(message); + } + + close() { + this.closed = true; + if (this.socket) this.socket.close(); + } +} diff --git a/client/js/renderer.js b/client/js/renderer.js new file mode 100644 index 0000000..89cd00e --- /dev/null +++ b/client/js/renderer.js @@ -0,0 +1,535 @@ +// @ts-check +/** + * Draws the page. Each layer has its own canvas, so hiding a layer or + * redrawing it after an erasure does not touch the others. + * + * A stroke is drawn as a chain of parts, one per point (see + * stroke_geometry.js). The part for a point can be drawn only when the + * following point is known, so the newest end of a stroke in progress (its + * *tail*) is drawn on a separate canvas that is cleared and redrawn as the + * stroke grows. + */ + +import { BLANK_PAGE_HEIGHT, BLANK_PAGE_WIDTH, POINT_SIZE, pointCount } from './page_model.js'; +import { partAt, widthAt } from './stroke_geometry.js'; +import { LEFT_GAP, LINE_HEIGHT, TOP_GAP, fontOf } from './text_layout.js'; + +/** @typedef {import('./page_model.js').Page} Page */ +/** @typedef {import('./page_model.js').Stroke} Stroke */ +/** @typedef {import('./page_model.js').TextBlock} TextBlock */ + +/** + * A rectangle in page units. + * @typedef {{x0: number, y0: number, x1: number, y1: number}} Region + */ + +/** + * One step of zooming, by button or scroll wheel, is this factor (10%, + * Andy's first choice to try, 2026-10-03). + */ +export const ZOOM_STEP = 1.1; +const LEAST_CSS_PIXELS_PER_UNIT = 0.05; +const MOST_CSS_PIXELS_PER_UNIT = 8; + +/** + * How the page is scaled on screen: fitted to the window, or a fixed + * number of CSS pixels per page unit. + * @typedef {'fit' | number} Zoom + */ + +/** + * Draw a text block. + * @param {CanvasRenderingContext2D} context + * @param {TextBlock} block + */ +function drawText(context, block) { + context.font = fontOf(block.size, block.font); + context.fillStyle = block.color; + context.textBaseline = 'top'; + block.lines.forEach((line, i) => { + context.fillText(line, block.x + block.size * LEFT_GAP, + block.y + block.size * (TOP_GAP + i * LINE_HEIGHT)); + }); +} + +/** + * Draw the part of a stroke that belongs to point `k`. + * @param {CanvasRenderingContext2D} context + * @param {Stroke} stroke + * @param {number} k + */ +function drawPart(context, stroke, k) { + const part = partAt(stroke, k); + context.beginPath(); + if (part.dot) { + context.arc(part.x, part.y, part.width / 2, 0, 2 * Math.PI); + context.fillStyle = stroke.color; + context.fill(); + return; + } + context.moveTo(part.fromX, part.fromY); + context.quadraticCurveTo(part.x, part.y, part.toX, part.toY); + context.strokeStyle = stroke.color; + context.lineWidth = part.width; + context.stroke(); +} + +export class Renderer { + /** + * @param {HTMLElement} pageElement Element that holds the canvases. + * @param {Page} page + */ + constructor(pageElement, page) { + this.pageElement = pageElement; + this.page = page; + /** @type {Map} */ + this.canvases = new Map(); + /** @type {Set} */ + this.hidden = new Set(); + /** @type {WeakMap} Parts of each stroke already drawn. */ + this.drawn = new WeakMap(); + /** @type {Map} Strokes in progress and their layers. */ + this.active = new Map(); + /** Region of the tail canvas in use, in page units, or null. */ + this.tailRegion = /** @type {Region | null} */ (null); + + this.tailCanvas = this.makeCanvas(); + this.tailCanvas.classList.add('tails'); + this.pageElement.append(this.tailCanvas); + + /** + * The elements that show the page's images beneath the layers, one for + * each image whose bytes are here, and the bytes each one shows. + * @type {{element: HTMLImageElement, data: Uint8Array | null}[]} + */ + this.imageElements = []; + + /** Canvas pixels per page unit. */ + this.scale = 1; + /** The page's left and top as last shown, for keeping the view still. */ + this.shownLeft = /** @type {number | null} */ (null); + this.shownTop = /** @type {number | null} */ (null); + /** @type {Zoom} */ + this.zoom = 'fit'; + /** CSS pixels per page unit as last shown. */ + this.cssPixelsPerUnit = 1; + /** + * True while the user is drawing a stroke. At "Fit" the page is not + * refitted then: if it were, writing past an edge would move the edge + * away from the pen with every step of growth. + */ + this.holdFit = false; + /** @type {(() => void) | null} Told when the zoom changes. */ + this.onZoomChanged = null; + /** + * True until a page that its images have made larger than a sheet has + * been shown for the first time. Such a page is a series of exercises + * to scroll through (Andy, 2026-10-05), so it is first shown at the + * scale at which one sheet fits the window, not all of it made small. + * (A reloaded tab shows it as it was last seen there: see showView.) + */ + this.sheetFirst = false; + /** + * The text block being edited, if any, which is shown by the editor's + * own text field and so not drawn here. + * @type {string | null} + */ + this.editingText = null; + this.setPage(page); + + // Ctrl and the scroll wheel zoom about the pointer, as elsewhere. + this.desk().addEventListener('wheel', (event) => { + if (!event.ctrlKey) return; + event.preventDefault(); + this.zoomBy(event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP, event.clientX, event.clientY); + }, { passive: false }); + } + + /** @returns {HTMLElement} The scrolling container of the page. */ + desk() { + return /** @type {HTMLElement} */ (this.pageElement.parentElement); + } + + /** + * Zoom in or out by a factor, from whatever the zoom is now. + * @param {number} factor + * @param {number} [clientX] The point to zoom about; else the middle. + * @param {number} [clientY] + */ + zoomBy(factor, clientX, clientY) { + this.setZoom(this.cssPixelsPerUnit * factor, clientX, clientY); + } + + /** + * Change the zoom. With a position given, the page point under it stays + * under it; otherwise the point at the middle of the view stays. + * @param {Zoom} zoom + * @param {number} [clientX] + * @param {number} [clientY] + */ + setZoom(zoom, clientX, clientY) { + if (typeof zoom === 'number') { + zoom = Math.max(LEAST_CSS_PIXELS_PER_UNIT, Math.min(MOST_CSS_PIXELS_PER_UNIT, zoom)); + } + const desk = this.desk(); + const deskBox = desk.getBoundingClientRect(); + const anchorX = clientX ?? deskBox.left + desk.clientWidth / 2; + const anchorY = clientY ?? deskBox.top + desk.clientHeight / 2; + const [pageX, pageY] = this.pageFromClient(anchorX, anchorY); + + this.zoom = zoom; + this.sheetFirst = false; + this.resize(); + + // Scroll so that the anchored page point is back under the anchor. + const box = this.pageElement.getBoundingClientRect(); + const nowX = box.left + (pageX - this.page.left) * this.cssPixelsPerUnit; + const nowY = box.top + (pageY - this.page.top) * this.cssPixelsPerUnit; + desk.scrollLeft += nowX - anchorX; + desk.scrollTop += nowY - anchorY; + this.onZoomChanged?.(); + } + + /** + * Show another page in place of the present one. + * @param {Page} page + */ + setPage(page) { + this.page = page; + this.shownLeft = null; + this.shownTop = null; + this.zoom = 'fit'; + this.sheetFirst = page.isLargerThanSheet(); + this.onZoomChanged?.(); + // A new page is seen from its top. + this.desk().scrollTop = 0; + this.desk().scrollLeft = 0; + for (const canvas of this.canvases.values()) canvas.remove(); + this.canvases.clear(); + this.hidden.clear(); + this.active.clear(); + this.showImages(); + this.resize(); + // Once more after the browser has laid the page out, in case anything + // above (a dialog closing, say) left the measurements stale. Andy saw + // a loaded page's image appear only after pressing Fit (2026-10-03), + // which could not be reproduced. + requestAnimationFrame(() => { + if (this.page === page) this.resize(); + }); + } + + /** + * How the page is being shown, so that it can be shown the same way + * after the browser page is reloaded. + * @returns {{zoom: Zoom, x: number, y: number}} The zoom, and the page + * point at the top left corner of the view. + */ + view() { + const desk = this.desk(); + return { + zoom: this.zoom, + x: this.page.left + desk.scrollLeft / this.cssPixelsPerUnit, + y: this.page.top + desk.scrollTop / this.cssPixelsPerUnit, + }; + } + + /** + * Show the page as view() described it. + * @param {{zoom: Zoom, x: number, y: number}} view + */ + showView(view) { + this.zoom = view.zoom === 'fit' ? 'fit' + : Math.max(LEAST_CSS_PIXELS_PER_UNIT, Math.min(MOST_CSS_PIXELS_PER_UNIT, view.zoom)); + this.sheetFirst = false; + this.resize(); + const desk = this.desk(); + desk.scrollLeft = (view.x - this.page.left) * this.cssPixelsPerUnit; + desk.scrollTop = (view.y - this.page.top) * this.cssPixelsPerUnit; + this.onZoomChanged?.(); + } + + /** + * Keep the present scale from now on, if the page is being fitted to the + * window. Called when an image is added or taken back: the page then + * extends beyond the window, to be scrolled to, and what is in view + * stays as it is (Andy, 2026-10-05). + */ + holdScale() { + if (this.zoom !== 'fit' || this.shownLeft === null) return; + this.zoom = this.cssPixelsPerUnit; + this.onZoomChanged?.(); + } + + /** Place the image elements where the images lie on the page. */ + layoutImages() { + const page = this.page; + for (const [i, image] of page.images.entries()) { + const style = this.imageElements[i]?.element.style; + if (!style) continue; + style.left = `${(image.x - page.left) / page.width * 100}%`; + style.top = `${(image.y - page.top) / page.height * 100}%`; + style.width = `${image.width / page.width * 100}%`; + style.height = `${image.height / page.height * 100}%`; + } + } + + /** + * Show the page's images whose bytes are here. An element that already + * shows the right bytes is left alone, so that adding an image does not + * make the others flicker. + */ + showImages() { + const images = this.page.images; + for (const [i, image] of images.entries()) { + let shown = this.imageElements[i]; + if (!shown) { + const element = document.createElement('img'); + element.className = 'page_image'; + element.alt = ''; + element.draggable = false; + element.hidden = true; + // Beneath the layers, in the sequence of the images. + const before = this.imageElements[i - 1]?.element.nextSibling ?? this.pageElement.firstChild; + this.pageElement.insertBefore(element, before); + shown = this.imageElements[i] = { element, data: null }; + } + if (shown.data === image.data) continue; + if (shown.element.src) URL.revokeObjectURL(shown.element.src); + shown.data = image.data; + shown.element.hidden = !image.data; + if (image.data) { + shown.element.src = URL.createObjectURL(new Blob([image.data], { type: image.type })); + } else { + shown.element.removeAttribute('src'); + } + } + for (const gone of this.imageElements.splice(images.length)) { + if (gone.element.src) URL.revokeObjectURL(gone.element.src); + gone.element.remove(); + } + this.layoutImages(); + } + + makeCanvas() { + const canvas = document.createElement('canvas'); + canvas.className = 'layer'; + return canvas; + } + + /** + * @param {HTMLCanvasElement} canvas + * @returns {CanvasRenderingContext2D} + */ + context(canvas) { + const context = /** @type {CanvasRenderingContext2D} */ (canvas.getContext('2d')); + context.setTransform(this.scale, 0, 0, this.scale, + -this.page.left * this.scale, -this.page.top * this.scale); + context.lineCap = 'round'; + context.lineJoin = 'round'; + return context; + } + + /** @param {HTMLCanvasElement} canvas */ + sizeCanvas(canvas) { + canvas.width = Math.round(this.page.width * this.scale); + canvas.height = Math.round(this.page.height * this.scale); + } + + /** + * Match the canvases to the page's rectangle and its displayed size, and + * redraw. Called when the window changes, when the page grows and when + * the zoom changes. + * + * At "Fit", the whole page is shown within the window, and refitted + * when the page grows, except while the user is drawing (see holdFit). + */ + resize() { + const desk = this.desk(); + const deskStyle = getComputedStyle(desk); + // offsetWidth, not clientWidth: a fitted page needs no scrollbars, so + // the room they take while zoomed in must not count. + const availableWidth = desk.offsetWidth - + parseFloat(deskStyle.paddingLeft) - parseFloat(deskStyle.paddingRight); + const availableHeight = desk.offsetHeight - + parseFloat(deskStyle.paddingTop) - parseFloat(deskStyle.paddingBottom); + if (!(availableWidth > 0) || !(availableHeight > 0)) return; + const first = this.zoom === 'fit' && this.sheetFirst; + this.sheetFirst = false; + if (first) { + this.zoom = Math.max(LEAST_CSS_PIXELS_PER_UNIT, Math.min(MOST_CSS_PIXELS_PER_UNIT, + 1 / Math.max(BLANK_PAGE_WIDTH / availableWidth, BLANK_PAGE_HEIGHT / availableHeight))); + } + let unitsPerCssPixel; + if (this.zoom !== 'fit') unitsPerCssPixel = 1 / this.zoom; + else if (this.holdFit && this.shownLeft !== null) unitsPerCssPixel = 1 / this.cssPixelsPerUnit; + else unitsPerCssPixel = Math.max(this.page.width / availableWidth, this.page.height / availableHeight); + this.cssPixelsPerUnit = 1 / unitsPerCssPixel; + this.pageElement.style.width = `${this.page.width / unitsPerCssPixel}px`; + this.pageElement.style.height = `${this.page.height / unitsPerCssPixel}px`; + this.layoutImages(); + // When the page grew to the left or upward, keep what is in view in view. + if (this.shownLeft !== null && this.page.left < this.shownLeft) { + desk.scrollLeft += (this.shownLeft - this.page.left) / unitsPerCssPixel; + } + if (this.shownTop !== null && this.page.top < this.shownTop) { + desk.scrollTop += (this.shownTop - this.page.top) / unitsPerCssPixel; + } + this.shownLeft = this.page.left; + this.shownTop = this.page.top; + + this.scale = (window.devicePixelRatio || 1) / unitsPerCssPixel; + this.sizeCanvas(this.tailCanvas); + this.tailRegion = null; + for (const id of this.canvases.keys()) this.redrawLayer(id, true); + this.drawTails(); + if (first) this.onZoomChanged?.(); + } + + /** + * Put the layer canvases in the given sequence, bottom to top, creating + * canvases for new layers. + * @param {string[]} ids + */ + setLayerOrder(ids) { + for (const id of ids) { + let canvas = this.canvases.get(id); + if (!canvas) { + canvas = this.makeCanvas(); + this.sizeCanvas(canvas); + this.canvases.set(id, canvas); + } + this.pageElement.insertBefore(canvas, this.tailCanvas); + } + } + + /** + * @param {string} id + * @param {boolean} visible + */ + setLayerVisible(id, visible) { + if (visible) this.hidden.delete(id); + else this.hidden.add(id); + const canvas = this.canvases.get(id); + if (canvas) canvas.hidden = !visible; + this.drawTails(); + } + + /** + * Draw a layer again from the page. + * @param {string} id + * @param {boolean} [resized] The canvas size must change as well. + */ + redrawLayer(id, resized = false) { + const canvas = this.canvases.get(id); + const layer = this.page.layers.get(id); + if (!canvas) return; + if (resized) this.sizeCanvas(canvas); + const context = this.context(canvas); + context.clearRect(this.page.left, this.page.top, this.page.width, this.page.height); + for (const [stroke, layerId] of this.active) { + if (layerId === id) this.active.delete(stroke); + } + if (!layer) return; + for (const stroke of layer.strokes.values()) { + this.drawn.set(stroke, 0); + this.drawNewParts(context, id, stroke); + } + for (const block of layer.texts.values()) { + if (block.id !== this.editingText) drawText(context, block); + } + this.drawTails(); + } + + /** + * A stroke gained points or was finished: draw what is new. + * @param {string} layerId + * @param {Stroke} stroke + */ + strokeProgress(layerId, stroke) { + const canvas = this.canvases.get(layerId); + if (!canvas) return; + this.drawNewParts(this.context(canvas), layerId, stroke); + this.drawTails(); + } + + /** + * @param {CanvasRenderingContext2D} context + * @param {string} layerId + * @param {Stroke} stroke + */ + drawNewParts(context, layerId, stroke) { + const count = pointCount(stroke); + // The part for the newest point waits until the stroke is finished or + // another point arrives. + const ready = stroke.done ? count : count - 1; + let k = this.drawn.get(stroke) ?? 0; + for (; k < ready; k++) drawPart(context, stroke, k); + this.drawn.set(stroke, Math.max(k, 0)); + if (stroke.done) this.active.delete(stroke); + else this.active.set(stroke, layerId); + } + + /** Redraw the tails of the strokes in progress. */ + drawTails() { + const context = this.context(this.tailCanvas); + if (this.tailRegion) { + const r = this.tailRegion; + context.clearRect(r.x0, r.y0, r.x1 - r.x0, r.y1 - r.y0); + this.tailRegion = null; + } + for (const [stroke, layerId] of this.active) { + const count = pointCount(stroke); + if (count === 0 || this.hidden.has(layerId)) continue; + const p = stroke.points; + const end = (count - 1) * POINT_SIZE; + const width = widthAt(stroke, count - 1); + context.beginPath(); + let startX = p[end]; + let startY = p[end + 1]; + if (count === 1) { + context.arc(p[end], p[end + 1], width / 2, 0, 2 * Math.PI); + context.fillStyle = stroke.color; + context.fill(); + } else { + startX = (p[end - POINT_SIZE] + p[end]) / 2; + startY = (p[end - POINT_SIZE + 1] + p[end + 1]) / 2; + context.moveTo(startX, startY); + context.lineTo(p[end], p[end + 1]); + context.strokeStyle = stroke.color; + context.lineWidth = width; + context.stroke(); + } + const margin = width + 2 / this.scale; + /** @type {Region} */ + const r = this.tailRegion ?? { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity }; + r.x0 = Math.min(r.x0, startX - margin, p[end] - margin); + r.y0 = Math.min(r.y0, startY - margin, p[end + 1] - margin); + r.x1 = Math.max(r.x1, startX + margin, p[end] + margin); + r.y1 = Math.max(r.y1, startY + margin, p[end + 1] + margin); + this.tailRegion = r; + } + } + + /** + * Convert a position in the window to page units. + * @param {number} clientX + * @param {number} clientY + * @returns {[number, number]} + */ + pageFromClient(clientX, clientY) { + const box = this.pageElement.getBoundingClientRect(); + return [ + this.page.left + (clientX - box.left) * this.page.width / box.width, + this.page.top + (clientY - box.top) * this.page.height / box.height, + ]; + } + + /** + * @param {number} cssPixels + * @returns {number} The same length in page units. + */ + pageLength(cssPixels) { + return cssPixels * this.page.width / this.pageElement.getBoundingClientRect().width; + } +} diff --git a/client/js/session.js b/client/js/session.js new file mode 100644 index 0000000..ef99010 --- /dev/null +++ b/client/js/session.js @@ -0,0 +1,974 @@ +// @ts-check +/** + * A session: the page, the participants, and the messages that keep every + * participant's copy of the page the same. + * + * The rule that makes this simple is that each layer has one writer. A + * participant is the authority on their own layer, so there are no + * conflicting edits to reconcile. See notes/protocol.md for the messages. + */ + +import { + decrypt, encrypt, fromBase64, hashOf, randomId, signClaim, toBase64, verifyClaim, +} from './crypto.js'; +import { + MAX_STROKE_POINTS, POINT_SIZE, Page, appendPoints, cleanName, cleanPageDescriptor, + cleanStroke, cleanText, placeImage, pointCount, validId, validPoints, +} from './page_model.js'; +import { RelayConnection } from './relay_connection.js'; + +/** @typedef {import('./crypto.js').Identity} Identity */ +/** @typedef {import('./page_model.js').Layer} Layer */ +/** @typedef {import('./page_model.js').Stroke} Stroke */ +/** @typedef {import('./page_model.js').PageImage} PageImage */ +/** @typedef {import('./page_model.js').TextBlock} TextBlock */ + +/** + * @typedef {object} Participant + * @property {string} id + * @property {string} name + * @property {number | null} peer Relay peer number; null when not connected. + * @property {boolean} self + * @property {boolean} host + */ + +/** + * @typedef {object} SessionEvents + * @property {(layerId: string) => void} onLayerReplaced + * A layer's contents changed as a whole and must be redrawn. + * @property {(layerId: string, stroke: Stroke) => void} onStrokeProgress + * A stroke gained points or was finished. + * @property {(layerId: string, block: TextBlock) => void} onTextChanged + * A text block was made or changed. + * @property {(oldPage: Page, newPage: Page) => void} onPageReplaced + * The session moved to another page. + * @property {() => void} onImagesChanged + * An image was added to the page, or the bytes of one have arrived. + * @property {(byImages: boolean) => void} onPageResized + * The page grew (or shrank) around its contents; `byImages` if an + * image was added or taken back, not if writing reached an edge. + * @property {() => void} onPermissionsChanged + * Who may start pages changed. + * @property {() => void} onParticipantsChanged + * @property {(status: 'connecting' | 'connected' | 'failed', detail?: string) => void} onStatus + */ + +/** + * @typedef {object} SessionOptions + * @property {string} relay + * @property {string} roomId + * @property {CryptoKey} key + * @property {Identity} identity + * @property {string} hostId + * @property {string} name Our display name. + * @property {Page} [page] The page as last saved, when returning to + * the session. + */ + +/** Approximate size at which a layer snapshot is split into messages. */ +const SNAPSHOT_CHUNK_CHARACTERS = 100000; + +/** An image is sent in pieces of this many bytes. */ +const IMAGE_CHUNK_BYTES = 96 * 1024; + +export class Session { + /** + * @param {SessionOptions} options + * @param {SessionEvents} events + */ + constructor(options, events) { + this.roomId = options.roomId; + this.key = options.key; + this.identity = options.identity; + this.hostId = options.hostId; + this.name = cleanName(options.name) || 'Unnamed'; + /** + * The image we have just added, which we may take back until anything + * else changes on the page. + * @type {PageImage | null} + */ + this.removableImage = null; + // Every event that reports a change to the page ends that. + /** @type {SessionEvents} */ + this.events = { + ...events, + onLayerReplaced: (layerId) => { + this.removableImage = null; + events.onLayerReplaced(layerId); + }, + onStrokeProgress: (layerId, stroke) => { + this.removableImage = null; + events.onStrokeProgress(layerId, stroke); + }, + onTextChanged: (layerId, block) => { + this.removableImage = null; + events.onTextChanged(layerId, block); + }, + onPageReplaced: (oldPage, newPage) => { + this.removableImage = null; + events.onPageReplaced(oldPage, newPage); + }, + onImagesChanged: () => { + this.removableImage = null; + events.onImagesChanged(); + }, + }; + + this.isHost = this.identity.id === this.hostId; + /** @type {Set} Guests the host allows to start pages. */ + this.permitted = new Set(); + // The host begins a session with page 1. A guest shows a placeholder + // until the host's page arrives. + this.page = options.page ?? + (this.isHost ? new Page(undefined, undefined, randomId(), 1) : new Page()); + this.ownLayer = this.page.layer(this.identity.id, this.name); + this.page.recomputeStrokeBounds(); + this.page.updateExtent(); + + /** @type {Map} */ + this.participants = new Map(); + this.participants.set(this.identity.id, { + id: this.identity.id, name: this.name, peer: null, self: true, + host: this.identity.id === this.hostId, + }); + for (const layer of this.page.layers.values()) { + if (layer.id === this.identity.id) continue; + this.participants.set(layer.id, { + id: layer.id, name: layer.name || 'Unnamed', peer: null, self: false, + host: layer.id === this.hostId, + }); + } + + /** @type {Map} Relay peer number to participant id. */ + this.peerIds = new Map(); + /** @type {Set} Peers the relay says are present. */ + this.presentPeers = new Set(); + /** @type {Map} Last sequence number seen from a peer. */ + this.lastSeq = new Map(); + /** @type {Map} Snapshots being received. */ + this.incoming = new Map(); + /** + * The bytes of the page's images received so far, by each image's hash. + * @type {Map, received: number}>} + */ + this.imageTransfers = new Map(); + /** @type {Map>} Peers waiting for an image from us, by its hash. */ + this.imageRequests = new Map(); + /** + * The peer whose announcement moved us to the present page. Its + * snapshots of any layer of that page are accepted: when a page is + * loaded from a notebook, that peer sends the writing kept with it. + */ + this.pageSourcePeer = 0; + + this.myPeer = 0; + /** The peer asked for the layers of absent participants. */ + this.statePeer = 0; + this.stateComplete = true; + /** + * True while our own layer may be completed from another participant's + * copy: on the first connection after the browser page was loaded, when + * the others may hold strokes of ours that we had not yet saved. + */ + this.recoverOwnLayer = false; + this.firstConnection = true; + + /** Counts connections, so that work queued for an old one is dropped. */ + this.epoch = 0; + this.seq = 0; + this.sendChain = Promise.resolve(); + this.receiveChain = Promise.resolve(); + + /** @type {Stroke | null} The stroke we are drawing. */ + this.currentStroke = null; + + this.connection = new RelayConnection(options.relay, options.roomId, { + onWelcome: (peer, peers) => this.welcomed(peer, peers), + onJoin: (peer) => { this.presentPeers.add(peer); }, + onLeave: (peer) => this.peerLeft(peer), + onMessage: (peer, body) => this.received(peer, body), + onStatus: (status, detail) => { + if (status !== 'connected') this.disconnected(); + this.events.onStatus(status, detail); + }, + }); + } + + start() { + this.connection.connect(); + } + + /** @returns {Participant[]} Ourselves first, then the others by name. */ + participantList() { + return [...this.participants.values()].sort((a, b) => + Number(b.self) - Number(a.self) || a.name.localeCompare(b.name) || a.id.localeCompare(b.id)); + } + + /** @returns {string[]} Layer ids from bottom to top. */ + layerOrder() { + return this.page.layerOrder(this.hostId); + } + + // Pages. + + /** @returns {boolean} Whether we may start pages: the host, or a guest the host allows. */ + mayMakePages() { + return this.isHost || this.permitted.has(this.identity.id); + } + + /** + * Allow a guest to start pages, or stop allowing it. Only the host may. + * @param {string} id + * @param {boolean} allowed + */ + setPermitted(id, allowed) { + if (!this.isHost || id === this.identity.id) return; + if (allowed) this.permitted.add(id); + else this.permitted.delete(id); + this.send(0, { type: 'permissions', ids: [...this.permitted] }); + this.events.onPermissionsChanged(); + } + + /** + * The page's rectangle may have changed with its contents. + * @param {boolean} [byImages] Whether its images were what changed. + */ + extentChanged(byImages = false) { + if (this.page.updateExtent()) this.events.onPageResized(byImages); + } + + /** + * Start a new blank page for everyone. The host may; so may a guest the + * host allows. + */ + newPage() { + if (!this.mayMakePages()) return; + const page = new Page(undefined, undefined, randomId(), this.page.number + 1); + page.updateExtent(); + this.replacePage(page); + this.send(0, { type: 'page', page: page.descriptor() }); + } + + /** + * Add an image to the page for everyone, below the images already on it. + * The host may; so may a guest the host allows. + * @param {object} prepared + * @param {PageImage['type']} prepared.type + * @param {Uint8Array} prepared.data + * @param {string} prepared.hash + * @param {number} prepared.naturalWidth In page units. + * @param {number} prepared.naturalHeight + * @param {boolean} [shrink] Whether an image wider than the sheet is + * scaled down to it; otherwise it keeps its size and widens the sheet. + * @returns {boolean} False if the page has no room for it. + */ + addImage(prepared, shrink = true) { + const page = this.page; + if (!this.mayMakePages() || page.number === 0) return false; + const place = placeImage(prepared.naturalWidth, prepared.naturalHeight, page.imagesBottom(), shrink); + if (!page.hasRoomForImage(prepared.data.length, place.y + place.height)) return false; + /** @type {PageImage} */ + const image = { + type: prepared.type, size: prepared.data.length, hash: prepared.hash, data: prepared.data, + x: place.x, y: place.y, width: place.width, height: place.height, + }; + page.images.push(image); + page.imagesRevision += 1; + page.baseWidth = Math.max(page.baseWidth, place.pageWidth); + page.baseHeight = Math.max(page.baseHeight, place.pageHeight); + this.send(0, { type: 'page', page: page.descriptor() }); + this.sendImage(0, image); + this.events.onImagesChanged(); + this.removableImage = image; + this.extentChanged(true); + return true; + } + + /** + * Take back the image we have just added: the remedy for having chosen + * the wrong file. It works only until anything else changes on the + * page (Andy, 2026-10-05), so nothing can have been written on the image, + * and since it is the lowest image, no other image is affected. + * @returns {boolean} Whether an image was taken back. + */ + removeAddedImage() { + const page = this.page; + const image = this.removableImage; + if (!image || page.images.at(-1) !== image) return false; + page.images.pop(); + page.imagesRevision += 1; + page.baseWidth = page.sheetWidth(); + page.baseHeight = page.sheetHeight(); + this.send(0, { type: 'page', page: page.descriptor() }); + this.events.onImagesChanged(); + this.extentChanged(true); + return true; + } + + /** + * Start a new page for everyone from a page kept in a notebook, with + * everything that was on it. Its layers keep their owners: a layer of + * someone present is theirs to erase again. + * @param {Page} kept As read from the page file. + */ + loadPage(kept) { + if (!this.mayMakePages()) return; + const page = new Page(kept.baseWidth, kept.baseHeight, randomId(), this.page.number + 1); + page.images = kept.images.map((image) => ({ ...image })); + page.imagesRevision = kept.imagesRevision; + for (const [id, layer] of kept.layers) { + const copy = page.layer(id, layer.name); + for (const stroke of layer.strokes.values()) { + copy.strokes.set(stroke.id, { ...stroke, points: stroke.points.slice(), done: true }); + } + for (const block of layer.texts.values()) copy.texts.set(block.id, { ...block, lines: block.lines.slice() }); + if (!this.participants.has(id)) { + this.participants.set(id, { + id, name: layer.name || 'Unnamed', peer: null, self: false, host: id === this.hostId, + }); + } + } + page.recomputeStrokeBounds(); + page.updateExtent(); + this.replacePage(page); + this.send(0, { type: 'page', page: page.descriptor() }); + const sent = new Set(); + for (const image of page.images) { + if (!sent.has(image.hash)) this.sendImage(0, image); + sent.add(image.hash); + } + for (const layer of page.layers.values()) { + if (!layer.isEmpty()) this.sendLayer(0, layer); + } + this.events.onParticipantsChanged(); + } + + // Text on our own layer. + + /** + * Make or change a text block of ours. The whole block is sent each + * time, so that it appears on the other screens as it is typed. + * @param {TextBlock} block + */ + setText(block) { + if (this.page.number === 0) return; + this.ownLayer.texts.set(block.id, block); + this.send(0, { type: 'text_set', page: this.page.id, text: block }); + this.page.recomputeStrokeBounds(); + this.events.onTextChanged(this.identity.id, block); + this.extentChanged(); + } + + /** @param {string[]} ids Text blocks of our own layer. */ + removeTexts(ids) { + const removed = ids.filter((id) => this.ownLayer.texts.delete(id)); + if (removed.length === 0) return; + this.send(0, { type: 'text_remove', page: this.page.id, ids: removed }); + this.page.recomputeStrokeBounds(); + this.events.onLayerReplaced(this.identity.id); + this.extentChanged(); + } + + /** + * @param {number} destination + * @param {PageImage} image An image of the present page whose bytes we hold. + */ + sendImage(destination, image) { + const data = image.data; + if (!data) return; + for (let at = 0; at < data.length; at += IMAGE_CHUNK_BYTES) { + this.send(destination, { + type: 'image', page: this.page.id, hash: image.hash, at, + data: toBase64(data.subarray(at, at + IMAGE_CHUNK_BYTES)), + }); + } + } + + /** + * Ask a peer that is on our page for the images whose bytes we lack. + * @param {number} peer + */ + requestImages(peer) { + const asked = new Set(); + for (const image of this.page.images) { + if (image.data || asked.has(image.hash)) continue; + asked.add(image.hash); + this.send(peer, { type: 'image_request', page: this.page.id, hash: image.hash }); + } + } + + /** + * A piece of one of the page's images arrived. Pieces are taken in + * sequence; the finished image must match its hash in the page + * descriptor, so it does not matter which participant sent it. + * @param {any} message + */ + async receivedImagePiece(message) { + const page = this.page; + if (message.page !== page.id || typeof message.data !== 'string') return; + const image = page.images.find((one) => one.hash === message.hash && !one.data); + if (!image) return; + let transfer = this.imageTransfers.get(image.hash); + if (!transfer) { + transfer = { buffer: new Uint8Array(image.size), received: 0 }; + this.imageTransfers.set(image.hash, transfer); + } + if (message.at !== transfer.received) return; + const piece = fromBase64(message.data); + if (transfer.received + piece.length > image.size) return; + transfer.buffer.set(piece, transfer.received); + transfer.received += piece.length; + if (transfer.received < image.size) return; + + this.imageTransfers.delete(image.hash); + const data = transfer.buffer; + const matches = await hashOf(data) === image.hash; + if (page !== this.page || !matches) return; // A damaged or false image: wait for another copy. + let filled = false; + for (const one of page.images) { + if (one.hash !== image.hash || one.data) continue; + one.data = data; + filled = true; + } + if (!filled) return; + this.events.onImagesChanged(); + for (const peer of this.imageRequests.get(image.hash) ?? []) this.sendImage(peer, image); + this.imageRequests.delete(image.hash); + } + + /** + * Move to a page that another participant is on, if it is newer than + * ours. Pages are numbered by the host, so the highest number is the + * session's current page; between two pages of the same number, the + * host's is taken. If it is the page we are on, it may have gained + * images. + * @param {unknown} received A page descriptor. + * @param {boolean} fromHost + * @param {number} peer Who told us. + */ + considerPage(received, fromHost, peer) { + const descriptor = cleanPageDescriptor(received); + if (!descriptor) return; + if (descriptor.id === this.page.id) { + this.considerImages(descriptor); + return; + } + const newer = descriptor.number > this.page.number || + (descriptor.number === this.page.number && fromHost); + if (!newer) return; + this.replacePage(Page.from(descriptor)); + this.pageSourcePeer = peer; + } + + /** + * Another participant's list of the images on the page we are on. The + * list with the higher revision is the later one. Of two different + * lists of the same revision (two participants changed the images at the + * same moment) everyone takes the same one, and the change that lost + * must be made again. + * @param {import('./page_model.js').PageDescriptor} descriptor + */ + considerImages(descriptor) { + const page = this.page; + /** @param {Omit[]} images */ + const listed = (images) => images.map((image) => + `${image.hash} ${image.x} ${image.y} ${image.width} ${image.height}`).join(','); + const ours = listed(page.images); + const theirs = listed(descriptor.images); + if (descriptor.revision < page.imagesRevision || + (descriptor.revision === page.imagesRevision && theirs <= ours)) return; + const held = new Map(); + for (const image of page.images) { + if (image.data) held.set(image.hash, image.data); + } + page.images = descriptor.images.map((image) => ({ ...image, data: held.get(image.hash) ?? null })); + page.imagesRevision = descriptor.revision; + page.baseWidth = descriptor.width; + page.baseHeight = descriptor.height; + // Bytes on their way for an image that is no longer wanted are dropped, + // so that the same image, added again, is received from its beginning. + for (const hash of this.imageTransfers.keys()) { + if (!page.images.some((image) => image.hash === hash && !image.data)) this.imageTransfers.delete(hash); + } + this.events.onImagesChanged(); + this.extentChanged(true); + } + + /** @param {Page} page */ + replacePage(page) { + this.endStroke(); + const old = this.page; + this.page = page; + this.ownLayer = page.layer(this.identity.id, this.name); + for (const participant of this.participants.values()) { + if (participant.peer !== null) page.layer(participant.id, participant.name); + } + this.incoming.clear(); + this.imageTransfers.clear(); + this.imageRequests.clear(); + this.pageSourcePeer = 0; + this.events.onPageReplaced(old, page); + } + + // Drawing on our own layer. + + /** + * @param {string} color + * @param {number} width + * @returns {Stroke} + */ + beginStroke(color, width) { + if (this.currentStroke) this.endStroke(); + const stroke = { id: randomId(), color, width, points: [], done: false }; + this.ownLayer.strokes.set(stroke.id, stroke); + this.currentStroke = stroke; + this.send(0, { + type: 'stroke_begin', page: this.page.id, stroke: { id: stroke.id, color, width }, + }); + return stroke; + } + + /** @param {number[]} points x, y, pressure for each point. */ + addPoints(points) { + let stroke = this.currentStroke; + if (!stroke || points.length === 0) return; + if (pointCount(stroke) + points.length / POINT_SIZE > MAX_STROKE_POINTS) { + const last = stroke.points.slice(-POINT_SIZE); + this.endStroke(); + stroke = this.beginStroke(stroke.color, stroke.width); + points = last.concat(points); + } + const at = pointCount(stroke); + appendPoints(stroke, at, points); + this.page.includeStroke(stroke, at); + this.send(0, { type: 'stroke_points', id: stroke.id, at, points }); + this.events.onStrokeProgress(this.identity.id, stroke); + this.extentChanged(); + } + + endStroke() { + const stroke = this.currentStroke; + if (!stroke) return; + this.currentStroke = null; + stroke.done = true; + this.send(0, { type: 'stroke_end', id: stroke.id }); + this.events.onStrokeProgress(this.identity.id, stroke); + } + + /** @param {string[]} ids Strokes of our own layer. */ + erase(ids) { + const erased = ids.filter((id) => this.ownLayer.strokes.delete(id)); + if (erased.length === 0) return; + this.send(0, { type: 'erase', page: this.page.id, ids: erased }); + this.page.recomputeStrokeBounds(); + this.events.onLayerReplaced(this.identity.id); + this.extentChanged(); + } + + // Sending. + + /** + * Queue a message. Messages are encrypted and sent in the sequence in + * which they were queued. + * @param {number} destination A peer number, or 0 for everyone else. + * @param {object | (() => Promise)} message + */ + send(destination, message) { + if (!this.connection.connected) return; + const epoch = this.epoch; + this.sendChain = this.sendChain.then(async () => { + if (epoch !== this.epoch) return; + const body = typeof message === 'function' ? await message() : message; + const bytes = await encrypt(this.key, { ...body, seq: this.seq++ }); + if (epoch === this.epoch) this.connection.send(destination, bytes); + }).catch((error) => console.error('Kokkoja: send failed', error)); + } + + /** + * Send a whole layer. The receiver replaces its copy of the layer. + * @param {number} destination + * @param {Layer} layer + */ + sendLayer(destination, layer) { + this.send(destination, { + type: 'layer_begin', page: this.page.id, layer: layer.id, name: layer.name, + }); + /** @type {Stroke[]} */ + let chunk = []; + let size = 0; + for (const stroke of layer.strokes.values()) { + chunk.push(stroke); + size += 100 + stroke.points.length * 8; + if (size >= SNAPSHOT_CHUNK_CHARACTERS) { + this.send(destination, { type: 'layer_strokes', layer: layer.id, strokes: chunk }); + chunk = []; + size = 0; + } + } + if (chunk.length) this.send(destination, { type: 'layer_strokes', layer: layer.id, strokes: chunk }); + if (layer.texts.size) { + this.send(destination, { type: 'layer_texts', layer: layer.id, texts: [...layer.texts.values()] }); + } + this.send(destination, { type: 'layer_end', layer: layer.id }); + } + + /** + * Announce ourselves, with proof that this connection is ours. + * @param {number} destination + * @param {boolean} reply True when answering another participant's hello. + */ + sendHello(destination, reply) { + const peer = this.myPeer; + this.send(destination, async () => ({ + type: 'hello', reply, name: this.name, publicKey: this.identity.publicKey, + signature: await signClaim(this.identity, this.roomId, peer), + page: this.page.number > 0 ? this.page.descriptor() : null, + permitted: this.isHost ? [...this.permitted] : undefined, + })); + } + + // Connection events. + + /** + * @param {number} peer + * @param {number[]} peers + */ + welcomed(peer, peers) { + this.epoch += 1; + this.myPeer = peer; + this.presentPeers = new Set(peers); + const self = this.participants.get(this.identity.id); + if (self) self.peer = peer; + this.recoverOwnLayer = this.firstConnection && peers.length > 0; + this.firstConnection = false; + + this.sendHello(0, false); + // While recovering, our layer is sent when the recovery is complete. + if (!this.recoverOwnLayer && !this.ownLayer.isEmpty()) this.sendLayer(0, this.ownLayer); + this.requestState(); + this.events.onParticipantsChanged(); + } + + /** Ask the longest-connected peer for the layers of absent participants. */ + requestState() { + this.stateComplete = this.presentPeers.size === 0; + if (this.stateComplete) this.recoverOwnLayer = false; + this.statePeer = this.stateComplete ? 0 : Math.min(...this.presentPeers); + if (this.statePeer) this.send(this.statePeer, { type: 'state_request' }); + } + + disconnected() { + this.epoch += 1; + this.peerIds.clear(); + this.presentPeers.clear(); + this.lastSeq.clear(); + this.incoming.clear(); + for (const participant of this.participants.values()) participant.peer = null; + this.finishStrokesOfAbsent(); + this.events.onParticipantsChanged(); + } + + /** @param {number} peer */ + peerLeft(peer) { + this.presentPeers.delete(peer); + this.lastSeq.delete(peer); + const id = this.peerIds.get(peer); + this.peerIds.delete(peer); + const participant = id ? this.participants.get(id) : undefined; + if (participant && participant.peer === peer) participant.peer = null; + this.finishStrokesOfAbsent(); + if (peer === this.statePeer && !this.stateComplete) this.requestState(); + this.events.onParticipantsChanged(); + } + + /** A stroke whose writer has gone will get no more points. */ + finishStrokesOfAbsent() { + for (const layer of this.page.layers.values()) { + const owner = this.participants.get(layer.id); + if (owner && (owner.self || owner.peer !== null)) continue; + for (const stroke of layer.strokes.values()) { + if (stroke.done) continue; + stroke.done = true; + this.events.onStrokeProgress(layer.id, stroke); + } + } + } + + // Receiving. + + /** + * @param {number} peer + * @param {Uint8Array} body + */ + received(peer, body) { + const epoch = this.epoch; + this.receiveChain = this.receiveChain.then(async () => { + if (epoch !== this.epoch) return; + let message; + try { + message = await decrypt(this.key, body); + } catch { + return; // Not made with the session key. + } + if (epoch !== this.epoch || !message || typeof message.seq !== 'number') return; + if (message.seq <= (this.lastSeq.get(peer) ?? -1)) return; + this.lastSeq.set(peer, message.seq); + if (message.type === 'hello') await this.receivedHello(peer, message, epoch); + else this.receivedFromParticipant(peer, message); + }).catch((error) => console.error('Kokkoja: receive failed', error)); + } + + /** + * @param {number} peer + * @param {any} message + * @param {number} epoch + */ + async receivedHello(peer, message, epoch) { + const id = await verifyClaim(message.publicKey, message.signature, this.roomId, peer); + if (epoch !== this.epoch || !id || id === this.identity.id) return; + + const name = cleanName(message.name) || 'Unnamed'; + const previous = this.participants.get(id); + if (previous && previous.peer !== null) this.peerIds.delete(previous.peer); + this.peerIds.set(peer, id); + this.participants.set(id, { id, name, peer, self: false, host: id === this.hostId }); + if (id === this.hostId) this.receivedPermissions(message.permitted); + this.considerPage(message.page, id === this.hostId, peer); + // This peer is on our page: it can give us the images we lack. + if (message.page?.id === this.page.id) this.requestImages(peer); + this.page.layer(id, name); + this.events.onParticipantsChanged(); + + if (!message.reply) { + this.sendHello(peer, true); + if (!this.ownLayer.isEmpty()) this.sendLayer(peer, this.ownLayer); + } + } + + /** + * A message other than hello. It is accepted only from a peer that has + * proved who it is, and changes only that participant's layer. + * @param {number} peer + * @param {any} message + */ + receivedFromParticipant(peer, message) { + const id = this.peerIds.get(peer); + if (!id) return; + if (message.type === 'page') { + if (id === this.hostId || this.permitted.has(id)) this.considerPage(message.page, true, peer); + return; + } + if (message.type === 'permissions') { + if (id === this.hostId) this.receivedPermissions(message.ids); + return; + } + if (message.type === 'image') { + this.receivedImagePiece(message); + return; + } + if (message.type === 'image_request') { + if (message.page !== this.page.id) return; + const image = this.page.images.find((one) => one.hash === message.hash); + if (!image) return; + if (image.data) { + this.sendImage(peer, image); + } else { + const waiting = this.imageRequests.get(image.hash) ?? new Set(); + waiting.add(peer); + this.imageRequests.set(image.hash, waiting); + } + return; + } + const layer = this.page.layer(id); + + switch (message.type) { + case 'stroke_begin': { + if (message.page !== this.page.id) break; + const stroke = cleanStroke({ ...message.stroke, points: [], done: false }); + if (stroke) layer.strokes.set(stroke.id, stroke); + break; + } + case 'stroke_points': { + const stroke = layer.strokes.get(message.id); + if (!stroke || stroke.done || !validPoints(message.points) || + !Number.isInteger(message.at) || message.at < 0) break; + const before = pointCount(stroke); + appendPoints(stroke, message.at, message.points); + if (pointCount(stroke) > MAX_STROKE_POINTS) stroke.points.length = MAX_STROKE_POINTS * POINT_SIZE; + this.page.includeStroke(stroke, before); + this.events.onStrokeProgress(id, stroke); + this.extentChanged(); + break; + } + case 'stroke_end': { + const stroke = layer.strokes.get(message.id); + if (!stroke || stroke.done) break; + stroke.done = true; + this.events.onStrokeProgress(id, stroke); + break; + } + case 'erase': { + if (message.page !== this.page.id || !Array.isArray(message.ids)) break; + let erased = false; + for (const strokeId of message.ids) erased = layer.strokes.delete(strokeId) || erased; + if (!erased) break; + this.page.recomputeStrokeBounds(); + this.events.onLayerReplaced(id); + this.extentChanged(); + break; + } + case 'text_set': { + if (message.page !== this.page.id) break; + const block = cleanText(message.text); + if (!block) break; + layer.texts.set(block.id, block); + this.page.recomputeStrokeBounds(); + this.events.onTextChanged(id, block); + this.extentChanged(); + break; + } + case 'text_remove': { + if (message.page !== this.page.id || !Array.isArray(message.ids)) break; + let removed = false; + for (const textId of message.ids) removed = layer.texts.delete(textId) || removed; + if (!removed) break; + this.page.recomputeStrokeBounds(); + this.events.onLayerReplaced(id); + this.extentChanged(); + break; + } + case 'layer_begin': + if (message.page === this.page.id && validId(message.layer) && + this.acceptsLayerFrom(peer, id, message.layer)) { + this.incoming.set(`${peer} ${message.layer}`, + { name: cleanName(message.name), strokes: [], texts: [] }); + } + break; + case 'layer_texts': { + const snapshot = this.incoming.get(`${peer} ${message.layer}`); + if (!snapshot || !Array.isArray(message.texts)) break; + for (const received of message.texts) { + const block = cleanText(received); + if (block) snapshot.texts.push(block); + } + break; + } + case 'layer_strokes': { + const snapshot = this.incoming.get(`${peer} ${message.layer}`); + if (!snapshot || !Array.isArray(message.strokes)) break; + for (const received of message.strokes) { + const stroke = cleanStroke(received); + if (stroke) snapshot.strokes.push(stroke); + } + break; + } + case 'layer_end': { + const key = `${peer} ${message.layer}`; + const snapshot = this.incoming.get(key); + this.incoming.delete(key); + if (snapshot && this.acceptsLayerFrom(peer, id, message.layer)) { + this.applySnapshot(message.layer, snapshot, message.layer === id); + } + break; + } + case 'state_request': + this.sendState(peer, id); + break; + case 'state_end': + if (peer !== this.statePeer) break; + this.stateComplete = true; + if (this.recoverOwnLayer) { + this.recoverOwnLayer = false; + if (!this.ownLayer.isEmpty()) this.sendLayer(0, this.ownLayer); + } + break; + } + } + + /** + * The host said who may start pages. + * @param {unknown} ids + */ + receivedPermissions(ids) { + if (!Array.isArray(ids)) return; + const permitted = new Set(ids.filter(validId)); + const same = permitted.size === this.permitted.size && [...permitted].every((id) => this.permitted.has(id)); + if (same) return; + this.permitted = permitted; + this.events.onPermissionsChanged(); + } + + /** + * Whether to accept a snapshot of a layer from a peer. A participant may + * always send their own layer. The peer that moved us to this page may + * send any of its layers, our own included while we have nothing on it + * (a page loaded from a notebook comes with its writing). The peer we + * asked for the session state may send the layers of participants who + * are not connected, and our own layer while we are recovering it (the + * browser page was reloaded). + * @param {number} peer + * @param {string} senderId + * @param {string} layerId + */ + acceptsLayerFrom(peer, senderId, layerId) { + if (layerId === senderId) return true; + if (peer === this.pageSourcePeer) { + return layerId !== this.identity.id || this.ownLayer.isEmpty(); + } + if (peer !== this.statePeer) return false; + if (layerId === this.identity.id) return this.recoverOwnLayer; + const owner = this.participants.get(layerId); + return !owner || owner.peer === null; + } + + /** + * @param {string} layerId + * @param {{name: string, strokes: Stroke[], texts: TextBlock[]}} snapshot + * @param {boolean} fromOwner + */ + applySnapshot(layerId, snapshot, fromOwner) { + const layer = this.page.layer(layerId, snapshot.name); + if (layerId === this.identity.id) { + // Recovering our own layer: keep anything drawn since we joined. + for (const stroke of snapshot.strokes) { + if (!layer.strokes.has(stroke.id)) layer.strokes.set(stroke.id, { ...stroke, done: true }); + } + for (const block of snapshot.texts) { + if (!layer.texts.has(block.id)) layer.texts.set(block.id, block); + } + } else { + layer.strokes.clear(); + for (const stroke of snapshot.strokes) { + layer.strokes.set(stroke.id, fromOwner ? stroke : { ...stroke, done: true }); + } + layer.texts.clear(); + for (const block of snapshot.texts) layer.texts.set(block.id, block); + if (!this.participants.has(layerId)) { + this.participants.set(layerId, { + id: layerId, name: snapshot.name || 'Unnamed', peer: null, self: false, + host: layerId === this.hostId, + }); + this.events.onParticipantsChanged(); + } + } + this.page.recomputeStrokeBounds(); + this.events.onLayerReplaced(layerId); + this.extentChanged(); + } + + /** + * Answer a state request: send the layers that their owners cannot send + * themselves. + * @param {number} peer + * @param {string} requesterId + */ + sendState(peer, requesterId) { + for (const layer of this.page.layers.values()) { + if (layer.id === this.identity.id || layer.isEmpty()) continue; + const owner = this.participants.get(layer.id); + const ownerConnected = owner !== undefined && owner.peer !== null; + if (layer.id === requesterId || !ownerConnected) this.sendLayer(peer, layer); + } + this.send(peer, { type: 'state_end' }); + } +} diff --git a/client/js/stroke_geometry.js b/client/js/stroke_geometry.js new file mode 100644 index 0000000..b4a65a9 --- /dev/null +++ b/client/js/stroke_geometry.js @@ -0,0 +1,63 @@ +// @ts-check +/** + * The shape of a stroke, shared by everything that draws one: the screen + * (renderer.js) and the page file (page_file.js). + * + * A stroke is a chain of *parts*, one per point. The part for a point runs + * from the midpoint before it to the midpoint after it, curving through the + * point, with a width taken from the pen pressure at that point. At the + * ends of the stroke the part runs to the end point itself. + */ + +import { POINT_SIZE, pointCount } from './page_model.js'; + +/** @typedef {import('./page_model.js').Stroke} Stroke */ + +/** + * @typedef {object} Part + * @property {number} fromX + * @property {number} fromY + * @property {number} x The point itself, the control point of the curve. + * @property {number} y + * @property {number} toX + * @property {number} toY + * @property {number} width + * @property {boolean} dot The stroke is a single point: a filled circle + * of diameter `width` at (x, y). + */ + +/** + * The drawn width of a stroke at one of its points. The stroke's nominal + * width is drawn at pressure 0.4, about the pressure of ordinary writing + * (Andy's averaged 0.34 to 0.45 on a Cintiq 16 and rarely passed 0.8). A + * firm press, 0.75, is about 2.6 times as wide. + * @param {Stroke} stroke + * @param {number} index Point index. + */ +export function widthAt(stroke, index) { + const pressure = stroke.points[index * POINT_SIZE + 2]; + return stroke.width * (0.36 + 4 * pressure * pressure); +} + +/** + * The part of a stroke that belongs to point `k`. + * @param {Stroke} stroke + * @param {number} k + * @returns {Part} + */ +export function partAt(stroke, k) { + const p = stroke.points; + const last = pointCount(stroke) - 1; + const x = p[k * POINT_SIZE]; + const y = p[k * POINT_SIZE + 1]; + return { + fromX: k === 0 ? x : (p[(k - 1) * POINT_SIZE] + x) / 2, + fromY: k === 0 ? y : (p[(k - 1) * POINT_SIZE + 1] + y) / 2, + x, + y, + toX: k === last ? x : (x + p[(k + 1) * POINT_SIZE]) / 2, + toY: k === last ? y : (y + p[(k + 1) * POINT_SIZE + 1]) / 2, + width: widthAt(stroke, k), + dot: last === 0, + }; +} diff --git a/client/js/text_editor.js b/client/js/text_editor.js new file mode 100644 index 0000000..c9de6ed --- /dev/null +++ b/client/js/text_editor.js @@ -0,0 +1,229 @@ +// @ts-check +/** + * Editing a text block: a real text field laid over the page, so that + * umlauts, dead keys and selection work as they do everywhere, with + * handles for moving the block and changing its width. + * + * While a block is being edited the renderer leaves it out, and the field + * shows it; everyone else sees it drawn, updated at every keystroke. + */ + +import { randomId } from './crypto.js'; +import { LEFT_GAP, LINE_HEIGHT, TOP_GAP, layoutText } from './text_layout.js'; + +/** @typedef {import('./page_model.js').TextBlock} TextBlock */ +/** @typedef {import('./page_model.js').TextFont} TextFont */ +/** @typedef {import('./renderer.js').Renderer} Renderer */ + +/** A block without a width is shown at least this wide, in page units. */ +const LEAST_FIELD_WIDTH = 300; + +export class TextEditor { + /** + * @param {HTMLElement} pageElement + * @param {Renderer} renderer + * @param {object} target + * @param {(block: TextBlock) => void} target.setText The block changed. + * @param {(id: string) => void} target.removeText The block was left empty. + */ + constructor(pageElement, renderer, target) { + this.pageElement = pageElement; + this.renderer = renderer; + this.target = target; + /** @type {TextBlock | null} */ + this.block = null; + + this.field = document.createElement('textarea'); + this.field.className = 'text_field'; + this.field.spellcheck = false; + this.field.hidden = true; + this.field.addEventListener('input', () => this.changed()); + this.field.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + this.stop(); + } + }); + + this.corner = this.makeHandle('text_handle corner', 'Move the text'); + this.edge = this.makeHandle('text_edge', 'Move the text'); + this.end = this.makeHandle('text_handle end', 'Change the width'); + /** Shows the top edge while it is being dragged out. */ + this.preview = document.createElement('div'); + this.preview.className = 'text_preview'; + this.preview.hidden = true; + this.pageElement.append(this.field, this.edge, this.corner, this.end, this.preview); + } + + /** + * Show where the top edge of a new block would run, from (x0, y0) to + * (x1, y0), while the pointer is dragging. + * @param {number} x0 + * @param {number} y0 + * @param {number} x1 + */ + showPreview(x0, y0, x1) { + const scale = this.renderer.cssPixelsPerUnit; + const page = this.renderer.page; + this.preview.hidden = false; + this.preview.style.left = `${(Math.min(x0, x1) - page.left) * scale}px`; + this.preview.style.top = `${(y0 - page.top) * scale}px`; + this.preview.style.width = `${Math.abs(x1 - x0) * scale}px`; + } + + hidePreview() { + this.preview.hidden = true; + } + + /** + * @param {string} className + * @param {string} help + * @returns {HTMLElement} + */ + makeHandle(className, help) { + const handle = document.createElement('div'); + handle.className = className; + handle.hidden = true; + handle.setAttribute('data-help', help); + const widening = className.includes('end'); + handle.addEventListener('pointerdown', (event) => { + if (!this.block) return; + event.preventDefault(); + event.stopPropagation(); + handle.setPointerCapture(event.pointerId); + const [startX, startY] = this.renderer.pageFromClient(event.clientX, event.clientY); + const { x, y, width } = this.block; + const move = (/** @type {PointerEvent} */ e) => { + if (!this.block) return; + const [nowX, nowY] = this.renderer.pageFromClient(e.clientX, e.clientY); + if (widening) { + this.block.width = Math.max(100, Math.round((width ?? 0) + nowX - startX)); + } else { + this.block.x = Math.round(x + nowX - startX); + this.block.y = Math.round(y + nowY - startY); + } + this.changed(); + }; + const finish = () => { + handle.removeEventListener('pointermove', move); + handle.removeEventListener('pointerup', finish); + handle.removeEventListener('pointercancel', finish); + this.field.focus(); + }; + handle.addEventListener('pointermove', move); + handle.addEventListener('pointerup', finish); + handle.addEventListener('pointercancel', finish); + }); + return handle; + } + + /** @returns {boolean} */ + get editing() { + return this.block !== null; + } + + /** + * Start a new block. + * @param {number} x + * @param {number} y + * @param {number | null} width + * @param {{color: string, size: number, font: TextFont}} style + */ + begin(x, y, width, style) { + this.stop(); + /** @type {TextBlock} */ + const block = { + id: randomId(), x: Math.round(x), y: Math.round(y), width, text: '', lines: [''], + size: style.size, font: style.font, color: style.color, boxWidth: 0, boxHeight: 0, + }; + Object.assign(block, layoutText('', width, style.size, style.font)); + this.edit(block); + } + + /** + * Give the block being edited another colour, size or font. + * @param {Partial<{color: string, size: number, font: TextFont}>} style + */ + restyle(style) { + if (!this.block) return; + Object.assign(this.block, style); + this.changed(); + } + + /** + * Edit a block of ours. + * @param {TextBlock} block + */ + edit(block) { + this.stop(); + this.block = block; + this.renderer.editingText = block.id; + this.field.value = block.text; + this.field.hidden = false; + this.layout(); + this.target.setText(block); // Redrawn without this block. + this.field.focus(); + } + + /** Finish editing. An empty block is removed. */ + stop() { + const block = this.block; + if (!block) return; + this.block = null; + this.renderer.editingText = null; + this.field.hidden = true; + this.field.value = ''; + for (const handle of [this.corner, this.edge, this.end]) handle.hidden = true; + if (block.text.trim() === '') this.target.removeText(block.id); + else this.target.setText(block); + } + + /** The text, position or width changed: lay it out and tell the page. */ + changed() { + const block = this.block; + if (!block) return; + block.text = this.field.value; + Object.assign(block, layoutText(block.text, block.width, block.size, block.font)); + this.layout(); + this.target.setText(block); + } + + /** Place the field and the handles over the block, at the present zoom. */ + layout() { + const block = this.block; + if (!block) return; + const scale = this.renderer.cssPixelsPerUnit; + const page = this.renderer.page; + const left = (block.x - page.left) * scale; + const top = (block.y - page.top) * scale; + const fieldWidth = (block.width ?? Math.max(LEAST_FIELD_WIDTH, block.boxWidth + block.size)) * scale; + const style = this.field.style; + style.left = `${left}px`; + style.top = `${top}px`; + // A little wider than the block, so that the field never wraps a line + // earlier than the layout did. + style.width = `${fieldWidth - block.size * LEFT_GAP * scale + 2}px`; + style.paddingTop = `${block.size * TOP_GAP * scale}px`; + style.paddingLeft = `${block.size * LEFT_GAP * scale}px`; + style.font = `${block.size * scale}px ${block.font}`; + style.lineHeight = String(LINE_HEIGHT); + style.color = block.color; + style.whiteSpace = block.width === null ? 'pre' : 'pre-wrap'; + // Exactly as tall as the lines: no spare line below the text. + style.height = `${block.lines.length * block.size * LINE_HEIGHT * scale}px`; + + this.corner.hidden = false; + this.corner.style.left = `${left}px`; + this.corner.style.top = `${top}px`; + const hasWidth = block.width !== null; + this.edge.hidden = !hasWidth; + this.end.hidden = !hasWidth; + if (hasWidth) { + this.edge.style.left = `${left}px`; + this.edge.style.top = `${top}px`; + this.edge.style.width = `${fieldWidth}px`; + this.end.style.left = `${left + fieldWidth}px`; + this.end.style.top = `${top}px`; + } + } +} diff --git a/client/js/text_layout.js b/client/js/text_layout.js new file mode 100644 index 0000000..eb3785e --- /dev/null +++ b/client/js/text_layout.js @@ -0,0 +1,111 @@ +// @ts-check +/** + * Lays out the text of a text block into lines, measured in page units. + * + * The writer's client does this and sends the lines along with the text, + * so that everyone shows the same line breaks even where fonts differ a + * little. A block with a width wraps at it; a block without one breaks + * lines only where the writer pressed Enter. + */ + + +/** Line height as a multiple of the font size. */ +export const LINE_HEIGHT = 1.25; + +/** + * Room between a block's top edge and its first line, as a multiple of + * the font size (Andy, 2026-10-03: the text should not sit on the edge). + */ +export const TOP_GAP = 0.3; + +/** + * Room between a block's left edge (its corner square) and the text, as a + * multiple of the font size: about half a character's width (Andy, + * 2026-10-03). + */ +export const LEFT_GAP = 0.25; + +/** + * @param {number} size Font size in page units. + * @param {string} family + * @returns {string} The CSS font for a canvas whose units are page units. + */ +export function fontOf(size, family) { + return `${size}px ${family}`; +} + +/** @type {CanvasRenderingContext2D | null} */ +let measurer = null; + +/** + * @param {string} text + * @param {number} size + * @param {string} family + * @returns {number} The text's width in page units. + */ +export function textWidth(text, size, family) { + if (!measurer) { + measurer = /** @type {CanvasRenderingContext2D} */ (document.createElement('canvas').getContext('2d')); + } + measurer.font = fontOf(size, family); + return measurer.measureText(text).width; +} + +/** + * Break one paragraph into lines no wider than `width`. Breaks come at + * spaces; a word wider than the width is broken where it must be. + * @param {string} paragraph + * @param {number} width + * @param {number} size + * @param {string} family + * @returns {string[]} + */ +function wrap(paragraph, width, size, family) { + /** @type {string[]} */ + const lines = []; + let line = ''; + for (const word of paragraph.split(' ')) { + const tried = line === '' ? word : `${line} ${word}`; + if (textWidth(tried, size, family) <= width || + line === '' && textWidth(word, size, family) <= width) { + line = tried; + continue; + } + if (line !== '') lines.push(line); + // The word alone: fits, or is broken by characters. + line = ''; + for (const character of word) { + if (line !== '' && textWidth(line + character, size, family) > width) { + lines.push(line); + line = ''; + } + line += character; + } + } + lines.push(line); + return lines; +} + +/** + * @param {string} text What the writer typed, with its own line breaks. + * @param {number | null} width Wrap at this width, or null for no wrapping. + * @param {number} size + * @param {string} family + * @returns {{lines: string[], boxWidth: number, boxHeight: number}} + * The lines as shown, and the rectangle they take up. + */ +export function layoutText(text, width, size, family) { + /** @type {string[]} */ + const lines = []; + const gap = size * LEFT_GAP; + for (const paragraph of text.split('\n')) { + if (width === null) lines.push(paragraph); + else lines.push(...wrap(paragraph, Math.max(size, width - gap), size, family)); + } + const widest = Math.max(0, ...lines.map((line) => textWidth(line, size, family))); + return { + lines, + boxWidth: width ?? Math.ceil(widest + gap), + boxHeight: Math.ceil(size * TOP_GAP + lines.length * size * LINE_HEIGHT), + }; +} diff --git a/client/js/tooltip.js b/client/js/tooltip.js new file mode 100644 index 0000000..e274d41 --- /dev/null +++ b/client/js/tooltip.js @@ -0,0 +1,77 @@ +// @ts-check +/** + * Tooltips: a full explanation of a button, shown below it after the + * pointer (or a pen hovering above the screen) has rested on it for a + * moment. The buttons themselves carry terse labels; this is where the + * explanation lives. + * + * Any element with a `data-help` attribute gets one. Elements added later + * are covered too, since the listeners are on the document. + */ + +const DELAY_MS = 1000; +const GAP_PX = 6; + +/** @type {HTMLElement | null} */ +let tooltip = null; +/** @type {number | null} */ +let timer = null; +/** @type {Element | null} */ +let shownFor = null; + +/** + * @param {EventTarget | null} target + * @returns {HTMLElement | null} The element with help under the pointer. + */ +function helpElement(target) { + return target instanceof Element ? target.closest('[data-help]') : null; +} + +/** @param {HTMLElement} element */ +function show(element) { + if (!tooltip) { + tooltip = document.createElement('div'); + tooltip.className = 'tooltip'; + tooltip.setAttribute('role', 'tooltip'); + document.body.append(tooltip); + } + tooltip.textContent = element.getAttribute('data-help'); + tooltip.hidden = false; + shownFor = element; + + // Below the element, aligned with its left edge, kept within the window. + const box = element.getBoundingClientRect(); + const width = tooltip.offsetWidth; + const left = Math.max(GAP_PX, Math.min(box.left, window.innerWidth - width - GAP_PX)); + tooltip.style.left = `${left}px`; + tooltip.style.top = `${box.bottom + GAP_PX}px`; +} + +function hide() { + if (timer !== null) clearTimeout(timer); + timer = null; + shownFor = null; + if (tooltip) tooltip.hidden = true; +} + +export function enableTooltips() { + document.addEventListener('pointerover', (event) => { + const element = helpElement(event.target); + if (!element) { + hide(); + return; + } + if (element === shownFor) return; + hide(); + timer = setTimeout(() => { timer = null; show(element); }, DELAY_MS); + }); + document.addEventListener('pointerleave', hide); + document.addEventListener('pointerdown', hide); + document.addEventListener('focusin', (event) => { + const element = helpElement(event.target); + if (element) show(element); + }); + document.addEventListener('focusout', hide); + // Typing means the user's attention is on the keys, not the tooltip. + document.addEventListener('keydown', hide); +} diff --git a/client/kokkoja.css b/client/kokkoja.css new file mode 100644 index 0000000..aba90f5 --- /dev/null +++ b/client/kokkoja.css @@ -0,0 +1,450 @@ +:root { + --ink: #1c1c1e; + --muted: #6b6b70; + --line: #d4d4d8; + --panel: #f6f6f7; + --desk: #e4e4e7; + --accent: #1f4fd8; + --text_edge: #ababab; /* The top edge of a text block being edited: 67% grey. */ + --text_handle: #ababab; /* Its squares: the same. */ + font-family: system-ui, sans-serif; + font-size: 15px; + color: var(--ink); +} + +* { box-sizing: border-box; } + +html, body { height: 100%; margin: 0; } + +[hidden] { display: none !important; } + +button, input { font: inherit; color: inherit; } + +button { + padding: 0.35rem 0.8rem; + border: 1px solid var(--line); + border-radius: 6px; + background: white; + cursor: pointer; +} + +button:hover { border-color: var(--muted); } + +button[aria-pressed="true"] { + border-color: var(--accent); + box-shadow: inset 0 0 0 1px var(--accent); +} + +input[type="text"] { + width: 100%; + padding: 0.4rem 0.5rem; + border: 1px solid var(--line); + border-radius: 6px; + background: white; +} + +h2 { + margin: 1.4rem 0 0.4rem; + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); +} + +/* Start screen */ + +#start { + max-width: 28rem; + margin: 0 auto; + padding: 3rem 1.5rem; +} + +#start h1 { margin: 0 0 1.5rem; font-size: 1.6rem; } +#start label { display: block; margin: 1rem 0 0.3rem; } +#start section { margin-top: 1.5rem; } +#start section p { margin: 0 0 0.6rem; color: var(--muted); } +#start section button { margin-top: 0.6rem; } + +.message { min-height: 1.4em; margin-top: 1.5rem; color: #b42318; } + +.note { margin: 0.3rem 0 0; font-size: 0.85rem; color: var(--muted); overflow-wrap: anywhere; } +.note.problem { color: #b42318; } +.file { margin: 0; overflow-wrap: anywhere; } + +/* Session */ + +#session { + display: flex; + flex-direction: column; + height: 100%; +} + +.toolbar { + display: flex; + align-items: center; + gap: 1.25rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--line); + background: var(--panel); +} + +.group { display: flex; align-items: center; gap: 0.35rem; } + +.swatch { + width: 1.5rem; + height: 1.5rem; + padding: 3px; + border-radius: 50%; + background: var(--swatch); + background-clip: content-box; +} + +/* The same height as the buttons, so the toolbar does not change height. */ +.toolbar button:not(.swatch), #text_size, #text_font { + box-sizing: border-box; + height: 2rem; + padding-top: 0; + padding-bottom: 0; +} + +#text_size, #text_font { + margin: 0; + padding-left: 0.4rem; + padding-right: 0.4rem; + border: 1px solid var(--line); + border-radius: 6px; + background: white; + font: inherit; +} + +#text_size { width: 3.6rem; } +#text_font { padding-right: 0.3rem; } + +/* The palette that "+" opens: no system dialog, which crashes Firefox on + Linux (Hollis, 2026-10-04). */ +.palette { + position: absolute; + z-index: 5; + display: grid; + grid-template-columns: repeat(8, 1.5rem); + gap: 0.3rem; + padding: 0.5rem; + border: 1px solid var(--line); + border-radius: 8px; + background: white; + box-shadow: 0 4px 16px rgb(0 0 0 / 0.2); +} + +.palette .swatch { width: 1.5rem; height: 1.5rem; } + +.swatch.more { + --swatch: white; + font-size: 1rem; + line-height: 1; + color: var(--muted); +} + +.width_choice { + display: flex; + align-items: center; + justify-content: center; + width: 2.4rem; + height: 1.9rem; + padding: 0; +} + +.width_choice span { + display: block; + width: 1.4rem; + border-radius: 99px; + background: var(--ink); +} + +#zoom_in, #zoom_out { + width: 2.2rem; + padding: 0.1rem 0; + font-size: 1.35rem; + line-height: 1; +} + +/* What concerns the page and the view, not the tool: it never moves. */ +.toolbar_right { + display: flex; + align-items: center; + gap: 1.25rem; + margin-left: auto; +} + +.page_number { color: var(--muted); } +.status { min-width: 6.5rem; text-align: right; color: var(--muted); } +.status.problem { color: #b42318; } + +.workspace { + display: flex; + flex: 1; + min-height: 0; +} + +.desk { + flex: 1; + min-width: 0; + overflow: auto; + padding: 1.25rem; + background: var(--desk); +} + +.page { + position: relative; + margin: 0 auto; + background: white; + box-shadow: 0 1px 4px rgb(0 0 0 / 0.25); + touch-action: none; + user-select: none; + cursor: crosshair; +} + +.page canvas, .page_image { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +/* Text being edited */ + +.text_field { + position: absolute; + z-index: 2; + box-sizing: content-box; + margin: 0; + padding: 0; + border: none; + border-radius: 0; + background: transparent; + resize: none; + overflow: hidden; + outline: none; +} + +.text_handle { + position: absolute; + z-index: 3; + width: 10px; + height: 10px; + margin: -5px 0 0 -5px; + border: 1px solid white; + border-radius: 2px; + background: var(--text_handle); + cursor: move; +} + +.text_handle.end { cursor: ew-resize; } + +.text_edge { + position: absolute; + z-index: 3; + height: 0; + margin-top: -1px; + border-top: 2px solid var(--text_edge); + cursor: move; +} + +.text_edge::before, .text_edge::after { + content: ""; + position: absolute; + top: 0; + width: 0; + height: 8px; + border-left: 2px solid var(--text_edge); +} + +.text_edge::before { left: 0; } +.text_edge::after { right: 0; } + +/* The top edge of a new block, while it is being dragged out. */ +.text_preview { + position: absolute; + z-index: 3; + height: 0; + margin-top: -1px; + border-top: 2px solid var(--text_edge); + pointer-events: none; +} + + +.panel { + width: 16rem; + padding: 0 1rem 1rem; + overflow: auto; + border-left: 1px solid var(--line); + background: var(--panel); +} + +.panel ul { margin: 0; padding: 0; list-style: none; } + +.panel li { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.2rem 0; +} + +.panel li.absent { color: var(--muted); } +.panel li label { flex: 1; } +.panel li .role { font-size: 0.8rem; color: var(--muted); } +.panel li .allow { order: 1; padding: 0.1rem 0.4rem; font-size: 0.75rem; } + +.panel button { margin-top: 0.4rem; } + +/* The notebook chooser */ + +.chooser { + width: min(56rem, 90vw); + height: min(36rem, 85vh); + padding: 1rem 1.25rem; + border: 1px solid var(--line); + border-radius: 10px; + box-shadow: 0 8px 30px rgb(0 0 0 / 0.25); +} + +.chooser::backdrop { background: rgb(0 0 0 / 0.3); } + +.chooser header { display: flex; align-items: center; } +.chooser header h2 { flex: 1; margin: 0; } +.chooser header button { width: 2rem; padding: 0.2rem 0; font-size: 1.1rem; line-height: 1; } + +.chooser_body { + display: flex; + gap: 1rem; + height: calc(100% - 4rem); + margin-top: 0.75rem; +} + +.chooser_notebooks { + display: flex; + flex-direction: column; + gap: 0.3rem; + width: 12rem; + overflow: auto; +} + +.chooser_notebooks button { text-align: left; } + +.chooser_pages { + display: grid; + flex: 1; + grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr)); + gap: 0.75rem; + align-content: start; + overflow: auto; +} + +.page_card { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + padding: 0.5rem; +} + +.page_card img { + width: 100%; + aspect-ratio: 210 / 297; + object-fit: contain; + background: var(--desk); +} + +.page_card span { font-size: 0.8rem; } + +/* The file chooser */ + +#file_chooser[open] { display: flex; flex-direction: column; } + +.files_place, .files_save { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.files_place input, .files_save input { flex: 1; min-width: 0; } + +/* Directories down the left, one to a line; the files beside them. Each + scrolls by itself, as in the notebook chooser. */ +.files_body { + display: flex; + flex: 1; + gap: 1rem; + min-height: 0; + margin-top: 0.75rem; +} + +.files_directories { + display: flex; + flex: none; + flex-direction: column; + gap: 0.3rem; + width: 12rem; + overflow: auto; +} + +.files_directories button { text-align: left; overflow-wrap: anywhere; } + +.files_entries { + flex: 1; + overflow: auto; +} + +.files_shrink { display: block; margin-top: 0.75rem; font-size: 0.9rem; } + +.files_existing { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.files_images { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr)); + gap: 0.75rem; + align-content: start; +} + +.file_card { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + padding: 0.5rem; +} + +.file_card canvas { + width: 100%; + aspect-ratio: 4 / 3; + object-fit: contain; + background: var(--desk); +} + +.file_card span { font-size: 0.8rem; overflow-wrap: anywhere; } + +.tooltip { + position: fixed; + z-index: 10; + max-width: 20rem; + padding: 0.4rem 0.6rem; + border-radius: 6px; + background: var(--ink); + color: white; + font-size: 0.85rem; + line-height: 1.35; + box-shadow: 0 2px 8px rgb(0 0 0 / 0.25); + pointer-events: none; +} + +.report { + margin-top: 1.5rem; + font-size: 0.8rem; + color: var(--muted); + font-variant-numeric: tabular-nums; +} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..03766d5 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,69 @@ +# The Kokkoja server + +A Kokkoja *server* is one machine that does two things for a group of +users: it runs the relay, which passes the participants' encrypted +messages, and it serves the client as a web site, so that a guest can +join by opening a link with nothing installed. Neither stores anything. +The first one is Andy's VPS at `kokkoja.andykopra.com`. + +| On the server | What it is | +|----------------------------|-------------------------------------------------| +| `/opt/kokkoja/relay/` | the relay's code (`relay/` in this repository) | +| `/opt/kokkoja/venv/` | Python with the `websockets` library | +| `/var/www/kokkoja/` | the client (`client/`) and its `config.json` | +| `kokkoja-relay.service` | the relay, run as the system user `kokkoja`, on 127.0.0.1:8770 | +| nginx site `kokkoja` | `https:///` serves the client; `wss:///relay` reaches the relay | + +The client's `config.json` on the site says +`{"relay": "wss://kokkoja.andykopra.com/relay", "storage": "browser", +"site": "https://kokkoja.andykopra.com/"}`: the notebooks of anyone using +the site are kept in their browser. A host with the local program uses +the server with `python3 kokkoja.py --relay wss://kokkoja.andykopra.com/relay`; +the invitation is then a link to the site. + +## Setting up or updating + +From the project directory on a development machine: + + deploy/deploy.sh + +copies the relay, the client and the files in this directory to the +server and runs `setup_vps.sh` there, which makes or updates everything +in the table and restarts the relay. It can be run again at any time. +`KOKKOJA_SERVER` names another ssh destination than +`root@vps313847.iceservers.net`. + +Steps a person does, once: + +1. DNS: an A record `kokkoja.andykopra.com` pointing at the VPS + (82.221.141.89), in cPanel's Zone Editor, since andykopra.com is on + the orangewebsite.com nameservers. Check with + `host kokkoja.andykopra.com buffy.ns.orangewebsite.com`. + +2. TLS, after the name resolves and `deploy.sh` has run once: + + ssh root@vps313847.iceservers.net certbot --nginx -d kokkoja.andykopra.com + + certbot adds the TLS listener to the nginx site and turns the plain + HTTP block into a redirect. Afterwards `setup_vps.sh` leaves the + installed nginx site alone, so a later change to `nginx-site.conf` + must be carried over by hand. + +3. The VPS sentinel sees a new service, a new user, a new nginx site and + a new listening socket. After deploying, as root on the VPS: + + vps-sentinel --accept + +Checks: `https://kokkoja.andykopra.com/config.json` shows the +configuration above; `systemctl status kokkoja-relay` is active; +`journalctl -u kokkoja-relay -n 20` shows it listening. + +## Security + +The relay sees only ciphertext and room ids and keeps nothing. It runs +as a user of its own under systemd's sandboxing (read-only system, no +home directories, no new privileges). The site sends a Content Security +Policy that lets the client load nothing from anywhere else and forbids +framing. What the site cannot protect against is itself: whoever +controls the server could serve a changed client to browser-only +guests. Participants running the local program are not exposed to that. diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..e8c993a --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Deploy the Kokkoja server: copy the relay, the client and the server +# files to the VPS, then run deploy/setup_vps.sh there. Run from the +# project directory on a development machine. +# +# deploy/deploy.sh +# +# KOKKOJA_SERVER names the ssh destination (default root@vps313847.iceservers.net). +# The site's config.json is written here: the relay and site addresses +# below are the server's public names. + +set -euo pipefail +cd "$(dirname "$0")/.." +server=${KOKKOJA_SERVER:-root@vps313847.iceservers.net} +site=https://kokkoja.andykopra.com/ +relay=wss://kokkoja.andykopra.com/relay + +staging=$(mktemp -d) +trap 'rm -rf "$staging"' EXIT +mkdir -p "$staging/relay" "$staging/client" "$staging/deploy" +cp relay/kokkoja_relay.py relay/requirements.txt "$staging/relay/" +rsync -a --exclude '__pycache__' client/ "$staging/client/" +printf '{"relay": "%s", "storage": "browser", "site": "%s"}\n' "$relay" "$site" > "$staging/client/config.json" +cp deploy/kokkoja-relay.service deploy/nginx-site.conf deploy/setup_vps.sh "$staging/deploy/" + +ssh "$server" 'mkdir -p /opt/kokkoja/incoming' +rsync -a --delete "$staging/" "$server:/opt/kokkoja/incoming/" +ssh "$server" 'bash /opt/kokkoja/incoming/deploy/setup_vps.sh' diff --git a/deploy/kokkoja-relay.service b/deploy/kokkoja-relay.service new file mode 100644 index 0000000..ae4ee85 --- /dev/null +++ b/deploy/kokkoja-relay.service @@ -0,0 +1,29 @@ +# The Kokkoja relay as a system service. Installed by deploy/setup_vps.sh +# as /etc/systemd/system/kokkoja-relay.service. +# +# The relay listens on this computer only; nginx terminates TLS and +# forwards wss:///relay/... to it (deploy/nginx-site.conf). + +[Unit] +Description=Kokkoja relay +After=network.target + +[Service] +User=kokkoja +Group=kokkoja +ExecStart=/opt/kokkoja/venv/bin/python /opt/kokkoja/relay/kokkoja_relay.py --host 127.0.0.1 --port 8770 +Restart=on-failure +RestartSec=5 +# The relay needs nothing on the system but its own code. +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectControlGroups=yes +RestrictAddressFamilies=AF_INET AF_INET6 +MemoryDenyWriteExecute=yes + +[Install] +WantedBy=multi-user.target diff --git a/deploy/nginx-site.conf b/deploy/nginx-site.conf new file mode 100644 index 0000000..f4aa71e --- /dev/null +++ b/deploy/nginx-site.conf @@ -0,0 +1,39 @@ +# nginx server block for a Kokkoja web site: the client's files at +# https://kokkoja.andykopra.com/ and the relay at wss://kokkoja.andykopra.com/relay. +# Installed by deploy/setup_vps.sh as /etc/nginx/sites-available/kokkoja. +# +# This is the plain HTTP form. certbot --nginx -d kokkoja.andykopra.com +# adds the TLS listener and turns this block into a redirect. + +server { + listen 80; + server_name kokkoja.andykopra.com; + + root /var/www/kokkoja; + index index.html; + + # The relay. The client connects to /room/, + # so /relay/room/ here becomes /room/ at the relay. + location /relay/ { + proxy_pass http://127.0.0.1:8770/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header X-Forwarded-For $remote_addr; + # The relay pings every 20 seconds; a quiet lesson must not be cut off. + proxy_read_timeout 1h; + proxy_send_timeout 1h; + } + + location / { + try_files $uri $uri/ =404; + } + + # The client is a few small files that change with every deployment: + # browsers ask whether they have changed rather than keeping old ones. + add_header Cache-Control "no-cache" always; + # The client loads nothing from anywhere else, and may not be framed. + add_header Content-Security-Policy "default-src 'self'; connect-src 'self' wss://kokkoja.andykopra.com; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; +} diff --git a/deploy/setup_vps.sh b/deploy/setup_vps.sh new file mode 100755 index 0000000..8f4dd2b --- /dev/null +++ b/deploy/setup_vps.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# Set up, or bring up to date, the Kokkoja server on this machine. Run as +# root ON THE SERVER, after deploy/deploy.sh has copied the files to +# /opt/kokkoja/incoming. Safe to run again: every step checks first. +# +# What it makes: +# /opt/kokkoja/relay/ the relay's code +# /opt/kokkoja/venv/ Python with the websockets library +# /var/www/kokkoja/ the client, with config.json +# kokkoja-relay.service the relay, as the system user "kokkoja" +# nginx site "kokkoja" the web site and the relay behind TLS +# +# TLS is the one step left to a person, once the name resolves to this +# machine (see deploy/README.md): +# certbot --nginx -d kokkoja.andykopra.com + +set -euo pipefail +incoming=/opt/kokkoja/incoming +test -d "$incoming" || { echo "nothing in $incoming: run deploy/deploy.sh first" >&2; exit 1; } + +if ! id kokkoja >/dev/null 2>&1; then + useradd --system --no-create-home --shell /usr/sbin/nologin kokkoja +fi + +# Python with the websockets library, in a virtual environment of its own. +if ! python3 -c 'import ensurepip' >/dev/null 2>&1; then + apt-get install -y python3-venv +fi +if [ ! -x /opt/kokkoja/venv/bin/python ]; then + python3 -m venv /opt/kokkoja/venv +fi +/opt/kokkoja/venv/bin/pip install --quiet --upgrade pip +/opt/kokkoja/venv/bin/pip install --quiet -r "$incoming/relay/requirements.txt" + +# The relay's code, and the client. +rm -rf /opt/kokkoja/relay +cp -r "$incoming/relay" /opt/kokkoja/relay +chmod -R a+rX /opt/kokkoja/relay +mkdir -p /var/www/kokkoja +rsync -a --delete "$incoming/client/" /var/www/kokkoja/ +chmod -R a+rX /var/www/kokkoja + +# The service. +install -m 644 "$incoming/deploy/kokkoja-relay.service" /etc/systemd/system/kokkoja-relay.service +systemctl daemon-reload +systemctl enable --quiet kokkoja-relay.service +systemctl restart kokkoja-relay.service + +# The nginx site. Once certbot has edited the installed copy, it is not +# replaced: certbot's additions would be lost. +if [ ! -e /etc/nginx/sites-available/kokkoja ]; then + install -m 644 "$incoming/deploy/nginx-site.conf" /etc/nginx/sites-available/kokkoja +elif ! grep -q "managed by Certbot" /etc/nginx/sites-available/kokkoja; then + install -m 644 "$incoming/deploy/nginx-site.conf" /etc/nginx/sites-available/kokkoja +else + echo "nginx site left as certbot made it; compare with deploy/nginx-site.conf by hand" +fi +ln -sf ../sites-available/kokkoja /etc/nginx/sites-enabled/kokkoja +nginx -t +systemctl reload nginx + +systemctl --no-pager --lines=3 status kokkoja-relay.service || true +echo "Kokkoja server is set up." diff --git a/docs/guest.md b/docs/guest.md new file mode 100644 index 0000000..9195883 --- /dev/null +++ b/docs/guest.md @@ -0,0 +1,244 @@ +# A guest's Kokkoja + +This part describes Kokkoja as a guest sees it: someone invited to a +session by its host. In a lesson, the guest is the student. The host's +screen is the same with a few more buttons, described in +[A host's Kokkoja](host.md). + +## The invitation + +The host sends you an invitation by whatever means you both use: email, a +chat message, a text message. It is a link of this shape: + + https://kokkoja.andykopra.com/#join=wss://kokkoja.andykopra.com/relay%23uQ5c__CGB4FvN64Qiv3jGA.aS_1O51Umk1YHXwgqBZANw + +The part after `join=` is the invitation itself, which the host's Kokkoja +may also give you on its own, without the link around it. Both forms +work wherever an invitation is asked for. The invitation carries the key +that protects the session, so pass it on only to people who belong in the +session, and treat it as you would a meeting link. An invitation is good +for one session: the host's next session has a new one. + +## Joining + +Open the link in a web browser. Firefox, Brave and Chrome are known to +work; other browsers built on the same engines very probably do. You see +Kokkoja's start screen with the invitation already filled in. Enter the +name the others should see and press **Join**. That is all: nothing is +installed, and there is no account to make. + +Your name is remembered by the browser for next time. So is a second +field, **Notebook for the pages of this session**, which you can leave as +it is for now; what it does is explained under "Your notebook" below. + +If you have the Kokkoja program installed on your computer (see +[A host's Kokkoja](host.md) for what that is and why you might want it), +the start screen also offers to open the invitation in it instead. If +you have not, ignore that line. + +If the host has not started yet, or has left, you are still in the +session. The page is shared among whoever is present, and the host's +place in it waits for the host to return. + +## The screen + +Across the top is the toolbar. On the left are the tools and their +settings; on the right, the zoom and, for the host, the buttons that +start pages. Below it, the page fills most of the window, with a panel on +the right. Every button explains itself: rest the pointer on it for a +second and its explanation appears beneath it. + +### The page + +The page is an A4 sheet, shown so that it fits the window. Write +anywhere on it. When your writing comes near an edge, the sheet grows in +that direction, in small steps, so there is always room, and the view +keeps its scale and extends beyond the window; scroll to reach the rest. +An image the host puts on the page lies at the top, or below the images +already there. + +The page as a whole is shared: everyone sees the same marks in the same +places. Your view of it is your own: how far it is zoomed and where it +is scrolled to concern nobody else. + +### Pen + +**Pen** is chosen when you arrive. Draw with a pen on a tablet, a mouse, +or a finger on a touch screen. With a pen, pressing harder makes the +line wider. The colours are to the right of the tools: black, blue, red +and green, and **+** opens a palette of more. A colour chosen from the +palette joins the row of colours and stays there in this browser. The +three lines to the right of the colours are the line widths: 0.4, 0.7 +and 1.2 mm at ordinary pressure. + +If a pen is in use, a hand resting on a touch screen is ignored. + +### Text + +**Text** types on the page. Tap where the text should begin and type; a +text field appears there and the text is laid out on the page as you +type. Enter begins a new line. Press Escape, or tap somewhere else, to +finish; a block with nothing in it disappears. + +A block made by a tap does not wrap: its lines are as long as you type +them, and the page grows to hold them. To make the text wrap at a chosen +width, drag along where its top edge should run instead of tapping: the +block begins at the left end of the drag and wraps at the right end. + +Tap your own text to change it. While a block of yours is being edited +it is drawn grey, and three handles appear: the one at its top-left +corner and the line along its top edge move it, the one at the right end +of that line changes its width. You cannot change anyone else's text. + +With the Text tool, the row of colours stays and the line widths give way +to the font, one of **Sans**, **Serif** and **Mono**, and the size in +points, 14 unless you change it. Both apply to the block you are typing +and to the next ones. + +### Eraser + +**Eraser** removes your own writing. Move it across a stroke and the +whole stroke goes; across a text block and the block goes. It removes +nothing of anyone else's, and nobody else's eraser can touch yours. An +erased stroke is gone from the page for everyone. + +### Zoom + +**Fit** shows the whole page in the window, and keeps doing so as the +page grows by writing, except while you are in the middle of a stroke of +your own. **+** and **−** zoom in and out by a tenth. Holding Ctrl and +turning the scroll wheel zooms about the pointer. When the page is +larger than the window, scroll it as you would any page. When the host +adds an image, the view keeps its scale rather than shrinking to fit; a +new image at the bottom of a long page is reached by scrolling. + +If you reload the browser tab, it comes back to the page as you last saw +it: same zoom, same place. + +### The panel + +**Participants** lists everyone in the session, with *you* and *host* +marked, and *away* beside anyone who has left or lost their connection; +an absent participant's writing stays on the page. The box in front of +each name shows or hides that participant's writing on your screen alone. +Hiding your teacher's corrections to try an exercise again, or hiding +your own answers to see the exercise as it was, changes nothing for +anyone else. + +**Invitation** shows the invitation of this session, with a button to +copy it, in case someone else should be invited. + +**Current notebook**, **Keep page** and **Export** concern your notebook, +below. Under them, a line reports what was last saved, and whether +anything went wrong. + +The last line of the panel reports what your pen or mouse is delivering: +the kind of device, the pressure, and how many positions per second it +sends. It is there for curiosity and for finding faults. + +### Status + +At the right end of the toolbar, **Page 3** is the number of the page you +are on, counted from the start of the session, and beside it *Connected*, +*Connecting…* or *Disconnected* says how you stand with the relay. If +the connection drops, Kokkoja reconnects by itself; what you wrote while +disconnected is sent when it returns. Until the host has started a +page, the toolbar says *Waiting for the host's page*. + +## Your notebook + +Every page of the session is saved for you, as it changes, without your +doing anything. Where it is saved depends on how you joined. + +### In the browser + +When you joined by opening the link, your notebook is kept inside the +browser, on the computer you are using. It stays there between sessions, +but it belongs to that browser: another browser, or another computer, +has its own. The page you are on is saved there continuously. When the +session moves to another page, the page you are leaving is kept under a +dated name, such as `2026_10_06_01.svg`, the date being the day it was +last changed and the number counting the pages kept that day. + +Two buttons bring pages out of the browser: + +* **Keep page** keeps a dated copy of the page as it is now, and + downloads it, so you find it among your browser's downloads. Pressing + it again before anything has changed makes no second copy. +* **Export** downloads a picture of the page as you see it, as a PNG + file; writing you have hidden is left out. Its name is the date and + the page number. + +A page file is an SVG file. Any web browser displays it, and vector +drawing programs such as Inkscape open it. Kokkoja itself can load one +again, writing and all, when someone with the right to start pages +chooses it (see "When the host gives you more" below, and the host's +part). + +The notebook field on the start screen names the notebook within the +browser's storage. Giving each course or each colleague a notebook of +their own keeps their pages apart; the name proposed is simply +*Notebook*. **Current notebook** in the panel shows the notebook in use +and lets you change it during the session. + +### On your computer + +If you would rather have your pages as files in a directory on your own +computer, where you can open, copy and back them up as you do any file, +install the Kokkoja program. It is small and needs only Python, which +Linux and macOS have or can quickly get; [A host's Kokkoja](host.md) +tells how, since every host needs it. Then join from the program's own +start screen, pasting the invitation, or follow the start screen's offer +to open the invitation in the installed program. + +With the program, a notebook is a directory in `Kokkoja` in your +Documents directory. The page you are on is `Current.svg` there, and +kept pages lie beside it under their dated names. **Keep page** makes +the dated copy; **Export** opens Kokkoja's own file chooser, in which you +pick a directory and a name for the PNG file. + +The program also keeps your identity, so that you are the same +participant in every session and from any browser on that computer, and +writing from an earlier lesson is still yours when its page is loaded +again. In the browser, your identity belongs to that browser. + +## Leaving and returning + +Close the tab and you have left. Nothing needs saving. If you come back +to the same session by opening the invitation again, your writing is +still on the page, from the others' copies and from your notebook, and +it is still yours to erase or change. A tab that was in a session and is +reloaded goes straight back in. + +## When the host gives you more + +Normally only the host puts images on the page, starts new pages, or +loads pages from a notebook. The host can give you the same right, for +instance in a discussion among colleagues where everyone brings +material. When that happens, three more buttons appear at the right of +your toolbar: + +* **New** starts a new blank page for everyone. The page being left is + kept in each person's notebook. +* **Image** puts an image file on the page for everyone, below any + images already there. Your browser's file chooser asks for the file + (with the Kokkoja program, Kokkoja's own chooser does, beginning where + the notebooks are, with a choice whether an image wider than the page + is shrunk to fit it). A PNG or JPEG file keeps its exact pixels unless + it is very large; other formats and very large images are converted + and reduced before they are shared. An image is placed at its natural + size, centred, 8 mm from the top or from the image above; one wider + than the sheet is scaled down to fit between the margins. To put an + image on a page of its own, press **New** first. +* **Notebook** starts a new page for everyone from a page kept in one of + your notebooks, with everything that was written on it. A chooser + shows your notebooks and small pictures of their kept pages. The kept + page itself is not changed: the session works on a copy. + +An image cannot be moved or removed once anyone has written on it or +added anything after it, since the marks on it would make no sense +without it. The one exception is a mistake caught at once: the person +who added an image can take it back with a tap of the eraser on it, as +long as nothing else has happened on the page since. + +The right stays with you until the host withdraws it or the session ends. diff --git a/docs/host.md b/docs/host.md new file mode 100644 index 0000000..a07e825 --- /dev/null +++ b/docs/host.md @@ -0,0 +1,211 @@ +# A host's Kokkoja + +The host starts a session, invites the guests and drives the session: +puts images on the page, starts new pages, loads pages from a notebook, +and decides which guests may do the same. In a lesson, the teacher is +the host. Everything in [A guest's Kokkoja](guest.md) applies to the host +too; this part describes what is added, and how to install the Kokkoja +program, which a host needs. + +## Installing the Kokkoja program + +A guest can join from a web site with nothing installed. A host runs the +Kokkoja program on their own computer. It is a small Python program with +two jobs: it shows Kokkoja in your web browser, and it reads and writes +your notebooks as ordinary directories and files. It listens only to +your own computer and talks to nothing else but the relay you name. + +You need: + +* **Linux or macOS.** Windows has not been tried yet. +* **Python 3.** Linux has it. On macOS, open Terminal and type + `python3 --version`; if Python is not installed, macOS offers to + install the command line developer tools, which include it. Any Python + from 3.9 on will do. +* **A web browser.** Firefox, Brave or Chrome; other browsers built on + the same engines very probably work. +* **A pen tablet**, if you want to write by hand. A mouse works for + pointing and for rough marks. + +Get Kokkoja from its repository, either by downloading it as a zip file +and unpacking it, or with git: + + git clone https://git.andykopra.com/ack/kokkoja-rel.git kokkoja + +Nothing else is installed and nothing is changed on your system. To +update later, download again or run `git pull` in that directory. To +remove Kokkoja, delete the directory; your notebooks are elsewhere and +stay. + +## Starting Kokkoja + +Open a terminal in the Kokkoja directory and run + + python3 kokkoja.py --relay wss://kokkoja.andykopra.com/relay + +The program prints an address beginning `http://localhost:8760/` and +opens your browser there. Leave the terminal open: the program must run +as long as you are in a session. Ctrl-C stops it. + +The address the program prints contains a secret token. Your browser +remembers it, so afterwards `http://localhost:8760/` alone brings Kokkoja +back; but if the browser ever says that the notebooks are locked, open +the full address the program printed. The token is what stops any other +web page you might have open from reading your notebooks. + +The relay address names the *Kokkoja server* you use. A server does two +things: it passes the participants' messages, encrypted so that it +cannot read them, and it serves the web site that your guests join from. +The address above is Andy's server; whoever runs a server for you gives +you its address. If you give the program no relay, it expects one on +your own computer, which is useful only for trying Kokkoja alone. + +Other options, all optional: + +* `--notebooks DIRECTORY` keeps the notebooks somewhere other than + `Kokkoja` in your Documents directory. +* `--port NUMBER` uses another port than 8760, if that one is taken. +* `--no-browser` does not open a browser; open the printed address + yourself. +* `--site ADDRESS` names the web site that invitations link to. You + need it only if your server's web site is not at the relay's host. + +## Starting a session + +On the start screen, enter your name and the notebook for this session, +and press **Start a session**. You are on page 1, a blank sheet, and the +panel on the right shows the invitation. + +### The notebook + +The notebook you name is a directory under `Kokkoja` in your Documents +directory, made when it is first needed, and the start screen offers the +names of the notebooks you already have. For teaching, a notebook per +student works well: everything a student is given, with everything they +and you wrote on it, gathers in that student's notebook, and the next +lesson with that student starts by naming it again. Your library of +exercises can live in notebooks of its own, such as *Exercises A1*, filled +by keeping pages in a session of your own with nobody else present. + +The notebook can be changed during a session, in **Current notebook** in +the panel. The page you are on then continues in the new notebook. + +### The invitation + +**Invitation** in the panel shows a link. **Copy** copies it; send it to +your guests by email, in a chat message, or however you reach them. A +guest opens it, enters a name and is in. A guest with the Kokkoja +program installed may paste it into their own program instead. The link +carries the secret that protects the session, so send it only to those +who should be there; anyone with the link can join and read the page. +Each session you start has a new invitation. + +An invitation is good as long as you keep the session open. A guest can +join at any time, see everything written so far, and leave and return. + +## Driving the session + +Three buttons at the right of the toolbar are the host's. + +**New** starts a new blank page for everyone. Pages are numbered from 1 +through the session. The page being left is kept in every participant's +notebook, by itself, under a dated name; nobody has to remember to save. + +**Image** puts an image file on the page for everyone. Kokkoja's own +file chooser opens, beginning where your notebooks are and remembering +where you last chose from; a directory path can also be typed into it. +It shows small pictures of the image files it finds. Choose one and it +is placed on the page: the first at the top of the sheet, each further +one 8 mm below the lowest image there, centred, at its natural size. An +image wider than the sheet is scaled down to fit between the margins, so +a full-page A4 scan appears at about 92%; the chooser has a setting to +turn that off, in which case the sheet widens to the image instead. PNG +and JPEG files are shared as they are unless very large; other formats, +and images over 2400 pixels on a side or 1.5 MB, are converted to JPEG +and reduced first. A page holds up to 12 images. + +For a page that begins with an image, press **New** and then **Image**. +For a series of exercises, keep pressing **Image** on the same page: the +sheet grows downward and everyone scrolls through them. A new image does +not change anyone's zoom; it appears below, to be scrolled to. + +If you chose the wrong file, a tap of the eraser on the image takes it +back, as long as nothing else has happened on the page since: no stroke, +no text, no erasure, no further image, by anyone. After that an image +stays, because the marks made on it would make no sense without it. + +**Notebook** starts a new page for everyone from a page kept in one of +your notebooks, with everything that was written on it. A chooser shows +your notebooks on the left and small pictures of the chosen notebook's +kept pages on the right. The loaded page is a copy: the kept page is +not changed by anything that happens next. Each layer on it still +belongs to the participant who wrote it, so a student's answers from an +earlier lesson are still theirs, and your corrections still yours. + +This is also how you return to an earlier page of the session: the page +was kept when you left it, and **Notebook** brings it back as a new page. +A lesson might go: **New**, **Image** for the exercise, work, **New** +for a point of grammar, work, **Notebook** to return to the exercise. + +## Guests' rights + +In the **Participants** list, each guest's name has a button **pages** in +front of it. Pressing it lets that guest start new pages, put images on +the page and load pages from their notebook, exactly as you can; the +three buttons appear on their toolbar. Pressing it again withdraws the +right, which otherwise lasts until the session ends. Guests can never erase or +change anyone's writing but their own; that needs no setting. + +## Hiding writing + +The box in front of each participant's name shows or hides that +participant's writing on your screen only. Hide a student's answers to +see the exercise clean, hide your own corrections to see what the +student sees, show them again; the others notice nothing. **Export** +leaves hidden writing out of the picture it makes, which is how to make a +picture of the exercise with the corrections alone. + +## Keeping and exporting + +The page you are on is written continuously to `Current.svg` in the +current notebook, and copied to a dated file whenever the session moves +on. **Keep page** makes such a copy now, named for the day and a serial +number, such as `2026_10_06_02.svg`; pressing it again before anything +has changed makes no second copy. Use it to mark a state worth keeping +on its own, or to add a finished page to a notebook of exercises. + +**Export** saves a picture of the page as you see it, as a PNG file at +200 dots per inch, in a directory and under a name you choose in +Kokkoja's file chooser. Replacing an existing file takes a second press +of the button, so a slip does not overwrite anything. + +The notebooks are ordinary directories, and a page is an ordinary SVG +file. Open one in a browser or in Inkscape, attach it to an email, keep +it with your other documents. Only Kokkoja can load it as a live page +again, with each person's writing still theirs. + +## Ending a session + +Close the browser tab, or stop the program with Ctrl-C in its terminal. +There is nothing to save: the page is already in your notebook, and in +each guest's. Guests who are still connected can go on writing on the +page among themselves, but no new page can be started without you or a +guest you have given the right, and a guest who arrives after everyone +has left finds an empty session. The next lesson is a new session with a +new invitation; load the pages you need from the student's notebook. + +## When something goes wrong + +* **The browser says the notebooks are locked.** Open Kokkoja from the + full address the program printed when it started, the one with the + token. +* **"Disconnected" in the toolbar.** Kokkoja reconnects by itself as + soon as the relay can be reached, and sends what was written meanwhile. + If it does not come back, check your connection; the page is safe in + your notebook either way. +* **A guest sees "Waiting for the host's page".** You have not started + a page yet, or your connection is down. The first page is started when + you start the session, so this usually means the latter. +* **Pressure does not work.** The last line of the panel reports what + the browser receives from your pen. If it says *mouse*, the browser is + not seeing the pen as a pen; try the other browser. diff --git a/docs/implementation.md b/docs/implementation.md new file mode 100644 index 0000000..23b858c --- /dev/null +++ b/docs/implementation.md @@ -0,0 +1,234 @@ +# How Kokkoja works + +This part is for the curious, for anyone who wants to run a Kokkoja +server, and for anyone who wants to read or change the code. It +describes the parts, what travels between them, what is protected and +what is not, and how pages are stored. The code is the final word; it +is short and commented. + +## The parts + +Kokkoja is three programs and a few static files. + +* **The client** (`client/`) runs in the web browser and does everything + the user sees: the pen, the page, the text, the encryption, the + saving. It is plain JavaScript modules, run by the browser as written, + with no build step and no third-party packages. Its types are written + in JSDoc comments and checked by the TypeScript compiler during + development. +* **The local program** (`kokkoja.py`) runs on the user's computer. It + serves the client to the browser at `http://localhost:8760/`, keeps the + user's identity, and reads and writes the notebook directories, which + a web page cannot do on its own. It uses nothing but Python's standard + library. +* **The relay** (`relay/kokkoja_relay.py`) runs on a server that every + participant can reach. It passes messages between the members of a + session and nothing more: it cannot read them and it stores nothing. + It is Python with one library, `websockets`. +* **The web site** is the `client/` directory served over HTTPS, with a + `config.json` beside it, so that a guest can join without the local + program. `deploy/` has the server setup. + +Every participant's computer holds the whole page. Only changes travel: +a stroke as it is drawn, a text block as it is typed, an erasure, a new +page, an image once. There is no central copy. + +## Why the relay exists + +Two computers behind home routers generally cannot connect to each other +directly, so something in between has to pass the messages. The relay is +the smallest such thing: a WebSocket server with *rooms*. A client +connects to `wss:///relay/room/`; the relay gives the +connection a number, tells the room that a peer has arrived, and from +then on sends every message it receives from one member to all the +others, stamped with the sender's number. When the last member leaves, +the room is gone. It holds at most 32 members per room and 500 rooms, +and refuses messages over 1 MB; clients split anything larger. + +## Sessions and encryption + +The invitation is + + #. + +The *secret* is 16 random bytes, made by the host's browser. From it +every client derives two values with HKDF-SHA-256: the *room id*, which +names the room at the relay, and the *message key*, a 256-bit AES-GCM +key with which every message is encrypted before it is sent and +decrypted on arrival. The relay sees the room id, the members' network +addresses, and the size and timing of encrypted messages. It cannot +compute the key from the room id, so it cannot read anything. Neither +can anyone watching the network between the participants and the relay, +which is TLS-protected in any case. + +A link invitation wraps the same text: + + https:///#join= + +Browsers never send the part of an address after `#` to the site, so the +secret stays between the host and the guests. + +## Identity and layers + +Each participant has a signing key pair (ECDSA on P-256), made in the +browser the first time and then kept, by the local program in its +configuration directory or by the browser in its own storage. The +*participant id* is the first 16 bytes of the SHA-256 hash of the public +key. When a client joins a room it announces itself with a signed +`hello` carrying its name and public key; the signature covers the room +id and the peer number the relay stamped on the message, so nobody can +replay it elsewhere or claim another peer's connection. Until a peer has +sent a valid `hello`, everything else from it is ignored. + +A page is a stack of layers, one per participant, keyed by participant +id. A client accepts a change to a layer only from the participant whose +id it is, so the rule "nobody can change anyone else's writing" is not a +permission that could be misconfigured but a consequence of how the data +is organised. Because the id is derived from the key, and the key is +kept, a person is the same participant in every session from the same +computer, and their writing on a page loaded weeks later is still theirs. + +The host is whoever holds the key whose id is in the invitation. Pages, +images and the list of guests allowed to start pages are accepted only +from the host (or from an allowed guest, for pages and images). + +## Pages + +Page coordinates are in units of 0.1 mm. A blank page is an A4 sheet, +2100 by 2970 units. Writing within 8 mm of an edge grows the sheet by +8 mm on that side. An image is placed at its natural size, taken from +the resolution recorded in the file, or 0.1 mm per pixel when there is +none, centred across the sheet, 8 mm from the top or from the image +above, scaled down if wider than the sheet inside its margins (or, if the +person adding it chooses, placed at natural size with the sheet widened +to hold it). A page holds at most 12 images and 24 MB of image data. +An image file over 2400 pixels on a side or 1.5 MB, or not PNG or JPEG, +is redrawn at most 2400 pixels and stored as JPEG before it is shared; +smaller PNG and JPEG files are shared byte for byte. + +A stroke is a list of points with pressure, and the line width is a +function of pressure: nominal at a pressure of 0.4, about 2.6 times that +at 0.75. Points are sent while the stroke is drawn, so the others see it +grow. A text block is sent whole at every keystroke, with its line +breaks already laid out by the writer's browser, so that everyone shows +the same lines. + +Each page has a number, counted from 1 in the session, and a random id. +A client that joins late asks the longest-connected member for the +layers of absent participants, and receives each present participant's +layer from that participant. A client that reconnects after a dropped +connection sends its whole layer again, so that nothing drawn meanwhile +is lost. + +## The page file + +A notebook is a directory. The page being written is `Current.svg` in +it, rewritten a moment after every change (at most every 400 ms while +writing continues, at least every 2 s). A *kept* page is a copy of +`Current.svg` named `YYYY_MM_DD_NN.svg`, the day being when the page was +last changed and NN a serial number within that day. A copy is made when +the user asks (**Keep page**), and by the program whenever `Current.svg` +is about to be given to another page: when the session moves to a new +page, when the notebook is changed, and when a session begins and finds +a page of another session in the notebook. Nothing is copied if the +page is blank or an identical copy already exists. + +The file is an SVG document that any viewer displays: the images as +embedded `` elements, each layer as a group with the participant's +name and id, each stroke as a path and each text block as text. +Beside the shapes, the same elements carry Kokkoja's own data in +attributes, the exact points and pressures, the text and its breaks, so +that Kokkoja reads the page back without loss, with each layer still +belonging to its participant. The session id and host id are recorded +too, which is how a reloaded tab knows that `Current.svg` is its own page +to continue from. + +With the local program, the notebooks are in `Kokkoja` in the Documents +directory (`--notebooks` chooses another place), the identity and the +browser's token in the configuration directory (`~/.config/kokkoja` on +Linux, `~/Library/Application Support/kokkoja` on macOS). In the +browser, the notebooks are in the site's IndexedDB under the same names +and rules, and the identity in its localStorage; a kept page and an +exported picture are offered as downloads, since that is the only way a +web page can hand a file to its user. + +## The local program and its token + +The local program listens on 127.0.0.1 only. Any web page open in the +browser could nevertheless send requests to `localhost:8760`, so every +request that touches files must carry a secret token, which the program +makes once, keeps in its configuration directory, and passes to the +browser in the address it opens (`#token=...`, which the browser keeps +in its local storage and never sends to any server). Requests without +the token are refused. The program also checks that requests come from +its own origin. Outside the notebooks it reads only image files and +writes only PNG files, each where the file chooser was pointed. + +## The web site, and whom you trust + +A client served by a web site is a web application like any other: the +site operator's code runs in your browser. The end-to-end encryption +protects the session from the relay and the network, but a dishonest +site could serve a client that gives the key away. Participants who run +the local program load the client from their own disk and are not +exposed to that; they trust the code they downloaded once. The site +sends a Content Security Policy that lets the client load nothing from +anywhere else, which protects against the ordinary web attacks but not +against the operator. For a lesson this is the same trust one places in +any video call service, and considerably less than in one that sees the +content; it is stated here so that nobody is surprised. + +## Running a server + +A server is a machine with a public name, TLS and nginx (or any reverse +proxy that passes WebSockets). `deploy/README.md` describes the one +Kokkoja comes with: the relay as a systemd service bound to localhost, +nginx terminating TLS, serving the client's files at `https:///` +and forwarding `wss:///relay/` to the relay, and a `config.json` +telling the client which relay to use and that notebooks are kept in the +browser. A host uses the server with + + python3 kokkoja.py --relay wss:///relay + +and invitations become links to `https:///`. One server serves +any number of hosts and sessions at once; the relay's work per session +is passing a few kilobytes per second. Since the relay cannot read what +it passes, a server run by someone else is a matter of availability, not +of privacy; the web site is the part that requires trust, as described +above. A teacher who wants no dependence on anyone's server needs a +machine of their own that is reachable from the Internet, which a +computer behind a home router is not without arrangement. + +## What is not protected yet + +* Only `hello` is signed. A participant working together with a + dishonest relay could forge another participant's strokes. Signing + every message, or page descriptors with the host's key, would close + this. +* A participant can announce a page with a higher number than the + host's, and the others move to it. +* The relay sees who talks to whom, when, and how much. + +## Pen input + +The client uses the browser's pointer events, which deliver every sample +a pen produces, with pressure, in Firefox and in Chromium-based browsers +on Linux and macOS. The panel's last line shows what arrives. When a +pen has been used, touches are ignored, so a hand resting on a touch +screen does not draw; this rule has not yet been tested with a device +that has both a pen and a touch sensor. Kokkoja opens none of the +browser's system dialogs (the colour palette and, with the local +program, the file chooser are its own), because one such dialog crashed +Firefox on Linux. + +## Development + +The code is Python and JavaScript only. The tests run with + + .venv/bin/python -m unittest discover tests -v + +where `tests/test_end_to_end.py` drives a headless Firefox through +geckodriver with several participants, with and without the local +program, and `npm run check` type-checks the client (the TypeScript +compiler is the only development dependency). The relay's `--host` and +`--port` and the local program's options are listed by `--help`. diff --git a/docs/introduction.md b/docs/introduction.md new file mode 100644 index 0000000..463088e --- /dev/null +++ b/docs/introduction.md @@ -0,0 +1,106 @@ +# Kokkoja: an introduction + +Kokkoja lets people at different computers write together on a shared +*page*. Everyone in a session sees the same page. Anyone can write on it +by hand or type on it, and what one person writes appears on the others' +screens as it is written. A page can begin blank, or it can begin with an +image, such as a scanned sheet or a photograph, which everyone then writes +on. Every page is saved automatically to a *notebook* on each person's +own computer, so that after the session each participant keeps a complete +copy of everything that was written. + +Kokkoja runs in a web browser, beside whatever video call you are already +using. It replaces the screen sharing and annotation of a video call +application with something made for writing together, and it leaves the +voice and video to the call. + +## What Kokkoja does + +**A shared page, not a shared screen.** In a video call one person's +screen is transmitted as a picture, and annotations float on top of that +picture. In Kokkoja every participant's computer holds the page itself: +the image, if there is one, and every mark made on it. Only the changes +travel over the network. The marks belong to the page, are saved with it, +and can be shown or hidden later. + +**Handwriting and typing.** Kokkoja is made for a pen on a drawing +tablet, with pressure giving the line its thickness, but a mouse or a +finger will do. Typed text is placed anywhere on the page and wraps at a +width you choose. Both come in any colour. + +**Everyone writes on their own layer.** Each participant's writing is a +layer of its own. You can erase your own writing and nothing else; nobody +else can erase yours. Any layer can be hidden on your own screen without +affecting anyone else's view. + +**A page that grows.** A page is an A4 sheet. When writing approaches an +edge, the sheet grows in that direction, so you are never short of room. +An image is placed at its natural size; one wider than the sheet is +scaled down to fit, and the sheet grows downward to hold further images. + +**Saved without a save command.** The page you are on is written to your +notebook continuously, as it changes. Nothing is lost if a connection +drops or a computer crashes. When the session moves to another page, the +page being left is kept as a dated file. You can also keep a dated copy +of a page at any moment, and export a picture of it as a PNG file. + +**Notebooks are yours.** A notebook is a directory on your own computer +and a page is a single file in it, an ordinary SVG file that any +image viewer or vector drawing program can open. Nothing is stored on any +server, and there are no accounts: you choose the name the others will +see, and that is all. + +**Private.** Everything that travels between participants is encrypted +on their own computers. The relay server that passes the messages cannot +read them. The invitation to a session carries the key, so only those +who were invited can read the page. + +**Nothing to install for guests.** A guest joins by opening a link in a +web browser. Someone who wants a notebook of real files on their own +computer installs the small Kokkoja program instead; that is also how a +host runs Kokkoja. + +**Free software.** Kokkoja is free and open-source. Anyone can use it +without paying, and nobody depends on a company's service. It runs in +Firefox and in browsers built on the Chromium engine, such as Brave and +Chrome, on Linux and macOS. + +## Who uses it + +Kokkoja is a general tool for working on a page together at a distance: +going through a document with a colleague, sketching a diagram while +talking it over, correcting a draft, teaching anything that is done on +paper. Wherever two people would otherwise lean over one sheet, Kokkoja +puts the sheet between their computers. + +Its ideal use, and the one it was made for first, is teaching a foreign +language over a video call. The teacher puts a scanned exercise on the +page. The student fills it in by hand or by typing, during the lesson, +while the teacher watches, corrects mistakes as they are made, and adds +notes in the margin. Each correction is in the teacher's own hand and +colour, on the teacher's own layer, where the student cannot change it. +When the lesson is over, the student's notebook holds every exercise of +the lesson, with the student's answers and the teacher's corrections +together on one page, and the teacher's notebook holds the same pages as +a record of what the student was given. The teacher's notebooks of +exercises become a library: loading a page from one of them puts a fresh +copy in front of a new student, and the original is never changed. + +## Hosts and guests + +A session has one *host*, the person who starts it, and any number of +*guests*. In a lesson the teacher is the host and the students are the +guests. The host sends the guests an invitation, by email, in a chat +message, or by any other means. The host *drives*: only the host puts +images on the page, starts new pages or loads pages from a notebook, and +everyone follows. The host can give any guest the same abilities, for a +discussion among colleagues. + +The rest of this documentation describes Kokkoja from the guest's point of +view first, since the host sees the same screen and has a few more +buttons, then from the host's, and finally explains how it works +underneath, for those who want to run their own server or read the code. + +* [A guest's Kokkoja](guest.md) +* [A host's Kokkoja](host.md) +* [How Kokkoja works](implementation.md) diff --git a/kokkoja.py b/kokkoja.py new file mode 100644 index 0000000..7095264 --- /dev/null +++ b/kokkoja.py @@ -0,0 +1,496 @@ +#!/usr/bin/env python3 +"""Kokkoja local program. + +Serves the Kokkoja client to a web browser on this computer, and reads +and writes the user's notebooks for it. A notebook is a directory; a page +is one file in it. It also lets the client's own file chooser look through +the user's directories for image files, and writes an exported picture of +a page where the user chooses, neither of which a web page can do itself. + +The client must be loaded from this computer (or over HTTPS) because +browsers allow their built-in cryptography only there. + +Only the user's own browser may use the notebooks. The program listens on +this computer alone, and every notebook request must carry a secret token. +The browser gets the token from the address this program opens or prints; +another user of the computer, or a web page from elsewhere, does not have it. + +Uses only the Python standard library. +""" + +import argparse +import functools +import hmac +import http.server +import json +import os +import pathlib +import re +import secrets +import subprocess +import sys +import threading +import time +import urllib.parse +import webbrowser + +CLIENT_DIRECTORY = pathlib.Path(__file__).resolve().parent / "client" + +MAX_PAGE_BYTES = 64 << 20 + +# The page being written in a notebook, and the pages kept from it. +CURRENT_PAGE = "Current.svg" +KEPT_PAGE = re.compile(r"^(\d{4}_\d{2}_\d{2})_(\d{2,})\.svg$") + +# Notebook files are changed by one request at a time. +notebook_lock = threading.Lock() + +# A notebook or page name: one path component, not hidden, no control +# characters, no characters that a file system might refuse. +NAME = re.compile(r'^[^\x00-\x1f/\\:*?"<>|.][^\x00-\x1f/\\:*?"<>|]{0,119}$') + +# The image files the file chooser offers, and what a browser is told they +# are. The chooser reads these and nothing else. +IMAGE_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp"} +MAX_IMAGE_FILE_BYTES = 64 << 20 + +# An exported page is a PNG file, and nothing else is written outside the +# notebooks: the name must end in .png and the bytes must begin like this. +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + +LOCAL_HOST = re.compile(r"^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$") +LOCAL_ORIGIN = re.compile(r"^http://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$") + + +def config_directory(): + """Where Kokkoja keeps its own settings, by each system's convention.""" + if sys.platform == "win32": + base = os.environ.get("APPDATA") or pathlib.Path.home() / "AppData" / "Roaming" + elif sys.platform == "darwin": + base = pathlib.Path.home() / "Library" / "Application Support" + else: + base = os.environ.get("XDG_CONFIG_HOME") or pathlib.Path.home() / ".config" + return pathlib.Path(base) / "kokkoja" + + +def default_notebooks_directory(): + """A Kokkoja directory in the user's documents directory.""" + documents = pathlib.Path.home() / "Documents" + if sys.platform.startswith("linux"): + # The documents directory has another name in other languages. + try: + found = subprocess.run(["xdg-user-dir", "DOCUMENTS"], capture_output=True, + text=True, timeout=5).stdout.strip() + if found and pathlib.Path(found) != pathlib.Path.home(): + documents = pathlib.Path(found) + except (OSError, subprocess.SubprocessError): + pass + return documents / "Kokkoja" + + +def load_token(directory): + """The secret token, made on first use and kept in a private file.""" + path = directory / "token" + try: + token = path.read_text().strip() + if token: + return token + except OSError: + pass + token = secrets.token_urlsafe(24) + path.parent.mkdir(parents=True, exist_ok=True) + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(descriptor, "w") as file: + file.write(token + "\n") + return token + + +def write_atomically(path, data): + """Replace a file so that it is never seen half written.""" + temporary = path.with_name("." + path.name + ".writing") + with open(temporary, "wb") as file: + file.write(data) + file.flush() + os.fsync(file.fileno()) + os.replace(temporary, path) + + +def keep_page(notebook): + """Keep a copy of the notebook's current page as a dated page. + + The copy is named for the day the page was last changed, with a serial + number: 2026_10_02_01.svg. Nothing is copied if the page is blank or + if an identical page is already kept. + + Returns (name, new): the name of the kept page, or None if there was + nothing to keep, and whether the copy was made just now. + """ + current = notebook / CURRENT_PAGE + try: + data = current.read_bytes() + changed = current.stat().st_mtime + except FileNotFoundError: + return None, False + if b"data-stroke=" not in data and b"/relay: + such a server serves the client at https:/// as well. + """ + if site: + return site.rstrip("/") + "/" + parts = urllib.parse.urlsplit(relay) + if parts.scheme == "wss" and parts.path.rstrip("/") == "/relay": + return f"https://{parts.netloc}/" + return "" + + +class Handler(http.server.SimpleHTTPRequestHandler): + relay = "" + site = "" + token = "" + notebooks = pathlib.Path() + config = pathlib.Path() + + # Replies. + + def send_json(self, value, status=200): + self.send_body(json.dumps(value).encode(), "application/json", status) + + def send_body(self, body, content_type, status=200): + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def refuse(self, status, reason): + self.send_json({"error": reason}, status) + + def end_headers(self): + # The client files change during development; never serve stale ones. + self.send_header("Cache-Control", "no-store") + super().end_headers() + + def log_message(self, format, *arguments): + pass + + # Requests. + + def do_GET(self): + path = self.path.split("?")[0] + if path == "/config.json": + # How the client was served: by this program, which keeps the + # notebooks as files. A web site serves its own config.json + # with "storage": "browser". + self.send_json({"relay": self.relay, "storage": "files", "site": self.site}) + elif path == "/api/identity": + self.identity_request("GET") + elif path == "/api/files": + self.files_request() + elif path == "/api/file": + self.file_request("GET") + elif path == "/api" or path.startswith("/api/"): + self.notebook_request("GET", path) + else: + super().do_GET() + + def do_PUT(self): + path = self.path.split("?")[0] + if path == "/api/identity": + self.identity_request("PUT") + elif path == "/api/file": + self.file_request("PUT") + else: + self.notebook_request("PUT", path) + + def do_POST(self): + self.notebook_request("POST", self.path.split("?")[0]) + + def allowed(self): + """Whether a notebook request comes from the user's own browser.""" + if not LOCAL_HOST.match(self.headers.get("Host", "")): + self.refuse(403, "not addressed to this computer") + return False + origin = self.headers.get("Origin") + if origin is not None and not LOCAL_ORIGIN.match(origin): + self.refuse(403, "request from another web site") + return False + given = self.headers.get("Authorization", "") + if not hmac.compare_digest(given.encode(), ("Bearer " + self.token).encode()): + self.refuse(401, "missing or wrong token") + return False + return True + + def identity_request(self, method): + """ + GET /api/identity the user's identity (signing keys), or 404 + PUT /api/identity store it, once; a second PUT is refused + + The identity is kept here rather than in the browser so that the + user is the same participant in every session, from any browser on + this computer, and their writing from an earlier lesson stays + theirs. The file is private to the user. + """ + if not self.allowed(): + return + path = self.config / "identity.json" + try: + if method == "GET": + self.send_body(path.read_bytes(), "application/json") + return + length = int(self.headers.get("Content-Length", "-1")) + if not 0 < length <= MAX_IDENTITY_BYTES: + return self.refuse(413, "not an identity") + data = self.rfile.read(length) + try: + json.loads(data) + except ValueError: + return self.refuse(400, "not an identity") + with notebook_lock: + if path.exists(): + return self.refuse(409, "an identity is already kept") + path.parent.mkdir(parents=True, exist_ok=True) + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "wb") as file: + file.write(data) + self.send_json({"kept": True}) + except FileNotFoundError: + self.refuse(404, "no identity yet") + except (OSError, ValueError) as error: + self.refuse(500, str(error)) + + def notebook_request(self, method, path): + """ + GET /api/notebooks the notebooks + GET /api/notebooks/ the pages of a notebook + GET /api/notebooks// a page + PUT /api/notebooks// write a page + POST /api/notebooks//keep keep a copy of Current.svg + """ + if not self.allowed(): + return + parts = [urllib.parse.unquote(part) for part in path.split("/")[2:]] + if not parts or parts[0] != "notebooks" or len(parts) > 3: + return self.refuse(404, "no such request") + names = parts[1:] + if not all(NAME.match(name) for name in names): + return self.refuse(400, "name not allowed") + keeping = method == "POST" and len(names) == 2 and names[1] == "keep" + if len(names) == 2 and not keeping and not names[1].endswith(".svg"): + return self.refuse(400, "a page name ends in .svg") + target = self.notebooks.joinpath(*names) + + try: + if keeping: + with notebook_lock: + name, new = keep_page(target.parent) if target.parent.is_dir() else (None, False) + self.send_json({"kept": name, "new": new}) + elif method == "POST": + self.refuse(405, "nothing to do") + elif method == "PUT": + if len(names) != 2: + return self.refuse(405, "only a page can be written") + length = int(self.headers.get("Content-Length", "-1")) + if not 0 <= length <= MAX_PAGE_BYTES: + return self.refuse(413, "page too large") + data = self.rfile.read(length) + with notebook_lock: + target.parent.mkdir(parents=True, exist_ok=True) + write_atomically(target, data) + self.send_json({"saved": names[1]}) + elif len(names) == 0: + self.send_json({"directory": str(self.notebooks), + "notebooks": self.listing(self.notebooks, directories=True)}) + elif len(names) == 1: + self.send_json({"pages": self.listing(target, directories=False)}) + else: + self.send_body(target.read_bytes(), "image/svg+xml") + except FileNotFoundError: + self.refuse(404, "not found") + except (OSError, ValueError) as error: + self.refuse(500, str(error)) + + def query(self): + """The named values after '?' in the request's address.""" + values = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query, + keep_blank_values=True) + return {name: value[-1] for name, value in values.items()} + + def files_request(self): + """ + GET /api/files?directory= the directories and the image + files in a directory + + Without a directory: the notebooks directory, or the user's home + directory if there are no notebooks yet. Hidden entries are left + out. Each entry comes with its full path, so that the client never + has to put a path together. + """ + if not self.allowed(): + return + given = self.query().get("directory", "") + try: + if given: + directory = pathlib.Path(given).expanduser() + if not directory.is_absolute(): + return self.refuse(400, "not a full path") + else: + directory = self.notebooks if self.notebooks.is_dir() else pathlib.Path.home() + directory = pathlib.Path(os.path.abspath(directory)) + directories = [] + files = [] + for entry in directory.iterdir(): + if entry.name.startswith("."): + continue + try: + if entry.is_dir(): + directories.append({"name": entry.name, "path": str(entry)}) + elif entry.is_file() and entry.suffix.lower() in IMAGE_TYPES: + files.append({"name": entry.name, "path": str(entry), + "size": entry.stat().st_size}) + except OSError: + continue + by_name = lambda entry: (entry["name"].casefold(), entry["name"]) + self.send_json({ + "directory": str(directory), + "parent": None if directory.parent == directory else str(directory.parent), + "directories": sorted(directories, key=by_name), + "files": sorted(files, key=by_name)}) + except FileNotFoundError: + self.refuse(404, "no such directory") + except NotADirectoryError: + self.refuse(400, "not a directory") + except PermissionError: + self.refuse(403, "this directory may not be read") + except (OSError, ValueError) as error: + self.refuse(500, str(error)) + + def file_request(self, method): + """ + GET /api/file?path= an image file + PUT /api/file?directory=&name=.png[&replace=1] + write an exported page + + Only image files are read, by their names. Only PNG files are + written, into a directory that exists, and an existing file is + replaced only when the request says so (409 otherwise). + """ + if not self.allowed(): + return + query = self.query() + try: + if method == "GET": + path = pathlib.Path(query.get("path", "")) + kind = IMAGE_TYPES.get(path.suffix.lower()) + if not path.is_absolute() or kind is None: + return self.refuse(400, "not an image file") + if path.stat().st_size > MAX_IMAGE_FILE_BYTES: + return self.refuse(413, "image too large") + return self.send_body(path.read_bytes(), kind) + directory = pathlib.Path(query.get("directory", "")) + name = query.get("name", "") + if not directory.is_absolute() or not directory.is_dir(): + return self.refuse(404, "no such directory") + if not NAME.match(name) or not name.lower().endswith(".png"): + return self.refuse(400, "the name must end in .png") + length = int(self.headers.get("Content-Length", "-1")) + if not 0 < length <= MAX_IMAGE_FILE_BYTES: + return self.refuse(413, "picture too large") + data = self.rfile.read(length) + if not data.startswith(PNG_SIGNATURE): + return self.refuse(400, "not a PNG picture") + target = directory / name + with notebook_lock: + if target.exists() and query.get("replace") != "1": + return self.refuse(409, "exists") + write_atomically(target, data) + self.send_json({"saved": str(target)}) + except FileNotFoundError: + self.refuse(404, "not found") + except PermissionError: + self.refuse(403, "not permitted there") + except (OSError, ValueError) as error: + self.refuse(500, str(error)) + + @staticmethod + def listing(directory, directories): + """Names of the notebooks in the notebooks directory, or of the + pages in a notebook. A notebook that does not exist yet is empty.""" + if not directory.is_dir(): + return [] + return sorted( + entry.name for entry in directory.iterdir() + if NAME.match(entry.name) and + (entry.is_dir() if directories else entry.is_file() and entry.name.endswith(".svg"))) + + +def make_server(port, relay, notebooks, token, config, site=""): + handler = type("ConfiguredHandler", (Handler,), { + "relay": relay.rstrip("/"), "site": site_of(relay, site), "token": token, + "notebooks": pathlib.Path(notebooks), "config": pathlib.Path(config)}) + handler = functools.partial(handler, directory=str(CLIENT_DIRECTORY)) + return http.server.ThreadingHTTPServer(("127.0.0.1", port), handler) + + +def main(): + parser = argparse.ArgumentParser(description="Kokkoja local program") + parser.add_argument("--port", type=int, default=8760) + parser.add_argument("--relay", default="ws://127.0.0.1:8770", + help="relay used for sessions started here " + "(default ws://127.0.0.1:8770)") + parser.add_argument("--site", default="", + help="Kokkoja web site that invitations link to, so that " + "guests can join with nothing installed (default: " + "https:/// when the relay is wss:///relay)") + parser.add_argument("--notebooks", type=pathlib.Path, default=None, + help="directory that holds the notebooks " + "(default: Kokkoja in your documents directory)") + parser.add_argument("--config", type=pathlib.Path, default=None, + help="directory for Kokkoja's own settings " + "(default: your system's usual place)") + parser.add_argument("--no-browser", action="store_true", + help="do not open a browser window") + arguments = parser.parse_args() + + notebooks = arguments.notebooks or default_notebooks_directory() + config = arguments.config or config_directory() + token = load_token(config) + server = make_server(arguments.port, arguments.relay, notebooks, token, config, arguments.site) + address = f"http://localhost:{arguments.port}/#token={token}" + print(f"Kokkoja is at {address}", flush=True) + print(f"Notebooks are in {notebooks}", flush=True) + print("Ctrl-C to stop.", flush=True) + if not arguments.no_browser: + webbrowser.open(address) + try: + server.serve_forever() + except KeyboardInterrupt: + print() + + +if __name__ == "__main__": + main() diff --git a/relay/kokkoja_relay.py b/relay/kokkoja_relay.py new file mode 100644 index 0000000..b49a902 --- /dev/null +++ b/relay/kokkoja_relay.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Kokkoja relay. + +Passes messages between the participants of a session. It stores nothing: +no accounts, no pages, no notebooks. Message bodies are encrypted by the +clients with a key the relay never sees, so the relay cannot read them. + +The protocol is described in notes/protocol.md. In short: + +* A client connects to ``/room/``. The relay gives the connection + a peer number, unique within the room, and sends ``welcome``. + +* A binary message from a client begins with a 4-byte destination peer + number (0 means everyone else in the room). The relay replaces those four + bytes with the sender's peer number and forwards the message. + +* The relay sends ``join`` and ``leave`` as peers come and go. + +Requires the ``websockets`` library, version 13 or later. +""" + +import argparse +import asyncio +import http +import json +import logging +import re +import signal + +from websockets.asyncio.server import broadcast, serve +from websockets.exceptions import ConnectionClosed + +ROOM_PATH = re.compile(r"^/room/([A-Za-z0-9_-]{22})$") + +MAX_MESSAGE_BYTES = 1 << 20 # Clients split anything larger. +MAX_PEERS_PER_ROOM = 32 +MAX_ROOMS = 500 + +# Close codes in the range reserved for applications. +CLOSE_ROOM_FULL = 4001 +CLOSE_RELAY_FULL = 4002 +CLOSE_BAD_MESSAGE = 4003 + +log = logging.getLogger("kokkoja_relay") + + +class Room: + def __init__(self): + self.peers = {} # peer number -> connection + self.next_peer = 1 # Peer numbers are never reused in a room. + + +rooms = {} # room id -> Room + + +def check_path(connection, request): + """Refuse the WebSocket handshake unless the path names a room.""" + if not ROOM_PATH.match(request.path): + return connection.respond(http.HTTPStatus.NOT_FOUND, "Not a Kokkoja room\n") + return None + + +def control(message_type, **fields): + return json.dumps({"type": message_type, **fields}) + + +async def handle(connection): + room_id = ROOM_PATH.match(connection.request.path).group(1) + room = rooms.get(room_id) + if room is None: + if len(rooms) >= MAX_ROOMS: + await connection.close(CLOSE_RELAY_FULL, "relay full") + return + room = rooms[room_id] = Room() + if len(room.peers) >= MAX_PEERS_PER_ROOM: + await connection.close(CLOSE_ROOM_FULL, "room full") + return + + peer = room.next_peer + room.next_peer += 1 + others = sorted(room.peers) + room.peers[peer] = connection + sender = peer.to_bytes(4, "big") + log.info("room %s..: peer %d joined (%d present)", room_id[:4], peer, len(room.peers)) + + try: + await connection.send(control("welcome", peer=peer, peers=others)) + broadcast([room.peers[p] for p in others], control("join", peer=peer)) + async for message in connection: + if not isinstance(message, bytes) or len(message) < 4: + await connection.close(CLOSE_BAD_MESSAGE, "bad message") + break + destination = int.from_bytes(message[:4], "big") + if destination == 0: + targets = [c for p, c in room.peers.items() if p != peer] + else: + target = room.peers.get(destination) + targets = [target] if target is not None and destination != peer else [] + broadcast(targets, sender + message[4:]) + except ConnectionClosed: + pass + finally: + del room.peers[peer] + if room.peers: + broadcast(list(room.peers.values()), control("leave", peer=peer)) + else: + del rooms[room_id] + log.info("room %s..: peer %d left (%d present)", room_id[:4], peer, len(room.peers)) + + +async def run(host, port): + stop = asyncio.get_running_loop().create_future() + + def stop_once(): + if not stop.done(): + stop.set_result(None) + + for signal_number in (signal.SIGINT, signal.SIGTERM): + asyncio.get_running_loop().add_signal_handler(signal_number, stop_once) + async with serve(handle, host, port, process_request=check_path, + max_size=MAX_MESSAGE_BYTES, ping_interval=20, ping_timeout=20): + log.info("relay listening on %s port %d", host, port) + await stop + + +def main(): + parser = argparse.ArgumentParser(description="Kokkoja relay") + parser.add_argument("--host", default="127.0.0.1", + help="address to listen on (default 127.0.0.1; 0.0.0.0 for all)") + parser.add_argument("--port", type=int, default=8770) + arguments = parser.parse_args() + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") + # The library reports every connection as well; our own lines suffice. + logging.getLogger("websockets").setLevel(logging.WARNING) + asyncio.run(run(arguments.host, arguments.port)) + + +if __name__ == "__main__": + main() diff --git a/relay/requirements.txt b/relay/requirements.txt new file mode 100644 index 0000000..9473ab6 --- /dev/null +++ b/relay/requirements.txt @@ -0,0 +1 @@ +websockets>=13 diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py new file mode 100644 index 0000000..eb8a7e7 --- /dev/null +++ b/tests/test_end_to_end.py @@ -0,0 +1,1555 @@ +"""End-to-end test in Firefox: two participants, one page, through the relay. + +Starts a relay and the local program on free ports, then drives a headless +Firefox through geckodriver (the WebDriver protocol, spoken here with the +standard library only). Skipped if geckodriver is not installed. + +Run from the project directory: + + .venv/bin/python -m unittest tests.test_end_to_end -v + +Set KOKKOJA_SCREENSHOT to a file name to save a picture of the page as +the second participant sees it at the end. +""" + +import base64 +import json +import os +import pathlib +import shutil +import socket +import struct +import subprocess +import sys +import tempfile +import time +import unittest +import urllib.error +import urllib.parse +import urllib.request +import zlib + +PROJECT = pathlib.Path(__file__).resolve().parent.parent + +# Describes the page as one participant holds it: for each layer, by the +# participant's name, the strokes with their points. +PAGE_STATE = """ + const session = window.kokkoja && window.kokkoja.session; + if (!session) return null; + const layers = {}; + const texts = {}; + for (const layer of session.page.layers.values()) { + layers[layer.name] = [...layer.strokes.values()].map( + (stroke) => ({id: stroke.id, done: stroke.done, points: stroke.points})); + texts[layer.name] = [...layer.texts.values()]; + } + // The first image, and all of them in sequence. + const images = session.page.images; + const image = images[0] ?? null; + const shown = document.querySelectorAll('.page_image'); + return { + status: document.getElementById('status').textContent, + page: {number: session.page.number, width: session.page.width, height: session.page.height}, + image: image && {type: image.type, size: image.size, hash: image.hash, + ready: image.data !== null, + shown: shown.length > 0 && !shown[0].hidden}, + placed: image && {x: image.x, y: image.y, width: image.width, height: image.height}, + images: images.map((one, i) => ({ + hash: one.hash, x: one.x, y: one.y, width: one.width, height: one.height, + ready: one.data !== null, shown: i < shown.length && !shown[i].hidden})), + participants: session.participantList().map( + (p) => ({name: p.name, host: p.host, present: p.self || p.peer !== null})), + layers, + texts, + }; +""" + +# A press and release with the text tool, from (fx0, fy0) to (fx1, fy1) as +# fractions of the page. +TEXT_GESTURE = """ + const [fx0, fy0, fx1, fy1] = arguments; + const page = document.getElementById('page'); + const box = page.getBoundingClientRect(); + const capture = page.setPointerCapture; + page.setPointerCapture = () => {}; + const fire = (type, x, y, buttons) => page.dispatchEvent( + new PointerEvent(type, {pointerId: 9, pointerType: 'mouse', isPrimary: true, + clientX: box.left + x, clientY: box.top + y, pressure: 0.5, + button: type === 'pointermove' ? -1 : 0, buttons, bubbles: true, cancelable: true})); + fire('pointerdown', fx0 * box.width, fy0 * box.height, 1); + fire('pointermove', fx1 * box.width, fy1 * box.height, 1); + fire('pointerup', fx1 * box.width, fy1 * box.height, 0); + page.setPointerCapture = capture; +""" + +# Drags one of the text editor's handles by (dx, dy) CSS pixels. +DRAG_HANDLE = """ + const [selector, dx, dy] = arguments; + const handle = document.querySelector(selector); + const box = handle.getBoundingClientRect(); + handle.setPointerCapture = () => {}; + const fire = (type, x, y, buttons) => handle.dispatchEvent( + new PointerEvent(type, {pointerId: 11, pointerType: 'mouse', isPrimary: true, + clientX: x, clientY: y, button: type === 'pointermove' ? -1 : 0, buttons, + bubbles: true, cancelable: true})); + const x = box.left + box.width / 2, y = box.top + box.height / 2; + fire('pointerdown', x, y, 1); + fire('pointermove', x + dx, y + dy, 1); + fire('pointerup', x + dx, y + dy, 0); +""" + +# Draws with a simulated pen: a wave with changing pressure. Arguments: +# x and y of the start, as fractions of the page width and height. +PEN_WAVE = """ + const [fx, fy] = arguments; + const page = document.getElementById('page'); + const box = page.getBoundingClientRect(); + const capture = page.setPointerCapture; + page.setPointerCapture = () => {}; + const fire = (type, x, y, pressure, buttons) => page.dispatchEvent( + new PointerEvent(type, {pointerId: 7, pointerType: 'pen', isPrimary: true, + clientX: box.left + x, clientY: box.top + y, pressure, + button: type === 'pointermove' ? -1 : 0, buttons, bubbles: true, cancelable: true})); + const x0 = fx * box.width; + const y0 = fy * box.height; + fire('pointerdown', x0, y0, 0.1, 1); + for (let i = 1; i <= 60; i++) { + const t = i / 60; + fire('pointermove', x0 + t * 150, y0 + Math.sin(t * Math.PI * 6) * 25, + 0.15 + 0.8 * Math.abs(Math.sin(t * Math.PI * 3)), 1); + } + fire('pointerup', x0 + 150, y0, 0, 0); + page.setPointerCapture = capture; +""" + +# Clicks the host's "pages" toggle beside the named participant. +TOGGLE_PERMISSION = """ + const find = () => [...document.querySelectorAll('#participants li')] + .find((li) => li.textContent.includes(arguments[0])).querySelector('.allow'); + find().click(); + return find().getAttribute('aria-pressed'); // The list is rebuilt by the click. +""" + +# Makes an image of the given size in pixels and puts it on the page, as +# the "Image" button does with a file. Arguments: width, height, kind +# ('png' or 'jpeg'), a resolution in dots per inch to record in the file, +# or 0 for none, and whether to press "New" first, for a page of its own. +LOAD_IMAGE = """ + const [width, height, kind, dpi, fresh, done] = arguments; + if (fresh) document.getElementById('new_page').click(); + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + context.fillStyle = '#f4f0e0'; + context.fillRect(0, 0, width, height); + context.fillStyle = '#203040'; + for (let y = 40; y < height; y += 60) context.fillRect(30, y, width - 60, 3); + + const crc32 = (bytes) => { + let crc = -1; + for (const byte of bytes) { + crc ^= byte; + for (let k = 0; k < 8; k++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + return (crc ^ -1) >>> 0; + }; + const withResolution = async (blob) => { + if (!dpi) return blob; + const bytes = new Uint8Array(await blob.arrayBuffer()); + if (kind === 'png') { + // A pHYs chunk after IHDR: pixels per metre, both ways, unit 1. + const perMetre = Math.round(dpi / 0.0254); + const chunk = new Uint8Array(4 + 4 + 9 + 4); + const view = new DataView(chunk.buffer); + view.setUint32(0, 9); + chunk.set([0x70, 0x48, 0x59, 0x73], 4); + view.setUint32(8, perMetre); + view.setUint32(12, perMetre); + chunk[16] = 1; + view.setUint32(17, crc32(chunk.subarray(4, 17))); + const ihdrEnd = 8 + 4 + 4 + 13 + 4; + const out = new Uint8Array(bytes.length + chunk.length); + out.set(bytes.subarray(0, ihdrEnd)); + out.set(chunk, ihdrEnd); + out.set(bytes.subarray(ihdrEnd), ihdrEnd + chunk.length); + return new Blob([out], {type: 'image/png'}); + } + // The JFIF header: units 1 (inches) and the density, at fixed offsets. + const view = new DataView(bytes.buffer); + if (view.getUint16(2) !== 0xffe0) throw new Error('no JFIF header to change'); + bytes[13] = 1; + view.setUint16(14, dpi); + view.setUint16(16, dpi); + return new Blob([bytes], {type: 'image/jpeg'}); + }; + canvas.toBlob((blob) => withResolution(blob) + .then((file) => window.kokkoja.loadImage(file).then(() => done(file.size))), + kind === 'png' ? 'image/png' : 'image/jpeg', 0.92); +""" + +# Puts the eraser down at (x, y), in page units, moves it dx to the right +# and lifts it. +ERASER_AT = """ + const [x, y, dx] = arguments; + const page = document.getElementById('page'); + const box = page.getBoundingClientRect(); + const extent = window.kokkoja.session.page; + const cx = (px) => box.left + (px - extent.left) / extent.width * box.width; + const cy = (py) => box.top + (py - extent.top) / extent.height * box.height; + const capture = page.setPointerCapture; + page.setPointerCapture = () => {}; + const fire = (type, px, buttons) => page.dispatchEvent( + new PointerEvent(type, {pointerId: 7, pointerType: 'pen', isPrimary: true, + clientX: cx(px), clientY: cy(y), pressure: 0.5, + button: type === 'pointermove' ? -1 : 0, buttons, bubbles: true, cancelable: true})); + document.querySelector('[data-tool=eraser]').click(); + fire('pointerdown', x, 1); + fire('pointermove', x + dx, 1); + fire('pointerup', x + dx, 0); + document.querySelector('[data-tool=pen]').click(); + page.setPointerCapture = capture; +""" + +# Describes how the page is shown: whether fitted to the window, at what +# scale, and whether it extends beyond the window. +VIEW = """ + const desk = document.getElementById('desk'); + return { + fit: document.getElementById('zoom_fit').getAttribute('aria-pressed') === 'true', + scale: Math.round(window.kokkoja.renderer.cssPixelsPerUnit * 1e6) / 1e6, + top: Math.round(desk.scrollTop), + scrollsDown: desk.scrollHeight > desk.clientHeight + 1, + scrollsAcross: desk.scrollWidth > desk.clientWidth + 1, + }; +""" + +# Describes the file chooser as it stands. +FILE_CHOOSER = """ + const texts = (selector) => [...document.querySelectorAll(selector)].map((e) => e.textContent); + return { + open: document.getElementById('file_chooser').open, + title: document.getElementById('files_title').textContent, + directory: document.getElementById('files_directory').value, + directories: texts('.files_directories button'), + files: texts('.file_card span'), + drawn: [...document.querySelectorAll('.file_card canvas')].map((c) => [c.width, c.height]), + existing: texts('.files_existing button'), + saving: !document.getElementById('files_save').hidden, + name: document.getElementById('files_name').value, + button: document.getElementById('files_save_here').textContent, + message: document.getElementById('files_message').textContent, + shrink: document.getElementById('files_shrink').checked, + shrinkShown: !document.getElementById('files_shrink_line').hidden, + }; +""" + +# Reads an exported picture back and returns the colours at three places: +# a corner of the page, the middle of its first image, and the middle of +# the teacher's latest stroke. Argument: the picture's full path. +PICTURE_COLOURS = """ + const [path, done] = arguments; + (async () => { + const {readFile} = await import('./js/files.js'); + const bitmap = await createImageBitmap(await readFile(path)); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext('2d'); + context.drawImage(bitmap, 0, 0); + const at = (x, y) => [...context.getImageData( + Math.round(x * 200 / 254), Math.round(y * 200 / 254), 1, 1).data].slice(0, 3); + const session = window.kokkoja.session; + const image = session.page.images[0]; + const points = [...session.ownLayer.strokes.values()].at(-1).points; + const middle = Math.floor(points.length / 6) * 3; + return {size: [bitmap.width, bitmap.height], corner: at(20, 20), + image: at(image.x + image.width / 2, image.y + image.height / 2), + stroke: at(points[middle], points[middle + 1])}; + })().then(done); +""" + + +def png_picture(width, height, colour): + """A PNG file of one colour.""" + def chunk(kind, data): + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) + row = b"\x00" + bytes(colour) * width + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(row * height)) + chunk(b"IEND", b"")) + + +# Moves the eraser down through the point (fx, fy) of the page. +ERASE_THROUGH = """ + const [fx, fy] = arguments; + const page = document.getElementById('page'); + const box = page.getBoundingClientRect(); + const capture = page.setPointerCapture; + page.setPointerCapture = () => {}; + const fire = (type, x, y, buttons) => page.dispatchEvent( + new PointerEvent(type, {pointerId: 7, pointerType: 'pen', isPrimary: true, + clientX: box.left + x, clientY: box.top + y, pressure: 0.5, + button: type === 'pointermove' ? -1 : 0, buttons, bubbles: true, cancelable: true})); + document.querySelector('[data-tool=eraser]').click(); + const x = fx * box.width; + const y = fy * box.height; + fire('pointerdown', x, y - 40, 1); + for (let i = 0; i <= 8; i++) fire('pointermove', x, y - 40 + i * 10, 1); + fire('pointerup', x, y + 40, 0); + document.querySelector('[data-tool=pen]').click(); + page.setPointerCapture = capture; +""" + + +def free_port(): + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + return listener.getsockname()[1] + + +def wait_for_port(port, seconds=15): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + with socket.socket() as probe: + if probe.connect_ex(("127.0.0.1", port)) == 0: + return + time.sleep(0.1) + raise TimeoutError(f"nothing is listening on port {port}") + + +class Firefox: + """A small WebDriver client.""" + + def __init__(self, port, downloads): + self.base = f"http://127.0.0.1:{port}" + capabilities = {"alwaysMatch": { + "browserName": "firefox", + "moz:firefoxOptions": { + "args": ["-headless", "--width=1200", "--height=900"], + # Downloads go to the given directory without asking. + "prefs": {"browser.download.folderList": 2, "browser.download.dir": str(downloads), + "browser.download.useDownloadDir": True, + "browser.download.always_ask_before_handling_new_types": False}, + }, + }} + self.session = self.command("POST", "/session", {"capabilities": capabilities})["sessionId"] + + def command(self, method, path, body=None): + data = None if body is None else json.dumps(body).encode() + request = urllib.request.Request(self.base + path, data=data, method=method, + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(request, timeout=60) as response: + return json.load(response)["value"] + except urllib.error.HTTPError as error: + raise RuntimeError(error.read().decode()) from None + + def in_session(self, method, path, body=None): + return self.command(method, f"/session/{self.session}{path}", body) + + def go(self, address): + self.in_session("POST", "/url", {"url": address}) + + def run(self, script, *arguments): + return self.in_session("POST", "/execute/sync", {"script": script, "args": list(arguments)}) + + def run_until_done(self, script, *arguments): + """Run a script that calls its last argument when it has finished.""" + return self.in_session("POST", "/execute/async", {"script": script, "args": list(arguments)}) + + def new_tab(self): + handle = self.in_session("POST", "/window/new", {"type": "tab"})["handle"] + self.switch_to(handle) + return handle + + def current_tab(self): + return self.in_session("GET", "/window") + + def switch_to(self, handle): + self.in_session("POST", "/window", {"handle": handle}) + + def drag(self, selector, from_x, from_y, to_x, to_y): + """Drag the mouse over an element; positions are from its top left.""" + box = self.run(f"const b = document.querySelector({json.dumps(selector)})" + ".getBoundingClientRect(); return [b.left, b.top];") + moves = [{"type": "pointerMove", "duration": 0, "origin": "viewport", + "x": round(box[0] + from_x), "y": round(box[1] + from_y)}, + {"type": "pointerDown", "button": 0}] + for step in range(1, 11): + moves.append({"type": "pointerMove", "duration": 20, "origin": "viewport", + "x": round(box[0] + from_x + (to_x - from_x) * step / 10), + "y": round(box[1] + from_y + (to_y - from_y) * step / 10)}) + moves.append({"type": "pointerUp", "button": 0}) + self.in_session("POST", "/actions", {"actions": [{ + "type": "pointer", "id": "mouse", "parameters": {"pointerType": "mouse"}, + "actions": moves}]}) + + def type_into(self, selector, text): + """Type text into an element, as the keyboard would.""" + found = self.in_session("POST", "/element", {"using": "css selector", "value": selector}) + element_id = next(iter(found.values())) + self.in_session("POST", f"/element/{element_id}/value", {"text": text}) + + def hover(self, selector): + """Rest the mouse on the middle of an element.""" + box = self.run(f"const b = document.querySelector({json.dumps(selector)})" + ".getBoundingClientRect(); return [b.left + b.width / 2, b.top + b.height / 2];") + self.in_session("POST", "/actions", {"actions": [{ + "type": "pointer", "id": "mouse", "parameters": {"pointerType": "mouse"}, + "actions": [{"type": "pointerMove", "duration": 0, "origin": "viewport", + "x": round(box[0]), "y": round(box[1])}]}]}) + + def screenshot(self, file_name): + pathlib.Path(file_name).write_bytes(base64.b64decode(self.in_session("GET", "/screenshot"))) + + def quit(self): + self.in_session("DELETE", "") + + +@unittest.skipUnless(shutil.which("geckodriver"), "geckodriver is not installed") +class EndToEndTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.processes = [] + cls.directory = tempfile.TemporaryDirectory() + cls.notebooks = pathlib.Path(cls.directory.name) / "notebooks" + relay_port, driver_port = free_port(), free_port() + cls.start([sys.executable, str(PROJECT / "relay" / "kokkoja_relay.py"), + "--port", str(relay_port)]) + # Each participant has their own local program, as on their own + # computer: it keeps their identity. All share one notebooks + # directory here, so that the test can read every notebook. + # A Kokkoja web site, as a server would host one: the client's files + # and a config.json that says the notebooks are kept in the browser. + # Its participants have nothing installed. + site_port = free_port() + site = pathlib.Path(cls.directory.name) / "site" + shutil.copytree(PROJECT / "client", site) + cls.site = f"http://127.0.0.1:{site_port}/" + (site / "config.json").write_text(json.dumps( + {"relay": f"ws://127.0.0.1:{relay_port}", "storage": "browser", "site": cls.site})) + cls.start([sys.executable, "-m", "http.server", str(site_port), "--bind", "127.0.0.1", + "--directory", str(site)]) + # Where the browser's downloads go. Not in the temporary directory: + # a Firefox installed as a snap has a /tmp of its own, and a download + # saved there is invisible to this test. A directory under the home + # directory is reachable by both. + cls.downloads = pathlib.Path(tempfile.mkdtemp(prefix="downloads_", dir=PROJECT / "tests")) + cls.programs = {} + for name, host in (("Teacher", "localhost"), ("Student", "127.0.0.1"), ("Late", "localhost"), + ("Host", "localhost")): + port = free_port() + config = pathlib.Path(cls.directory.name) / f"config_{name}" + # The "Host" program knows the web site, so its invitations are links. + site_option = ["--site", cls.site] if name == "Host" else [] + cls.start([sys.executable, str(PROJECT / "kokkoja.py"), "--no-browser", + "--port", str(port), "--relay", f"ws://127.0.0.1:{relay_port}", + "--notebooks", str(cls.notebooks), "--config", str(config), *site_option]) + cls.programs[name] = (f"http://{host}:{port}/", config) + cls.start(["geckodriver", "--port", str(driver_port)]) + try: + for port in (relay_port, driver_port, site_port): + wait_for_port(port) + for address, config in cls.programs.values(): + wait_for_port(int(address.rsplit(":", 1)[1].rstrip("/"))) + cls.programs[[k for k, v in cls.programs.items() if v[0] == address][0]] = ( + address, (config / "token").read_text().strip()) + cls.firefox = Firefox(driver_port, cls.downloads) + except Exception: + cls.stop() + raise + + def address(self, name, join=None): + """The Kokkoja address of a participant's own local program, with + its token, and an invitation to join if given.""" + base, token = self.programs[name] + joining = "" if join is None else f"&join={urllib.parse.quote(join, safe='')}" + return f"{base}#token={token}{joining}" + + @classmethod + def start(cls, command): + cls.processes.append(subprocess.Popen( + command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)) + + @classmethod + def stop(cls): + for process in reversed(cls.processes): + process.terminate() + for process in cls.processes: + process.wait(timeout=10) + + @classmethod + def tearDownClass(cls): + try: + cls.firefox.quit() + finally: + cls.stop() + cls.directory.cleanup() + shutil.rmtree(cls.downloads, ignore_errors=True) + + def state(self): + return self.firefox.run(PAGE_STATE) + + def wait_until_true(self, read, condition, what, seconds=15): + """Wait until condition(read()) is true and return the value read.""" + deadline = time.monotonic() + seconds + value = None + while time.monotonic() < deadline: + value = read() + if condition(value): + return value + time.sleep(0.1) + self.fail(f"timed out waiting for {what}; last value: {value}") + + def wait_until(self, condition, what, seconds=15): + """Wait until condition(state) is true and return the state.""" + deadline = time.monotonic() + seconds + state = None + while time.monotonic() < deadline: + state = self.state() + if state and condition(state): + return state + time.sleep(0.1) + self.fail(f"timed out waiting for {what}; last state: {json.dumps(state)[:600]}") + + def enter_name(self, name, button): + # The start screen appears once the client has read config.json. + self.wait_until_true(lambda: self.firefox.run("return !document.getElementById('start').hidden;"), + bool, "the start screen") + self.firefox.run("document.getElementById('name').value = arguments[0];" + "document.getElementById('notebook').value = arguments[0] + ' notebook';" + "document.getElementById(arguments[1]).click();", name, button) + return self.wait_until(lambda s: s["status"] == "Connected", f"{name} to connect") + + @staticmethod + def stroke_counts(text): + """For each layer of a page file that has strokes, by name, their number.""" + counts = {layer.split('data-name="')[1].split('"')[0]: layer.count(" { + const {notebooks, directory} = await m.listNotebooks(); + done({notebooks, directory, pages: await m.listKeptPages('Guest notebook'), + current: await m.readCurrentPage('Guest notebook')}); + })().catch((e) => done({error: String(e)})); + """) + self.assertEqual(in_browser["notebooks"], ["Guest notebook"]) + self.assertEqual(in_browser["directory"], "") + self.assertEqual(in_browser["pages"], []) + self.assertEqual(self.stroke_counts(in_browser["current"]), {"Host": 1, "Guest": 1}) + + # Keeping the page keeps it in the browser and downloads it. + firefox.run("document.getElementById('keep_page').click();") + kept = self.wait_for_download(f"{today}_01.svg").decode() + self.assertEqual(self.stroke_counts(kept), {"Host": 1, "Guest": 1}) + self.assertIn("Kept as", firefox.run("return document.getElementById('save_status').textContent;")) + self.assertEqual(firefox.run_until_done(""" + const done = arguments[0]; + window.kokkoja.notebook.listKeptPages('Guest notebook').then(done); + """), [f"{today}_01.svg"]) + + # Exporting downloads a picture. + firefox.run("document.getElementById('export_page').click();") + picture = self.wait_for_download(f"{today}_page_1.png") + self.assertTrue(picture.startswith(b"\x89PNG")) + self.assertIn("downloads", firefox.run("return document.getElementById('save_status').textContent;")) + + # Allowed by the host, the guest puts an image on the page (the + # file would come from the browser's own chooser). + self.assertTrue(firefox.run("return document.getElementById('host_tools').hidden;")) + firefox.switch_to(host_tab) + self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Guest"), "true") + firefox.switch_to(guest_tab) + self.wait_until(lambda s: not firefox.run("return document.getElementById('host_tools').hidden;"), + "the guest to be allowed to start pages") + firefox.run_until_done(LOAD_IMAGE, 300, 400, "png", 0, False) + guest = self.wait_until(lambda s: s["image"] and s["image"]["ready"], "the guest's image") + firefox.switch_to(host_tab) + self.wait_until(lambda s: s["image"] and s["image"]["ready"] and s["image"]["hash"] == guest["image"]["hash"], + "the host to receive the image") + + # Reloaded, the guest's tab comes back as the same participant with + # the page from the browser's notebook. + firefox.switch_to(guest_tab) + firefox.run("location.reload();") + time.sleep(0.5) + reloaded = self.wait_until( + lambda s: len(s["layers"].get("Guest", [])) == 1 and len(s["layers"].get("Host", [])) == 1 + and s["image"] and s["image"]["ready"], + "the guest's page after a reload") + self.assertEqual(reloaded["layers"]["Guest"], guest["layers"]["Guest"]) + self.assertEqual(len(reloaded["participants"]), 2) + + # The host leaves the session; the tabs are closed for the next test. + firefox.in_session("DELETE", "/window") + firefox.switch_to(host_tab) + firefox.go("about:blank") + + def test_two_participants(self): + firefox = self.firefox + + # The teacher starts a session and draws two lines with the mouse. + teacher_tab = firefox.current_tab() + firefox.go(self.address("Teacher")) + self.assertNotIn("token", firefox.run("return location.href;")) + self.enter_name("Teacher", "start_session") + invitation = firefox.run("return document.getElementById('invitation').value;") + self.assertRegex(invitation, r"^ws://127\.0\.0\.1:\d+#[\w-]{22}\.[\w-]{22}$") + firefox.drag("#page", 100, 100, 400, 180) + firefox.drag("#page", 100, 250, 400, 230) + teacher = self.wait_until( + lambda s: len(s["layers"]["Teacher"]) == 2 and all(k["done"] for k in s["layers"]["Teacher"]), + "the teacher's two strokes") + self.assertGreaterEqual(len(teacher["layers"]["Teacher"][0]["points"]), 6) + + # The student joins later, from the invitation, and receives them. + student_tab = firefox.new_tab() + firefox.go(self.address("Student", join=invitation)) + self.assertEqual(firefox.run("return document.getElementById('invitation_entry').value;"), + invitation) + self.enter_name("Student", "join_session") + student = self.wait_until(lambda s: len(s["layers"].get("Teacher", [])) == 2, + "the student to receive the teacher's layer") + self.assertEqual(student["layers"]["Teacher"], teacher["layers"]["Teacher"]) + teacher_first_page = {"Teacher": teacher["layers"]["Teacher"]} + self.assertEqual([p["host"] for p in student["participants"]], [False, True]) + + # The student writes with a pen; the teacher sees the same strokes. + for fx in (0.15, 0.4, 0.65): + firefox.run(PEN_WAVE, fx, 0.45) + student = self.state() + self.assertEqual(len(student["layers"]["Student"]), 3) + pressures = student["layers"]["Student"][0]["points"][2::3] + self.assertGreater(max(pressures) - min(pressures), 0.5) + firefox.switch_to(teacher_tab) + teacher = self.wait_until( + lambda s: len(s["layers"].get("Student", [])) == 3 and all(k["done"] for k in s["layers"]["Student"]), + "the teacher to receive the student's strokes") + self.assertEqual(teacher["layers"]["Student"], student["layers"]["Student"]) + + # The student erases the middle stroke. + firefox.switch_to(student_tab) + box = firefox.run("const b = document.getElementById('page').getBoundingClientRect();" + "return [b.width, b.height];") + firefox.run(ERASE_THROUGH, 0.4 + 75 / box[0], 0.45) + student = self.state() + self.assertEqual(len(student["layers"]["Student"]), 2) + firefox.switch_to(teacher_tab) + teacher = self.wait_until(lambda s: len(s["layers"]["Student"]) == 2, + "the erasure to reach the teacher") + self.assertEqual(teacher["layers"]["Student"], student["layers"]["Student"]) + + # The teacher cannot erase the student's writing: the eraser acts + # only on one's own layer. + firefox.run(ERASE_THROUGH, 0.15 + 75 / box[0], 0.45) + self.assertEqual(len(self.state()["layers"]["Student"]), 2) + + # The student reloads the page and gets everything back, as the + # same participant. + firefox.switch_to(student_tab) + firefox.run("location.reload();") + time.sleep(0.5) + reloaded = self.wait_until( + lambda s: len(s["layers"].get("Student", [])) == 2 and len(s["layers"].get("Teacher", [])) == 2, + "the student's page after a reload") + self.assertEqual(reloaded["layers"]["Student"], student["layers"]["Student"]) + self.assertEqual(len(reloaded["participants"]), 2) + + # Each participant's notebook holds the page, with both layers. + counts = {"Teacher": 2, "Student": 2} + self.current_page("Student notebook", counts, number=1) + saved = self.current_page("Teacher notebook", counts, number=1) + self.assertTrue(saved.startswith('\n { + const {pageToSvg, pageFromSvg} = await import('./js/page_file.js'); + const session = window.kokkoja.session; + const text = pageToSvg(session.page, {session: session.roomId, hostId: session.hostId}); + const read = pageFromSvg(text); + const strokes = (page) => JSON.stringify([...page.layers.values()] + .map((layer) => [layer.id, layer.name, [...layer.strokes.values()]]) + .sort()); + return read.hostId === session.hostId && read.session === session.roomId && + JSON.stringify(read.page.descriptor()) === JSON.stringify(session.page.descriptor()) && + strokes(read.page) === strokes(session.page); + })(); + """)) + + # The student keeps the page. Asking again makes no second copy. + today = time.strftime("%Y_%m_%d") + firefox.switch_to(student_tab) + firefox.run("document.getElementById('keep_page').click();") + self.assertEqual(self.wait_for_kept("Student notebook", 1), [f"{today}_01.svg"]) + firefox.run("document.getElementById('keep_page').click();") + time.sleep(1) + self.assertEqual(self.kept_pages("Student notebook"), [f"{today}_01.svg"]) + self.assertIn("Already kept", firefox.run( + "return document.getElementById('save_status').textContent;")) + + # Only the host can start a new page. + self.assertTrue(firefox.run("return document.getElementById('host_tools').hidden;")) + firefox.switch_to(teacher_tab) + self.assertFalse(firefox.run("return document.getElementById('host_tools').hidden;")) + + # The host starts a new page. Everyone moves to it, and everyone's + # old page is kept without anyone asking: the teacher's now, the + # student's already. + firefox.run("document.getElementById('new_page').click();") + self.assertEqual(self.state()["layers"], {"Teacher": [], "Student": []}) + self.assertEqual(self.wait_for_kept("Teacher notebook", 1), [f"{today}_01.svg"]) + kept = (self.notebooks / "Teacher notebook" / f"{today}_01.svg").read_text() + self.assertEqual(self.stroke_counts(kept), counts) + self.assertIn('data-number="1"', kept) + self.current_page("Teacher notebook", {}, number=2) + self.current_page("Student notebook", {}, number=2) + self.assertEqual(self.kept_pages("Student notebook"), [f"{today}_01.svg"]) + firefox.switch_to(student_tab) + self.assertEqual(firefox.run("return document.getElementById('page_number').textContent;"), + "Page 2") + + # Writing continues on the new page. + firefox.run(PEN_WAVE, 0.2, 0.3) + student = self.state() + self.assertEqual(len(student["layers"]["Student"]), 1) + firefox.switch_to(teacher_tab) + teacher = self.wait_until( + lambda s: len(s["layers"].get("Student", [])) == 1 and s["layers"]["Student"][0]["done"], + "the student's stroke on the new page") + self.assertEqual(teacher["layers"]["Student"], student["layers"]["Student"]) + self.current_page("Teacher notebook", {"Student": 1}, number=2) + + # The host starts a new page and puts an image on it. The page that + # was being written is kept in each notebook. + size = firefox.run_until_done(LOAD_IMAGE, 300, 400, "png", 0, True) + teacher = self.wait_until(lambda s: s["page"]["number"] == 3, "the teacher's image page") + # The image lies at the top of an A4 sheet, centred across it, + # inside the 8 mm margin, a pixel being 0.1 mm. + self.assertEqual(teacher["page"], {"number": 3, "width": 2100, "height": 2970}) + self.assertEqual(teacher["placed"], {"x": 900, "y": 80, "width": 300, "height": 400}) + self.assertEqual(teacher["image"]["type"], "image/png") + self.assertEqual(teacher["image"]["size"], size) + self.assertTrue(teacher["image"]["ready"] and teacher["image"]["shown"]) + self.assertEqual(self.wait_for_kept("Teacher notebook", 2), + [f"{today}_01.svg", f"{today}_02.svg"]) + self.current_page("Teacher notebook", {}, number=3, images=1) + + # Zoom is each person's own. "+" and "-" zoom by 10%; "Fit" fits + # the whole page to the window; Ctrl and the wheel zoom about the + # pointer. None of it changes the page. + zoom = lambda: firefox.run(""" + const r = window.kokkoja.renderer; + return {zoom: r.zoom, width: document.getElementById('page').getBoundingClientRect().width, + fit: document.getElementById('zoom_fit').getAttribute('aria-pressed')}; + """) + before = zoom() + self.assertEqual(before["fit"], "true") + firefox.run("document.getElementById('zoom_in').click();") + larger = zoom() + self.assertAlmostEqual(larger["width"], before["width"] * 1.1, delta=1) + self.assertEqual(larger["fit"], "false") + firefox.run("document.getElementById('zoom_out').click();") + self.assertAlmostEqual(zoom()["width"], before["width"], delta=1) + firefox.run(""" + document.getElementById('desk').dispatchEvent(new WheelEvent('wheel', + {deltaY: -100, ctrlKey: true, clientX: 300, clientY: 300, bubbles: true, cancelable: true})); + """) + self.assertAlmostEqual(zoom()["width"], before["width"] * 1.1, delta=1) + firefox.run("document.getElementById('zoom_fit').click();") + self.assertEqual(zoom(), before) + self.assertEqual(self.state()["page"], teacher["page"]) + + # Resting the pointer on a button brings up its explanation below it. + tooltip = lambda: firefox.run(""" + const tip = document.querySelector('.tooltip'); + const button = document.getElementById('new_page').getBoundingClientRect(); + if (!tip || tip.hidden) return null; + const box = tip.getBoundingClientRect(); + return {text: tip.textContent, below: box.top > button.bottom, left: box.left, buttonLeft: button.left}; + """) + firefox.hover("#new_page") + time.sleep(0.5) + self.assertIsNone(tooltip()) + time.sleep(1) + shown = tooltip() + self.assertEqual(shown["text"], firefox.run( + "return document.getElementById('new_page').getAttribute('data-help');")) + self.assertTrue(shown["below"]) + self.assertAlmostEqual(shown["left"], shown["buttonLeft"], delta=1) + firefox.hover("#page") + self.assertIsNone(tooltip()) + # A tooltip goes away as soon as a key is pressed. + firefox.hover("#new_page") + time.sleep(1.5) + self.assertIsNotNone(tooltip()) + firefox.run("document.dispatchEvent(new KeyboardEvent('keydown', {key: 'a', bubbles: true}));") + self.assertIsNone(tooltip()) + + # Text. A tap with the text tool places the corner of a block; + # typing goes through a real field, umlauts and line breaks + # included, and reaches the others as it is typed. + firefox.run("document.querySelector('[data-tool=text]').click();") + firefox.run(TEXT_GESTURE, 0.1, 0.6, 0.1, 0.6) + self.assertFalse(firefox.run("return document.querySelector('.text_field').hidden;")) + firefox.type_into(".text_field", "Grüße\nzwei Zeilen") + typed = self.wait_until(lambda s: len(s["texts"]["Teacher"]) == 1 and + s["texts"]["Teacher"][0]["text"] == "Grüße\nzwei Zeilen", + "the typed text") + block = typed["texts"]["Teacher"][0] + self.assertEqual(block["lines"], ["Grüße", "zwei Zeilen"]) + self.assertIsNone(block["width"]) + # The field shows every line and no spare one, with room above the first. + self.assertTrue(firefox.run(""" + const field = document.querySelector('.text_field'); + const style = getComputedStyle(field); + const lineHeight = parseFloat(style.fontSize) * 1.25; + const content = parseFloat(style.height); + return Math.abs(content - 2 * lineHeight) < 1 && parseFloat(style.paddingTop) > 2; + """)) + self.assertEqual(block["color"], "#1c1c1e") + self.assertEqual(block["font"], "sans-serif") + firefox.switch_to(student_tab) + self.wait_until(lambda s: s["texts"].get("Teacher") == [block], + "the text to reach the student") + firefox.switch_to(teacher_tab) + + # With the text tool, a size and a font stand where the line widths + # were. Changing them changes the block being typed. + self.assertTrue(firefox.run("return document.getElementById('widths').hidden;")) + self.assertFalse(firefox.run("return document.getElementById('text_style').hidden;")) + self.assertEqual(block["size"], 49.4) # 14 points. + firefox.run(""" + const size = document.getElementById('text_size'); + size.value = '24'; + size.dispatchEvent(new Event('change')); + const font = document.getElementById('text_font'); + font.value = 'serif'; + font.dispatchEvent(new Event('change')); + """) + restyled = self.wait_until(lambda s: s["texts"]["Teacher"][0]["size"] == 84.7 and + s["texts"]["Teacher"][0]["font"] == "serif", "the restyled text") + self.assertGreater(restyled["texts"]["Teacher"][0]["boxHeight"], block["boxHeight"]) + firefox.run(""" + const size = document.getElementById('text_size'); + size.value = '14'; + size.dispatchEvent(new Event('change')); + const font = document.getElementById('text_font'); + font.value = 'sans-serif'; + font.dispatchEvent(new Event('change')); + """) + block = self.wait_until(lambda s: s["texts"]["Teacher"][0]["size"] == 49.4, + "the text back at 14 points")["texts"]["Teacher"][0] + + # A drag along the top edge shows the edge while dragging, and makes + # a block that wraps at its width; the right handle widens it and + # the corner handle moves it. + firefox.run(""" + const page = document.getElementById('page'); + const box = page.getBoundingClientRect(); + page.setPointerCapture = () => {}; + const fire = (type, x, y, buttons) => page.dispatchEvent( + new PointerEvent(type, {pointerId: 9, pointerType: 'mouse', isPrimary: true, + clientX: box.left + x, clientY: box.top + y, button: type === 'pointermove' ? -1 : 0, + buttons, bubbles: true, cancelable: true})); + fire('pointerdown', 0.1 * box.width, 0.75 * box.height, 1); + fire('pointermove', 0.25 * box.width, 0.75 * box.height, 1); + """) + preview = firefox.run(""" + const p = document.querySelector('.text_preview'); + return {hidden: p.hidden, width: p.getBoundingClientRect().width}; + """) + self.assertFalse(preview["hidden"]) + self.assertGreater(preview["width"], 50) + firefox.run(TEXT_GESTURE, 0.1, 0.75, 0.3, 0.75) + self.assertTrue(firefox.run("return document.querySelector('.text_preview').hidden;")) + self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('.text_field')).borderStyle;"), "none") + firefox.type_into(".text_field", "Ein langer Satz, der an der rechten Kante umbrochen wird.") + wrapped = self.wait_until(lambda s: len(s["texts"]["Teacher"]) == 2 and + len(s["texts"]["Teacher"][1]["lines"]) > 1, "the wrapped text") + wide = wrapped["texts"]["Teacher"][1] + self.assertGreater(wide["width"], 100) + self.assertEqual(" ".join(wide["lines"]), wide["text"]) + firefox.run(DRAG_HANDLE, ".text_handle.end", 300, 0) + widened = self.wait_until(lambda s: s["texts"]["Teacher"][1]["width"] > wide["width"], + "the block to widen")["texts"]["Teacher"][1] + self.assertLess(len(widened["lines"]), len(wide["lines"])) + firefox.run(DRAG_HANDLE, ".text_handle.corner", 40, 60) + moved = self.wait_until(lambda s: s["texts"]["Teacher"][1]["y"] > wide["y"], + "the block to move")["texts"]["Teacher"][1] + self.assertGreater(moved["x"], wide["x"]) + firefox.switch_to(student_tab) + self.wait_until(lambda s: len(s["texts"].get("Teacher", [])) == 2 and + s["texts"]["Teacher"][1] == moved, "the moved text to reach the student") + firefox.switch_to(teacher_tab) + + # The page file holds the text, and reads it back. + self.wait_until_true( + lambda: (self.notebooks / "Teacher notebook" / "Current.svg").read_text(), + lambda saved: " { + const {pageToSvg, pageFromSvg} = await import('./js/page_file.js'); + const session = window.kokkoja.session; + const read = pageFromSvg(pageToSvg(session.page, {session: session.roomId, hostId: session.hostId})); + const texts = (page) => JSON.stringify([...page.layers.values()] + .filter((l) => l.texts.size).map((l) => [l.id, [...l.texts.values()]])); + return texts(read.page) === texts(session.page) || 'READ ' + texts(read.page) + ' LIVE ' + texts(session.page); + })(); + """) + if round_trip is not True: + self.fail(round_trip) + + # The eraser removes a whole block of one's own. + firefox.run("document.querySelector('[data-tool=eraser]').click();") + page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.width, b.height];") + state = self.state() + first = state["texts"]["Teacher"][0] + fx = (first["x"] + 10 - state["page"].get("left", 0)) / state["page"]["width"] + fy = (first["y"] + 10) / state["page"]["height"] + firefox.run(ERASE_THROUGH, fx, fy + 20 / page_box[1]) + self.wait_until(lambda s: len(s["texts"]["Teacher"]) == 1 and s["texts"]["Teacher"][0]["id"] == moved["id"], + "the first text to be erased") + firefox.run("document.querySelector('[data-tool=eraser]').click();") + self.assertTrue(firefox.run(""" + return ['colors', 'widths', 'text_style'].every((id) => document.getElementById(id).hidden); + """)) + firefox.run("document.querySelector('[data-tool=pen]').click();") + self.assertFalse(firefox.run("return document.getElementById('widths').hidden;")) + self.assertFalse(firefox.run("return document.getElementById('colors').hidden;")) + self.assertTrue(firefox.run("return document.getElementById('text_style').hidden;")) + teacher = self.state() # The text may have grown the page. + + firefox.switch_to(student_tab) + student = self.wait_until(lambda s: s["image"] and s["image"]["ready"] and s["page"] == teacher["page"], + "the image to reach the student") + self.assertEqual(student["image"], teacher["image"]) + self.assertEqual(student["placed"], teacher["placed"]) + self.assertEqual(self.wait_for_kept("Student notebook", 2), + [f"{today}_01.svg", f"{today}_02.svg"]) + self.current_page("Student notebook", {}, number=3, images=1) + firefox.run(PEN_WAVE, 0.2, 0.3) + student = self.state() + + # Someone who joins now gets the page, the image and the writing. + # Arriving with an invitation shows the start screen, where the name + # and the notebook are chosen. + firefox.new_tab() + firefox.go(self.address("Late", join=invitation)) + self.assertIsNone(self.state()) + self.assertEqual(firefox.run("return document.getElementById('invitation_entry').value;"), + invitation) + late = self.enter_name("Late", "join_session") + late = self.wait_until( + lambda s: s["image"] and s["image"]["ready"] and len(s["layers"].get("Student", [])) == 1, + "the image and writing to reach the late joiner") + self.assertEqual(late["image"], teacher["image"]) + self.assertEqual(late["layers"]["Student"], student["layers"]["Student"]) + saved = self.current_page("Late notebook", {"Student": 1}, number=3) + self.assertIn(f'data-hash="{teacher["image"]["hash"]}" x="900" y="80" width="300" height="400"', saved) + firefox.go("about:blank") + + # Everyone leaves. The teacher comes back alone: the page, with its + # image, is restored from the notebook, and writing continues in the + # same file. + firefox.switch_to(student_tab) + firefox.go("about:blank") + firefox.switch_to(teacher_tab) + self.wait_until(lambda s: [p["present"] for p in s["participants"]] == [True, False, False], + "the others to leave") + firefox.run("location.reload();") + time.sleep(0.5) + alone = self.wait_until(lambda s: s["status"] == "Connected", "the teacher to reconnect") + self.assertEqual(alone["layers"], {"Student": student["layers"]["Student"], "Teacher": []}) + self.assertEqual(alone["page"], teacher["page"]) + self.assertEqual(alone["image"], teacher["image"]) + self.assertEqual(alone["participants"], [ + {"name": "Teacher", "host": True, "present": True}, + {"name": "Student", "host": False, "present": False}]) + firefox.drag("#page", 100, 500, 300, 520) + self.current_page("Teacher notebook", {"Teacher": 1, "Student": 1}, number=3) + self.assertEqual(len(self.kept_pages("Teacher notebook")), 2) + + # Changing notebook writes the page there from then on. + firefox.run(""" + const field = document.getElementById('session_notebook'); + field.value = 'Other notebook'; + field.dispatchEvent(new Event('change')); + """) + self.current_page("Other notebook", {"Teacher": 1, "Student": 1}, number=3) + firefox.run(""" + const field = document.getElementById('session_notebook'); + field.value = 'Teacher notebook'; + field.dispatchEvent(new Event('change')); + """) + time.sleep(1) + self.assertEqual(len(self.kept_pages("Teacher notebook")), 2) + + # A new session in the same notebook: the page the last session + # left there is kept, without anyone having asked. + firefox.go(self.programs["Teacher"][0]) + firefox.run("location.reload();") + time.sleep(0.5) + self.enter_name("Teacher", "start_session") + self.assertEqual(self.wait_for_kept("Teacher notebook", 3)[-1], f"{today}_03.svg") + last = (self.notebooks / "Teacher notebook" / f"{today}_03.svg").read_text() + self.assertEqual(self.stroke_counts(last), {"Teacher": 1, "Student": 1}) + self.assertIn(" 2100, "the page to grow") + self.assertEqual(grown["page"]["width"] % 80, 0) + firefox.switch_to(teacher_tab) + self.wait_until(lambda s: s["page"] == grown["page"], "the teacher's page to grow the same way") + box = f'viewBox="0 0 {grown["page"]["width"]} {grown["page"]["height"]}"' + deadline = time.monotonic() + 10 + while box not in (self.notebooks / "Teacher notebook" / "Current.svg").read_text(): + self.assertLess(time.monotonic(), deadline, f"the saved page never got {box}") + time.sleep(0.1) + + # An image that records its resolution is placed at that size: at + # 300 dots per inch, 1200 pixels are 101.6 mm. PNG and JPEG alike. + firefox.run_until_done(LOAD_IMAGE, 1200, 600, "png", 300, True) + scanned = self.wait_until(lambda s: s["page"]["number"] == 4, "the scanned PNG page") + self.assertEqual(scanned["placed"], {"x": 542, "y": 80, "width": 1016, "height": 508}) + firefox.run_until_done(LOAD_IMAGE, 1200, 600, "jpeg", 300, True) + scanned = self.wait_until(lambda s: s["page"]["number"] == 5, "the scanned JPEG page") + self.assertEqual(scanned["image"]["type"], "image/jpeg") + self.assertEqual(scanned["placed"], {"x": 542, "y": 80, "width": 1016, "height": 508}) + + # A further image goes on the same page, below the lowest image + # there and the margin apart, centred like the first. The sheet + # grows downward when an image needs the room. Everyone gets them. + sheet_view = firefox.run(VIEW) + self.assertEqual((sheet_view["fit"], sheet_view["scrollsDown"]), (True, False)) + firefox.run_until_done(LOAD_IMAGE, 300, 400, "png", 0, False) + self.assertEqual(firefox.run(VIEW), sheet_view) # The sheet did not grow. + firefox.run_until_done(LOAD_IMAGE, 1000, 2500, "png", 0, False) + stacked = self.wait_until(lambda s: len(s["images"]) == 3, "the three images") + # The view keeps its scale: the page now extends below the window, + # to be scrolled to, and is no longer fitted to it. + long_view = {"fit": False, "scale": sheet_view["scale"], "top": 0, + "scrollsDown": True, "scrollsAcross": False} + self.assertEqual(firefox.run(VIEW), long_view) + self.assertEqual(stacked["page"], {"number": 5, "width": 2100, "height": 3728}) + self.assertEqual( + [[i["x"], i["y"], i["width"], i["height"]] for i in stacked["images"]], + [[542, 80, 1016, 508], [900, 668, 300, 400], [550, 1148, 1000, 2500]]) + self.assertTrue(all(i["ready"] and i["shown"] for i in stacked["images"])) + # Each is shown where it lies on the page. + self.assertEqual(firefox.run(""" + const page = document.getElementById('page').getBoundingClientRect(); + return [...document.querySelectorAll('.page_image')].map((img) => { + const box = img.getBoundingClientRect(); + return [(box.left - page.left) / page.width, (box.top - page.top) / page.height, + box.width / page.width, box.height / page.height] + .map((v) => Math.round(v * 1000)); + }); + """), [[258, 21, 484, 136], [429, 179, 143, 107], [262, 308, 476, 671]]) + firefox.switch_to(student_tab) + student = self.wait_until( + lambda s: len(s["images"]) == 3 and all(i["ready"] and i["shown"] for i in s["images"]), + "the three images to reach the student") + self.assertEqual(student["images"], stacked["images"]) + self.assertEqual(student["page"], stacked["page"]) + self.assertEqual(firefox.run(VIEW), long_view) + saved = self.current_page("Student notebook", {}, number=5, images=3) + # A reloaded tab shows the page as it was last seen there: zoomed + # and scrolled as it was, not fitted anew. + firefox.run(""" + document.getElementById('zoom_in').click(); + document.getElementById('desk').scrollTop = 240; + """) + seen = firefox.run(VIEW) + self.assertEqual((seen["fit"], seen["top"]), (False, 240)) + self.assertGreater(seen["scale"], long_view["scale"]) + firefox.run("location.reload();") + time.sleep(0.5) + self.wait_until(lambda s: s["status"] == "Connected" and len(s["images"]) == 3 + and all(i["shown"] for i in s["images"]), "the student to return to the long page") + self.assertEqual(firefox.run(VIEW), seen) + firefox.run("document.getElementById('zoom_fit').click();") + fitted = firefox.run(VIEW) + self.assertEqual((fitted["fit"], fitted["scrollsDown"]), (True, False)) + firefox.run("location.reload();") + time.sleep(0.5) + self.wait_until(lambda s: s["status"] == "Connected" and len(s["images"]) == 3, + "the student to return again") + self.assertEqual(firefox.run(VIEW), fitted) + self.assertIn('data-base="2100 3728"', saved) + # The page file reads back with its images, in their sequence. + self.assertTrue(firefox.run(""" + return (async () => { + const {pageToSvg, pageFromSvg} = await import('./js/page_file.js'); + const session = window.kokkoja.session; + const read = pageFromSvg(pageToSvg(session.page, {session: session.roomId, hostId: session.hostId})); + return JSON.stringify(read.page.descriptor()) === JSON.stringify(session.page.descriptor()) && + read.page.images.every((image, i) => image.data.length === session.page.images[i].size); + })(); + """)) + firefox.switch_to(teacher_tab) + + # The host loads a page kept in a notebook: the first page of the + # first session, with the teacher's and the student's writing. It + # comes back as a new page for everyone, and the student's writing + # is the student's again. + firefox.run("document.getElementById('load_notebook_page').click();") + notebooks = self.wait_until_true(lambda: firefox.run(""" + return [...document.querySelectorAll('#chooser_notebooks button')].map((b) => b.textContent); + """), lambda names: "Teacher notebook" in names, "the chooser to list the notebooks") + self.assertEqual(notebooks, sorted(notebooks)) + firefox.run(""" + [...document.querySelectorAll('#chooser_notebooks button')] + .find((b) => b.textContent === 'Teacher notebook').click(); + """) + cards = self.wait_until_true(lambda: firefox.run(""" + return [...document.querySelectorAll('#chooser_pages .page_card span')].map((s) => s.textContent); + """), lambda titles: len(titles) >= 3, "the chooser to list the kept pages") + self.assertEqual(cards[0], f"{today[:4]}-{today[5:7]}-{today[8:10]} (1)") + firefox.run("document.querySelector('#chooser_pages .page_card').click();") + loaded = self.wait_until(lambda s: s["page"]["number"] == 6, "the page loaded from the notebook") + self.assertFalse(firefox.run("return document.getElementById('notebook_chooser').open;")) + self.assertEqual({name: len(strokes) for name, strokes in loaded["layers"].items()}, + {"Teacher": 2, "Student": 2}) + self.assertEqual(loaded["layers"]["Teacher"], teacher_first_page["Teacher"]) + firefox.switch_to(student_tab) + student = self.wait_until( + lambda s: s["page"]["number"] == 6 and len(s["layers"].get("Student", [])) == 2 + and len(s["layers"].get("Teacher", [])) == 2, + "the loaded page to reach the student") + self.assertEqual(student["layers"], loaded["layers"]) + page_width = firefox.run("return document.getElementById('page').getBoundingClientRect().width;") + firefox.run(ERASE_THROUGH, 0.15 + 75 / page_width, 0.45) + self.assertEqual(len(self.state()["layers"]["Student"]), 1) + firefox.switch_to(teacher_tab) + self.wait_until(lambda s: len(s["layers"]["Student"]) == 1, + "the student's erasure on the loaded page to reach the teacher") + + # A kept page with an image comes back with the image, shown to + # everyone without further ado. + firefox.run("document.getElementById('load_notebook_page').click();") + self.wait_until_true(lambda: firefox.run(""" + return document.querySelectorAll('#chooser_pages .page_card').length; + """), lambda n: n >= 3, "the chooser to list the kept pages again") + firefox.run("document.querySelectorAll('#chooser_pages .page_card')[2].click();") + loaded = self.wait_until(lambda s: s["page"]["number"] == 7, "the loaded image page") + self.assertTrue(loaded["image"]["ready"] and loaded["image"]["shown"]) + self.assertEqual(loaded["placed"], {"x": 900, "y": 80, "width": 300, "height": 400}) + firefox.switch_to(student_tab) + student = self.wait_until(lambda s: s["page"]["number"] == 7 and s["image"] and s["image"]["ready"], + "the loaded image to reach the student") + self.assertTrue(student["image"]["shown"]) + self.assertEqual(firefox.run(""" + const img = document.querySelector('.page_image'); + const s = getComputedStyle(img); + return [img.naturalWidth, s.display, parseFloat(s.width) > 10]; + """), [300, "block", True]) + firefox.switch_to(teacher_tab) + + # The page of three images was kept when it was left, and comes + # back from the notebook with all three, for everyone. + firefox.run("document.getElementById('load_notebook_page').click();") + count = self.wait_until_true(lambda: firefox.run(""" + return document.querySelectorAll('#chooser_pages .page_card').length; + """), lambda n: n >= 4, "the chooser to list the kept pages a third time") + for card in range(count): + firefox.run("document.querySelectorAll('#chooser_pages .page_card')[arguments[0]].click();", card) + loaded = self.wait_until(lambda s: s["page"]["number"] == 8 + card, "a kept page to load") + if len(loaded["images"]) == 3: + break + firefox.run("document.getElementById('load_notebook_page').click();") + self.wait_until_true(lambda: firefox.run(""" + return document.querySelectorAll('#chooser_pages .page_card').length; + """), lambda n: n >= count, "the chooser to list the kept pages again") + self.assertEqual(loaded["images"], stacked["images"]) + self.assertEqual(loaded["page"]["height"], 3728) + # A page that its images have made longer than a sheet is first + # shown at the scale at which one sheet fits the window. + self.assertEqual(firefox.run(VIEW), long_view) + firefox.switch_to(student_tab) + student = self.wait_until( + lambda s: s["page"]["number"] == loaded["page"]["number"] and len(s["images"]) == 3 + and all(i["ready"] and i["shown"] for i in s["images"]), + "the loaded page of three images to reach the student") + self.assertEqual(student["images"], stacked["images"]) + self.assertEqual(firefox.run(VIEW), long_view) + firefox.switch_to(teacher_tab) + + # "+" among the colours opens a palette of our own; a chosen colour + # becomes a swatch and the pen's colour. + swatches = lambda: firefox.run("return document.querySelectorAll('#colors .swatch:not(.more)').length;") + count = swatches() + firefox.run("document.getElementById('more_colors').click();") + self.assertFalse(firefox.run("return document.getElementById('color_palette').hidden;")) + firefox.run(""" + [...document.querySelectorAll('#color_palette .swatch')] + .find((s) => s.getAttribute('aria-label') === 'Color #1e88e5').click(); + """) + self.assertTrue(firefox.run("return document.getElementById('color_palette').hidden;")) + self.assertEqual(swatches(), count + 1) + self.assertEqual(firefox.run(""" + const pressed = [...document.querySelectorAll('#colors .swatch[aria-pressed=true]')]; + return pressed.map((s) => s.getAttribute('aria-label')); + """), ["Color #1e88e5"]) + firefox.run("document.querySelector('[data-tool=pen]').click();") + firefox.drag("#page", 100, 700, 300, 720) + picked = self.wait_until(lambda s: s["layers"]["Teacher"] and s["layers"]["Teacher"][-1]["done"], + "the stroke in the picked colour") + self.assertEqual(firefox.run(""" + const layer = window.kokkoja.session.ownLayer; + return [...layer.strokes.values()].at(-1).color; + """), "#1e88e5") + + # "Image" opens a chooser of our own. It begins where the notebooks + # are, reaches other directories through the local program, and + # shows image files only, each with a small picture. + scans = pathlib.Path(self.directory.name) / "Scans" + scans.mkdir() + (scans / "exercise.png").write_bytes(png_picture(300, 200, (200, 60, 60))) + (scans / "notes.txt").write_text("not an image") + chooser = lambda condition, what: self.wait_until_true( + lambda: firefox.run(FILE_CHOOSER), condition, what) + firefox.run("document.getElementById('new_page').click();") + number = self.wait_until(lambda s: not s["images"], "a new page")["page"]["number"] + firefox.run("document.getElementById('load_image').click();") + shown = chooser(lambda c: c["open"] and c["directory"] == str(self.notebooks), + "the chooser to show the notebooks directory") + self.assertEqual(shown["title"], "Image") + self.assertIn("Teacher notebook", shown["directories"]) + self.assertEqual((shown["files"], shown["saving"]), ([], False)) + self.assertEqual(shown["message"], "No image files here.") + firefox.run("document.getElementById('files_up').click();") + shown = chooser(lambda c: c["directory"] == self.directory.name, "the directory above") + self.assertIn("Scans", shown["directories"]) + firefox.run(""" + [...document.querySelectorAll('.files_directories button')] + .find((b) => b.textContent === 'Scans').click(); + """) + shown = chooser(lambda c: c["directory"] == str(scans) and c["drawn"] == [[240, 160]], + "the image file and its small picture") + self.assertEqual((shown["files"], shown["message"]), (["exercise.png"], "")) + firefox.run("document.querySelector('.file_card').click();") + chosen = self.wait_until(lambda s: len(s["images"]) == 1 and s["image"]["shown"], + "the chosen image on the page") + self.assertEqual(chosen["page"]["number"], number) + self.assertEqual(chosen["placed"], {"x": 900, "y": 80, "width": 300, "height": 200}) + self.assertFalse(firefox.run(FILE_CHOOSER)["open"]) + # The chooser begins where it was last used. + firefox.run("document.getElementById('load_image').click();") + chooser(lambda c: c["open"] and c["directory"] == str(scans), "the chooser to return to Scans") + # Closed and opened again at once, it stays open: the first close + # is reported late and must not end the second use. + firefox.run(""" + document.getElementById('files_close').click(); + document.getElementById('load_image').click(); + """) + time.sleep(0.3) + chooser(lambda c: c["open"] and c["files"] == ["exercise.png"], "the chooser to stay open") + firefox.run("document.getElementById('files_close').click();") + chooser(lambda c: not c["open"], "the chooser to close") + self.assertEqual(len(self.state()["images"]), 1) + + # "Export" saves a picture of the page where the user chooses: a + # PNG at 200 dots per inch, which records that resolution. + firefox.drag("#page", 200, 400, 320, 420) + self.wait_until(lambda s: s["layers"]["Teacher"] and s["layers"]["Teacher"][-1]["done"], + "a stroke to find in the picture") + firefox.run("document.getElementById('export_page').click();") + shown = chooser(lambda c: c["open"] and c["directory"] == str(self.notebooks), + "the export chooser to show the notebooks directory") + self.assertEqual((shown["title"], shown["saving"], shown["button"]), ("Export", True, "Export")) + self.assertFalse(shown["shrinkShown"]) + self.assertEqual(shown["name"], f"{today}_page_{number}.png") + self.assertEqual(shown["files"], []) + firefox.run(""" + const field = document.getElementById('files_directory'); + field.value = arguments[0]; + field.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true})); + """, str(scans)) + shown = chooser(lambda c: c["directory"] == str(scans) and c["existing"], "the typed directory") + self.assertEqual((shown["existing"], shown["directories"]), (["exercise.png"], [])) + export = """ + document.getElementById('files_name').value = 'page'; + document.getElementById('files_name').dispatchEvent(new Event('input', {bubbles: true})); + document.getElementById('files_save_here').click(); + """ + firefox.run(export) + chooser(lambda c: not c["open"], "the export to finish") + picture = scans / "page.png" + self.assertEqual(firefox.run("return document.getElementById('save_status').textContent;"), + f"Exported to {picture}") + data = picture.read_bytes() + self.assertEqual(data[:8], b"\x89PNG\r\n\x1a\n") + self.assertEqual(struct.unpack(">II", data[16:24]), (1654, 2339)) + self.assertEqual((data[33:41], struct.unpack(">IIB", data[41:50])), + (b"\x00\x00\x00\x09pHYs", (7874, 7874, 1))) + colours = firefox.run_until_done(PICTURE_COLOURS, str(picture)) + self.assertEqual((colours["size"], colours["corner"]), ([1654, 2339], [255, 255, 255])) + self.assertTrue(all(abs(a - b) <= 3 for a, b in zip(colours["image"], (200, 60, 60))), colours) + self.assertLess(sum(colours["stroke"]), 500, colours) + + # Layers hidden on one's own screen are left out of the picture. A + # file is replaced only at a second press of the button. + firefox.run("document.querySelector('#participants input[type=checkbox]').click();") + firefox.run("document.getElementById('export_page').click();") + chooser(lambda c: c["open"] and c["directory"] == str(scans) and "page.png" in c["existing"], + "the export chooser to return to Scans") + firefox.run(export) + shown = chooser(lambda c: c["button"] == "Replace", "the offer to replace") + self.assertEqual((shown["open"], shown["message"]), (True, "page.png exists here.")) + self.assertEqual(picture.read_bytes(), data) + firefox.run("document.getElementById('files_save_here').click();") + chooser(lambda c: not c["open"], "the replacing export to finish") + colours = firefox.run_until_done(PICTURE_COLOURS, str(picture)) + self.assertEqual(colours["stroke"], [255, 255, 255]) + self.assertTrue(all(abs(a - b) <= 3 for a, b in zip(colours["image"], (200, 60, 60))), colours) + firefox.run("document.querySelector('#participants input[type=checkbox]').click();") + + # An image just added can be taken back with a tap of the eraser by + # the one who added it, until anything else changes on the page. + firefox.run("document.getElementById('new_page').click();") + number = self.wait_until(lambda s: not s["images"], "another new page")["page"]["number"] + firefox.run_until_done(LOAD_IMAGE, 300, 400, "png", 0, False) + firefox.run_until_done(LOAD_IMAGE, 1000, 2700, "png", 0, False) + two = self.wait_until(lambda s: len(s["images"]) == 2, "two images") + self.assertEqual(two["page"], {"number": number, "width": 2100, "height": 3340}) + rectangles = lambda state: [[i["x"], i["y"], i["width"], i["height"]] for i in state["images"]] + self.assertEqual(rectangles(two), [[900, 80, 300, 400], [550, 560, 1000, 2700]]) + firefox.switch_to(student_tab) + self.wait_until(lambda s: len(s["images"]) == 2 and all(i["ready"] for i in s["images"]), + "the two images to reach the student") + # Not the student, who did not add it. + firefox.run(ERASER_AT, 1050, 1500, 5) + firefox.switch_to(teacher_tab) + # Not by moving the eraser across it, and not the earlier image. + firefox.run(ERASER_AT, 1050, 1500, 100) + firefox.run(ERASER_AT, 1050, 280, 5) + time.sleep(0.3) + self.assertEqual(rectangles(self.state()), rectangles(two)) + firefox.run(ERASER_AT, 1050, 1500, 5) + one = self.wait_until(lambda s: len(s["images"]) == 1, "the image to be taken back") + self.assertEqual(one["page"], {"number": number, "width": 2100, "height": 2970}) + self.assertEqual(rectangles(one), [[900, 80, 300, 400]]) + self.assertEqual(firefox.run("return document.querySelectorAll('.page_image').length;"), 1) + firefox.switch_to(student_tab) + student = self.wait_until(lambda s: len(s["images"]) == 1, "the student to lose the image too") + self.assertEqual(student["page"], one["page"]) + self.current_page("Student notebook", {}, number=number, images=1) + # The right image, added next, lands where the wrong one was. + firefox.switch_to(teacher_tab) + firefox.run_until_done(LOAD_IMAGE, 1000, 2700, "png", 0, False) + again = self.wait_until(lambda s: len(s["images"]) == 2, "the image added again") + self.assertEqual(rectangles(again), rectangles(two)) + firefox.switch_to(student_tab) + self.wait_until(lambda s: len(s["images"]) == 2 and all(i["ready"] and i["shown"] for i in s["images"]), + "the image added again to reach the student") + # Once anyone has written, the image stays. + firefox.run(PEN_WAVE, 0.3, 0.5) + firefox.switch_to(teacher_tab) + self.wait_until(lambda s: len(s["layers"].get("Student", [])) == 1 and s["layers"]["Student"][0]["done"], + "the student's writing on the image") + firefox.run(ERASER_AT, 1050, 1500, 5) + time.sleep(0.3) + self.assertEqual(rectangles(self.state()), rectangles(two)) + self.current_page("Teacher notebook", {"Student": 1}, number=number, images=2) + + # An image wider than the page is shrunk to fit it unless the + # chooser is told otherwise. Then it keeps its size and begins at + # the left margin, and the sheet widens to hold it. + (scans / "Wide").mkdir() + (scans / "Wide" / "wide.png").write_bytes(png_picture(2600, 300, (60, 120, 200))) + firefox.run("document.getElementById('new_page').click();") + number = self.wait_until(lambda s: not s["images"], "a page for the wide image")["page"]["number"] + sheet_view = firefox.run(VIEW) + firefox.run("document.getElementById('load_image').click();") + shown = chooser(lambda c: c["open"] and "Wide" in c["directories"], "the chooser to list Wide") + self.assertEqual((shown["shrinkShown"], shown["shrink"]), (True, True)) + firefox.run(""" + document.getElementById('files_shrink').click(); + [...document.querySelectorAll('.files_directories button')] + .find((b) => b.textContent === 'Wide').click(); + """) + chooser(lambda c: c["files"] == ["wide.png"] and not c["shrink"], "the wide image file") + firefox.run("document.querySelector('.file_card').click();") + wide = self.wait_until(lambda s: len(s["images"]) == 1 and s["image"]["shown"], + "the wide image at its own size") + self.assertEqual(rectangles(wide), [[80, 80, 2600, 300]]) + self.assertEqual(wide["page"], {"number": number, "width": 2760, "height": 2970}) + view = firefox.run(VIEW) # The scale is kept here too. + self.assertEqual((view["fit"], view["scale"]), (False, sheet_view["scale"])) + firefox.switch_to(student_tab) + student = self.wait_until(lambda s: len(s["images"]) == 1 and s["image"]["ready"], + "the wide image to reach the student") + self.assertEqual(student["page"], wide["page"]) + self.assertIn('data-base="2760 2970"', self.current_page("Student notebook", {}, number=number, images=1)) + # Taken back, it leaves an A4 sheet. + firefox.switch_to(teacher_tab) + firefox.run(ERASER_AT, 1000, 200, 5) + back = self.wait_until(lambda s: not s["images"], "the wide image to be taken back") + self.assertEqual(back["page"], {"number": number, "width": 2100, "height": 2970}) + # The choice was remembered. Shrunk, the image is as wide as the + # sheet inside its margins. + firefox.run("document.getElementById('load_image').click();") + shown = chooser(lambda c: c["open"] and c["files"] == ["wide.png"], "the chooser to return to Wide") + self.assertFalse(shown["shrink"]) + firefox.run(""" + document.getElementById('files_shrink').click(); + document.querySelector('.file_card').click(); + """) + shrunk = self.wait_until(lambda s: len(s["images"]) == 1, "the wide image, shrunk") + self.assertEqual(rectangles(shrunk), [[80, 80, 1940, 224]]) + self.assertEqual(shrunk["page"], {"number": number, "width": 2100, "height": 2970}) + + screenshot = os.environ.get("KOKKOJA_SCREENSHOT") + if screenshot: + firefox.screenshot(screenshot) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_local_program.py b/tests/test_local_program.py new file mode 100644 index 0000000..495cb1f --- /dev/null +++ b/tests/test_local_program.py @@ -0,0 +1,260 @@ +"""Tests of the local program's notebook requests. Run from the project +directory: + + .venv/bin/python -m unittest tests.test_local_program -v +""" + +import json +import os +import pathlib +import sys +import tempfile +import threading +import time +import unittest +import urllib.error +import urllib.parse +import urllib.request + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) +import kokkoja # noqa: E402 + +TOKEN = "test-token" +PAGE = b'\n' +PNG = b"\x89PNG\r\n\x1a\n" + b"the rest of a picture" +WRITTEN_PAGE = b'\n' + + +class LocalProgramTest(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.notebooks = pathlib.Path(self.directory.name) / "notebooks" + self.config = pathlib.Path(self.directory.name) / "config" + self.server = kokkoja.make_server(0, "ws://relay.example:8770/", self.notebooks, TOKEN, + self.config) + self.port = self.server.server_address[1] + thread = threading.Thread(target=self.server.serve_forever, daemon=True) + thread.start() + self.addCleanup(thread.join) + self.addCleanup(self.server.server_close) + self.addCleanup(self.server.shutdown) + + def request(self, method, path, body=None, token=TOKEN, headers=None): + """Returns (status, body).""" + all_headers = {} if token is None else {"Authorization": f"Bearer {token}"} + all_headers.update(headers or {}) + request = urllib.request.Request(f"http://127.0.0.1:{self.port}{path}", data=body, + method=method, headers=all_headers) + try: + with urllib.request.urlopen(request, timeout=10) as response: + return response.status, response.read() + except urllib.error.HTTPError as error: + with error: + return error.code, error.read() + + def json(self, method, path, **arguments): + status, body = self.request(method, path, **arguments) + return status, json.loads(body) + + def test_client_and_config_need_no_token(self): + status, body = self.request("GET", "/", token=None) + self.assertEqual(status, 200) + self.assertIn(b"Kokkoja", body) + self.assertEqual(self.json("GET", "/config.json", token=None), + (200, {"relay": "ws://relay.example:8770", "storage": "files", "site": ""})) + + def test_site_comes_from_a_kokkoja_server_relay_or_is_given(self): + self.assertEqual(kokkoja.site_of("wss://kokkoja.example/relay", ""), "https://kokkoja.example/") + self.assertEqual(kokkoja.site_of("wss://kokkoja.example/relay/", ""), "https://kokkoja.example/") + self.assertEqual(kokkoja.site_of("ws://127.0.0.1:8770", ""), "") + self.assertEqual(kokkoja.site_of("wss://relay.example:8770", ""), "") + self.assertEqual(kokkoja.site_of("ws://127.0.0.1:8770", "http://127.0.0.1:9000"), + "http://127.0.0.1:9000/") + + def test_write_list_and_read_a_page(self): + self.assertEqual(self.json("GET", "/api/notebooks")[1]["notebooks"], []) + page = "/api/notebooks/" + urllib.parse.quote("Übungen A1") + "/2026_10_02_page.svg" + self.assertEqual(self.json("PUT", page, body=PAGE), (200, {"saved": "2026_10_02_page.svg"})) + self.assertEqual(self.request("GET", page), (200, PAGE)) + self.assertEqual((self.notebooks / "Übungen A1" / "2026_10_02_page.svg").read_bytes(), PAGE) + self.assertEqual(self.json("GET", "/api/notebooks")[1]["notebooks"], ["Übungen A1"]) + self.assertEqual(self.json("GET", "/api/notebooks/" + urllib.parse.quote("Übungen A1")), + (200, {"pages": ["2026_10_02_page.svg"]})) + + def test_writing_again_replaces_the_page_and_leaves_nothing_else(self): + page = "/api/notebooks/Andy/page.svg" + self.request("PUT", page, body=b"first") + self.request("PUT", page, body=PAGE) + self.assertEqual(self.request("GET", page), (200, PAGE)) + self.assertEqual([entry.name for entry in (self.notebooks / "Andy").iterdir()], ["page.svg"]) + + def test_token_is_required(self): + for token in (None, "wrong", ""): + with self.subTest(token=token): + self.assertEqual(self.request("GET", "/api/notebooks", token=token)[0], 401) + self.assertEqual( + self.request("PUT", "/api/notebooks/Andy/page.svg", body=PAGE, token=token)[0], 401) + self.assertFalse(self.notebooks.exists()) + + def test_request_from_another_web_site_is_refused(self): + for headers in ({"Origin": "https://evil.example"}, {"Host": "evil.example:8760"}): + with self.subTest(headers=headers): + status, _ = self.request("PUT", "/api/notebooks/Andy/page.svg", body=PAGE, + headers=headers) + self.assertEqual(status, 403) + status, _ = self.request("PUT", "/api/notebooks/Andy/page.svg", body=PAGE, + headers={"Origin": f"http://localhost:{self.port}"}) + self.assertEqual(status, 200) + + def test_names_cannot_leave_the_notebooks_directory(self): + outside = pathlib.Path(self.directory.name) / "outside.svg" + for path in ("/api/notebooks/../outside.svg", + "/api/notebooks/%2E%2E/outside.svg", + "/api/notebooks/Andy/..%2F..%2Foutside.svg", + "/api/notebooks/Andy%2F..%2F../outside.svg", + "/api/notebooks/.hidden/page.svg", + "/api/notebooks/Andy/.hidden.svg", + "/api/notebooks/Andy/sub/page.svg"): + with self.subTest(path=path): + status, _ = self.request("PUT", path, body=PAGE) + self.assertIn(status, (400, 404)) + self.assertFalse(outside.exists()) + self.assertFalse(self.notebooks.exists()) + + def test_only_pages_can_be_written(self): + self.assertEqual(self.request("PUT", "/api/notebooks/Andy/notes.txt", body=b"x")[0], 400) + self.assertEqual(self.request("PUT", "/api/notebooks/Andy", body=b"x")[0], 405) + + def test_keeping_the_current_page(self): + current = "/api/notebooks/Andy/Current.svg" + keep = "/api/notebooks/Andy/keep" + today = time.strftime("%Y_%m_%d") + + # Nothing to keep: no notebook, then a blank page. + self.assertEqual(self.json("POST", keep), (200, {"kept": None, "new": False})) + self.request("PUT", current, body=PAGE) + self.assertEqual(self.json("POST", keep), (200, {"kept": None, "new": False})) + + # A page with writing is kept once, however often it is asked. + self.request("PUT", current, body=WRITTEN_PAGE) + self.assertEqual(self.json("POST", keep), (200, {"kept": f"{today}_01.svg", "new": True})) + self.assertEqual(self.json("POST", keep), (200, {"kept": f"{today}_01.svg", "new": False})) + self.assertEqual((self.notebooks / "Andy" / f"{today}_01.svg").read_bytes(), WRITTEN_PAGE) + + # A changed page is kept under the next serial number. + changed = WRITTEN_PAGE + b"" + self.request("PUT", current, body=changed) + self.assertEqual(self.json("POST", keep), (200, {"kept": f"{today}_02.svg", "new": True})) + self.assertEqual(self.json("GET", "/api/notebooks/Andy")[1]["pages"], + [f"{today}_01.svg", f"{today}_02.svg", "Current.svg"]) + + def test_a_kept_page_is_dated_when_it_was_last_changed(self): + notebook = self.notebooks / "Andy" + notebook.mkdir(parents=True) + (notebook / "Current.svg").write_bytes(WRITTEN_PAGE) + last_week = time.mktime((2026, 9, 25, 16, 30, 0, 0, 0, -1)) + os.utime(notebook / "Current.svg", (last_week, last_week)) + (notebook / "2026_09_25_01.svg").write_bytes(b"an earlier page of that day") + self.assertEqual(self.json("POST", "/api/notebooks/Andy/keep"), + (200, {"kept": "2026_09_25_02.svg", "new": True})) + self.assertEqual((notebook / "2026_09_25_02.svg").stat().st_mtime, last_week) + + def test_identity_is_kept_once_and_privately(self): + self.assertEqual(self.request("GET", "/api/identity")[0], 404) + self.assertEqual(self.request("GET", "/api/identity", token=None)[0], 401) + identity = b'{"publicKey": "abc", "privateKey": {"kty": "EC"}}' + self.assertEqual(self.json("PUT", "/api/identity", body=identity), (200, {"kept": True})) + self.assertEqual(self.request("GET", "/api/identity"), (200, identity)) + self.assertEqual(self.request("PUT", "/api/identity", body=b'{"other": 1}')[0], 409) + self.assertEqual(self.request("GET", "/api/identity"), (200, identity)) + self.assertEqual(self.request("PUT", "/api/identity", body=b"not json")[0], 400) + path = self.config / "identity.json" + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + + def test_missing_page(self): + self.assertEqual(self.request("GET", "/api/notebooks/Andy/none.svg")[0], 404) + self.assertEqual(self.json("GET", "/api/notebooks/Andy"), (200, {"pages": []})) + + # The file chooser and exporting. + + def files(self, directory=None): + query = "" if directory is None else "?directory=" + urllib.parse.quote(str(directory)) + return self.json("GET", "/api/files" + query) + + def test_files_lists_directories_and_image_files(self): + home = pathlib.Path(self.directory.name) / "Bilder" + (home / "Übungen").mkdir(parents=True) + (home / ".hidden").mkdir() + (home / "b.PNG").write_bytes(PNG) + (home / "a.jpg").write_bytes(b"jpeg") + (home / "notes.txt").write_bytes(b"text") + (home / ".secret.png").write_bytes(PNG) + status, listing = self.files(home) + self.assertEqual(status, 200) + self.assertEqual(listing, { + "directory": str(home), "parent": self.directory.name, + "directories": [{"name": "Übungen", "path": str(home / "Übungen")}], + "files": [{"name": "a.jpg", "path": str(home / "a.jpg"), "size": 4}, + {"name": "b.PNG", "path": str(home / "b.PNG"), "size": len(PNG)}]}) + # A path is tidied, so that going up always leads somewhere. + self.assertEqual(self.files(home / "Übungen" / "..")[1]["directory"], str(home)) + + def test_files_begins_in_the_notebooks_directory(self): + # Before there are notebooks: the user's home directory. + self.assertEqual(self.files()[1]["directory"], str(pathlib.Path.home())) + self.notebooks.mkdir() + self.assertEqual(self.files()[1]["directory"], str(self.notebooks)) + + def test_files_refusals(self): + self.assertEqual(self.files("relative/path")[0], 400) + self.assertEqual(self.files(pathlib.Path(self.directory.name) / "missing")[0], 404) + self.assertEqual(self.request("GET", "/api/files", token=None)[0], 401) + self.assertEqual(self.request("GET", "/api/file?path=/etc/hostname", token=None)[0], 401) + + def test_an_image_file_is_read_and_nothing_else(self): + directory = pathlib.Path(self.directory.name) + (directory / "scan.png").write_bytes(PNG) + (directory / "letter.txt").write_bytes(b"private") + read = lambda path: self.request("GET", "/api/file?path=" + urllib.parse.quote(str(path))) + self.assertEqual(read(directory / "scan.png"), (200, PNG)) + self.assertEqual(read(directory / "letter.txt")[0], 400) + self.assertEqual(read("scan.png")[0], 400) + self.assertEqual(read(directory / "missing.png")[0], 404) + + def export(self, directory, name, body=None, replace=False): + query = urllib.parse.urlencode({"directory": str(directory), "name": name, + **({"replace": "1"} if replace else {})}) + return self.json("PUT", "/api/file?" + query, body=PNG if body is None else body) + + def test_export_writes_a_png_where_asked(self): + directory = pathlib.Path(self.directory.name) / "Export nach Hause" + directory.mkdir() + self.assertEqual(self.export(directory, "Seite 1.png"), + (200, {"saved": str(directory / "Seite 1.png")})) + self.assertEqual((directory / "Seite 1.png").read_bytes(), PNG) + self.assertEqual([entry.name for entry in directory.iterdir()], ["Seite 1.png"]) + + def test_export_replaces_a_file_only_when_told_to(self): + directory = pathlib.Path(self.directory.name) + (directory / "page.png").write_bytes(b"earlier") + self.assertEqual(self.export(directory, "page.png"), (409, {"error": "exists"})) + self.assertEqual((directory / "page.png").read_bytes(), b"earlier") + self.assertEqual(self.export(directory, "page.png", replace=True)[0], 200) + self.assertEqual((directory / "page.png").read_bytes(), PNG) + + def test_export_refusals(self): + directory = pathlib.Path(self.directory.name) + self.assertEqual(self.export(directory, "page.svg")[0], 400) + self.assertEqual(self.export(directory, "../page.png")[0], 400) + self.assertEqual(self.export(directory, ".hidden.png")[0], 400) + self.assertEqual(self.export(directory, "page.png", body=b"#!/bin/sh\n")[0], 400) + self.assertEqual(self.export(directory / "missing", "page.png")[0], 404) + self.assertEqual(self.export("relative", "page.png")[0], 404) + query = urllib.parse.urlencode({"directory": str(directory), "name": "page.png"}) + self.assertEqual(self.request("PUT", "/api/file?" + query, body=PNG, token=None)[0], 401) + self.assertEqual(list(directory.glob("*.png")), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_relay.py b/tests/test_relay.py new file mode 100644 index 0000000..9f5c444 --- /dev/null +++ b/tests/test_relay.py @@ -0,0 +1,129 @@ +"""Tests of the relay. Run from the project directory: + + .venv/bin/python -m unittest discover tests +""" + +import asyncio +import json +import pathlib +import sys +import unittest + +from websockets.asyncio.client import connect +from websockets.asyncio.server import serve +from websockets.exceptions import ConnectionClosed, InvalidStatus + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "relay")) +import kokkoja_relay as relay # noqa: E402 + +ROOM_A = "A" * 22 +ROOM_B = "B" * 22 + + +def to_peer(destination, body): + return destination.to_bytes(4, "big") + body + + +class RelayTest(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + relay.rooms.clear() + self.server = await serve(relay.handle, "127.0.0.1", 0, + process_request=relay.check_path, + max_size=relay.MAX_MESSAGE_BYTES) + port = self.server.sockets[0].getsockname()[1] + self.address = f"ws://127.0.0.1:{port}" + + async def asyncTearDown(self): + self.server.close() + await self.server.wait_closed() + + async def join(self, room): + connection = await connect(f"{self.address}/room/{room}") + self.addAsyncCleanup(connection.close) + welcome = json.loads(await connection.recv()) + self.assertEqual(welcome["type"], "welcome") + return connection, welcome + + async def test_welcome_join_leave(self): + first, welcome = await self.join(ROOM_A) + self.assertEqual(welcome, {"type": "welcome", "peer": 1, "peers": []}) + second, welcome = await self.join(ROOM_A) + self.assertEqual(welcome, {"type": "welcome", "peer": 2, "peers": [1]}) + self.assertEqual(json.loads(await first.recv()), {"type": "join", "peer": 2}) + await second.close() + self.assertEqual(json.loads(await first.recv()), {"type": "leave", "peer": 2}) + + async def test_broadcast_reaches_the_others_only(self): + first, _ = await self.join(ROOM_A) + second, _ = await self.join(ROOM_A) + third, _ = await self.join(ROOM_A) + await first.recv() # join 2 + await first.recv() # join 3 + await second.recv() # join 3 + await second.send(to_peer(0, b"hello")) + self.assertEqual(await first.recv(), to_peer(2, b"hello")) + self.assertEqual(await third.recv(), to_peer(2, b"hello")) + # The sender gets nothing back: the next thing it receives is this. + await first.send(to_peer(0, b"next")) + self.assertEqual(await second.recv(), to_peer(1, b"next")) + + async def test_directed_message_reaches_one_peer(self): + first, _ = await self.join(ROOM_A) + second, _ = await self.join(ROOM_A) + third, _ = await self.join(ROOM_A) + await first.recv() + await first.recv() + await second.recv() + await first.send(to_peer(3, b"for three")) + await first.send(to_peer(0, b"for all")) + self.assertEqual(await third.recv(), to_peer(1, b"for three")) + self.assertEqual(await third.recv(), to_peer(1, b"for all")) + self.assertEqual(await second.recv(), to_peer(1, b"for all")) + + async def test_rooms_are_separate(self): + in_a, _ = await self.join(ROOM_A) + in_b, welcome = await self.join(ROOM_B) + self.assertEqual(welcome["peers"], []) + other_a, _ = await self.join(ROOM_A) + await in_a.recv() # join + await in_b.send(to_peer(0, b"b only")) + await other_a.send(to_peer(0, b"a only")) + self.assertEqual(await in_a.recv(), to_peer(2, b"a only")) + + async def test_room_is_forgotten_when_empty(self): + first, _ = await self.join(ROOM_A) + await first.close() + await first.wait_closed() + for _ in range(50): + if not relay.rooms: + break + await asyncio.sleep(0.01) + self.assertEqual(relay.rooms, {}) + + async def test_bad_path_is_refused(self): + for path in ("/", "/room/short", f"/room/{ROOM_A}/extra", "/room/" + "!" * 22): + with self.assertRaises(InvalidStatus): + await connect(self.address + path) + + async def test_text_message_closes_the_connection(self): + first, _ = await self.join(ROOM_A) + await first.send("text is not allowed") + with self.assertRaises(ConnectionClosed) as caught: + await first.recv() + self.assertEqual(caught.exception.rcvd.code, relay.CLOSE_BAD_MESSAGE) + + async def test_room_full(self): + relay.MAX_PEERS_PER_ROOM, saved = 2, relay.MAX_PEERS_PER_ROOM + try: + await self.join(ROOM_A) + await self.join(ROOM_A) + extra = await connect(f"{self.address}/room/{ROOM_A}") + with self.assertRaises(ConnectionClosed) as caught: + await extra.recv() + self.assertEqual(caught.exception.rcvd.code, relay.CLOSE_ROOM_FULL) + finally: + relay.MAX_PEERS_PER_ROOM = saved + + +if __name__ == "__main__": + unittest.main()