Isyncrasy
Table of Contents
Isyncrasy is the project I find hardest to explain.
The short version: a made-up company, an operating system in the browser, and a real, helium-filled airship you can fly through a building. The long version is the rest of this page.
The Idea
It all started with a concept deck, which is still sitting in the OS’s own filesystem under /home/presentations — you can just open it in there. The bullet points on slide two are basically the entire project in five lines:
The first concept
Retrofuturism · Exploration gamified · Discovering the location from bird’s eye view flying a stationary airship · Users will follow a cyber-narrative · Orientation via 8D-sounds
So you explore a real place from a bird’s eye view by flying an airship, and get led through a story while doing it. The airship floats because helium is cheaper than battery life, and people control it through an interface that pretends it’s 1998.
The rest of the project is really just the question of how you get those three things to meet.
The Company
Before you get to see the OS at all, you get hired.
There is a small mail service (mail/) that polls a mailbox. Whoever writes to [email protected] gets a reply from a bot, in which the HR department of the “Isyncrasy Corporation” kindly informs them that their application was accepted:
Dear ${user.name},
Congratulations! Our HR Team just accepted you for an
entry position in our security oversight team,
Your employee account should be activated any minute.
You can now access the IsyncrasyOS at os.isyncrasy.com.
Please use your new Employee Code to log in:
The “Employee Code” is really an OTP that the API generates when it creates the user, and the login in the terminal is then simply:
login 780a8432-30d3-46df-8057-79d3a6c80024
What I like about this is that there is no sign-up dialog. The way into the project is an email, and the fiction starts before you have seen any software at all.
Two messages are already waiting in the filesystem, one from the “ISyncrasy Group” and one from Miriam Forster, the “head of our security taskforce”. The second one starts with Hi {name}, — there is a tiny template renderer that swaps curly braces against the state:
const replaceVars = string =>
string.replace(/(?<=\{).*?(?=\})/, variable => state.get(variable))
.replace("{", "").replace("}", "");
So the made-up colleague addresses you by the name you gave yourself. If you never gave yourself one, the state generates it — while testing I was called Anon_VoluminousPie.
Booting
If you open the page, the first thing you get is 120 lines of kernel log. That is not fake text, it’s a real boot log from a Raspberry Pi Model B that I replay line by line with random timestamps.
Boot
This is a detail that still amuses me: the Pi in that boot log is exactly the hardware that later hangs inside the airship. After that comes an ASCII logo and a terminal, and the terminal is all you get. Unless you type startx.
Visually the whole thing is 98.css for the window decorations and VT323 as the typeface. The fonts are self-hosted in the repo by now, because I got tired of Google Fonts.
Programs
The part I like most architecturally. There is exactly one kind of thing in the system — a Program — and it can take three shapes:
interface TerminalProgram extends Program {
exec: (options?: ParsedCommand, stdout?: any) => any;
}
interface GUIProgram extends Program {
app: import('svelte').Component<any, any, any>;
id: string;
// ...
}
interface HybridProgram extends GUIProgram, TerminalProgram {}
A TerminalProgram is pure CLI (cat, cd, ls, tree, cowsay). A GUIProgram is a Svelte component that ends up in a window. And a HybridProgram is both — explorer for example opens a window when you click it, and if you call it as explorer /home in the terminal, it opens the window in the right folder.
The terminal itself has no idea which programs exist. It just walks through all of them and takes the first one that feels responsible:
export const handle = async (line, stdout) => {
const command = parseCommand(line);
for (const func of functions) {
let r = await func.exec(command, stdout);
if (r) return r;
}
stdout(`command ${command.command} not found`);
};
So every program gets the parsed command and returns false if it can’t do anything with it. And the list of programs that get asked in the first place comes out of the virtual filesystem:
export const setPrograms = (programs) => {
functions = fs
.get('/usr/bin')
.children.map((c) => c.name in programs && programs[c.name])
.filter((s) => !!s);
};
The fake /usr/bin is the PATH. Delete a file in there and the command stops existing. That was one of those moments where I was very pleased with myself.
Not every program is meant seriously. ssh for instance is complete:
export default {
exec: ({ command }, stdout) => {
if (command !== "ssh") return false;
stdout("eyyy, please say [y]es");
return {
stdin: ({ command }) => {
stdout([command.toLowerCase() === "y" ? "yeeeees" : "noooo"]);
return true;
}
};
}
}
But you can also see here how a program takes over the input: instead of true it returns an object with a stdin function, and then the next line goes straight there instead of through the program list. That is also how login and message work.
Programs
The 3D viewer, by the way, renders at a maximum of 128 pixels per edge and then gets scaled up to window size with image-rendering: pixelated. That’s not a performance trick, it just looks better:
const ratio = 128 / Math.max(w, h);
renderer.setSize(w * ratio, h * ratio);
And the presentation program is secretly a synchronised thing: the slide index comes over the API, everybody sees the same slide, and only whoever typed the word control beforehand is allowed to advance it. There is no button for it, you just have to know.
The Filesystem
The filesystem is a JSON tree. On first start it comes from initialFS.json, after that it gets merged with whatever is in localStorage:
fs = fsID in localStorage
? mergeFS(initialFS, JSON.parse(localStorage.getItem(fsID)))
: initialFS;
The merging matters more than it sounds: it lets me ship new files later (a new message, a new program) without people losing their progress. Files the OS needs to function are marked with "protected": true, so that an enthusiastic rm -r / doesn’t eat the whole operating system.
There is also a file in /home called PASSWORDS. It contains the script of the Bee Movie.
Windows
The window manager is a Svelte store that writes position, size and whether a window is minimised into localStorage. So you can close the tab, come back, and everything is still where it was.
The one problem with that: a Svelte component can’t be serialised. After loading you have the geometry of the windows, but not what used to be inside them. Which is why this step exists:
export const restoreWindows = (programs) => {
Object.values(windows).forEach((w) => {
if (!w.app) {
const prog = Object.values(programs).find((p) => 'id' in p && p.id === w.id);
w.app = prog.app;
}
});
};
Every program has a fixed id (co1 for the terminal, ex1 for the explorer, 3d1 for the viewer), and after a reload the component gets reattached through that id.
The Airship
And now the part people usually don’t believe: the airship is real.
There is a program in the OS called manual, and it is the complete build guide. Including the shopping list:
The manual
A 90 cm foil balloon, a Raspberry Pi Zero, a 3.7V LiPo, three propellers on two L9110s motor drivers, an OV5647 camera, an HC-SR04 ultrasonic sensor, an ATtiny 85, helium, cable ties and velcro. The rest of the manual explains how to SSH into the thing and deploy updates — with git push airship main, because there is a bare repo with a hook sitting on the Pi. If you can’t get it onto a wifi, you open a hotspot with the SSID isyncrasy-net and it connects by itself.
The controller in the OS is then relatively unspectacular: three sliders for speed, direction and brightness that go out over Socket.IO, and an <img> that the camera stream runs into.
$: if (rawDirection !== rDirection || rawSpeed !== rSpeed || rawBrightness !== rBrightness) {
api.emit("airship.control", { speed, direction, brightness });
}
The detail I’m proudest of, though, is what happens when the connection drops: instead of an error message, the last camera frame freezes and slowly gets destroyed with glitch-canvas. A crashed airship shouldn’t feel like an HTTP timeout.
The ultrasonic sensor and the audio were meant for the part the concept calls “Orientation via 8D-sounds” — you were supposed to be able to hear where in the room you are. That’s the part that got the least far.
The Map
So that you know where the airship is actually flying, there is a map mode. You upload a floorplan as an SVG, the server runs it through svgo and svgson and turns it into a list of lines:
function toLines(node: INode): number[] | null {
switch (node.name) {
case 'line': return lineToLine(node);
case 'polyline': return polylineToLines(node);
case 'rect': return rectToLines(node);
default: return null;
}
}
In the browser every line then becomes a box three metres tall:
const WALL_HEIGHT = 3;
const createLine = (gl, program, x1, y1, x2, y2) => {
const depth = distance2D(x1, y1, x2, y2);
const angle = angleOfVector(x2 - x1, y2 - y1);
const mesh = new Mesh(gl, {
geometry: new Box(gl, { width: 0.2, depth, height: WALL_HEIGHT }),
program,
});
mesh.rotation.y = angle;
mesh.position.set(x1 + (x2 - x1) * 0.5, WALL_HEIGHT / 2, y1 + (y2 - y1) * 0.5);
return mesh;
};
You draw a floorplan in Inkscape and walk through it in 3D two seconds later. Very little code for a lot of effect, my favourite kind of feature.
The Backend
The API is Hono on node:sqlite. Migrations are an array of SQL strings that is only ever allowed to grow, and how far along you are lives in SQLite’s built-in user_version:
export const migrations: readonly string[] = [
// 1 — initial schema
`CREATE TABLE users ( ... );`,
];
More interesting is the permission handling, because it has to serve two very different kinds of client. There is a permission object (USER_CREATE, MAP_READ, VAR_UPDATE and so on), roles bundle permissions, and the Authorization header can be either:
if (scheme === 'JWT' && token) {
// a human who logged in
} else if (scheme === 'Bearer' && token) {
// an access key, so a machine
}
JWT means logged-in humans, Bearer means access keys for machines — the mail bot, for example, which has to be able to create users without being one. Access keys get an explicit list of permissions and nothing else.
On top of that there is a table called vars that simply stores global variables. That is the story state: I can flip a variable in the admin during a running session and change what the messages in the OS tell people.
The admin interface itself is a second little Svelte app in the same 98 look, just with the clouds wallpaper instead of the dark one:
Deployment
This is split in two by now. The OS is entirely static and gets uploaded over sftp with rclone, where the hashed assets in _app are synced with --size-only, because by definition they never change:
rclone sync --update --size-only ./os/dist/_app sftp-remote:${REMOTE_DIR}/_app
rclone sync --update --exclude _app/** ./os/dist/ sftp-remote:${REMOTE_DIR}
API and admin go into one Docker image together, which the API serves itself — the SQLite file lives on a volume, and tini makes sure the database handle closes cleanly on SIGTERM. And the commit hash gets stamped into <html data-commit> at build time, so you can tell a deployed page which build it actually is.
The Rewrite
The project sat untouched for a couple of years. When I picked it up again it became a different project within two days:
- The frontends from Svelte 3 to Svelte 5 and Vite 8.
- The backend from MongoDB, Typegoose and restify to
node:sqliteand Hono. Fresh database, no migration — there was nothing worth saving. bcryptout,scryptfromnode:cryptoin. That means the Docker image no longer needs a native toolchain.- The JWT algorithm is pinned now, so a forged
algheader can’t weaken verification. strict,noUncheckedIndexedAccessanderasableSyntaxOnlyon. The last one is the reasonPermissionsandRoleare plain objects and not TypeScript enums: Node can then run the sources directly, and the dev server has no build step at all anymore.
A few programs got left behind commented out along the way — map, airship, sound and volume are in the registry, but with a // in front. Those need hardware that isn’t currently floating around my living room.