refactor: aufgeraeumt — Pakete hinten, Widgets vorne

Go: store, auth, weather und config sind eigene Pakete unter internal/. Vorher
lagen fuenf Dateien in einem Topf, und jede kannte jede.

Web: os.js hatte 1425 Zeilen und alles darin. Jetzt core.js (Zustand, Server,
Einstellungen), glyphs.js (die Pixelraster), js/widgets/ mit einem Widget je
Datei, os.js nur noch die Ansichten. Stylesheet nach css/.

Die HoneyBar bleibt eine einzelne Datei ohne Importe — sie wird von fremden
Seiten mit einer Zeile eingebunden, da darf nichts nachladen.

Nebenbei: Das gebaute Binary lag mit 9,8 MB im Repo und ist jetzt ignoriert.
Und der Test gegen eine zweite Versionsnummer geht nicht mehr auf os.js, sondern
durch alle .js — sonst haette ihn dieser Umbau still ausgehebelt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-29 20:51:21 +02:00
1 parent d08b0a229c
commit 52deffa30e
20 files changed
+780 -624

No files matched your search

+2
View File
@@ -1,3 +1,5 @@
# Gebautes Binary — gehört nicht ins Repo (lag dort 9,8 MB lang)
/honeyos
devdata/
.env
*.tmp
+1
View File
@@ -5,6 +5,7 @@ WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY *.go ./
COPY internal/ ./internal/
COPY web/ ./web/
RUN CGO_ENABLED=0 go build -buildvcs=false -ldflags="-s -w" -o /honeyos .
+22
View File
@@ -402,6 +402,28 @@ Wer die Felder in seiner `honeyos.json` noch stehen hat, braucht nichts zu tun
sie werden beim nächsten Speichern still verworfen.
## Wie das Projekt liegt
```
main.go Start, Routen, Handler
internal/
config/ was aus der Umgebung kommt
store/ die eine JSON-Datei: Apps und Nutzer
auth/ wer fragt da? (OIDC, Proxy, offen)
weather/ Wetter holen und zehn Minuten behalten
web/
index.html
css/style.css
js/core.js Zustand, Server, Einstellungen, Kleinkram
js/glyphs.js die Pixelsymbole
js/os.js die beiden Ansichten, Fenster, Dialoge
js/widgets/ ein Widget je Datei
honeybar/ die HoneyBar für fremde Seiten — bewusst eine Datei
```
Die HoneyBar bleibt absichtlich **eine** Datei ohne Importe: Sie wird von
fremden Seiten mit einer Zeile eingebunden, da darf nichts nachladen.
## Entwickeln
```bash
+49 -34
View File
@@ -1,6 +1,11 @@
package main
import (
"honeyos/internal/auth"
"honeyos/internal/config"
"honeyos/internal/store"
"io/fs"
"crypto"
"crypto/rand"
"crypto/rsa"
@@ -98,18 +103,18 @@ func (f *fakeIdP) idToken(t *testing.T) string {
func newTestServer(t *testing.T, issuer string) *Server {
t.Helper()
store, err := OpenStore(dataPath(t.TempDir()))
st, err := store.Open(store.DataPath(t.TempDir()))
if err != nil {
t.Fatal(err)
}
cfg := Config{
cfg := config.Config{
PublicURL: "https://os.example.test",
SessionSecret: strings.Repeat("k", 40),
OIDCIssuer: issuer,
OIDCClientID: "honeyos",
OIDCClientSecret: "geheim",
}
return &Server{cfg: cfg, auth: NewAuth(cfg), store: store}
return &Server{cfg: cfg, auth: auth.New(cfg), store: st}
}
// TestAnmeldeablauf geht den Weg einmal ganz durch: Umleitung zum Anbieter,
@@ -140,7 +145,7 @@ func TestAnmeldeablauf(t *testing.T) {
var stateCk *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == stateCookie {
if c.Name == auth.StateCookie {
stateCk = c
}
}
@@ -163,9 +168,9 @@ func TestAnmeldeablauf(t *testing.T) {
var sess, csrf *http.Cookie
for _, c := range rec.Result().Cookies() {
switch c.Name {
case sessionCookie:
case auth.SessionCookie:
sess = c
case csrfCookie:
case auth.CSRFCookie:
csrf = c
}
}
@@ -215,7 +220,7 @@ func TestGefälschtesCookieZähltNicht(t *testing.T) {
h := newTestServer(t, idp.URL).routes()
req := httptest.NewRequest("GET", "/api/me", nil)
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "c3ViLTk5OQ.9999999999.gefälscht"})
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: "c3ViLTk5OQ.9999999999.gefälscht"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
@@ -315,7 +320,7 @@ func TestOffenerBetrieb(t *testing.T) {
if rec.Code != http.StatusNoContent {
t.Fatalf("Schreiben im offenen Betrieb: %d", rec.Code)
}
if s.store.User(localSub).Settings["phosphor"] != "green" {
if s.store.User(store.LocalSub).Settings["phosphor"] != "green" {
t.Error("Einstellung im offenen Betrieb nicht gespeichert")
}
}
@@ -326,7 +331,7 @@ func TestLeisteNurFürEingetrageneApps(t *testing.T) {
idp := newFakeIdP(t, "honeyos")
s := newTestServer(t, idp.URL)
h := s.routes()
if err := s.store.SetApps([]App{{ID: "aether", Name: "AETHER", URL: "https://cloud.example.test/"}}); err != nil {
if err := s.store.SetApps([]store.App{{ID: "aether", Name: "AETHER", URL: "https://cloud.example.test/"}}); err != nil {
t.Fatal(err)
}
@@ -361,7 +366,7 @@ func signIn(t *testing.T, h http.Handler) (sess, csrf *http.Cookie) {
loc, _ := url.Parse(rec.Header().Get("Location"))
var stateCk *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == stateCookie {
if c.Name == auth.StateCookie {
stateCk = c
}
}
@@ -375,9 +380,9 @@ func signIn(t *testing.T, h http.Handler) (sess, csrf *http.Cookie) {
h.ServeHTTP(rec, req)
for _, c := range rec.Result().Cookies() {
switch c.Name {
case sessionCookie:
case auth.SessionCookie:
sess = c
case csrfCookie:
case auth.CSRFCookie:
csrf = c
}
}
@@ -390,18 +395,18 @@ func signIn(t *testing.T, h http.Handler) (sess, csrf *http.Cookie) {
// ── Betrieb hinter einem Anmelde-Proxy ─────────────────────────────────────
// Kein eigener Anmeldeschirm: Pangolin o. ä. hat die Tür schon zugemacht.
func proxyServer(t *testing.T, cfg Config) *Server {
func proxyServer(t *testing.T, cfg config.Config) *Server {
t.Helper()
store, err := OpenStore(dataPath(t.TempDir()))
st, err := store.Open(store.DataPath(t.TempDir()))
if err != nil {
t.Fatal(err)
}
cfg.SessionSecret = strings.Repeat("k", 40)
return &Server{cfg: cfg, auth: NewAuth(cfg), store: store}
return &Server{cfg: cfg, auth: auth.New(cfg), store: st}
}
func TestProxyBetriebLässtDurch(t *testing.T) {
h := proxyServer(t, Config{ProxyAuth: true}).routes()
h := proxyServer(t, config.Config{ProxyAuth: true}).routes()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/me", nil))
@@ -420,7 +425,7 @@ func TestProxyBetriebLässtDurch(t *testing.T) {
// Nennt der Proxy einen Namen, bekommt jeder Name seine eigenen Einstellungen.
func TestProxyKopfzeileTrenntEinstellungen(t *testing.T) {
s := proxyServer(t, Config{ProxyAuth: true, UserHeader: "Remote-User"})
s := proxyServer(t, config.Config{ProxyAuth: true, UserHeader: "Remote-User"})
h := s.routes()
put := func(user, body string) {
@@ -451,7 +456,7 @@ func TestAppListeBrauchtDenSchlüssel(t *testing.T) {
idp := newFakeIdP(t, "honeyos")
s := newTestServer(t, idp.URL)
s.cfg.AppsKey = "geheim123"
s.auth = NewAuth(s.cfg)
s.auth = auth.New(s.cfg)
h := s.routes()
get := func(mod func(*http.Request)) int {
@@ -483,7 +488,7 @@ func TestSchlüsselDarfNichtSchreiben(t *testing.T) {
idp := newFakeIdP(t, "honeyos")
s := newTestServer(t, idp.URL)
s.cfg.AppsKey = "geheim123"
s.auth = NewAuth(s.cfg)
s.auth = auth.New(s.cfg)
h := s.routes()
req := httptest.NewRequest("PUT", "/api/apps", strings.NewReader(`[{"id":"x","url":"https://a.test"}]`))
@@ -497,7 +502,7 @@ func TestSchlüsselDarfNichtSchreiben(t *testing.T) {
// Die Oberfläche muss den Schlüssel mitbekommen, sonst fragt sie sich selbst aus.
func TestStartseiteTrägtDenSchlüssel(t *testing.T) {
s := proxyServer(t, Config{ProxyAuth: true, AppsKey: "geheim123"})
s := proxyServer(t, config.Config{ProxyAuth: true, AppsKey: "geheim123"})
rec := httptest.NewRecorder()
s.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
@@ -506,7 +511,7 @@ func TestStartseiteTrägtDenSchlüssel(t *testing.T) {
}
// Ohne Schlüssel darf auch nichts drinstehen.
s2 := proxyServer(t, Config{ProxyAuth: true})
s2 := proxyServer(t, config.Config{ProxyAuth: true})
rec = httptest.NewRecorder()
s2.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
if strings.Contains(rec.Body.String(), "honeyos-key") {
@@ -518,7 +523,7 @@ func TestStartseiteTrägtDenSchlüssel(t *testing.T) {
// jede Anfrage als angemeldet. Der Schlüssel darf dann NICHT auf diese Prüfung
// zurückfallen, sonst gibt der freigegebene Pfad die Liste an jeden heraus.
func TestSchlüsselGiltAuchHinterDemProxy(t *testing.T) {
h := proxyServer(t, Config{ProxyAuth: true, AppsKey: "geheim123"}).routes()
h := proxyServer(t, config.Config{ProxyAuth: true, AppsKey: "geheim123"}).routes()
get := func(key string) int {
req := httptest.NewRequest("GET", "/api/apps", nil)
@@ -543,11 +548,11 @@ func TestSchlüsselGiltAuchHinterDemProxy(t *testing.T) {
// Die Leiste stellt genau eine Anfrage — sie muss Liste und Aussehen bringen.
func TestAppListeBringtDasAussehenMit(t *testing.T) {
s := proxyServer(t, Config{ProxyAuth: true, AppsKey: "geheim123"})
if err := s.store.SetSettings(localSub, map[string]string{"phosphor": "green", "glow": "off"}); err != nil {
s := proxyServer(t, config.Config{ProxyAuth: true, AppsKey: "geheim123"})
if err := s.store.SetSettings(store.LocalSub, map[string]string{"phosphor": "green", "glow": "off"}); err != nil {
t.Fatal(err)
}
if err := s.store.SetApps([]App{{ID: "aether", Name: "AETHER", URL: "https://cloud.example.test"}}); err != nil {
if err := s.store.SetApps([]store.App{{ID: "aether", Name: "AETHER", URL: "https://cloud.example.test"}}); err != nil {
t.Fatal(err)
}
@@ -557,7 +562,7 @@ func TestAppListeBringtDasAussehenMit(t *testing.T) {
s.routes().ServeHTTP(rec, req)
var got struct {
Apps []App
Apps []store.App
Settings map[string]string
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
@@ -574,7 +579,7 @@ func TestAppListeBringtDasAussehenMit(t *testing.T) {
// Die angezeigte Version muss vom Server kommen. Vorher stand sie zusätzlich in
// os.js und blieb bei 0.2 stehen, während die Images schon 0.4 hiessen.
func TestVersionKommtVomServer(t *testing.T) {
h := proxyServer(t, Config{ProxyAuth: true}).routes()
h := proxyServer(t, config.Config{ProxyAuth: true}).routes()
for _, pfad := range []string{"/api/public", "/api/me"} {
rec := httptest.NewRecorder()
@@ -586,14 +591,24 @@ func TestVersionKommtVomServer(t *testing.T) {
}
}
// Und in der Oberfläche darf keine zweite fest eingetragen sein.
raw, err := webFS.ReadFile("web/os.js")
// Und in der Oberfläche darf keine zweite fest eingetragen sein — in keiner
// Datei, egal wie die Oberfläche später aufgeteilt wird.
err := fs.WalkDir(webFS, ".", func(pfad string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(pfad, ".js") {
return err
}
raw, err := webFS.ReadFile(pfad)
if err != nil {
return err
}
if strings.Contains(string(raw), `const VERSION = "`) {
t.Errorf("%s trägt wieder eine eigene Version — die läuft mit den Image-Tags auseinander", pfad)
}
return nil
})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), `const VERSION = "`) {
t.Error("os.js trägt wieder eine eigene Version — die läuft mit den Image-Tags auseinander")
}
}
// Der Fall, nach dem Marc gefragt hat: Einstellungen am Rechner setzen, und die
@@ -601,7 +616,7 @@ func TestVersionKommtVomServer(t *testing.T) {
// also beim Standardnutzer — die Einstellungen können aber unter einem Namen
// aus der Proxy-Kopfzeile liegen.
func TestLeisteFindetDasAussehenAuchÜberDieSiteGrenze(t *testing.T) {
s := proxyServer(t, Config{ProxyAuth: true, UserHeader: "Remote-User", AppsKey: "geheim123"})
s := proxyServer(t, config.Config{ProxyAuth: true, UserHeader: "Remote-User", AppsKey: "geheim123"})
h := s.routes()
// Am Rechner, durch den Proxy: grün.
@@ -628,7 +643,7 @@ func TestLeisteFindetDasAussehenAuchÜberDieSiteGrenze(t *testing.T) {
// Bei mehreren Nutzern darf nicht geraten werden, wessen Farbe gilt.
func TestBeiMehrerenNutzernWirdNichtGeraten(t *testing.T) {
s := proxyServer(t, Config{ProxyAuth: true, UserHeader: "Remote-User", AppsKey: "geheim123"})
s := proxyServer(t, config.Config{ProxyAuth: true, UserHeader: "Remote-User", AppsKey: "geheim123"})
for name, farbe := range map[string]string{"marc": "green", "gast": "ice"} {
if err := s.store.SetSettings("proxy:"+name, map[string]string{"phosphor": farbe}); err != nil {
t.Fatal(err)
BIN
View File
Binary file not shown.
+31 -21
View File
@@ -1,6 +1,13 @@
package main
// Package auth beantwortet eine einzige Frage: Wer fragt da?
//
// Drei Antworten sind möglich — der eigene OIDC-Anbieter, ein vorgelagerter
// Proxy, oder niemand (offener Betrieb).
package auth
import (
"honeyos/internal/config"
"honeyos/internal/store"
"context"
"crypto/hmac"
"crypto/rand"
@@ -25,9 +32,11 @@ import (
// nichts zu verlieren, ein Neustart wirft niemanden hinaus.
const (
sessionCookie = "honeyos_session"
csrfCookie = "honeyos_csrf"
stateCookie = "honeyos_state"
// Exportiert, weil die Server-Tests im Wurzelpaket dieselben Namen brauchen
// — sonst stünden die Zeichenketten zweimal da und liefen auseinander.
SessionCookie = "honeyos_session"
CSRFCookie = "honeyos_csrf"
StateCookie = "honeyos_state"
sessionTTL = 30 * 24 * time.Hour
)
@@ -38,7 +47,7 @@ type Identity struct {
}
type Auth struct {
cfg Config
cfg config.Config
secret []byte
once sync.Once
@@ -48,7 +57,7 @@ type Auth struct {
verifier *gooidc.IDTokenVerifier
}
func NewAuth(cfg Config) *Auth {
func New(cfg config.Config) *Auth {
return &Auth{cfg: cfg, secret: []byte(cfg.SessionSecret)}
}
@@ -95,17 +104,17 @@ func (a *Auth) sign(payload string) string {
func (a *Auth) issueSession(w http.ResponseWriter, r *http.Request, sub string) {
payload := base64.RawURLEncoding.EncodeToString([]byte(sub)) + "." +
strconv.FormatInt(time.Now().Add(sessionTTL).Unix(), 10)
setCookie(w, r, sessionCookie, payload+"."+a.sign(payload), int(sessionTTL.Seconds()), true)
setCookie(w, r, SessionCookie, payload+"."+a.sign(payload), int(sessionTTL.Seconds()), true)
// Zweites Cookie, absichtlich lesbar: der Browser schickt es als Kopfzeile
// zurück, und nur wer die Seite wirklich geöffnet hat, kann das.
tok := randomString(24)
setCookie(w, r, csrfCookie, tok, int(sessionTTL.Seconds()), false)
setCookie(w, r, CSRFCookie, tok, int(sessionTTL.Seconds()), false)
}
func (a *Auth) clearSession(w http.ResponseWriter, r *http.Request) {
setCookie(w, r, sessionCookie, "", -1, true)
setCookie(w, r, csrfCookie, "", -1, false)
func (a *Auth) ClearSession(w http.ResponseWriter, r *http.Request) {
setCookie(w, r, SessionCookie, "", -1, true)
setCookie(w, r, CSRFCookie, "", -1, false)
}
// SubFromRequest liest die Sitzung. Zweiter Rückgabewert sagt, ob sie gilt.
@@ -120,9 +129,9 @@ func (a *Auth) SubFromRequest(r *http.Request) (string, bool) {
return "proxy:" + u, true
}
}
return localSub, true
return store.LocalSub, true
}
c, err := r.Cookie(sessionCookie)
c, err := r.Cookie(SessionCookie)
if err != nil {
return "", false
}
@@ -145,11 +154,12 @@ func (a *Auth) SubFromRequest(r *http.Request) (string, bool) {
return string(sub), true
}
func (a *Auth) checkCSRF(r *http.Request) error {
// CheckCSRF prüft das doppelt gesendete Prüfwort. Nötig bei allem, was schreibt.
func (a *Auth) CheckCSRF(r *http.Request) error {
if !a.Enabled() {
return nil
}
c, err := r.Cookie(csrfCookie)
c, err := r.Cookie(CSRFCookie)
if err != nil || c.Value == "" {
return errors.New("kein CSRF-Cookie")
}
@@ -172,14 +182,14 @@ func (a *Auth) Login(w http.ResponseWriter, r *http.Request) {
verifier := oauth2.GenerateVerifier()
// State und Prüfwort reisen zusammen im selben Cookie — sie gehören zum
// selben Versuch und laufen zusammen ab.
setCookie(w, r, stateCookie, state+"|"+verifier, 600, true)
setCookie(w, r, StateCookie, state+"|"+verifier, 600, true)
http.Redirect(w, r, a.oauth.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier)), http.StatusFound)
}
func (a *Auth) Callback(w http.ResponseWriter, r *http.Request, store *Store) {
func (a *Auth) Callback(w http.ResponseWriter, r *http.Request, st *store.Store) {
fail := func(reason string) {
setCookie(w, r, stateCookie, "", -1, true)
setCookie(w, r, StateCookie, "", -1, true)
http.Redirect(w, r, "/?fehler="+reason, http.StatusFound)
}
@@ -189,7 +199,7 @@ func (a *Auth) Callback(w http.ResponseWriter, r *http.Request, store *Store) {
return
}
c, err := r.Cookie(stateCookie)
c, err := r.Cookie(StateCookie)
if err != nil {
fail("abgelaufen")
return
@@ -199,7 +209,7 @@ func (a *Auth) Callback(w http.ResponseWriter, r *http.Request, store *Store) {
fail("state")
return
}
setCookie(w, r, stateCookie, "", -1, true)
setCookie(w, r, StateCookie, "", -1, true)
code := r.URL.Query().Get("code")
if code == "" {
@@ -243,7 +253,7 @@ func (a *Auth) Callback(w http.ResponseWriter, r *http.Request, store *Store) {
return
}
if err := store.SeenUser(claims.Sub, name, claims.Picture); err != nil {
if err := st.SeenUser(claims.Sub, name, claims.Picture); err != nil {
log.Printf("konnte Nutzer nicht merken: %v", err)
}
a.issueSession(w, r, claims.Sub)
+55
View File
@@ -0,0 +1,55 @@
// Package config hält, was HoneyOS beim Start aus der Umgebung liest.
//
// Eigenes Paket, damit sowohl der Server als auch die Anmeldung darauf zugreifen
// können, ohne dass eines vom anderen abhängt.
package config
import (
"os"
"strings"
)
type Config struct {
Addr string
PublicURL string
DataDir string
SessionSecret string
// Anmeldung — genau einer der beiden Wege:
//
// OIDCIssuer gesetzt → HoneyOS meldet selbst an, eigener Anmeldeschirm.
// ProxyAuth gesetzt → ein vorgelagerter Proxy (Pangolin o. ä.) hat das
// schon erledigt. HoneyOS fragt dann nicht nach.
//
// Keines von beiden heißt: offen. Das ist zum Entwickeln gedacht und sagt
// beim Start deutlich, dass es so nicht ins Netz gehört.
OIDCIssuer string
OIDCClientID string
OIDCClientSecret string
OIDCRedirect string
ButtonLabel string
AllowedUsers []string
ProxyAuth bool
// UserHeader nennt die Kopfzeile, in die der Proxy den Nutzernamen schreibt
// (z. B. Remote-User). Leer heißt: ein einziger Nutzer für alle.
UserHeader string
// AppsKey schützt die App-Liste, wenn ihr Pfad im Proxy von der Anmeldung
// befreit ist. Ohne Schlüssel liest sie jeder, der die Adresse kennt.
AppsKey string
}
func (c Config) RedirectURL() string {
if c.OIDCRedirect != "" {
return c.OIDCRedirect
}
return strings.TrimRight(c.PublicURL, "/") + "/api/auth/callback"
}
func Env(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return fallback
}
+10 -3
View File
@@ -1,4 +1,6 @@
package main
// Package store hält den ganzen Zustand von HoneyOS: die App-Liste und je
// Nutzer ein paar Einstellungen.
package store
import (
"encoding/json"
@@ -17,6 +19,10 @@ import (
// mit einem Editor reparieren. Geschrieben wird atomar (tmp + rename), damit
// ein Absturz mitten im Schreiben keine halbe Datei hinterlässt.
// LocalSub ist die ID des einen Nutzers im offenen Betrieb — dann meldet sich
// niemand an, und alles gehört ihm.
const LocalSub = "local"
type App struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -55,7 +61,7 @@ var (
settingKeyRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]{0,31}$`)
)
func OpenStore(path string) (*Store, error) {
func Open(path string) (*Store, error) {
s := &Store{path: path, d: data{Apps: []App{}, Users: map[string]*User{}}}
raw, err := os.ReadFile(path)
@@ -253,4 +259,5 @@ func (s *Store) AppOrigins() map[string]bool {
return out
}
func dataPath(dir string) string { return filepath.Join(dir, "honeyos.json") }
// DataPath nennt die eine Datei, in der alles steht.
func DataPath(dir string) string { return filepath.Join(dir, "honeyos.json") }
+37
View File
@@ -0,0 +1,37 @@
package weather
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// FakeFuerTest gibt einen Dienst, der einen erfundenen Wetterserver befragt.
//
// Steht bewusst nicht in der _test.go: Auch die Server-Tests im Wurzelpaket
// brauchen ihn, und kein Test in diesem Projekt fragt je wirklich bei
// Open-Meteo an — das wäre langsam, unzuverlässig und benutzte einen fremden
// Dienst für unsere Testläufe.
func FakeFuerTest(t *testing.T, code int, grad float64) *Dienst {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "search") {
json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{
{"name": "Köln", "country": "Deutschland", "admin1": "Nordrhein-Westfalen",
"latitude": 50.938, "longitude": 6.96},
}})
return
}
json.NewEncoder(w).Encode(map[string]any{"current": map[string]any{
"temperature_2m": grad, "weather_code": code,
}})
}))
t.Cleanup(srv.Close)
d := Neu()
d.wetterURL = srv.URL + "/forecast"
d.sucheURL = srv.URL + "/search"
return d
}
+12 -10
View File
@@ -1,4 +1,6 @@
package main
// Package weather holt das Wetter — serverseitig, damit nicht jede Seite in
// jeder App selbst beim Wetterdienst anfragt.
package weather
import (
"context"
@@ -37,13 +39,13 @@ type Wetter struct {
}
type Ort struct {
Name string `json:"name"`
Land string `json:"land"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Name string `json:"name"`
Land string `json:"land"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
type wetterDienst struct {
type Dienst struct {
mu sync.Mutex
cache map[string]wetterEintrag
client *http.Client
@@ -58,8 +60,8 @@ type wetterEintrag struct {
bis time.Time
}
func neuerWetterdienst() *wetterDienst {
return &wetterDienst{
func Neu() *Dienst {
return &Dienst{
cache: map[string]wetterEintrag{},
client: &http.Client{Timeout: wetterTimeout},
wetterURL: "https://api.open-meteo.com/v1/forecast",
@@ -95,7 +97,7 @@ func wmo(code int) (glyph, text string) {
// frisch ist. Das Wetter ändert sich langsamer, als Seiten neu geladen werden;
// ohne diesen Speicher stellte jeder Seitenaufruf in jeder App eine Anfrage
// nach draußen.
func (d *wetterDienst) Jetzt(ctx context.Context, ort string, lat, lon float64) (Wetter, error) {
func (d *Dienst) Jetzt(ctx context.Context, ort string, lat, lon float64) (Wetter, error) {
schlüssel := fmt.Sprintf("%.3f,%.3f", lat, lon)
d.mu.Lock()
@@ -156,7 +158,7 @@ func (d *wetterDienst) Jetzt(ctx context.Context, ort string, lat, lon float64)
// Suche findet Orte zu einem Namen. Nur für die Einstellungen: Der gefundene
// Ort wird mit seinen Koordinaten gespeichert, damit die spätere Abfrage nicht
// jedes Mal erneut sucht.
func (d *wetterDienst) Suche(ctx context.Context, name string) ([]Ort, error) {
func (d *Dienst) Suche(ctx context.Context, name string) ([]Ort, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("kein Suchbegriff")
@@ -1,4 +1,4 @@
package main
package weather
import (
"context"
@@ -13,7 +13,7 @@ import (
// Ein erfundener Wetterdienst. Kein Test in diesem Projekt fragt je wirklich
// bei Open-Meteo an: Das wäre langsam, unzuverlässig und würde einen fremden
// Dienst für unsere Testläufe benutzen.
func fakeWetter(t *testing.T, code int, grad float64, aufrufe *int32) *wetterDienst {
func fakeWetter(t *testing.T, code int, grad float64, aufrufe *int32) *Dienst {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if aufrufe != nil {
@@ -32,7 +32,7 @@ func fakeWetter(t *testing.T, code int, grad float64, aufrufe *int32) *wetterDie
}))
t.Cleanup(srv.Close)
d := neuerWetterdienst()
d := Neu()
d.wetterURL = srv.URL + "/forecast"
d.sucheURL = srv.URL + "/search"
return d
@@ -114,37 +114,3 @@ func TestSucheOhneBegriffFragtNichtAn(t *testing.T) {
t.Fatalf("leerer Begriff ging trotzdem nach draußen (%d)", aufrufe)
}
}
// Ohne eingestellten Ort ist 204 die richtige Antwort: kein Fehler, nur nichts
// zu zeigen. Die Leiste lässt das Symbol dann weg, statt etwas zu melden.
func TestOhneOrtGibtEsKeinWetter(t *testing.T) {
s := newTestServer(t, "") // ohne Anbieter: offener Betrieb, kein Anmelden nötig
s.wetter = fakeWetter(t, 0, 20, nil)
rec := httptest.NewRecorder()
s.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/api/weather", nil))
if rec.Code != http.StatusNoContent {
t.Fatalf("ohne Ort erwartet 204, bekam %d", rec.Code)
}
}
func TestMitOrtKommtDasWetter(t *testing.T) {
s := newTestServer(t, "")
s.wetter = fakeWetter(t, 3, 8.6, nil)
if err := s.store.SetSettings(localSub, map[string]string{
"weatherCity": "Köln", "weatherLat": "50.938", "weatherLon": "6.96",
}); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
s.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/api/weather", nil))
if rec.Code != http.StatusOK {
t.Fatalf("erwartet 200, bekam %d: %s", rec.Code, rec.Body)
}
var w Wetter
json.Unmarshal(rec.Body.Bytes(), &w)
if w.Ort != "Köln" || w.Glyph != "wolke" {
t.Fatalf("falsches Wetter: %+v", w)
}
}
+32 -74
View File
@@ -6,6 +6,12 @@
package main
import (
"cmp"
"honeyos/internal/auth"
"honeyos/internal/config"
"honeyos/internal/store"
"honeyos/internal/weather"
"bytes"
"crypto/subtle"
"embed"
@@ -22,76 +28,35 @@ import (
//go:embed all:web
var webFS embed.FS
// localSub ist die Kennung im offenen Betrieb — dann gibt es genau einen Nutzer.
const localSub = "local"
// Version ist die einzige Stelle, an der sie steht. build.sh liest sie hier aus
// und vergibt denselben Docker-Tag; die Oberfläche holt sie über /api/public.
// Vorher stand sie zusätzlich in os.js und lief mit den Image-Tags auseinander.
const Version = "1.0"
type Config struct {
Addr string
PublicURL string
DataDir string
SessionSecret string
// Anmeldung — genau einer der beiden Wege:
//
// OIDCIssuer gesetzt → HoneyOS meldet selbst an, eigener Anmeldeschirm.
// ProxyAuth gesetzt → ein vorgelagerter Proxy (Pangolin o. ä.) hat das
// schon erledigt. HoneyOS fragt dann nicht nach.
//
// Keines von beiden heißt: offen. Das ist zum Entwickeln gedacht und sagt
// beim Start deutlich, dass es so nicht ins Netz gehört.
OIDCIssuer string
OIDCClientID string
OIDCClientSecret string
OIDCRedirect string
ButtonLabel string
AllowedUsers []string
ProxyAuth bool
// UserHeader nennt die Kopfzeile, in die der Proxy den Nutzernamen schreibt
// (z. B. Remote-User). Leer heißt: ein einziger Nutzer für alle.
UserHeader string
// AppsKey schützt die App-Liste, wenn ihr Pfad im Proxy von der Anmeldung
// befreit ist. Ohne Schlüssel liest sie jeder, der die Adresse kennt.
AppsKey string
}
func (c Config) RedirectURL() string {
if c.OIDCRedirect != "" {
return c.OIDCRedirect
}
return strings.TrimRight(c.PublicURL, "/") + "/api/auth/callback"
}
type Server struct {
cfg Config
auth *Auth
store *Store
wetter *wetterDienst
cfg config.Config
auth *auth.Auth
store *store.Store
wetter *weather.Dienst
}
func main() {
log.SetFlags(log.LstdFlags)
cfg := Config{
Addr: env("HONEYOS_ADDR", ":80"),
PublicURL: env("HONEYOS_PUBLIC_URL", ""),
DataDir: env("HONEYOS_DATA_DIR", "/data"),
SessionSecret: env("HONEYOS_SESSION_SECRET", ""),
OIDCIssuer: strings.TrimRight(env("HONEYOS_OIDC_ISSUER", ""), "/"),
OIDCClientID: env("HONEYOS_OIDC_CLIENT_ID", ""),
OIDCClientSecret: env("HONEYOS_OIDC_CLIENT_SECRET", ""),
OIDCRedirect: env("HONEYOS_OIDC_REDIRECT_URL", ""),
ButtonLabel: env("HONEYOS_BUTTON_LABEL", "Anmelden"),
AllowedUsers: splitList(env("HONEYOS_ALLOWED_USERS", "")),
ProxyAuth: env("HONEYOS_PROXY_AUTH", "") == "true",
UserHeader: env("HONEYOS_USER_HEADER", ""),
AppsKey: env("HONEYOS_APPS_KEY", ""),
cfg := config.Config{
Addr: config.Env("HONEYOS_ADDR", ":80"),
PublicURL: config.Env("HONEYOS_PUBLIC_URL", ""),
DataDir: config.Env("HONEYOS_DATA_DIR", "/data"),
SessionSecret: config.Env("HONEYOS_SESSION_SECRET", ""),
OIDCIssuer: strings.TrimRight(config.Env("HONEYOS_OIDC_ISSUER", ""), "/"),
OIDCClientID: config.Env("HONEYOS_OIDC_CLIENT_ID", ""),
OIDCClientSecret: config.Env("HONEYOS_OIDC_CLIENT_SECRET", ""),
OIDCRedirect: config.Env("HONEYOS_OIDC_REDIRECT_URL", ""),
ButtonLabel: config.Env("HONEYOS_BUTTON_LABEL", "Anmelden"),
AllowedUsers: splitList(config.Env("HONEYOS_ALLOWED_USERS", "")),
ProxyAuth: config.Env("HONEYOS_PROXY_AUTH", "") == "true",
UserHeader: config.Env("HONEYOS_USER_HEADER", ""),
AppsKey: config.Env("HONEYOS_APPS_KEY", ""),
}
if cfg.OIDCIssuer != "" {
@@ -125,12 +90,12 @@ func main() {
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
log.Fatalf("Datenverzeichnis %s: %v", cfg.DataDir, err)
}
store, err := OpenStore(dataPath(cfg.DataDir))
st, err := store.Open(store.DataPath(cfg.DataDir))
if err != nil {
log.Fatal(err)
}
s := &Server{cfg: cfg, auth: NewAuth(cfg), store: store, wetter: neuerWetterdienst()}
s := &Server{cfg: cfg, auth: auth.New(cfg), store: st, wetter: weather.Neu()}
srv := &http.Server{
Addr: cfg.Addr,
@@ -139,7 +104,7 @@ func main() {
IdleTimeout: 60 * time.Second,
}
log.Printf("HoneyOS %s lauscht auf %s (Daten: %s, Anmeldung: %s)",
Version, cfg.Addr, dataPath(cfg.DataDir), map[bool]string{true: cfg.OIDCIssuer, false: "aus"}[s.auth.Enabled()])
Version, cfg.Addr, store.DataPath(cfg.DataDir), map[bool]string{true: cfg.OIDCIssuer, false: "aus"}[s.auth.Enabled()])
log.Fatal(srv.ListenAndServe())
}
@@ -265,7 +230,7 @@ func (s *Server) appsReadable(next func(http.ResponseWriter, *http.Request, stri
given = r.URL.Query().Get("key")
}
if subtle.ConstantTimeCompare([]byte(given), []byte(key)) == 1 {
next(w, r, localSub)
next(w, r, store.LocalSub)
return
}
// Ohne eigene Anmeldung gibt es keine Sitzung, die den Schlüssel
@@ -288,7 +253,7 @@ func (s *Server) requireAuth(next func(http.ResponseWriter, *http.Request, strin
return
}
if r.Method != http.MethodGet {
if err := s.auth.checkCSRF(r); err != nil {
if err := s.auth.CheckCSRF(r); err != nil {
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
return
}
@@ -310,7 +275,7 @@ func (s *Server) handlePublic(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
s.auth.clearSession(w, r)
s.auth.ClearSession(w, r)
w.WriteHeader(http.StatusNoContent)
}
@@ -318,7 +283,7 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request, sub string) {
u := s.store.User(sub)
writeJSON(w, http.StatusOK, map[string]any{
"sub": sub,
"name": firstNonEmpty(u.Name, "marc"),
"name": cmp.Or(u.Name, "marc"),
"picture": u.Picture,
"settings": u.Settings,
"oidc": s.auth.Enabled(),
@@ -388,7 +353,7 @@ func (s *Server) handleWeatherSearch(w http.ResponseWriter, r *http.Request, sub
}
func (s *Server) handleSetApps(w http.ResponseWriter, r *http.Request, sub string) {
var in []App
var in []store.App
if err := decode(r, &in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
@@ -414,13 +379,6 @@ func writeJSON(w http.ResponseWriter, code int, v any) {
_ = json.NewEncoder(w).Encode(v)
}
func env(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return fallback
}
func splitList(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
View File
File renamed without changes.
+2 -2
View File
@@ -6,7 +6,7 @@
<meta name="theme-color" content="#0a0705" />
<title>HoneyOS</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' fill='%230a0705'/%3E%3Cpath d='M4 4h2v3h4V4h2v8h-2V9H6v3H4z' fill='%23ffb641'/%3E%3C/svg%3E" />
<link rel="stylesheet" href="./style.css" />
<link rel="stylesheet" href="./css/style.css" />
</head>
<body>
<!-- Bildschirm: alles liegt darin, damit Scanlines/Vignette darüber passen -->
@@ -110,6 +110,6 @@
<div class="flicker" aria-hidden="true"></div>
</div>
<script src="./os.js"></script>
<script type="module" src="./js/os.js"></script>
</body>
</html>
+120
View File
@@ -0,0 +1,120 @@
/* Der gemeinsame Unterbau: Zustand, Server, Einstellungen, Kleinkram.
*
* Alles, was mehr als eine Datei braucht, steht hier — und nur das. Die
* Ansichten (os.js) und die Widgets holen es sich von hier, statt sich
* gegenseitig zu kennen.
*/
export const LS_SETTINGS = "honeyos.settings";
export const SS_BOOTED = "honeyos.booted";
export const DEFAULT_SETTINGS = {
mode: "terminal", // terminal | desktop
phosphor: "amber", // amber | green | ice
scanlines: "on",
flicker: "on",
glow: "on",
openIn: "tab", // tab | same
// Wetter. Zwei Schalter statt einem: Der Schreibtisch und die HoneyBar sind
// zwei verschiedene Orte, und wer das Wetter beim Arbeiten in einer App will,
// will es nicht zwingend auch auf einer Seite, die er selten aufruft.
weatherBar: "off",
weatherDesk: "off",
weatherCity: "",
weatherLat: "",
weatherLon: "",
};
export const state = {
cfg: { user: "", hostname: "honeyos", version: "…", apps: [] },
me: null, // {sub, name, picture} sobald angemeldet
weather: null, // zuletzt geholtes Wetter, oder null
oidc: false,
settings: { ...DEFAULT_SETTINGS },
probes: {}, // appId -> "up" | "down" | "unknown"
wins: {}, // id -> {el, task, min}
zTop: 10,
};
export const $ = (id) => document.getElementById(id);
export const isTouch = () => window.matchMedia("(pointer: coarse)").matches;
/* ── Server ────────────────────────────────────────────────────────────────
* Ein winziger Aufrufhelfer. Schreibende Aufrufe brauchen die CSRF-Kopfzeile:
* ihr Wert steht in einem absichtlich lesbaren Cookie, das nur bekommt, wer
* die Seite wirklich geöffnet hat.
*/
/** Der Schlüssel für die App-Liste steht als Meta-Angabe in der Seite — der
* Server legt ihn dort hinein, wenn einer eingerichtet ist. */
export const APPS_KEY = document.querySelector('meta[name="honeyos-key"]')?.content || "";
export async function api(path, opts = {}) {
const headers = { ...(opts.headers || {}) };
if (APPS_KEY) headers["X-HoneyOS-Key"] = APPS_KEY;
if (opts.method && opts.method !== "GET") {
headers["Content-Type"] = "application/json";
const tok = cookie("honeyos_csrf");
if (tok) headers["X-CSRF-Token"] = tok;
}
const res = await fetch(path, { credentials: "same-origin", cache: "no-store", ...opts, headers });
if (res.status === 204) return null;
const body = await res.json().catch(() => null);
if (!res.ok) throw new Error((body && body.error) || `${res.status} ${res.statusText}`);
return body;
}
export function cookie(name) {
return document.cookie
.split(";")
.map((c) => c.trim().split("="))
.find(([k]) => k === name)?.[1] || "";
}
export function loadLocalSettings() {
state.settings = { ...DEFAULT_SETTINGS, ...(readJson(LS_SETTINGS) || {}) };
}
export function readJson(key) {
try {
return JSON.parse(localStorage.getItem(key) || "null");
} catch {
return null;
}
}
let syncTimer = null;
export function saveSettings() {
try {
localStorage.setItem(LS_SETTINGS, JSON.stringify(state.settings));
} catch {
/* Privatmodus: dann gilt es nur für diese Sitzung — der Server merkt es trotzdem */
}
applySettings();
// Zum Server gebündelt: wer dreimal hintereinander die Röhre wechselt,
// soll nicht dreimal schreiben lassen.
clearTimeout(syncTimer);
syncTimer = setTimeout(() => {
api("/api/settings", { method: "PUT", body: JSON.stringify(state.settings) }).catch((e) =>
console.warn("Einstellungen nicht gespeichert:", e.message)
);
}, 600);
}
export function escapeHtml(s) {
return String(s).replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
}
export function escapeAttr(s) {
return escapeHtml(s).replace(/"/g, "&quot;");
}
/* Die Einstellungen schlagen als Datenattribute auf die Seite durch; das CSS
hängt daran. Steht hier, weil saveSettings() es sofort anwenden muss. */
export function applySettings() {
const r = document.documentElement;
r.dataset.phosphor = state.settings.phosphor;
r.dataset.scanlines = state.settings.scanlines;
r.dataset.flicker = state.settings.flicker;
r.dataset.glow = state.settings.glow;
r.dataset.mode = state.settings.mode;
}
+244
View File
@@ -0,0 +1,244 @@
/* Die Pixelsymbole von HoneyOS.
*
* Eigene Datei, weil sie Daten sind und nicht Verhalten: zwölf mal zwölf
* Zeichen je Symbol, mehr passiert hier nicht. Dieselben Raster stehen noch
* einmal in honeybar/honeybar.js — die soll eine einzelne Datei bleiben, die
* eine fremde App mit einer Zeile einbindet (siehe IDEAS 1.3).
*/
/* ── Pixel-Glyphen ─────────────────────────────────────────────────────────
* 12×12-Raster als Zeichenketten. Ein `#` ist ein Pixel. So bleiben die Icons
* im Retro-Raster statt aus einer Icon-Bibliothek zu kommen.
*/
export const GLYPHS = {
// Wettersymbole. Dieselben Namen wie in weather.go (wmo) und in der HoneyBar —
// solange 1.3 (geteilte Symbolquelle) nicht gebaut ist, stehen sie zweimal da.
sonne: [
"............",
".....##.....",
"..#..##..#..",
"...#....#...",
"....####....",
"..##....##..",
"..##....##..",
"....####....",
"...#....#...",
"..#..##..#..",
".....##.....",
"............",
],
heiter: [
"............",
"...##.......",
"..####..#...",
"...##..###..",
"......#####.",
"...####...#.",
"..#....##.#.",
".##........#",
"#..........#",
".##########.",
"............",
"............",
],
wolke: [
"............",
"....####....",
"...#....#...",
"..#......##.",
".##........#",
"##.........#",
"#..........#",
"#..........#",
".##########.",
"............",
"............",
"............",
],
nebel: [
"............",
"....####....",
"..##....##..",
".##.......#.",
"#..........#",
".##########.",
"............",
".#########..",
"............",
"..#########.",
"............",
".#########..",
],
regen: [
"............",
"....####....",
"...#....#...",
"..#......##.",
".##........#",
"##.........#",
".##########.",
"............",
"..#..#..#...",
".#..#..#....",
"..#..#..#...",
".#..#..#....",
],
schnee: [
"............",
"....####....",
"...#....#...",
"..#......##.",
".##........#",
"##.........#",
".##########.",
"............",
"..#...#...#.",
".#.#.#.#.#..",
"..#...#...#.",
"............",
],
gewitter: [
"............",
"....####....",
"...#....#...",
"..#......##.",
".##........#",
"##.........#",
".##########.",
"......##....",
".....##.....",
"....####....",
"......##....",
".....##.....",
],
cloud: [
"............",
"....####....",
"...#....#...",
"..#......##.",
".##........#",
"##.........#",
"#..........#",
"#..........#",
".##########.",
"............",
"............",
"............",
],
note: [
"..########..",
"..#......#..",
"..#.####.#..",
"..#......#..",
"..#.####.#..",
"..#......#..",
"..#.####.#..",
"..#......#..",
"..#.##...#..",
"..#......#..",
"..########..",
"............",
],
box: [
"............",
".##########.",
".#........#.",
".#........#.",
".#...##...#.",
".#..####..#.",
".#..####..#.",
".#...##...#.",
".#........#.",
".#........#.",
".##########.",
"............",
],
chip: [
"............",
"..#.#.#.#...",
".##########.",
".#........#.",
"##.######.##",
".#.#....#.#.",
"##.#....#.##",
".#.######.#.",
".#........#.",
".##########.",
"..#.#.#.#...",
"............",
],
gear: [
"....####....",
"..#.####.#..",
"..########..",
".####..####.",
"###......###",
"##........##",
"##........##",
"###......###",
".####..####.",
"..########..",
"..#.####.#..",
"....####....",
],
globe: [
"............",
"...######...",
"..#..#...#..",
".#...#....#.",
"############",
"#....#.....#",
"#....#.....#",
"############",
".#...#....#.",
"..#..#...#..",
"...######...",
"............",
],
disk: [
"............",
"############",
"#..######..#",
"#..#....#..#",
"#..#....#..#",
"#..######..#",
"#..........#",
"#.########.#",
"#.#......#.#",
"#.#......#.#",
"############",
"............",
],
folder: [
"............",
".####.......",
"#....#......",
"#.....######",
"#..........#",
"#..........#",
"#..........#",
"#..........#",
"#..........#",
"############",
"............",
"............",
],
};
export function glyphSvg(name) {
const grid = GLYPHS[name] || GLYPHS.box;
const rects = [];
grid.forEach((row, y) => {
// Waagerechte Läufe zusammenfassen: weniger Rechtecke, gleiches Bild.
let run = 0;
for (let x = 0; x <= row.length; x++) {
if (row[x] === "#") {
run++;
} else if (run) {
rects.push(`<rect x="${x - run}" y="${y}" width="${run}" height="1"/>`);
run = 0;
}
}
});
return `<svg viewBox="0 0 12 12" fill="currentColor" shape-rendering="crispEdges" aria-hidden="true">${rects.join("")}</svg>`;
}
+9 -443
View File
@@ -1,3 +1,12 @@
import {
$, api, APPS_KEY, cookie, DEFAULT_SETTINGS, escapeAttr, escapeHtml, isTouch,
applySettings, loadLocalSettings, LS_SETTINGS, readJson, saveSettings,
SS_BOOTED, state,
} from "./core.js";
import { GLYPHS, glyphSvg } from "./glyphs.js";
import { renderUser } from "./widgets/user.js";
import { loadWeather, renderWeather } from "./widgets/weather.js";
/*
* HoneyOS — Desktop-Logik.
*
@@ -16,308 +25,8 @@
* gemeinsamen Teile hier oben und die beiden Ansichten weiter unten getrennt.
*/
const LS_SETTINGS = "honeyos.settings";
const SS_BOOTED = "honeyos.booted";
const DEFAULT_SETTINGS = {
mode: "terminal", // terminal | desktop
phosphor: "amber", // amber | green | ice
scanlines: "on",
flicker: "on",
glow: "on",
openIn: "tab", // tab | same
// Wetter. Zwei Schalter statt einem: Der Schreibtisch und die HoneyBar sind
// zwei verschiedene Orte, und wer das Wetter beim Arbeiten in einer App will,
// will es nicht zwingend auch auf einer Seite, die er selten aufruft.
weatherBar: "off",
weatherDesk: "off",
weatherCity: "",
weatherLat: "",
weatherLon: "",
};
const state = {
cfg: { user: "", hostname: "honeyos", version: "…", apps: [] },
me: null, // {sub, name, picture} sobald angemeldet
weather: null, // zuletzt geholtes Wetter, oder null
oidc: false,
settings: { ...DEFAULT_SETTINGS },
probes: {}, // appId -> "up" | "down" | "unknown"
wins: {}, // id -> {el, task, min}
zTop: 10,
};
const $ = (id) => document.getElementById(id);
const isTouch = () => window.matchMedia("(pointer: coarse)").matches;
/* ── Pixel-Glyphen ─────────────────────────────────────────────────────────
* 12×12-Raster als Zeichenketten. Ein `#` ist ein Pixel. So bleiben die Icons
* im Retro-Raster statt aus einer Icon-Bibliothek zu kommen.
*/
const GLYPHS = {
// Wettersymbole. Dieselben Namen wie in weather.go (wmo) und in der HoneyBar —
// solange 1.3 (geteilte Symbolquelle) nicht gebaut ist, stehen sie zweimal da.
sonne: [
"............",
".....##.....",
"..#..##..#..",
"...#....#...",
"....####....",
"..##....##..",
"..##....##..",
"....####....",
"...#....#...",
"..#..##..#..",
".....##.....",
"............",
],
heiter: [
"............",
"...##.......",
"..####..#...",
"...##..###..",
"......#####.",
"...####...#.",
"..#....##.#.",
".##........#",
"#..........#",
".##########.",
"............",
"............",
],
wolke: [
"............",
"....####....",
"...#....#...",
"..#......##.",
".##........#",
"##.........#",
"#..........#",
"#..........#",
".##########.",
"............",
"............",
"............",
],
nebel: [
"............",
"....####....",
"..##....##..",
".##.......#.",
"#..........#",
".##########.",
"............",
".#########..",
"............",
"..#########.",
"............",
".#########..",
],
regen: [
"............",
"....####....",
"...#....#...",
"..#......##.",
".##........#",
"##.........#",
".##########.",
"............",
"..#..#..#...",
".#..#..#....",
"..#..#..#...",
".#..#..#....",
],
schnee: [
"............",
"....####....",
"...#....#...",
"..#......##.",
".##........#",
"##.........#",
".##########.",
"............",
"..#...#...#.",
".#.#.#.#.#..",
"..#...#...#.",
"............",
],
gewitter: [
"............",
"....####....",
"...#....#...",
"..#......##.",
".##........#",
"##.........#",
".##########.",
"......##....",
".....##.....",
"....####....",
"......##....",
".....##.....",
],
cloud: [
"............",
"....####....",
"...#....#...",
"..#......##.",
".##........#",
"##.........#",
"#..........#",
"#..........#",
".##########.",
"............",
"............",
"............",
],
note: [
"..########..",
"..#......#..",
"..#.####.#..",
"..#......#..",
"..#.####.#..",
"..#......#..",
"..#.####.#..",
"..#......#..",
"..#.##...#..",
"..#......#..",
"..########..",
"............",
],
box: [
"............",
".##########.",
".#........#.",
".#........#.",
".#...##...#.",
".#..####..#.",
".#..####..#.",
".#...##...#.",
".#........#.",
".#........#.",
".##########.",
"............",
],
chip: [
"............",
"..#.#.#.#...",
".##########.",
".#........#.",
"##.######.##",
".#.#....#.#.",
"##.#....#.##",
".#.######.#.",
".#........#.",
".##########.",
"..#.#.#.#...",
"............",
],
gear: [
"....####....",
"..#.####.#..",
"..########..",
".####..####.",
"###......###",
"##........##",
"##........##",
"###......###",
".####..####.",
"..########..",
"..#.####.#..",
"....####....",
],
globe: [
"............",
"...######...",
"..#..#...#..",
".#...#....#.",
"############",
"#....#.....#",
"#....#.....#",
"############",
".#...#....#.",
"..#..#...#..",
"...######...",
"............",
],
disk: [
"............",
"############",
"#..######..#",
"#..#....#..#",
"#..#....#..#",
"#..######..#",
"#..........#",
"#.########.#",
"#.#......#.#",
"#.#......#.#",
"############",
"............",
],
folder: [
"............",
".####.......",
"#....#......",
"#.....######",
"#..........#",
"#..........#",
"#..........#",
"#..........#",
"#..........#",
"############",
"............",
"............",
],
};
function glyphSvg(name) {
const grid = GLYPHS[name] || GLYPHS.box;
const rects = [];
grid.forEach((row, y) => {
// Waagerechte Läufe zusammenfassen: weniger Rechtecke, gleiches Bild.
let run = 0;
for (let x = 0; x <= row.length; x++) {
if (row[x] === "#") {
run++;
} else if (run) {
rects.push(`<rect x="${x - run}" y="${y}" width="${run}" height="1"/>`);
run = 0;
}
}
});
return `<svg viewBox="0 0 12 12" fill="currentColor" shape-rendering="crispEdges" aria-hidden="true">${rects.join("")}</svg>`;
}
/* ── Server ────────────────────────────────────────────────────────────────
* Ein winziger Aufrufhelfer. Schreibende Aufrufe brauchen die CSRF-Kopfzeile:
* ihr Wert steht in einem absichtlich lesbaren Cookie, das nur bekommt, wer
* die Seite wirklich geöffnet hat.
*/
/** Der Schlüssel für die App-Liste steht als Meta-Angabe in der Seite — der
* Server legt ihn dort hinein, wenn einer eingerichtet ist. */
const APPS_KEY = document.querySelector('meta[name="honeyos-key"]')?.content || "";
async function api(path, opts = {}) {
const headers = { ...(opts.headers || {}) };
if (APPS_KEY) headers["X-HoneyOS-Key"] = APPS_KEY;
if (opts.method && opts.method !== "GET") {
headers["Content-Type"] = "application/json";
const tok = cookie("honeyos_csrf");
if (tok) headers["X-CSRF-Token"] = tok;
}
const res = await fetch(path, { credentials: "same-origin", cache: "no-store", ...opts, headers });
if (res.status === 204) return null;
const body = await res.json().catch(() => null);
if (!res.ok) throw new Error((body && body.error) || `${res.status} ${res.statusText}`);
return body;
}
function cookie(name) {
return document.cookie
.split(";")
.map((c) => c.trim().split("="))
.find(([k]) => k === name)?.[1] || "";
}
/* ── Laden & Speichern ─────────────────────────────────────────────────── */
@@ -336,46 +45,8 @@ async function loadFromServer() {
/** Einstellungen vorab aus localStorage, damit die Röhre nicht erst weiß
* aufblitzt und dann umspringt. */
function loadLocalSettings() {
state.settings = { ...DEFAULT_SETTINGS, ...(readJson(LS_SETTINGS) || {}) };
}
function readJson(key) {
try {
return JSON.parse(localStorage.getItem(key) || "null");
} catch {
return null;
}
}
let syncTimer = null;
function saveSettings() {
try {
localStorage.setItem(LS_SETTINGS, JSON.stringify(state.settings));
} catch {
/* Privatmodus: dann gilt es nur für diese Sitzung — der Server merkt es trotzdem */
}
applySettings();
// Zum Server gebündelt: wer dreimal hintereinander die Röhre wechselt,
// soll nicht dreimal schreiben lassen.
clearTimeout(syncTimer);
syncTimer = setTimeout(() => {
api("/api/settings", { method: "PUT", body: JSON.stringify(state.settings) }).catch((e) =>
console.warn("Einstellungen nicht gespeichert:", e.message)
);
}, 600);
}
function applySettings() {
const r = document.documentElement;
r.dataset.phosphor = state.settings.phosphor;
r.dataset.scanlines = state.settings.scanlines;
r.dataset.flicker = state.settings.flicker;
r.dataset.glow = state.settings.glow;
r.dataset.mode = state.settings.mode;
}
/* ── Boot ──────────────────────────────────────────────────────────────── */
@@ -436,13 +107,6 @@ function runBoot() {
/* ── Gemeinsames: Menüleiste, Uhr, Stand, Start ────────────────────────── */
function greeting() {
const h = new Date().getHours();
if (h < 5 || h >= 23) return "Noch wach?";
if (h < 11) return "Guten Morgen.";
if (h < 18) return "Willkommen zurück.";
return "Guten Abend.";
}
function renderShell() {
$("desktop").hidden = false;
@@ -497,99 +161,7 @@ function renderMode() {
paintProbes();
}
/* ── Wer angemeldet ist ────────────────────────────────────────────────────
*
* Stand früher rechts in der Menüleiste. Dort war es das einzige Stück, das
* die HoneyBar in den Apps nicht hat — beide sollen aber gleich aussehen, sonst
* merkt man beim Wechsel, dass es zwei verschiedene Leisten sind. Auf dem
* Schreibtisch ist ohnehin Platz dafür, und die Begrüßung gehört dazu.
*/
function userHtml() {
const bild = state.me && state.me.picture
? `<img class="uw-bild" src="${escapeAttr(state.me.picture)}" alt="" />`
: `<span class="uw-bild uw-leer">${glyphSvg("chip")}</span>`;
return `
<div class="uw">
${bild}
<div class="uw-text">
<b class="uw-name">${state.cfg.user}</b>
<span class="uw-gruss">${greeting()}</span>
<span class="uw-host">${state.cfg.user}@${state.cfg.hostname}</span>
</div>
</div>`;
}
function renderUser() {
const tile = $("userTile");
const desk = $("userDesk");
const desktopMode = state.settings.mode === "desktop";
const html = userHtml();
tile.hidden = desktopMode;
desk.hidden = !desktopMode;
tile.querySelector(".user-body").innerHTML = html;
desk.innerHTML = html;
}
/* ── Wetter ────────────────────────────────────────────────────────────────
*
* Der Server holt es und legt es zehn Minuten beiseite; hier wird nur gezeigt,
* was er hergibt. Kein Ort eingestellt oder Dienst nicht erreichbar: 204, und
* das Widget bleibt weg. Eine Zahl von gestern wäre schlechter als keine.
*/
async function loadWeather() {
if (!state.settings.weatherCity) {
state.weather = null;
renderWeather();
return;
}
try {
state.weather = await api("/api/weather");
} catch {
state.weather = null;
}
renderWeather();
}
function weatherHtml(w) {
return `
<div class="wx">
<span class="wx-glyph">${glyphSvg(w.glyph)}</span>
<div class="wx-text">
<b class="wx-temp">${Math.round(w.grad)}°</b>
<span class="wx-desc">${w.text}</span>
<span class="wx-city">${w.ort}</span>
</div>
</div>`;
}
function renderWeather() {
const w = state.weather;
const tile = $("wxTile");
const desk = $("wxDesk");
const bar = $("mbWx");
const desktopMode = state.settings.mode === "desktop";
// Das Widget: Fenster im Terminal-Modus, Schild auf dem Schreibtisch.
const alsWidget = state.settings.weatherDesk === "on" && w;
tile.hidden = !alsWidget || desktopMode;
desk.hidden = !alsWidget || !desktopMode;
if (alsWidget) {
const html = weatherHtml(w);
tile.querySelector(".wx-body").innerHTML = html;
desk.innerHTML = html;
}
// Und in der HoneyBar. Die Menüleiste hier ist zwar anderer Code als
// honeybar.js in den Apps, aber für den, der davorsitzt, dieselbe HoneyBar —
// ein Schalter, der nur woanders wirkt, wäre nicht zu erklären.
const inLeiste = state.settings.weatherBar === "on" && w;
bar.hidden = !inLeiste;
if (inLeiste) {
bar.innerHTML = `${glyphSvg(w.glyph)}<span class="mb-wx-temp">${Math.round(w.grad)}°</span>`;
bar.title = `${w.ort}: ${w.text}, ${Math.round(w.grad)}°`;
}
}
function setMode(mode) {
if (state.settings.mode === mode) return;
@@ -1219,12 +791,6 @@ function openAbout() {
/* ── Hilfsmittel ───────────────────────────────────────────────────────── */
function escapeHtml(s) {
return String(s).replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
}
function escapeAttr(s) {
return escapeHtml(s).replace(/"/g, "&quot;");
}
/* ── Startmenü ─────────────────────────────────────────────────────────── */
+44
View File
@@ -0,0 +1,44 @@
import { $, escapeAttr, state } from "../core.js";
import { glyphSvg } from "../glyphs.js";
function greeting() {
const h = new Date().getHours();
if (h < 5 || h >= 23) return "Noch wach?";
if (h < 11) return "Guten Morgen.";
if (h < 18) return "Willkommen zurück.";
return "Guten Abend.";
}
/* ── Wer angemeldet ist ────────────────────────────────────────────────────
*
* Stand früher rechts in der Menüleiste. Dort war es das einzige Stück, das
* die HoneyBar in den Apps nicht hat — beide sollen aber gleich aussehen, sonst
* merkt man beim Wechsel, dass es zwei verschiedene Leisten sind. Auf dem
* Schreibtisch ist ohnehin Platz dafür, und die Begrüßung gehört dazu.
*/
function userHtml() {
const bild = state.me && state.me.picture
? `<img class="uw-bild" src="${escapeAttr(state.me.picture)}" alt="" />`
: `<span class="uw-bild uw-leer">${glyphSvg("chip")}</span>`;
return `
<div class="uw">
${bild}
<div class="uw-text">
<b class="uw-name">${state.cfg.user}</b>
<span class="uw-gruss">${greeting()}</span>
<span class="uw-host">${state.cfg.user}@${state.cfg.hostname}</span>
</div>
</div>`;
}
export function renderUser() {
const tile = $("userTile");
const desk = $("userDesk");
const desktopMode = state.settings.mode === "desktop";
const html = userHtml();
tile.hidden = desktopMode;
desk.hidden = !desktopMode;
tile.querySelector(".user-body").innerHTML = html;
desk.innerHTML = html;
}
+62
View File
@@ -0,0 +1,62 @@
import { $, api, state } from "../core.js";
import { glyphSvg } from "../glyphs.js";
/* ── Wetter ────────────────────────────────────────────────────────────────
*
* Der Server holt es und legt es zehn Minuten beiseite; hier wird nur gezeigt,
* was er hergibt. Kein Ort eingestellt oder Dienst nicht erreichbar: 204, und
* das Widget bleibt weg. Eine Zahl von gestern wäre schlechter als keine.
*/
export async function loadWeather() {
if (!state.settings.weatherCity) {
state.weather = null;
renderWeather();
return;
}
try {
state.weather = await api("/api/weather");
} catch {
state.weather = null;
}
renderWeather();
}
function weatherHtml(w) {
return `
<div class="wx">
<span class="wx-glyph">${glyphSvg(w.glyph)}</span>
<div class="wx-text">
<b class="wx-temp">${Math.round(w.grad)}°</b>
<span class="wx-desc">${w.text}</span>
<span class="wx-city">${w.ort}</span>
</div>
</div>`;
}
export function renderWeather() {
const w = state.weather;
const tile = $("wxTile");
const desk = $("wxDesk");
const bar = $("mbWx");
const desktopMode = state.settings.mode === "desktop";
// Das Widget: Fenster im Terminal-Modus, Schild auf dem Schreibtisch.
const alsWidget = state.settings.weatherDesk === "on" && w;
tile.hidden = !alsWidget || desktopMode;
desk.hidden = !alsWidget || !desktopMode;
if (alsWidget) {
const html = weatherHtml(w);
tile.querySelector(".wx-body").innerHTML = html;
desk.innerHTML = html;
}
// Und in der HoneyBar. Die Menüleiste hier ist zwar anderer Code als
// honeybar.js in den Apps, aber für den, der davorsitzt, dieselbe HoneyBar —
// ein Schalter, der nur woanders wirkt, wäre nicht zu erklären.
const inLeiste = state.settings.weatherBar === "on" && w;
bar.hidden = !inLeiste;
if (inLeiste) {
bar.innerHTML = `${glyphSvg(w.glyph)}<span class="mb-wx-temp">${Math.round(w.grad)}°</span>`;
bar.title = `${w.ort}: ${w.text}, ${Math.round(w.grad)}°`;
}
}
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"honeyos/internal/store"
"honeyos/internal/weather"
)
// Ohne eingestellten Ort ist 204 die richtige Antwort: kein Fehler, nur nichts
// zu zeigen. Die Leiste lässt das Symbol dann weg, statt etwas zu melden.
func TestOhneOrtGibtEsKeinWetter(t *testing.T) {
s := newTestServer(t, "") // ohne Anbieter: offener Betrieb, kein Anmelden nötig
s.wetter = weather.FakeFuerTest(t, 0, 20)
rec := httptest.NewRecorder()
s.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/api/weather", nil))
if rec.Code != http.StatusNoContent {
t.Fatalf("ohne Ort erwartet 204, bekam %d", rec.Code)
}
}
func TestMitOrtKommtDasWetter(t *testing.T) {
s := newTestServer(t, "")
s.wetter = weather.FakeFuerTest(t, 3, 8.6)
if err := s.store.SetSettings(store.LocalSub, map[string]string{
"weatherCity": "Köln", "weatherLat": "50.938", "weatherLon": "6.96",
}); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
s.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/api/weather", nil))
if rec.Code != http.StatusOK {
t.Fatalf("erwartet 200, bekam %d: %s", rec.Code, rec.Body)
}
var w weather.Wetter
json.Unmarshal(rec.Body.Bytes(), &w)
if w.Ort != "Köln" || w.Glyph != "wolke" {
t.Fatalf("falsches Wetter: %+v", w)
}
}