# Eigene App bauen

Die App ist deine Webseite: im Look von Samy, mit samy.js und Anmeldung per Session-Token.

Eine App ist eine Webseite, die du selbst baust. Samy zeigt sie als Kachel im Tab **Apps** und öffnet sie unter dem Kopf der App, so breit wie der Bildschirm. Du hast alle Freiheit von HTML, und mit `samy.css` sieht deine Seite trotzdem aus wie Samy.

## Kanal oder App

| | [Kanal](https://packlets.app/docs/senden.md) | App |
|---|---|---|
| So geht es | Dein Tool schickt Nachrichten, Samy zeigt sie untereinander. | Die ganze App ist deine Webseite. |
| Du brauchst | nur HTTP-Aufrufe | einen Server, der die Seite über `https://` ausliefert |
| Gut für | Meldungen: ein Server fällt aus, ein Deploy schlägt fehl, eine Bestellung kommt rein | Arbeit mit Listen und Daten: Rechnungen freigeben, Bestellungen ansehen, Tickets beantworten |
| Aussehen | immer wie Samy | mit `samy.css` wie Samy, sonst frei |
| Bedienung kommt an als | [Webhook-Ereignis](https://packlets.app/docs/ereignisse.md) | normale Anfrage deiner Seite an deinen Server |

Fang mit einem Kanal an, wenn dein Tool sich vor allem melden soll. Auch dort gehen Knöpfe, Schalter, Diagramme und Tabellen, als [Elemente in Nachrichten](https://packlets.app/docs/nachrichten-elemente.md). Eine App lohnt sich, wenn du immer wieder etwas nachsehen oder abarbeiten willst.

Ideen, was eine App sein kann:

- **Rechnungen freigeben:** Jede offene Rechnung als Karte. Wisch nach rechts gibt sie frei, nach links lehnt sie ab.
- **Server und Deploys:** Status deiner Dienste, letzte Deploys, Knöpfe für Neustart und Rollback.
- **Freigaben:** Pull Requests oder Spesen ansehen und mit einem Tipp freigeben.
- **Support-Postfach:** Neue Anfragen lesen und kurz antworten.
- **Bestellungen:** Die Bestellungen deines kleinen Shops von heute, offen und versendet.

Wer Shopify kennt: Es ist dasselbe Prinzip wie bei eingebetteten Shopify-Apps mit App Bridge und Session-Token.

**Du brauchst:** einen Server mit `https`-Adresse, der deine Seite ausliefert.

## 1. Die Seite bauen

Eine normale HTML-Seite. Zwei Dateien von Samy machen sie zur App: `samy.css` für das Aussehen und `samy.js` für die Verbindung zur App.

```html
<!doctype html>
<html lang="de">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="https://packlets.app/embed/samy.css">
    <script src="https://packlets.app/embed/samy.js"></script>
  </head>
  <body>
    <main class="samy">
      <div class="samy-row samy-spread">
        <div>
          <p class="samy-label" id="firma">Rechnung</p>
          <p class="samy-big" id="betrag">–</p>
          <p class="samy-sub" id="info"></p>
        </div>
        <span class="samy-chip" id="offen"></span>
      </div>
      <div class="samy-row">
        <button class="samy-button is-secondary samy-grow" onclick="entscheide('ablehnen')">Ablehnen</button>
        <button class="samy-button samy-grow" onclick="entscheide('freigeben')">Freigeben</button>
      </div>
    </main>
    <script>
      let rechnung;
      async function naechste() {
        const res = await samy.fetch("/api/rechnungen/naechste");
        rechnung = await res.json();
        document.getElementById("firma").textContent = rechnung.firma;
        document.getElementById("betrag").textContent = rechnung.betrag;
        document.getElementById("info").textContent = `Fällig am ${rechnung.faellig} · ${rechnung.nummer}`;
        document.getElementById("offen").textContent = `${rechnung.offen} offen`;
      }
      async function entscheide(aktion) {
        const res = await samy.fetch(`/api/rechnungen/${rechnung.id}/${aktion}`, { method: "POST" });
        if (!res.ok) return samy.toast("Das hat nicht geklappt");
        samy.haptic("success");
        naechste();
      }
      naechste();
    </script>
  </body>
</html>
```

Die Klassen wie `samy-row` und `samy-button` kommen aus `samy.css`. Alle stehen in der [Referenz](https://packlets.app/docs/api-seiten.md#samy-css).

## 2. Die Seite zur App machen

Leg in Samy ein Packlet an und wähle **Eigene App bauen**. Dann schickt dein Tool einmal die Adresse deiner Seite:

```bash
curl -X PUT https://api.packlets.app/v1/ui \
  -H "Authorization: Bearer $SAMY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"page": "https://dein-server.de/rechnungen"}'
```

Antwort: `{"ok": true, "page": "https://dein-server.de/rechnungen"}`. Samy zeigt **Verbunden!**, und die Kachel steht im Tab **Apps**.

- Nur `https://`. Im Body steht nur `page`.
- Beim ersten Öffnen zeigt Samy die Adresse deiner Seite und fragt nach, bevor es sie lädt. Wechselst du später auf eine andere Adresse (anderer Host oder Port), fragt Samy erneut.
- Eine andere Adresse setzt du mit einem neuen `PUT`.
- Einmal reicht. Was die Seite zeigt, bestimmt danach dein Server.

## 3. Mit der App sprechen: samy.js

Über das Objekt `samy` nutzt deine Seite, was nur die App kann:

| Aufruf | Was passiert |
|---|---|
| `samy.toast("Gespeichert")` | Kurze Meldung in der App. |
| `samy.haptic("success")` | Vibration. |
| `samy.open("https://…")` | Öffnet eine Adresse im Browser. |
| `await samy.fetch(url, init)` | Wie `fetch()`, aber mit Anmeldung. Siehe nächster Schritt. |
| `samy.ready()` | Seite ist fertig, Samy blendet den Platzhalter aus (mit `data-ready="manual"`). |
| `samy.storage.set("ort", "Berlin")` | Speichert Text auf dem Gerät, getrennt nach Konto. |

Samy hält geöffnete Seiten im Speicher. Kommt jemand zurück, ist deine Seite sofort da, ohne neu zu laden, und bekommt das Ereignis `samy:activated`. Samys Farben stehen als CSS-Variablen bereit (`--samy-bg`, `--samy-text`, `--samy-cta` …).

Im normalen Browser gibt es die App nicht, und die Aufrufe schlagen fehl. Frag deshalb vorher `samy.inSamy` ab, wenn deine Seite auch dort laufen soll:

```html
<script>
  if (!samy.inSamy) document.body.textContent = "Öffne diese Seite in Samy.";
</script>
```

Alle Aufrufe stehen in der [Referenz](https://packlets.app/docs/api-seiten.md#samy-js).

## 4. Wissen, wer die Seite öffnet

Deine Seite ist öffentlich erreichbar. Zeigt sie private Daten, muss dein Server wissen, dass die Anfrage wirklich aus deiner App in Samy kommt. Ein eigenes Login brauchst du dafür nicht: Samy weiß schon, wer angemeldet ist, und bestätigt es deinem Server mit einem **Session-Token**.

1. Deine Seite ruft `samy.fetch()` auf. Das holt bei Samy ein frisches Token und schickt es als `Authorization: Bearer …` an deinen Server.
2. Dein Server prüft das Token. Stimmt es, weiß er, wer die Seite öffnet.

Das Token ist ein JWT, 60 Sekunden gültig und signiert mit einem Geheimnis, das nur du und Samy kennen: dem SHA-256-Hash deines Schlüssels. Geprüft wird auf deinem Server:

```python
import hashlib, os
import jwt  # pip install pyjwt

SECRET = hashlib.sha256(os.environ["SAMY_KEY"].encode()).hexdigest()
PACKLET_ID = os.environ["SAMY_PACKLET_ID"]

def samy_user(request):
    token = request.headers.get("Authorization", "").removeprefix("Bearer ")
    claims = jwt.decode(token, SECRET, algorithms=["HS256"], audience=PACKLET_ID)
    return {"id": claims["sub"], "email": claims.get("email"), "name": claims.get("name")}
```

```typescript
import { createHash } from "node:crypto";
import { jwtVerify } from "jose"; // npm install jose

const secret = new TextEncoder().encode(createHash("sha256").update(process.env.SAMY_KEY ?? "").digest("hex"));

export async function samyUser(authorization = "") {
  const token = authorization.replace(/^Bearer /, "");
  const { payload } = await jwtVerify(token, secret, { algorithms: ["HS256"], audience: process.env.SAMY_PACKLET_ID });
  return { id: payload.sub, email: payload.email as string | undefined, name: payload.name as string | undefined };
}
```

Schlägt die Prüfung fehl, werfen beide Funktionen einen Fehler. Antworte dann mit `401`.

- **`SAMY_PACKLET_ID`** ist die ID deines Packlets. Du bekommst sie in der Seite mit `(await samy.info()).packlet_id`, und sie steht in jedem Webhook-Ereignis als `packlet_id`.
- **Kein Cookie, keine Sitzung.** `samy.fetch()` holt für jede Anfrage ein frisches Token.
- **Ohne JWT-Bibliothek** kann dein Server das Token auch [von Samy prüfen lassen](https://packlets.app/docs/api-seiten.md#token-von-samy-pruefen-lassen).

> **Achtung:** Der Schlüssel gehört auf deinen Server, nie in die Seite. Was im HTML oder JavaScript steht, kann jeder lesen.

## 5. iframes und fremde Inhalte

Deine Seite selbst ist kein iframe: Samy zeigt sie in einem eigenen Browser in der App. Zwei Dinge solltest du trotzdem regeln.

**Fremde Inhalte in deiner Seite.** Bettest du ein iframe von einer anderen Adresse ein (Video, Karte, Widget), kann es `samy` nicht benutzen. Samy gibt der Seite beim Laden einen geheimen Brücken-Schlüssel, den nur deine Seite sieht, und ignoriert jeden Aufruf ohne ihn. Was das iframe sonst tut, entscheidest du:

```html
<iframe src="https://www.youtube-nocookie.com/embed/…"
        sandbox="allow-scripts allow-same-origin"
        referrerpolicy="no-referrer"></iframe>
```

- **`sandbox`** immer setzen und nur freigeben, was das iframe braucht. Ohne `allow-top-navigation` kann es deine Seite nicht wegschicken, ohne `allow-popups` keine Fenster öffnen.
- **Fremde Skripte** (Werbung, Tracking) direkt in deiner Seite laufen mit allen Rechten deiner Seite, auch mit `samy.fetch()`. Bind nur Skripte ein, denen du vertraust.
- **Inhalte von Nutzern** nie als HTML einsetzen (`innerHTML`), sondern als Text (`textContent`).

**Deine Seite in fremden Seiten.** Deine Adresse ist öffentlich. Eine andere Webseite könnte sie in ein iframe laden und Nutzer zu Klicks verleiten. Ein Token bekommt sie dort nicht, weil es Samy dort nicht gibt. Verbiete das Einbetten trotzdem mit einem Header deines Servers:

```http
Content-Security-Policy: frame-ancestors 'none'
X-Frame-Options: DENY
```

Samy stört das nicht, denn die App bettet deine Seite nicht in ein iframe ein.

## Damit es sich wie eine App anfühlt

- **Weißer Hintergrund**, kein eigener Rahmen und kein Schatten außen herum.
- **Kein eigener Kopf.** Name und Avatar zeigt Samy schon. Was wichtig ist, gehört nach oben.
- **Tippflächen mindestens 44 Pixel** hoch, Text mindestens 12 Pixel.
- **Kein Hover, keine Pop-ups**, kein `alert()`. Für Meldungen gibt es `samy.toast()`.
- **Links** auf deiner eigenen Adresse bleiben in der App. Links zu anderen Adressen öffnen sich im Browser.
- **Testen** kannst du im Browser in Handy-Breite, zum Beispiel mit 375 Pixeln in den Entwicklerwerkzeugen.

## Benachrichtigen

Eine App kann auch Nachrichten schicken, mit demselben [`POST /v1/push`](https://packlets.app/docs/api-push.md) wie ein Kanal. Die Nachricht kommt als Push, und die Kachel zeigt einen Zähler. So meldet sich zum Beispiel die Rechnungs-App, wenn neue Rechnungen warten. In der App selbst siehst du nur deine Seite, keinen Verlauf.

## Ausprobieren

Die beiden Seiten oben sind echte Apps:

- **Wetter** zeigt, wie frei du gestalten kannst. Jedes neue Konto hat sie in Samy unter **Apps**.
- **Konto** zeigt das Session-Token: Die Seite holt bei jeder Anfrage ein frisches Token, ihr Server prüft es und zeigt deinen Namen. Im Browser zeigt dieselbe Seite nur einen Hinweis, denn dort gibt es kein Token.

## Checkliste

- Adresse der Seite mit `PUT /v1/ui` und `{"page": "https://…"}` geschickt.
- `<meta name="viewport" content="width=device-width, initial-scale=1">` gesetzt.
- `samy.css` und `samy.js` eingebunden.
- Anfragen an deinen Server laufen über `samy.fetch()`.
- Dein Server prüft bei jeder Anfrage Signatur, Ablauf und `aud` des Tokens.
- Der Schlüssel steht nur auf dem Server.
- Fremde iframes mit `sandbox`, deine Seite mit `frame-ancestors 'none'` ausgeliefert.

## Wie es weitergeht

- Alle Aufrufe von `samy.js`, das Token und `samy.css`: [Referenz](https://packlets.app/docs/api-seiten.md).
- Feld, Antwort und Fehler von `PUT /v1/ui`: [PUT /v1/ui](https://packlets.app/docs/api-ui.md).
