Isyncrasy
Isyncrasy ist das Projekt, bei dem ich mich am schwersten tue zu erklären was es eigentlich ist.
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.
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:
Das erste Konzept
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 [email protected] 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:
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:
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.
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.
Boot
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 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:
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:
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:
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:
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.
Programme
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:
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:
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:
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:
Das Manual
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.
$: 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:
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:
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:
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:
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:
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:
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:sqliteund Hono. Fresh database, keine Migration — es gab nichts zu retten. bcryptraus,scryptausnode:cryptorein. 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,noUncheckedIndexedAccessunderasableSyntaxOnlyan. Letzteres ist der Grund, warumPermissionsundRolenormale 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.