diff --git a/README.md b/README.md index 505f975..570aa6d 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,12 @@ may be used. A guest needs nothing but a web browser: open the invitation link, enter a name, join. A host can start a session from the same web site, -`https://kokkoja.andykopra.com/`, with the notebooks kept in the +`https://app.kokkoja.com/`, with the notebooks kept in the browser. A host who wants notebooks as files needs Python 3 and this directory: - python3 kokkoja.py --relay wss://kokkoja.andykopra.com/relay + python3 kokkoja.py --relay wss://app.kokkoja.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 diff --git a/client/js/character_codes.js b/client/js/character_codes.js new file mode 100644 index 0000000..26c11b5 --- /dev/null +++ b/client/js/character_codes.js @@ -0,0 +1,147 @@ +// @ts-check +/** + * Klammertext's character codes, for typing letters a keyboard lacks: + * `^o"` is ö, `^s^` is ß, `^a1` is ā, `^00E9^` is é, `^130ED^` an + * Egyptian hieroglyph, `^9053 53EF^` two characters from one pair of + * carets, and `^^` is a caret. The text field replaces a code as soon + * as the keystroke that completes it is typed (text_editor.js), like a + * compose key. + * + * The tables and the rules are those of Klammertext's K/mac/character.h, + * character.cpp and katom_process.cpp (the live tree; K/src is + * deprecated), copied 2026-10-13; `kdesc -c` lists the tables. Left + * out, since a text block is not a document: the `^'...'^` literal span + * (a code is replaced only at the caret here, so nothing needs + * protecting), and the one-hex-digit form `^a^`, which makes a control + * character. A single character standing for itself inside a sequence + * (`^9053 a^` is 道a) applies only when at least one member is a code + * point (Andy, 2026-10-13), so that a typed `x^2 + y^2` stays. An + * invalid code point, which is an error in a document, stays as typed. + * The result is normalized to its precomposed form, as Klammertext's + * `compose` does, so that ö is one character in the page file and to + * Backspace. + */ + +/** The diacritic each mark letter stands for, as a combining character. */ +export const DIACRITICS = { + '`': '̀', // grave accent + "'": '́', // acute accent + 'h': '̂', // circumflex (hat) + '~': '̃', // tilde + '-': '̄', // macron + '"': '̈', // dieresis + 'b': '̆', // breve + 'd': '̇', // dot above + 'r': '̊', // ring above + 'w': '̌', // wedge (caron) + 'c': '̧', // cedilla + 'a': '̋', // double acute accent +}; + +/** Letters outside the Roman alphabet and ligatures, `^code^`. */ +export const EXTENDED_LATIN = { + 's': 'ß', // Eszett + 'i': 'ı', // dotless i + 'I': 'İ', // capital dotted i + 't': 'þ', // thorn + 'T': 'Þ', // thorn capital + 'e': 'ð', // eth + 'E': 'Ð', // eth capital + 'o': 'ø', // o stroke + 'O': 'Ø', // o stroke capital + 'd': 'đ', // d stroke + 'D': 'Đ', // d stroke capital + 'ae': 'æ', // ae ligature + 'AE': 'Æ', // ae ligature capital + 'oe': 'œ', // oe ligature + 'OE': 'Œ', // oe ligature capital +}; + +/** Mandarin pinyin tones on a vowel, `^a1` to `^a4`. */ +export const PINYIN_TONES = { + '1': '̄', // high + '2': '́', // rising + '3': '̌', // falling-rising + '4': '̀', // falling +}; + +/** A code point in hex: 4 to 6 digits (2 and 3 would collide with the mnemonics). */ +const HEX = '[0-9A-Fa-f]{4,6}'; + +/** + * Whether the number is a code point a character can be made of + * (Klammertext's `valid_codepoint`): in range, not a surrogate, not a + * noncharacter. + * @param {number} cp + * @returns {boolean} + */ +export function validCodePoint(cp) { + if (cp < 0 || cp > 0x10FFFF) return false; + if (0xD800 <= cp && cp <= 0xDFFF) return false; + if (0xFDD0 <= cp && cp <= 0xFDEF) return false; + if ((cp & 0xFFFE) === 0xFFFE) return false; + return true; +} + +/** + * What a sequence's members stand for: a hex member its code point, any + * other, a single character, itself; or null when no member is a code + * point (the run is not a sequence) or one is not a valid code point. + * @param {string[]} members + * @returns {string | null} + */ +function charactersOf(members) { + const hexRe = new RegExp(`^${HEX}$`, 'u'); + if (!members.some((m) => hexRe.test(m))) return null; + let result = ''; + for (const member of members) { + if (hexRe.test(member)) { + const cp = parseInt(member, 16); + if (!validCodePoint(cp)) return null; + result += String.fromCodePoint(cp); + } else { + result += member; + } + } + return result; +} + +/** + * The code that the text ends with, if it ends with a complete one. + * Checked in Klammertext's order: an extended Latin letter, a code point + * or a sequence, the caret itself, a diacritic, a pinyin tone. + * @param {string} text + * @returns {{length: number, character: string} | null} + * How many characters of the text the code takes, and what it means. + */ +export function codeAtEnd(text) { + const extended = /\^(ae|AE|oe|OE|[siItTeEoOdD])\^$/u.exec(text); + if (extended) { + const name = /** @type {keyof typeof EXTENDED_LATIN} */ (extended[1]); + return { length: name.length + 2, character: EXTENDED_LATIN[name] }; + } + // One pair of carets around members separated by spaces, on one line, + // no space inside the carets: code points of 4 to 6 hex digits and, + // once one of those is present, single characters standing for + // themselves (a caret cannot be one: it would close the sequence). + const member = `(?:${HEX}|[^\\s^])`; + const sequence = new RegExp(`\\^(${member}(?: +${member})*)\\^$`, 'u').exec(text); + if (sequence) { + const character = charactersOf(sequence[1].split(/ +/)); + if (character !== null) return { length: sequence[0].length, character }; + } + if (text.endsWith('^^')) return { length: 2, character: '^' }; + // The base is a letter of any alphabet: not a space, a digit or ASCII + // punctuation (Klammertext's rule). + const diacritic = /\^([^\s0-9!-/:-@[-`{-~])([-'`h~"cbrdwa])$/u.exec(text); + if (diacritic) { + const mark = /** @type {keyof typeof DIACRITICS} */ (diacritic[2]); + return { length: diacritic[0].length, character: (diacritic[1] + DIACRITICS[mark]).normalize('NFC') }; + } + const tone = /\^([aeiou])([1-4])$/u.exec(text); + if (tone) { + const number = /** @type {keyof typeof PINYIN_TONES} */ (tone[2]); + return { length: 3, character: (tone[1] + PINYIN_TONES[number]).normalize('NFC') }; + } + return null; +} diff --git a/client/js/text_editor.js b/client/js/text_editor.js index 76565d5..3d9c62b 100644 --- a/client/js/text_editor.js +++ b/client/js/text_editor.js @@ -9,6 +9,7 @@ * shows it; everyone else sees it drawn, updated at every keystroke. */ +import { codeAtEnd } from './character_codes.js'; import { randomId } from './crypto.js'; import { textBounds } from './page_model.js'; import { TEXT_FRAME_WIDTH, TEXT_MARK } from './page_file.js'; @@ -51,7 +52,13 @@ export class TextEditor { this.field.className = 'text_field'; this.field.spellcheck = false; this.field.hidden = true; - this.field.addEventListener('input', () => this.changed()); + /** True while a character code is being replaced in the field. */ + this.replacing = false; + this.field.addEventListener('input', () => { + if (this.replacing) return; + this.replaceCode(); + this.changed(); + }); this.watchForHold(); this.field.addEventListener('keydown', (event) => { if (event.key === 'Escape') { @@ -231,6 +238,30 @@ export class TextEditor { return block; } + /** + * The text before the caret ends with one of Klammertext's character + * codes (character_codes.js): replace it by the character it stands + * for. Done through the browser's own editing command so that its + * undo history survives (assigning the field's value would discard + * it); the command fires an input event of its own, ignored here. + */ + replaceCode() { + const field = this.field; + const caret = field.selectionStart; + if (caret !== field.selectionEnd) return; + const code = codeAtEnd(field.value.slice(0, caret)); + if (!code) return; + field.setSelectionRange(caret - code.length, caret); + this.replacing = true; + try { + if (!document.execCommand('insertText', false, code.character)) { + field.setRangeText(code.character, caret - code.length, caret, 'end'); + } + } finally { + this.replacing = false; + } + } + /** The text, position or width changed: lay it out and tell the page. */ changed() { const block = this.block; diff --git a/deploy/README.md b/deploy/README.md index 5e367fe..46a07bf 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -4,7 +4,8 @@ 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`. +The first one is Andy's VPS, reached at `app.kokkoja.com` (the older +name `kokkoja.andykopra.com` still works, redirected there). | On the server | What it is | |----------------------------|-------------------------------------------------| @@ -15,10 +16,10 @@ The first one is Andy's VPS at `kokkoja.andykopra.com`. | 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 +`{"relay": "wss://app.kokkoja.com/relay", "storage": "browser", +"site": "https://app.kokkoja.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 server with `python3 kokkoja.py --relay wss://app.kokkoja.com/relay`; the invitation is then a link to the site. ## Setting up or updating @@ -38,14 +39,16 @@ denied (publickey)". 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`. +1. DNS: an A record `app.kokkoja.com` pointing at the VPS + (82.221.141.89), wherever kokkoja.com's DNS is managed (its registrar, + or the orangewebsite.com nameservers if delegated there). The older + name `kokkoja.andykopra.com` keeps its A record in cPanel's Zone + Editor and is redirected to app.kokkoja.com by nginx. Check with + `host app.kokkoja.com`. 2. TLS, after the name resolves and `deploy.sh` has run once: - ssh root@vps313847.iceservers.net certbot --nginx -d kokkoja.andykopra.com + ssh root@vps313847.iceservers.net certbot --nginx -d app.kokkoja.com -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 @@ -57,7 +60,7 @@ Steps a person does, once: vps-sentinel --accept -Checks: `https://kokkoja.andykopra.com/config.json` shows the +Checks: `https://app.kokkoja.com/config.json` shows the configuration above; `systemctl status kokkoja-relay` is active; `journalctl -u kokkoja-relay -n 20` shows it listening. diff --git a/deploy/deploy.sh b/deploy/deploy.sh index e8c993a..eba030f 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -12,8 +12,8 @@ set -euo pipefail cd "$(dirname "$0")/.." server=${KOKKOJA_SERVER:-root@vps313847.iceservers.net} -site=https://kokkoja.andykopra.com/ -relay=wss://kokkoja.andykopra.com/relay +site=https://app.kokkoja.com/ +relay=wss://app.kokkoja.com/relay staging=$(mktemp -d) trap 'rm -rf "$staging"' EXIT diff --git a/deploy/nginx-site.conf b/deploy/nginx-site.conf index f4aa71e..48011a6 100644 --- a/deploy/nginx-site.conf +++ b/deploy/nginx-site.conf @@ -1,13 +1,14 @@ # 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. +# https://app.kokkoja.com/ and the relay at wss://app.kokkoja.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. +# This is the plain HTTP form. certbot --nginx -d app.kokkoja.com -d +# kokkoja.andykopra.com adds the TLS listeners and turns the HTTP blocks +# into redirects. server { listen 80; - server_name kokkoja.andykopra.com; + server_name app.kokkoja.com; root /var/www/kokkoja; index index.html; @@ -33,7 +34,22 @@ server { # 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 Content-Security-Policy "default-src 'self'; connect-src 'self' wss://app.kokkoja.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; } + +# The old name, kept so that bookmarks and links to the SITE still reach +# it: everything is redirected to app.kokkoja.com. The browser carries a +# link's #join fragment across the redirect, so the join page opens; but +# an invitation made before the move names the old relay inside that +# fragment, and a WebSocket does not follow the 301 (and the new CSP +# forbids the old host), so a pre-move invitation opens but cannot +# connect. Invitations made from app.kokkoja.com name the new relay and +# are fine; live invitations are per-session, so none is affected in +# practice. +server { + listen 80; + server_name kokkoja.andykopra.com; + return 301 https://app.kokkoja.com$request_uri; +} diff --git a/deploy/setup_vps.sh b/deploy/setup_vps.sh index 8f4dd2b..855d874 100755 --- a/deploy/setup_vps.sh +++ b/deploy/setup_vps.sh @@ -12,7 +12,7 @@ # # 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 +# certbot --nginx -d app.kokkoja.com -d kokkoja.andykopra.com set -euo pipefail incoming=/opt/kokkoja/incoming diff --git a/docs/guest.md b/docs/guest.md deleted file mode 100644 index 1036293..0000000 --- a/docs/guest.md +++ /dev/null @@ -1,336 +0,0 @@ -# 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. A host in the same room may -instead show you the link as a QR code on their screen: point your -phone's or tablet's camera at it and open the link it offers. 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 on a -computer, and Safari on an iPad or an iPhone, are known to work; other -browsers built on the same engines very probably do. You see -Kokkoja's start screen, headed "Join a session", 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. (Opened without an invitation, the same screen also offers to -start a session of your own; an invitation is a request to join one -session, so with one it offers joining alone.) - -Your name is remembered by the browser for next time. So is a second -choice, **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, one row holding everything in three -groups: on the left the tools, their settings and **Undo**; in the -middle the page and the view; on the right the host's page buttons, -**Notebooks**, your notebook, and **Session**, which opens a window with -the participants and the invitation. Below it the page has the whole width of the window. Every button -explains itself: rest the pointer on it for a second and its -explanation appears beneath it. - -### The page - -The page is A4 in size, lying on its side, shown so that it fits the -window. Write -anywhere on it. When your writing comes near an edge, the page 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, red, -green, blue and white, and **+** opens a palette of more, in which the -colours already in the row leave their places empty. A colour chosen -from the palette joins the row and stays there in this browser. White -is for painting over. The four lines to the right of the colours are -the line widths: 0.4, 0.7, 1.2 and 2.4 mm at ordinary pressure. - -If a pen is in use, a hand resting on a touch screen is ignored. A -click on the page that only brings the browser window to the front, when -you come back to it from another window, makes no mark. - -### 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. You need not finish to -move a block: rest the pen on it while typing, and it follows the pen, -field and all; lift, and go on typing. - -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; a red corner mark of 5 mm at its -top left shows where it begins. To make a *text rectangle* -instead, drag it open where you want it: a red box, white inside, in -which the text wraps at the box's width, and which grows downward by -lines when you type more than it holds. The box is part of the page, -seen by everyone, and where it lies over an image it covers the image -and nothing else; a teacher uses one to say "write your answer here". - -Tap your own text to change it, anywhere in a box, and the cursor is at -the end of the text. To move a block of yours, rest the pen -on it, without moving, for a little over half a second: a green outline -appears, and it follows the pen. To resize a text rectangle, rest the -pen near one of its corners and drag the corner; the text wraps anew at -the new 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, until you undo the -erasure. - -### Undo - -**Undo**, the last button of the drawing tools, takes back your latest mark: a -stroke, what one movement of the eraser removed, a text block as you -last typed or moved it, a Hide rectangle made, moved or removed. Press -it again for the mark before that, and so on back to the beginning of -the page. Ctrl+Z (Command+Z on a Mac) does the same, and so do two -quick taps of the Eraser on empty paper, away from anything the eraser -would remove; a tap that erases something is never half of such a -double tap. Undo acts only on your own marks, since nobody can change -anyone else's, and only on marks made on this page since you opened it: -after a reload of the tab, or on a page loaded from a notebook, there is -nothing to undo although the marks are there. The button is grey when -there is nothing to undo. An image cannot be undone; put it back with -**Image** if a tap of the eraser removed it by mistake. - -### The view - -A page is shown whole when you first see it. Afterwards the view keeps -its scale: when the page grows, or the host adds an image, the page -extends beyond the window and you scroll to the rest. Three buttons and -two gestures change the view, and none of them changes the page: - -* **Fit** shows the whole page in the window. -* **A4** shows the page at the scale a new page has, a full A4 page - filling the window, so that a page shrunk to a small exercise is seen - at its own size rather than enlarged to fill the window. -* When the host presses **Fit** or **A4**, every participant's view - follows, each in their own window. Your own Fit and A4 change only - your screen. -* **Expand**, which only the host and co-hosts have, makes the page - itself fill the window, for everyone, at the present zoom, in both - directions. The page grows equally on every side, so nothing on it - moves and nothing is rescaled; there is simply more room around it. -* **Shrink**, also for the host and co-hosts, makes the page, for everyone, just large enough for what is on it, - with the usual margin around. Nothing on it moves either. -* **Extend** is a box, ticked to begin with. Ticked, the page grows - when your writing comes near an edge, so there is always room. - Unticked, your writing stops short of the edge; a stroke that leaves - the page resumes when the pen comes back. Images always get the room - they need. -* Hold **Ctrl** (Command on a Mac) and turn the scroll wheel to zoom in - and out about the pointer. -* Hold **Ctrl** (Command on a Mac) and draw a stroke with the pen: up - zooms in, down zooms out, by twice for every 100 pixels of movement, - about the point where the pen touched down. Nothing is drawn while - the key is held. - -If you reload the browser tab, it comes back to the page as you last saw -it: same zoom, same place. - -### Session - -**Session** in the toolbar opens a window with the people and the -invitation. **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 and, below, as a QR code for a camera, in case someone else -should be invited. **Log** is a record of what happened in this -session, pages and connection and every press and release of the pen, -with a button to copy it: if something goes wrong, copy it and send it -to whoever looks after Kokkoja. Above it the window says which page -you are on, counted from the start of the session, how you stand with -the relay, and when the page was last saved; below it, where your -notebooks are kept. - -### Notices - -While all is well Kokkoja says nothing. When there is something to -know, a line appears between the toolbar and the page: in red while -a problem lasts, in grey for a few seconds after something was done. -*Not connected. Reconnecting…* means the relay cannot be reached; -Kokkoja reconnects by itself, and what you wrote meanwhile is sent when -it returns. *The page could not be saved* means the notebook is out of -reach; the page is still in memory and the saving is tried again. -*Waiting for the host's page* means the host has not started a page -yet, or your connection is down. The grey notices say that a page was -kept or exported, and where. - -For finding faults, the browser's console (F12) keeps a short log of the -session's events: pages, images, the connection, and what your pen or -mouse delivers. - -## 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. - -**Notebooks** in the toolbar opens a chooser that shows your notebooks -and small pictures of the pages kept in them, wherever they are kept. -Rest the pointer on a page for a second and its facts appear: the first -text on it, when it was last changed, who wrote on it, what is on it -and the size of the page; on a notebook, how many pages it keeps and -from when, when it was last written in, and where it is. -There you can delete pages and notebooks you no longer want: mark them -with the × at their left, then press **Delete** twice. In the browser -a deleted page is gone for good; with the Kokkoja program it goes to the -Trash. Starting a page for everyone from a kept page is the host's -business, and a co-host's. - -### 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** 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 and no - second download. Any mark counts as a change, even a dot from a - stray touch of the pen. -* **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 the host or a co-host chooses it (see "When the host gives you more" below, and the host's -part). - -The notebook list 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; until you make one, everything goes -into the notebook *General*. The last entry of the list, **New -notebook…**, asks for a name and makes a notebook of that name; it -exists from the moment a page is written into it. **Current notebook** -in the toolbar is the same list, and lets you change notebook, or make -one, 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. 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** 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 makes you a co-host - -Normally only the host puts images on the page, starts new pages, or -loads pages from a notebook. The host can make you a *co-host*, which -gives you the same powers, for instance in a discussion among -colleagues where everyone brings material. The Session window then -says *co-host* beside your name, and 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). 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, 5 mm below the image above or at the top of the page; - one wider than the page widens it. To put an image on a page of its - own, press **New** first. -* **Notebooks**, which everyone has, now also starts a new page for - everyone from a page kept in one of your notebooks, with everything - that was written on it: choose the page in the chooser. The kept page - itself is not changed: the session works on a copy. -* **Hide**, among the tools, hides part of the page for everyone: drag - open a rectangle and whatever lies under it, writing included, is - covered in white, marked by blue corners so that nobody writes there - unknowingly. Rest the pen on it to move it, near a corner to resize - it; a tap of the eraser removes it and uncovers what was there. A Hide - stays even for someone who hides your writing on their own screen. - -As a co-host you can also move and scale images, as the host can: rest the pen on an image for a little over half a second -and it follows the pen; rest it near a corner and the corner follows, -the shape kept. Writing stays where it is, so images are arranged -first and written on afterwards. A new image always arrives below the -others, centred, at its own size, to be arranged by hand. A tap of the -eraser on an image removes it; writing on it stays. An image you added -yourself you may always remove, even after you have stopped being a -co-host. - -You stay a co-host until the host decides otherwise or the session ends. diff --git a/docs/host.md b/docs/host.md deleted file mode 100644 index d4996a0..0000000 --- a/docs/host.md +++ /dev/null @@ -1,341 +0,0 @@ -# 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. - -## Hosting from the web site, or with the Kokkoja program - -A host, like a guest, can use Kokkoja with nothing installed: open -`https://kokkoja.andykopra.com/` in a browser, enter a name, press -**Start a session**, and the invitation is ready in the Session window. -Your notebooks are then kept inside that browser, as a guest's are (see -"Your notebook" in the guest's part): they belong to that browser on -that computer, and a page leaves it by **Keep** or **Export**, which -download it. "Image" asks for the file with the browser's own chooser. - -If you would rather have your notebooks as ordinary directories of -files, where you can open, copy, back up and send them, run the Kokkoja -program on your 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 directories and files. It listens only to your own -computer and talks to nothing else but the relay you name. Your -identity, too, then belongs to your computer rather than to one -browser, so your writing from an earlier lesson is yours from any -browser there. A teacher who keeps notebooks of exercises and a -notebook per student will want the program; for a first session the -web site is enough. - -## Installing the Kokkoja program - -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, or Safari on a Mac; - 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: download - - https://git.andykopra.com/ack/kokkoja-rel/archive/main.zip - -and unpack it, or, if you have 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. -Kokkoja is free software under the GNU General Public License; the -`LICENSE.md` file in the directory is the license. To -remove Kokkoja, delete the directory; your notebooks are elsewhere and -stay. - -### On a Mac - -The same steps, with four things to know: - -* Terminal is in Applications → Utilities. The first `python3` or - `git` command may make macOS offer to install the command line - developer tools; accept, and run the command again when it has - finished. -* The first time the program writes a notebook, macOS asks whether - Terminal may reach your Documents folder. Allow it, once; the - notebooks are there. The answer can be changed later under System - Settings → Privacy & Security → Files and Folders. -* The program opens Kokkoja in your default browser, Safari included. - To use another, start the program with `--no-browser` and open the - address it prints in the browser you prefer. -* Wherever this documentation says Ctrl for zooming, use Command on a - Mac. Both work, so a hand used to Ctrl is not punished. - -## 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. The program remembers it, so from the second time on - - python3 kokkoja.py - -is enough, and the program says which relay it is using. Give the -option again only to change servers. Started for the first time with no -relay, it stops and asks for one. - -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. -* `--trash DIRECTORY` is where deleted notebooks and pages are moved, - instead of your system's Trash; `--trash none` deletes them outright. - -## 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, blank, and -**Session** in the toolbar shows the invitation. - -### The notebook - -A notebook is a directory under `Kokkoja` in your Documents directory. -The start screen lists the notebooks you have; until you make one, there -is *General*, the notebook for whatever has no notebook of its own. The -last entry of the list, **New notebook…**, asks for a name and makes a -notebook of that name, which exists from the moment a page is written -into it. 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 choosing 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 the notebook list in -the toolbar. The page you are on then continues in the notebook chosen. - -### The invitation - -**Session** in the toolbar opens a window; **Invitation** there 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. Below the link the same -invitation stands as a QR code: a guest with a phone or a tablet points -its camera at your screen, and the link opens there. 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 in one way always: 5 mm below the lowest image -there, or at the top of the page if there is none, centred across the -page's width, at its natural size, in front of the images already -there. Natural size is the size the file records: a scan arrives at -the size of the paper it came from. A photo from a phone or camera -records no useful size and arrives at a tenth of a millimetre per -pixel. An image wider than the page widens it on both sides, and one -taller than the page lengthens it downward, so the whole image has room. -From there you move and scale it by hand, as described next; -there is no other setting to choose. 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, which -does not change the size they arrive at. A page holds up to 12 images. - -To move an image, rest the pen on it, without moving, for a little over -half a second: a green outline appears, and the image follows the pen -until you lift it. It comes to the front of the other images, which a -hold without moving also does. To scale an image, rest the pen near one -of its corners: a green square marks the corner, and dragging it scales -the image along its diagonal, the opposite corner staying put and the -shape kept. This works with every tool: a dot the pen may have drawn -while resting is removed, and the eraser erases nothing while it rests. -Writing is not attached to images: an image moved from under writing -leaves the writing where it was, so compose the images first and write -afterwards. Only you and co-hosts can move 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 -page grows downward and everyone scrolls through them. A new image does -not change anyone's zoom; it appears below, to be scrolled to. - -A tap of the eraser on an image removes it, whoever added it and -whenever. Writing on it stays where it was, so remove an image before -anyone writes on it unless the writing is meant to stay. The page keeps -the size the image gave it; **Shrink** trims it. A guest who added an -image may remove it, even after they have stopped being a co-host. - -**Fit** and **A4**, pressed by you, act on every participant's screen, -each in their own window: the way to bring everyone back to the whole -page. A guest's Fit and A4 change only that guest's screen. - -**Hide**, the tool beside the Eraser, covers part of the page for -everyone: drag open a rectangle, and whatever lies under it, writing of -anyone included, is covered in white, marked by blue corners that tell -the students something is there. Use it to cover a solution until the -student has tried, or a part of the exercise for later. Rest the pen on -it to move it, near a corner to resize it; a tap of the eraser removes -it and shows what was covered, and **Undo** takes back any of these as -it does your writing. Nothing under a Hide is lost, and a -student cannot uncover it by hiding your writing on their screen: a Hide -belongs to the page, not to your writing. A Hide is white in an -exported picture and in the page file as other programs see it. - -With the Text tool, a rectangle dragged open is a *text rectangle*: a -red box with a white ground, which covers the image beneath it and no -writing, in which text wraps and which grows downward as it fills. It -is the "write your answer here" box of a worksheet: draw it on the scan -where the answer belongs, and the student types into their own box or -beside yours. Only its owner can type in a box, as with any text. - -**Expand**, among the view buttons, makes the page fill your window for -everyone, at your present zoom, in both directions: the quickest way to -more room for writing around an exercise. The page grows equally on -every side, so nothing on it moves. The page keeps the new size -in everyone's notebook. **Shrink** is its opposite: the page becomes -just large enough for everything on it, images and writing, with the -margin of 5 mm around, which trims a page down to its exercise before you keep -or export it. A blank page cannot shrink. - -**Notebooks** 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 **Notebooks** brings it back as a new page. -A lesson might go: **New**, **Image** for the exercise, work, **New** -for a point of grammar, work, **Notebooks** to return to the exercise. - -The same chooser deletes. Every page card and every notebook in the -list carries a small × at its left; a tap marks it with a tick, a second tap unmarks it, -and a marked notebook goes with every page in it. As soon as something -is marked, a **Delete** button in the chooser's header says how much: -"Delete 3 pages?", "Delete 1 notebook and 3 pages?". The first press -says what will happen, "Press again to move 3 pages to the Trash"; -the second does it. With the Kokkoja program, deleted notebooks and -pages are moved to your system's Trash, where the -file manager can put them back; the program's `--trash` option -names another directory, or `none` to delete outright. In the browser -there is no Trash, and the button says "for good". The notebook -you are writing in cannot be deleted while you are: choose another -notebook in the toolbar first. - -## Co-hosts - -In the **Participants** list of the Session window, each guest's name -has a box **Co-host** beside it. Ticking it makes that guest a -co-host: they may then start new pages, put images on the page and -arrange them, load pages from their notebook, expand, shrink and hide, -exactly as you can, and the buttons for it appear on their toolbar. -Unticking it takes it back; otherwise it lasts until the session -ends. What stays yours alone: making co-hosts, and bringing everyone's -view to the page with Fit or A4. Guests, co-hosts or not, 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** 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, and neither does moving on from a -page that is already kept, such as one loaded from the notebook and left -as it was. Any mark counts as a change, even a -dot from a stray touch of the pen. 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 -co-host, 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. -* **"Not connected" above the page.** 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 browser's console (F12) logs what - the browser receives from your pen at your first stroke. If it says - *mouse*, the browser is not seeing the pen as a pen; try the other - browser. -* **"Address already in use" when starting the program.** A copy of - the program is already running, usually in another terminal window. - Only one can run at a time; use it, or stop it there with Ctrl-C and - start again. -* **Guests cannot connect.** Look at the line `Relay: ...` the program - printed when it started: it names the relay the session goes through. - If it is not your server's address, stop the program and start it - again with `--relay` and the right address. diff --git a/docs/implementation.md b/docs/implementation.md deleted file mode 100644 index e72c810..0000000 --- a/docs/implementation.md +++ /dev/null @@ -1,310 +0,0 @@ -# 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, and a host can - start a session, 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 that passes messages among -the members of a session. What a user calls a session, the relay calls a -*room*: the word appears in the relay's code and log and in the address a -client connects to, `wss:///relay/room/`, and nowhere -else. The relay gives each connection a number, tells the others 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, nothing of the session remains at the relay. It -holds at most 32 members per session and 500 sessions, 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 session 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. The Session window also -shows the link as a QR code, so that a phone or a tablet in the room can -join by camera; the encoder is Kokkoja's own (`qr_code.js`: byte mode, -error correction level M, any version), checked by the tests against -Nayuki's reference encoder. - -The client writes its singular events (pages, images, the connection, -the co-host right, the first input report) to the browser's console -and keeps them, with a trace of every press, release and cancellation -of the pointer (an eraser's release says how many strokes and text -blocks it removed), every change of tool and the touch events, in -memory (`log.js`, the last 400 entries). The Session window shows the record with a Copy button, -so that a guest on a tablet, where there is no console, can send it -when the pen misbehaves. The record stays in the browser tab; nothing -of it is sent anywhere by Kokkoja. - -## 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 connects to a session 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 co-hosts 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 A4 -in landscape, 2970 by 2100 units. "Expand" makes the page fill the -window in both directions, and -"Shrink" makes it the bounding box of everything on the page plus the -greater of the two margins, exactly, on every side; both are changes to the page like adding -an image, carried in the page descriptor (its `left` and `top` edges and -its size) with the images' revision raised. Writing within 5 mm of an edge grows the page by -5 mm on that side, counted from the page's edge. 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 (the nominal 72 and 96 dpi that cameras and screenshots write count -as none), centred across the page, 5 mm from the top or from the image -above, the page widened equally on both sides if the image is wider -than it and lengthened downward if the image is taller; only one wider -than a page can be, 2 m, is scaled down. 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 text block is free text, or a *text rectangle* with a width and a -height, drawn with a red frame on a white ground; the ground is drawn -beneath every layer, so that it hides an image and never anyone's -writing, and the frame within the owner's layer. A *Hide* rectangle is -a page object like an image, in the page descriptor and the page file, -drawn white above every layer with blue corners on screen; made, moved -and removed by the host and co-hosts. Both are moved by a hold of -the pen and resized from their corners, like images but with their -shape free; an image keeps its shape. - -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. - -Undo is personal, since nobody can change another's layer, and is a -stack of one's own gestures kept in the browser tab for the page being -written: a stroke; the strokes and text blocks one movement of the -eraser removed, kept whole; a text block as it was before an editing or -a move; a Hide rectangle as it was before it was made, moved or removed. -Undoing sends the ordinary messages, except that erased strokes come -back by a `stroke_restore` message carrying them whole. Images are not -in the stack, because their data is not kept. The stack is cleared when -the page changes and is not in the page file, so a reloaded tab starts -with nothing to undo. - -## 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**), 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 the same page is already kept in that notebook; a -page loaded from a notebook and left unchanged is therefore not kept -again, although the file names it as a new page. A kept page or a -whole notebook can be deleted from the Notebooks chooser, whose -tooltips describe each page (from the page file itself, which the -chooser fetches for the picture) and each notebook (from the listing, -which names the pages and says when each was last written); the local -program moves it to the system's Trash (the freedesktop Trash on -Linux, with the info file the file manager wants for putting it back; -`~/.Trash` on macOS) unless `--trash` says otherwise, and the -browser's notebook, having none, deletes for good. - -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 browser's console 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. - -## Adjustable choices - -Some choices of the interface are named constants at the top of the -file that uses them, so that they can be changed in one place, or offered -as preferences later: the line widths and the five colours (`main.js`), -the zoom step of the wheel and the zoom per 100 pixels of a pen drag -(`renderer.js`), the dead zone of that drag (`pen_input.js`), the margin -by which the page grows (`page_model.js`), the tooltip delay (1 s, -`DELAY_MS` in `tooltip.js`) and how long after one tooltip the next -button's comes at once (1 s, `WARM_MS` there), the time within which a press after the window comes to the front is taken for the click that raised it and draws nothing (250 ms, `FOCUS_SETTLE_MS` in `pen_input.js`), the hold that moves an image (600 ms, with 6 pixels of -slack, `pen_input.js`), the reach of an image's corner (24 pixels, -`renderer.js`), the smallest an image can be scaled to (1 cm, -`page_model.js`), how often a dragged image's place is sent (40 ms, -`session.js`) and the points of a stroke (25 ms, `session.js`), the -interval of the eraser's double tap (400 ms, `DOUBLE_TAP_MS` in -`main.js`) and how many gestures can be undone (100, `UNDO_LIMIT` in -`session.js`), the colour and width of a text rectangle's frame -(`page_file.js`) and whether the frame is shown whole or as corners on -screen (`TEXT_FRAME_STYLE` in `renderer.js`; the file and the export -always hold the whole frame). - -## 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 deleted file mode 100644 index 2b5687f..0000000 --- a/docs/introduction.md +++ /dev/null @@ -1,157 +0,0 @@ -# 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 document 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 A4 in size, lying on its side. When writing approaches an -edge, the page grows in that direction, so you are never short of room. -An image is placed at its natural size; one wider than the page is -scaled down to fit, and the page grows to hold an image taller than it -or further images below. - -**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.** A guest joins by opening a link in a web -browser, and a host can start a session from the web site in the same -way. Someone who wants a notebook of real files on their own computer, -a teacher with a notebook per student for instance, installs the small -Kokkoja program instead. - -**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, in browsers built on the Chromium engine, such as Brave or -Chrome, and in Safari, on Linux and macOS; a guest can also join from -Safari on an iPad or an iPhone, writing with a pencil or a finger. - -## 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 of -paper, Kokkoja puts that page 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 make any guest a *co-host*, with the -same powers, for a discussion among colleagues. - -## What runs where - -Three kinds of machine take part, and it helps to have the picture in -mind before reading on. - -``` - Host's computer Server Guests - --------------- ------ ------ - Kokkoja program kokkoja.andykopra.com Guest A: a browser - serves the client -+ | opens the link - to the browser at | +----+-----+ the server serves - localhost:8760 | | relay | <-------------> the client to it; - keeps the notebook | | (passes | notebook in the - as files | | messages,| browser, downloads - | | | reads | - browser <-----------+ | nothing) | <-------------> Guest B: has the - | +----------+ Kokkoja program too; - +---- encrypted ----------^ | joins from its own - messages | start screen; note- - web site: book as files - the client's files -``` - -* **The host** in the picture runs the small Kokkoja program on their - own computer. It shows Kokkoja in the host's browser, at the address - `http://localhost:8760/`, and it keeps the host's notebooks as - directories of files. It must be running for as long as the host is - in a session; the browser alone cannot reach the notebooks. A host - can also do without it and start the session from the web site, like - guest A, with the notebooks in the browser. - -* **The server** does two things for everyone. Its *relay* passes the - participants' messages, which are encrypted before they leave each - computer, so the relay reads nothing. Its *web site* serves the - Kokkoja client to guests who join by link. The host's program is - told the server's address once, when it is started. - -* **A guest** needs only a browser: opening the invitation link fetches - the client from the server's web site, and the guest's notebook lives - in that browser, with kept pages and exports offered as downloads. A - guest who wants a notebook of real files runs the Kokkoja program as - the host does, and joins from the program's own start screen. - -Every participant's computer holds the whole page. Only changes travel, -through the relay, to everyone else. Nothing is stored on the server. - -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. - -* [Kokkoja in five minutes](quickstart.md), the shortest path for - someone at home in a terminal -* [A guest's Kokkoja](guest.md) -* [A host's Kokkoja](host.md) -* [How Kokkoja works](implementation.md) diff --git a/docs/quickstart.md b/docs/quickstart.md deleted file mode 100644 index ed01aac..0000000 --- a/docs/quickstart.md +++ /dev/null @@ -1,107 +0,0 @@ -# Kokkoja in five minutes - -The shortest path from nothing to a shared page, for someone who is at -home in a terminal. Everything here is said at more length in [A host's -Kokkoja](host.md), [A guest's Kokkoja](guest.md) and [How Kokkoja -works](implementation.md). - -## What you are installing - -Kokkoja has three parts. The **client** is plain JavaScript that runs -in a browser. The **local program**, `kokkoja.py`, serves the client to -your own browser and keeps your notebooks as directories of SVG files; -without it the notebooks live in the browser's storage. The **relay** -passes the participants' messages, encrypted end to end so that it -cannot read them, and serves the client as a web site, from which anyone -can host or join with nothing installed; Andy's relay at -`kokkoja.andykopra.com` is there for you, and you can run your own. - -## Requirements - -* Linux or macOS. Windows is untried. -* Python 3. The local program uses the standard library only; nothing - is installed with pip. -* Firefox, Brave, Chrome or Safari. -* A pen tablet, or the pencil of a tablet, for handwriting. A mouse - does for pointing and rough marks. - -## Install - - git clone https://git.andykopra.com/ack/kokkoja-rel.git kokkoja - cd kokkoja - -or download and unpack -`https://git.andykopra.com/ack/kokkoja-rel/archive/main.zip`. Nothing -else touches your system. Updating is `git pull`; removal is deleting -the directory. - -## Host a session - -With nothing installed: open `https://kokkoja.andykopra.com/`, enter a -name, press **Start a session**; the notebooks are then in the browser. -With the program, for notebooks as files: - - python3 kokkoja.py --relay wss://kokkoja.andykopra.com/relay - -The program prints `http://localhost:8760/#token=…` and opens your -browser there. Enter a name, choose a notebook, press **Start a -session**. The relay is remembered, so from now on `python3 kokkoja.py` -suffices. Keep the terminal open while the session lasts; Ctrl-C ends -the program. - -**Session** in the toolbar shows the invitation: a link such as - - https://kokkoja.andykopra.com/#join=wss://kokkoja.andykopra.com/relay%23. - -Send it by any means, or let the other person scan the QR code under it. -The part after `#` is never sent to the web site; it carries the -session's secret, so treat the link as you would a meeting link. - -## Join a session - -Open the invitation link in a browser. Enter a name and press **Join**. -That is all; the page is in front of you. A guest with the local -program installed can instead paste the link into their own program's -start screen, and then their notebooks are files on their computer -rather than in the browser's storage. - -## The screen in one paragraph - -The toolbar holds the pen, text, eraser and hide tools, colours and -line widths, the view commands (Fit, A4, Expand, Shrink, Extend), the -host's page buttons (New, Image, Notebooks), your notebook, Keep, Export -and Session. Rest the pointer on any button for a second for its -explanation. Each participant writes on a layer of their own that -nobody else can change. The page is saved continuously to the current -notebook, `Current.svg`; Keep makes a dated copy. Rest the pen on an -image to move it, near a corner to scale it. Ctrl (Command on a Mac) -with the wheel, or with a pen drag, zooms. - -## Where things are - -* Notebooks: `~/Documents/Kokkoja//`, one SVG file per page, - which any browser displays. -* The program's settings, including your identity key and the remembered - relay: `~/.config/kokkoja/` on Linux, `~/Library/Application - Support/kokkoja/` on macOS. -* Options: `--notebooks DIR`, `--port N` (default 8760), `--no-browser`, - `--site URL`, `--config DIR`. - -## Your own relay - - python3 -m venv .venv && .venv/bin/pip install -r relay/requirements.txt - .venv/bin/python relay/kokkoja_relay.py --host 0.0.0.0 --port 8770 - -Then hosts start with `--relay ws://:8770`. For a public server -with TLS, the web site and a systemd service, `deploy/` has the nginx -and systemd files and `deploy/README.md` the steps. The relay stores -nothing and sees only ciphertext. - -## Tests and types - - .venv/bin/pip install -r tests/requirements.txt - .venv/bin/python -m unittest discover tests - npm install && npm run check - -The end-to-end test needs Firefox and geckodriver; the type check is -TypeScript over JSDoc comments and is the only use of npm. diff --git a/tests/test_character_codes.py b/tests/test_character_codes.py new file mode 100644 index 0000000..6ee37d7 --- /dev/null +++ b/tests/test_character_codes.py @@ -0,0 +1,100 @@ +"""The client's reading of Klammertext's character codes (character_codes.js) +against the tables `kdesc -c` prints and the rules of the live Klammertext +tree, K/mac (character.cpp, katom_process.cpp; K/src is deprecated), as +`ktext -s` showed them on 2026-10-13. Needs node (the type checker's). +""" + +import json +import os +import subprocess +import unittest + +CLIENT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "client", "js") + +HARNESS = """ +import { codeAtEnd } from %s; +const texts = JSON.parse(await new Promise((resolve) => { + let data = ''; + process.stdin.on('data', (chunk) => { data += chunk; }); + process.stdin.on('end', () => resolve(data)); +})); +console.log(JSON.stringify(texts.map((text) => codeAtEnd(text)))); +""" + +# `kdesc -c`, 2026-10-13: Displayed, Written. +DIACRITICS = [("é", "^e'"), ("à", "^a`"), ("ô", "^oh"), ("ñ", "^n~"), ("ü", '^u"'), ("ç", "^cc"), + ("ō", "^o-"), ("ğ", "^gb"), ("å", "^ar"), ("ė", "^ed"), ("š", "^sw"), ("ő", "^oa")] +EXTENDED_LATIN = [("ß", "^s^"), ("ı", "^i^"), ("İ", "^I^"), ("þ", "^t^"), ("Þ", "^T^"), ("ð", "^e^"), + ("Ð", "^E^"), ("ø", "^o^"), ("Ø", "^O^"), ("đ", "^d^"), ("Đ", "^D^"), ("æ", "^ae^"), + ("Æ", "^AE^"), ("œ", "^oe^"), ("Œ", "^OE^")] +PINYIN = [("ā", "^a1"), ("á", "^a2"), ("ǎ", "^a3"), ("à", "^a4")] + + +def codes_at_end(texts): + harness = HARNESS % json.dumps(os.path.join(CLIENT, "character_codes.js")) + result = subprocess.run(["node", "--input-type=module", "-e", harness], + input=json.dumps(texts), capture_output=True, text=True, check=True) + return json.loads(result.stdout) + + +class CharacterCodesTest(unittest.TestCase): + + def check(self, cases): + """[(text, expected)]: expected is the character, or None.""" + results = codes_at_end([text for text, _ in cases]) + for (text, expected), result in zip(cases, results): + with self.subTest(text=text): + if expected is None: + self.assertIsNone(result) + else: + self.assertEqual(result, {"length": len(text) - len("Vor "), "character": expected}) + + def test_the_tables(self): + cases = [("Vor " + written, shown) for shown, written in DIACRITICS + EXTENDED_LATIN + PINYIN] + self.check(cases) + # Precomposed, one code point each, as the tables display them. + for shown, _ in DIACRITICS + EXTENDED_LATIN + PINYIN: + self.assertEqual(len(shown), 1) + + def test_other_bases_and_capitals(self): + """The base of a diacritic is a letter of any alphabet, not a + digit, a space or ASCII punctuation.""" + self.check([("Vor ^A'", "Á"), ("Vor ^U\"", "Ü"), ("Vor ^Ss", None), ("Vor ^Sw", "Š"), + ("Vor ^c'", "ć"), ("Vor ^Ccedilla", None), ("Vor ^e1", "ē"), ("Vor ^u3", "ǔ"), + ("Vor ^x1", None), ("Vor ^q~", "q̃"), ("Vor ^1~", None), ("Vor ^.~", None), + ("Vor ^ö-", "\u022B"), ("Vor ^α'", "ά")]) + + def test_code_points_and_the_caret(self): + self.check([("Vor ^00E9^", "é"), ("Vor ^00e9^", "é"), ("Vor ^20AC^", "€"), ("Vor ^^", "^"), + ("Vor ^00AE^", "®"), # not ^E^ at its end + ("Vor ^130ED^", "𓃭"), ("Vor ^1F600^", "😀"), ("Vor ^10FFFD^", "\U0010FFFD"), + # 1 to 3 digits are not a code point: a control + # character, or a collision with the mnemonics. + ("Vor ^a^", None), ("Vor ^7^", None), ("Vor ^ae^", "æ"), ("Vor ^0E9^", None), + # Not a character: beyond the range, a surrogate, a + # noncharacter. Stays as typed (an error in a document). + ("Vor ^110000^", None), ("Vor ^D800^", None), ("Vor ^FFFE^", None), ("Vor ^FDD0^", None)]) + + def test_sequences(self): + """Code points separated by spaces between one pair of carets.""" + self.check([("Vor ^9053 53EF 9053 975E 5A38 9053^", "道可道非娸道"), ("Vor ^9053 53EF^", "道可"), + ("Vor ^0041 1F600^", "A😀"), + ("Vor ^ 9053^", None), ("Vor ^9053 ^", None), # no space inside the carets + ("Vor ^9053 D800^", None), # one bad member spoils it + # A single character stands for itself, as in + # Klammertext, once a code point is among the members + # (Andy, 2026-10-13); without one, x^2 + y^2 stays. + ("Vor ^9053 a 53EF^", "道a可"), ("Vor ^a 1F600^", "a😀"), ("Vor ^9053 @^", "道@"), + ("Vor ^2 + y^", None), ("Vor ^a b^", None), ("Vor ^ab 9053^", None), + ("Vor ^a^", None)]) + + def test_an_unfinished_or_no_code(self): + """A keystroke at a time: nothing until the code is complete, and + a caret with something else after it stays.""" + self.check([("Vor ^", None), ("Vor ^o", None), ("Vor ^ae", None), ("Vor ^00E", None), + ("Vor ^00E9", None), ("Vor ^ a", None), ("Vor ^o\"a", None), ("Vor 2^3", None), + ("Vor ^@'", None), ("Vor ", None), ("", None)]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index b88694d..6d91414 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -1346,6 +1346,51 @@ class EndToEndTest(unittest.TestCase): firefox.switch_to(host_tab) firefox.go("about:blank") + def test_character_codes_in_text(self): + """Andy, 2026-10-13: Klammertext's character codes in a text + block, replaced as each is completed; the field's own undo + survives a replacement.""" + firefox = self.firefox + host_tab = firefox.current_tab() + firefox.go(self.address("Late")) + self.enter_name("Typist", "start_session") + invitation = firefox.run("return document.getElementById('invitation').value;") + guest_tab = firefox.new_tab() + firefox.go(self.address("Student", join=invitation)) + self.enter_name("Onlooker", "join_session") + self.wait_until(lambda s: s["page"]["number"] == 1 and len(s["participants"]) == 2, "the onlooker") + firefox.switch_to(host_tab) + field_text = lambda: firefox.run("return document.querySelector('.text_field').value;") + + firefox.run("document.querySelector('[data-tool=text]').click();") + firefox.run(TEXT_GESTURE, 0.2, 0.3, 0.2, 0.3) + firefox.type_into(".text_field", 'Gr^u"^s^e, ^a` 2^^3, ^OE^uvre, ^00E9^, m^a1, ^130ED^ ^9053 53EF^') + self.assertEqual(field_text(), "Grüße, à 2^3, Œuvre, é, mā, 𓃭 道可") + self.wait_until(lambda s: s["texts"]["Typist"] and s["texts"]["Typist"][0]["text"] == "Grüße, à 2^3, Œuvre, é, mā, 𓃭 道可", + "the text with its codes replaced") + # The caret counts UTF-16 units, so the hieroglyph counts twice. + self.assertEqual(firefox.run("return document.querySelector('.text_field').selectionStart;"), + len("Grüße, à 2^3, Œuvre, é, mā, 𓃭 道可".encode("utf-16-le")) // 2) + + # In the middle of the text too, and only at the caret: the ^ + # left earlier stays. + firefox.run("const f = document.querySelector('.text_field'); f.setSelectionRange(2, 2);") + firefox.type_into(".text_field", "^o-") + self.assertEqual(field_text(), "Grōüße, à 2^3, Œuvre, é, mā, 𓃭 道可") + self.assertEqual(firefox.run("return document.querySelector('.text_field').selectionStart;"), 3) + + # The browser's own undo in the field is not lost by a replacement: + # one step gives back the code as typed. + firefox.chord("\ue009", "z") + self.assertEqual(field_text(), "Gr^o-üße, à 2^3, Œuvre, é, mā, 𓃭 道可") + + firefox.run("document.querySelector('[data-tool=pen]').click();") + shown = self.wait_until(lambda s: s["texts"]["Typist"] and "üße" in s["texts"]["Typist"][0]["text"], + "the finished block")["texts"]["Typist"][0]["text"] + firefox.switch_to(guest_tab) + self.wait_until(lambda s: s["texts"].get("Typist") and s["texts"]["Typist"][0]["text"] == shown, + "the onlooker to see the same text") + def test_text_being_typed_stays_on_the_page_left(self): """Andy, 2026-10-06: a guest was typing when the host pressed New, and the text reappeared on the new page.""" diff --git a/tests/test_qr_code.py b/tests/test_qr_code.py index e7234e1..b69da60 100644 --- a/tests/test_qr_code.py +++ b/tests/test_qr_code.py @@ -64,7 +64,7 @@ class QrCodeTest(unittest.TestCase): self.assertEqual(matrix, expected) def test_invitation_link(self): - link = ("https://kokkoja.andykopra.com/#join=wss://kokkoja.andykopra.com/relay%23" + link = ("https://app.kokkoja.com/#join=wss://app.kokkoja.com/relay%23" "AAAAAAAAAAAAAAAAAAAAAA.BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") self.check([(link, "M"), ("", "M"), ("Kokkoja", "M"), ("Mahendra & Artha", "L"), ("Übung 1: äöü", "M")])