feat: some updates
Deploy to SFTP Server / build (push) Successful in 30m25s

This commit is contained in:
max committed 2026-09-26 18:35:23 +02:00
1 parent 8e13077ada
commit f79308510b
7 files changed
+53 -314

No files matched your search

-1
View File
@@ -48,7 +48,6 @@ jobs:
- name: 🔑 Configure rclone
run: |
echo "$SSH_PRIVATE_KEY" > /tmp/id_rsa
cat /tmp/id_rsa
chmod 600 /tmp/id_rsa
mkdir -p ~/.config/rclone
echo -e "[sftp-remote]\ntype = sftp\nhost = ${SSH_HOST}\nuser = ${SSH_USER}\nport = ${SSH_PORT}\nkey_file = /tmp/id_rsa" > ~/.config/rclone/rclone.conf
+2 -1
View File
@@ -24,7 +24,8 @@ const paths = [
text: t("nav.photos"),
},
{
link: translatePath("/resources"),
// Resources are published from the notes, outside the i18n routes.
link: "/notes/resources/",
text: t("nav.resources"),
},
];
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+39 -312
View File
@@ -15,325 +15,52 @@ links:
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.
import Image from "@components/Image.astro";
import ImageGallery from "@components/ImageGallery.svelte";
import ImageSlider from "@components/ImageSlider.svelte";
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 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";
import Image from "@components/Image.astro"
import ImageGallery from "@components/ImageGallery.svelte"
import ImageSlider from "@components/ImageSlider.svelte"
> Note: Ich habe diesen Blogpost erst mit Claude geschrieben, habe mich aber jetzt dazu entschieden ihn noch einmal komplett von hand zu schreiben (inklusiv Rechtschreibfehlern)
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"
## Ursprung
<ImageGallery client:load/>
Isyncrasy ist aus einem Uni-Projekt 2021 entstanden für den Kurs [Project Make 2](https://coco.study/study/courses/project-make-2). Wir sollten ein Artefakt erstellen welches sich durch den Raum bewegt. Mein Team hat sich dafür entschieden einen Zeppelin zu bauen.
<Image src={Main} alt="Der Desktop von IsyncrasyOS" />
## Zeppelin
# Die Idee
Den Zeppelin bestand aus zwei großen Helium-Ballons, darunter war ein kleines 3D gedrucktes Gestell, welches 2 Propeller, eine Kamera und einen Raspberry Pi Zero trägt.
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:
Außerdem gibts es einen weiteren kleinen Microcontroller der per Attiny, einem ultraschall Distanz-Sensor und einem nach unten gerichteten Propeller den Abstand zum Boden kontrolliert.
<ImageSlider title="Das erste Konzept" client:load>
<Image src={Concept1} alt="Titelfolie: Idiosyncrasy.Inc" />
<Image src={Concept2} alt="Konzeptfolie: Idea" />
## Isyncrasy Group
Aus irgendeinem Grund hatte ich die Idee dem ganzen eine Story zu geben. Mich faszinieren ARGs schon länger, Overwatch hatte ein sehr cooles und ein eigenes ARG zu entwickeln ist schon seit langem ein Traum von mir.
Für die Story habe ich die Isyncrasy Group™️ erfunden, eine ominöse Cyber-Security Firma, welche dich einstellt um per Ferngesteuerten Zeppelin ein Büro zu überwachen.
Als Interface habe ich eine Art virtuelles OS entwickelt, also eine Website die sich wie ein Betriebssystem verhält.
## IsyncrasyOS
ISyncrasyOS ist eine Web-App welche sich wie ein Betriebssystem verhält. Es gibt ein persistentes Dateisystem, es gibt einen Terminal, ein Mail-Program und diverse ander Programme.
Es gibt sogar eine Boot-Sequenz die ich aus dem Pi-Zero welcher den Zeppelin kontrolliert kopiert habe.
Eines dieser Programme streamt zum Beispiel die WebCam des Zeppelins und erlaubte es dem User den Zeppelin zu steuern.
<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
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.
<Image src={Mail} alt="The Mail program" />