Projects

A squash scorer whose share link is the whole match

Squash keeps every match on the device and still shares one. The link carries the events rather than a reference, why that makes a hostile link a denial-of-service problem, how an await can spend the user activation a share sheet needs, and the iOS split that made a paste box necessary.

Squash is a scorer for a squash match: two names, a serve box, a point to each rally, and at the end a result you can keep. It runs at squashscore.tamasvigh.com, installs to a home screen, and works on a court with no signal, which is the only place it is any use.

There is no account and no server copy. Every match lives in IndexedDB on the device that scored it. That is an easy promise to make and an awkward one to keep, because the first thing anyone wants after a match is to send the result to the person they just played.

The share link is https://squashscore.tamasvigh.com/#/m/<payload>, and everything after the # is the match: the configuration, and every event that happened in it. Nothing after a fragment is ever sent to a server, so the receiving browser is handed the whole match directly and the scorer needs no backend at all to share one.

What it carries is the events, not the score. The app is event-sourced, a reduce over lets, strokes, points and handouts produces the state, so the link encodes what happened and the other device replays it through the same function. The receiver does not take my word for the result. It recomputes it. The encoding is hand-rolled: little-endian integers, varints for the gaps, base64url on the way out.

The payload is signed, and the comment in the source is careful about what that is worth:

/**
 * Integrity, not authenticity: this salt lives in the client, so the hash stops
 * hand-edited and corrupted links but not someone who re-implements this encoder.
 * Server-side signing is the documented later option.
 */
const SALT = 'squash-share-v1';

A salt shipped to every browser is not a secret. The four bytes of SHA-256 catch a link that was truncated by a chat app or edited by hand. They do nothing against anyone who reads this paragraph and encodes their own. For a friendly game that is the right trade, and saying so in the file is cheaper than discovering later that somebody believed otherwise.

The moment the link carries data rather than an id, decoding it is parsing untrusted input, and the failure is not a wrong score. It is the tab.

/** A real match has a few hundred events; a hostile link claiming 65535 of them froze the tab for tens of seconds in reduce. */
const MAX_EVENTS = 4096;

That number came from trying it. A real match is a few hundred events. A crafted length field claiming sixty-five thousand sent reduce away for tens of seconds with the page frozen behind it. The varint reader is bounded the same way at five bytes, because five carry thirty-five bits and no genuine gap needs more, so a payload cannot describe a number big enough to be interesting.

Neither bound protects a secret. They protect the one thing a page with no server still has to lose, which is the ability to respond.

An await can spend the thing you needed

navigator.share opens the system share sheet, and it only works inside the user activation of the tap that asked for it. The link needs SHA-256, which is async. Await the hash inside the handler and the activation may be gone by the time you ask to share, and on a phone the failure is silent, because nothing appears.

The fix is to stop doing the work in the handler:

The link takes an awaited SHA-256, and navigator.share only works inside the user activation of the tap, which an await can use up. So the link is made when the sheet opens, and a tap that finds it ready shares the string at once.

Which introduces the next question, because the sheet can be open while the match carries on. A link built for one set of events must not be shared after the log has changed, so the made link is kept with the events it was made from and used only if they are still the ones on screen. Otherwise the effect builds another. A tap that arrives before any link is ready makes one itself and says so if that fails.

This is the sort of thing that never appears in a test on a desktop, because a desktop browser is more forgiving about activation than a phone is.

The iOS split that needed a paste box

The last one is not a bug in the app and could not be fixed inside it.

A shared link arrives in Messages and opens in Safari. The installed app and Safari are the same engine but not the same storage, so “Save to my matches” in Safari saves it to Safari, and the app, two icons away, shows nothing. Everything worked and the match was not there.

So Settings has a box to paste a link into. It is not elegant. It is the honest shape of the platform showing through the app, and hiding it would have meant a feature that silently does nothing on the device most of these links are opened on.

What is not done

v1 cannot import an exported file. The export is JSON, one file per match, and it is a backup in the sense that the data is yours and legible, but the way back in is a share link, not the file. A match exported and then lost is a match you can read and not restore, which is a gap worth naming rather than describing the export as a backup and leaving it there.