chore: rework isync blog post closes #1
Deploy to SFTP Server / build (push) Successful in 27m0s

This commit is contained in:
max committed 2026-08-10 12:05:42 +02:00
1 parent f3eac48f18
commit 8e13077ada
12 files changed
+675 -20

No files matched your search

+4
View File
@@ -1,3 +1,7 @@
allowBuilds:
esbuild: true
exifreader: true
sharp: true
onlyBuiltDependencies:
- esbuild
- exifreader
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+322 -8
View File
@@ -1,25 +1,339 @@
---
date: 2019-06-10
date: 2021-05-18
title: "Isyncrasy"
draft: false
toc: true
cover: ./images/main.png
icon: "/projects/isyncrasy/favicon.ico"
description: "A small fun virtual OS build with svelte"
tags: ["svelte", "web", "os"]
description: "A made-up company, an operating system in the browser and a real airship made of helium and a Raspberry Pi."
links:
[
["live", "https://max-richter.dev/isync"],
["git", "https://git.max-richter.dev/max/isyncrasy"],
["making-of", "https://www.youtube.com/watch?v=oN4pymU5fLk"],
]
tags: ["svelte", "web", "os", "arg", "raspberry-pi"]
---
# Isyncrasy
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.
import Image from "@components/Image.astro"
import ImageGallery from "@components/ImageGallery.svelte"
import ImageSlider from "@components/ImageSlider.svelte"
import Main from "./images/main.png"
import Mail from "./images/mail.png"
import Terminal from "./images/terminal.png"
import ImageGallery from "@components/ImageGallery.svelte"
import BootDmesg from "./images/boot-dmesg.png"
import BootLogo from "./images/boot-logo.png"
import StartMenu from "./images/startmenu.png"
import Programs from "./images/programs.png"
import Manual from "./images/manual.png"
import ManualParts from "./images/manual-parts.png"
import Concept1 from "./images/concept-1.png"
import Concept2 from "./images/concept-2.png"
import AdminUsers from "./images/admin-users.png"
<ImageGallery client:load/>
<Image src={Main} alt="Isyncrasy" />
<Image src={Mail} alt="Isyncrasy" />
<Image src={Terminal} alt="Isyncrasy" />
<Image src={Main} alt="The IsyncrasyOS desktop" />
# 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:
<ImageSlider title="The first concept" client:load>
<Image src={Concept1} alt="Title slide: Idiosyncrasy.Inc" />
<Image src={Concept2} alt="Concept slide: Idea" />
</ImageSlider>
> 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 `hr@isyncrasy.com` 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:
```bash
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:
```javascript
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`.
<Image src={Mail} alt="The Mail program" />
# 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.
<ImageSlider title="Boot" client:load>
<Image src={BootDmesg} alt="The boot log scrolling by" />
<Image src={BootLogo} alt="ASCII logo after booting" />
</ImageSlider>
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](https://jdan.github.io/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:
```typescript
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:
```javascript
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:
```javascript
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:
```javascript
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.
<ImageSlider title="Programs" client:load>
<Image src={Terminal} alt="Terminal running ls and cat" />
<Image src={StartMenu} alt="Start menu with the program list" />
<Image src={Programs} alt="dimension16, the icon viewer and the 3D viewer" />
</ImageSlider>
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:
```javascript
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:
```javascript
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:
```javascript
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:
<ImageSlider title="The manual" client:load>
<Image src={Manual} alt="The airship manual inside the OS" />
<Image src={ManualParts} alt="The parts of the airship" />
</ImageSlider>
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.
```javascript
$: 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:
```typescript
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:
```javascript
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`:
```typescript
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:
```typescript
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:
<Image src={AdminUsers} alt="The admin interface" />
# 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:
```bash
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:sqlite` and Hono. Fresh database, no migration — there was nothing worth saving.
- `bcrypt` out, `scrypt` from `node:crypto` in. That means the Docker image no longer needs a native toolchain.
- The JWT algorithm is pinned now, so a forged `alg` header can't weaken verification.
- `strict`, `noUncheckedIndexedAccess` and `erasableSyntaxOnly` on. The last one is the reason `Permissions` and `Role` are 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.
+322 -12
View File
@@ -1,29 +1,339 @@
---
date: 2019-06-10
date: 2021-05-18
title: "Isyncrasy"
draft: false
toc: true
cover: ./images/main.png
icon: "/projects/isyncrasy/favicon.ico"
description: "A small fun virtual OS build with svelte"
links: [["live", "https://isyncrasy.com"]]
tags: ["svelte", "web", "os"]
description: "Eine erfundene Firma, ein Betriebssystem im Browser und ein echtes Luftschiff aus Helium und Raspberry Pi."
links:
[
["live", "https://max-richter.dev/isync"],
["git", "https://git.max-richter.dev/max/isyncrasy"],
["making-of", "https://www.youtube.com/watch?v=oN4pymU5fLk"],
]
tags: ["svelte", "web", "os", "arg", "raspberry-pi"]
---
Isyncrasy ist das Projekt, bei dem ich mich am schwersten tue zu erklären was es eigentlich ist.
Isyncrasy ist ein kleines virtuelles Betriebssystem, das mit Svelte erstellt wurde. Es ist ein kleines Projekt, das ich gemacht habe, um Svelte zu lernen. Es ist ein einfaches Betriebssystem, das eine E-Mail-Anwendung und eine Terminalanwendung enthält. Es ist ein einfaches Projekt, aber es war sehr lehrreich.
Die kurze Version: eine erfundene Firma, ein Betriebssystem im Browser und ein echtes, mit Helium gefülltes Luftschiff das man durch ein Gebäude fliegen kann. Die lange Version ist der Rest dieser Seite.
import Image from "@components/Image.astro"
import Main from "./images/main.png"
import Mail from "./images/mail.png"
import Terminal from "./images/terminal.png"
import ImageGallery from "@components/ImageGallery.svelte"
import ImageSlider from "@components/ImageSlider.svelte"
import Main from "./images/main.png"
import Mail from "./images/mail.png"
import Terminal from "./images/terminal.png"
import BootDmesg from "./images/boot-dmesg.png"
import BootLogo from "./images/boot-logo.png"
import StartMenu from "./images/startmenu.png"
import Programs from "./images/programs.png"
import Manual from "./images/manual.png"
import ManualParts from "./images/manual-parts.png"
import Concept1 from "./images/concept-1.png"
import Concept2 from "./images/concept-2.png"
import AdminUsers from "./images/admin-users.png"
<ImageGallery client:load/>
<ImageSlider title="Design" client:load>
<Image src={Main} alt="Desktop Overview" />
<Image src={Mail} alt="Mail Application" />
<Image src={Terminal} alt="Terminal Application" />
<Image src={Main} alt="Der Desktop von IsyncrasyOS" />
# Die Idee
Angefangen hat das ganze mit einem Konzept, das immer noch als Präsentation im Dateisystem des OS liegt. Man kann sie sich da auch direkt angucken, unter `/home/presentations`. Die Stichpunkte auf Folie zwei sind eigentlich das komplette Projekt in fünf Zeilen:
<ImageSlider title="Das erste Konzept" client:load>
<Image src={Concept1} alt="Titelfolie: Idiosyncrasy.Inc" />
<Image src={Concept2} alt="Konzeptfolie: Idea" />
</ImageSlider>
> 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
Man erkundet also einen realen Ort aus der Vogelperspektive, indem man ein Luftschiff fliegt, und wird dabei durch eine Geschichte geführt. Das Luftschiff schwebt, weil Helium billiger ist als Akkulaufzeit, und die Nutzer bedienen es über ein Interface, das so tut als wäre es 1998.
Der Rest des Projekts ist im Prinzip nur die Frage, wie man die drei Sachen zusammenbekommt.
# Die Firma
Bevor man das OS überhaupt sehen darf, wird man eingestellt.
Es gibt einen kleinen Mail-Service (`mail/`), der ein Postfach abfragt. Wer eine Mail an `hr@isyncrasy.com` schreibt, bekommt von einem Bot eine Antwort, in der die HR-Abteilung der "Isyncrasy Corporation" freundlich mitteilt, dass die Bewerbung angenommen wurde:
```
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:
```
Der "Employee Code" ist in Wahrheit ein OTP, das die API beim Anlegen des Users generiert, und der Login im Terminal ist dann einfach:
```bash
login 780a8432-30d3-46df-8057-79d3a6c80024
```
Ich mag daran, dass es keinen Registrierungsdialog gibt. Der Einstieg in das Projekt ist eine Email, und die Fiktion fängt an bevor man irgendeine Software gesehen hat.
Im Dateisystem liegen dann schon zwei Nachrichten bereit, eine von der "ISyncrasy Group" und eine von Miriam Forster, der "head of our security taskforce". Die zweite fängt mit `Hi {name},` an — es gibt einen winzigen Template-Renderer, der geschweifte Klammern gegen den State austauscht:
```javascript
const replaceVars = string =>
string.replace(/(?<=\{).*?(?=\})/, variable => state.get(variable))
.replace("{", "").replace("}", "");
```
Damit spricht einen die erfundene Kollegin mit dem Namen an, den man sich selbst gegeben hat. Wenn man sich keinen gegeben hat, generiert der State einen — ich hieß beim Testen `Anon_VoluminousPie`.
<Image src={Mail} alt="Das Mail Programm" />
# Der Bootvorgang
Wer die Seite aufruft, kriegt erstmal 120 Zeilen Kernel-Log. Das ist kein Fake-Text, das ist ein echter Bootlog von einem Raspberry Pi Model B, den ich Zeile für Zeile mit zufälligen Zeitstempeln wieder abspiele.
<ImageSlider title="Boot" client:load>
<Image src={BootDmesg} alt="Der Bootlog scrollt durch" />
<Image src={BootLogo} alt="ASCII Logo nach dem Boot" />
</ImageSlider>
Das ist ein Detail, das mir immer noch Spaß macht: der Pi im Bootlog ist genau die Hardware, die später im Luftschiff hängt. Danach kommt ein ASCII-Logo und ein Terminal, und das Terminal ist erstmal alles was man hat. Es sei denn man tippt `startx`.
Optisch macht das ganze [98.css](https://jdan.github.io/98.css/) für die Fensterdekoration und VT323 als Schriftart. Die Fonts liegen mittlerweile selbstgehostet im Repo, weil ich keine Lust mehr auf Google Fonts hatte.
# Programme
Der Teil, der mir architektonisch am besten gefällt. Es gibt genau eine Sorte Ding im System — ein `Program` — und das kann drei Formen haben:
```typescript
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 {}
```
Ein `TerminalProgram` ist reines CLI (`cat`, `cd`, `ls`, `tree`, `cowsay`). Ein `GUIProgram` ist eine Svelte-Komponente, die in einem Fenster landet. Und ein `HybridProgram` ist beides — `explorer` zum Beispiel öffnet ein Fenster, wenn man ihn anklickt, und wenn man ihn mit `explorer /home` im Terminal aufruft, öffnet er das Fenster im richtigen Ordner.
Das Terminal weiß dabei überhaupt nicht, welche Programme es gibt. Es geht einfach alle durch und nimmt das erste, das sich zuständig fühlt:
```javascript
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`);
};
```
Jedes Programm kriegt also den geparsten Befehl und gibt `false` zurück, wenn es nichts damit anfangen kann. Und die Liste der Programme, die überhaupt gefragt werden, kommt aus dem virtuellen Dateisystem:
```javascript
export const setPrograms = (programs) => {
functions = fs
.get('/usr/bin')
.children.map((c) => c.name in programs && programs[c.name])
.filter((s) => !!s);
};
```
Das gefakte `/usr/bin` **ist** der PATH. Wenn man eine Datei da rauslöscht, existiert das Kommando nicht mehr. Das war einer der Momente, wo ich mich sehr über mich selbst gefreut habe.
Nicht jedes Programm ist dabei ernst gemeint. `ssh` zum Beispiel ist vollständig:
```javascript
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;
}
};
}
}
```
Man sieht hier aber auch, wie ein Programm die Eingabe übernimmt: es gibt statt `true` ein Objekt mit einer `stdin`-Funktion zurück, und dann geht die nächste Zeile direkt dahin statt durch die Programmliste. So funktionieren auch `login` und `message`.
<ImageSlider title="Programme" client:load>
<Image src={Terminal} alt="Terminal mit ls und cat" />
<Image src={StartMenu} alt="Startmenü mit der Programmliste" />
<Image src={Programs} alt="dimension16, Iconviewer und der 3D Viewer" />
</ImageSlider>
Der 3D-Viewer rendert übrigens mit maximal 128 Pixel Kantenlänge und wird dann per `image-rendering: pixelated` auf Fenstergröße hochskaliert. Das ist kein Performance-Trick, das sieht einfach besser aus:
```javascript
const ratio = 128 / Math.max(w, h);
renderer.setSize(w * ratio, h * ratio);
```
Und die Präsentation ist heimlich ein synchronisiertes Ding: der Slide-Index kommt über die API, alle sehen dieselbe Folie, und weiterklicken darf nur, wer vorher das Wort `control` getippt hat. Es gibt keinen Knopf dafür, man muss es einfach wissen.
# Das Dateisystem
Das Dateisystem ist ein JSON-Baum. Beim ersten Start kommt er aus `initialFS.json`, danach wird er mit dem gemerged, was im localStorage liegt:
```javascript
fs = fsID in localStorage
? mergeFS(initialFS, JSON.parse(localStorage.getItem(fsID)))
: initialFS;
```
Das Mergen ist wichtiger als es klingt: dadurch kann ich neue Dateien nachliefern (eine neue Nachricht, ein neues Programm), ohne dass Leute ihren Fortschritt verlieren. Dateien, die das OS zum Funktionieren braucht, sind mit `"protected": true` markiert, damit ein enthusiastisches `rm -r /` nicht das ganze Betriebssystem frisst.
In `/home` liegt außerdem eine Datei namens `PASSWORDS`. Da steht das Skript vom Bee Movie drin.
# Fenster
Der Window-Manager ist ein Svelte Store, der Position, Größe und ob ein Fenster minimiert ist in den localStorage schreibt. Man kann also den Tab schließen, wiederkommen und alles liegt noch da wo es war.
Das eine Problem daran: eine Svelte-Komponente lässt sich nicht serialisieren. Beim Laden hat man also die Geometrie der Fenster, aber nicht mehr das, was drin war. Deswegen gibt es diesen Schritt:
```javascript
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;
}
});
};
```
Jedes Programm hat eine feste ID (`co1` fürs Terminal, `ex1` für den Explorer, `3d1` für den Viewer), und nach dem Reload wird über diese ID die Komponente wieder drangehängt.
# Das Luftschiff
Und jetzt der Teil, den mir Leute meistens nicht glauben: das Luftschiff gibt es wirklich.
Im OS liegt ein Programm namens `manual`, das ist die komplette Bauanleitung. Inklusive Einkaufsliste:
<ImageSlider title="Das Manual" client:load>
<Image src={Manual} alt="Das Airship Manual im OS" />
<Image src={ManualParts} alt="Die Einzelteile des Luftschiffs" />
</ImageSlider>
90 cm Folienballon, Raspberry Pi Zero, ein 3,7V LiPo, drei Propeller an zwei L9110s-Motortreibern, eine OV5647-Kamera, ein HC-SR04-Ultraschallsensor, ein ATtiny 85, Helium, Kabelbinder und Klettverschluss. Der Rest des Manuals erklärt, wie man sich per SSH auf das Ding verbindet und Updates deployed — und zwar mit `git push airship main`, weil auf dem Pi ein bare Repo mit Hook liegt. Wenn man in kein WLAN kommt, macht man einen Hotspot mit der SSID `isyncrasy-net` auf, dann verbindet es sich von selbst.
Der Controller im OS ist dann relativ unspektakulär: drei Slider für Geschwindigkeit, Richtung und Helligkeit, die über Socket.IO rausgehen, und ein `<img>`, in das der Kamerastream läuft.
```javascript
$: if (rawDirection !== rDirection || rawSpeed !== rSpeed || rawBrightness !== rBrightness) {
api.emit("airship.control", { speed, direction, brightness });
}
```
Das Detail, auf das ich am meisten stolz bin, ist aber was passiert wenn die Verbindung abreißt: statt einer Fehlermeldung friert das letzte Kamerabild ein und wird mit `glitch-canvas` langsam kaputtgerechnet. Ein abgestürztes Luftschiff soll sich nicht anfühlen wie ein HTTP-Timeout.
Der Ultraschallsensor und das Audio waren für den Teil gedacht, der im Konzept "Orientation via 8D-sounds" heißt — man sollte hören können, wo im Raum man ist. Das ist der Teil, der es am wenigsten weit geschafft hat.
# Die Karte
Damit man weiß wo das Luftschiff eigentlich langfliegt, gibt es einen Kartenmodus. Man lädt einen Grundriss als SVG hoch, der Server jagt ihn durch svgo und svgson und macht daraus eine Liste von Linien:
```typescript
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;
}
}
```
Im Browser wird dann jede Linie zu einer Box mit 3 Metern Höhe:
```javascript
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;
};
```
Man malt in Inkscape einen Grundriss und läuft ihn zwei Sekunden später in 3D ab. Sehr wenig Code für sehr viel Effekt, meine Lieblingssorte Feature.
# Das Backend
Die API ist Hono auf `node:sqlite`. Migrations sind ein Array von SQL-Strings, das nur wachsen darf, und wie weit man ist steht in SQLites eingebautem `user_version`:
```typescript
export const migrations: readonly string[] = [
// 1 — initial schema
`CREATE TABLE users ( ... );`,
];
```
Interessanter ist die Rechteverwaltung, weil sie zwei sehr unterschiedliche Sorten von Clients bedienen muss. Es gibt ein Permission-Objekt (`USER_CREATE`, `MAP_READ`, `VAR_UPDATE` und so weiter), Rollen bündeln Permissions, und der `Authorization`-Header kann beides sein:
```typescript
if (scheme === 'JWT' && token) {
// ein Mensch, der sich eingeloggt hat
} else if (scheme === 'Bearer' && token) {
// ein Access Key, also eine Maschine
}
```
`JWT` sind eingeloggte Menschen, `Bearer` sind Access Keys für Maschinen — zum Beispiel der Mail-Bot, der Nutzer anlegen können muss, ohne selbst einer zu sein. Access Keys kriegen eine explizite Liste von Permissions und sonst nichts.
Dazu kommt eine Tabelle namens `vars`, die einfach globale Variablen speichert. Das ist der Story-State: ich kann während einer laufenden Session im Admin eine Variable umstellen und damit ändern, was die Nachrichten im OS erzählen.
Das Admin-Interface selbst ist eine zweite kleine Svelte-App, im selben 98er-Look, nur mit der Wolken-Wallpaper statt der dunklen:
<Image src={AdminUsers} alt="Das Admin Interface" />
# Deployment
Das ist mittlerweile zweigeteilt. Das OS ist komplett statisch und wird per rclone über sftp hochgeladen, wobei die gehashten Assets in `_app` mit `--size-only` synchronisiert werden, weil sie sich per Definition nie ändern:
```bash
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 und Admin gehen zusammen in ein Docker-Image, das die API selbst ausliefert — die SQLite-Datei liegt auf einem Volume, tini kümmert sich darum, dass beim `SIGTERM` das Datenbank-Handle sauber zugeht. Und der Commit-Hash wird beim Build in `<html data-commit>` gestempelt, damit man einer deployten Seite ansehen kann, welcher Stand da eigentlich läuft.
# Der Rewrite
Das Projekt lag ein paar Jahre. Als ich es wieder angefasst habe, war es innerhalb von zwei Tagen ein anderes Projekt:
- Die Frontends von Svelte 3 auf Svelte 5 und Vite 8.
- Das Backend von MongoDB, Typegoose und restify auf `node:sqlite` und Hono. Fresh database, keine Migration — es gab nichts zu retten.
- `bcrypt` raus, `scrypt` aus `node:crypto` rein. Damit braucht das Docker-Image keine native Toolchain mehr.
- Der JWT-Algorithmus ist jetzt festgenagelt, damit ein gefälschter `alg`-Header die Prüfung nicht abschwächen kann.
- `strict`, `noUncheckedIndexedAccess` und `erasableSyntaxOnly` an. Letzteres ist der Grund, warum `Permissions` und `Role` normale Objekte sind und keine TypeScript-Enums: Node kann die Sourcen dann direkt ausführen und im Dev-Server läuft gar kein Buildstep mehr.
Ein paar Programme sind dabei auskommentiert liegengeblieben — `map`, `airship`, `sound` und `volume` stehen im Registry, aber mit einem `//` davor. Die brauchen Hardware, die gerade nicht in meinem Wohnzimmer schwebt.