diff --git a/.gitignore b/.gitignore index 8fb09dc..e5c4aa6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +# Gebautes Binary — gehört nicht ins Repo (lag dort 9,8 MB lang) +/honeyos devdata/ .env *.tmp diff --git a/Dockerfile b/Dockerfile index 93bc6bd..47b629c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 . diff --git a/README.md b/README.md index ace1d0c..961c66a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/auth_test.go b/auth_test.go index 82c64f7..1e2626e 100644 --- a/auth_test.go +++ b/auth_test.go @@ -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) diff --git a/honeyos b/honeyos deleted file mode 100755 index abbaf57..0000000 Binary files a/honeyos and /dev/null differ diff --git a/auth.go b/internal/auth/auth.go similarity index 85% rename from auth.go rename to internal/auth/auth.go index e7fa27d..c71060d 100644 --- a/auth.go +++ b/internal/auth/auth.go @@ -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) diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..74766c4 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/store.go b/internal/store/store.go similarity index 94% rename from store.go rename to internal/store/store.go index 55e8880..362e974 100644 --- a/store.go +++ b/internal/store/store.go @@ -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") } diff --git a/internal/weather/faketest.go b/internal/weather/faketest.go new file mode 100644 index 0000000..286cc96 --- /dev/null +++ b/internal/weather/faketest.go @@ -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 +} diff --git a/weather.go b/internal/weather/weather.go similarity index 92% rename from weather.go rename to internal/weather/weather.go index fc19d1b..ca56eb5 100644 --- a/weather.go +++ b/internal/weather/weather.go @@ -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") diff --git a/weather_test.go b/internal/weather/weather_test.go similarity index 73% rename from weather_test.go rename to internal/weather/weather_test.go index b2f58ca..ffe3c84 100644 --- a/weather_test.go +++ b/internal/weather/weather_test.go @@ -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) - } -} diff --git a/main.go b/main.go index 9e3c9a3..27b5b18 100644 --- a/main.go +++ b/main.go @@ -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, ",") { diff --git a/web/style.css b/web/css/style.css similarity index 100% rename from web/style.css rename to web/css/style.css diff --git a/web/index.html b/web/index.html index b4e44b8..ebf9917 100644 --- a/web/index.html +++ b/web/index.html @@ -6,7 +6,7 @@ HoneyOS - + @@ -110,6 +110,6 @@ - + diff --git a/web/js/core.js b/web/js/core.js new file mode 100644 index 0000000..dd74de0 --- /dev/null +++ b/web/js/core.js @@ -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) => ({ "&": "&", "<": "<", ">": ">" })[c]); +} +export function escapeAttr(s) { + return escapeHtml(s).replace(/"/g, """); +} + +/* 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; +} diff --git a/web/js/glyphs.js b/web/js/glyphs.js new file mode 100644 index 0000000..ab8668f --- /dev/null +++ b/web/js/glyphs.js @@ -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(``); + run = 0; + } + } + }); + return ``; +} diff --git a/web/os.js b/web/js/os.js similarity index 74% rename from web/os.js rename to web/js/os.js index e39f49e..36fe4d1 100644 --- a/web/os.js +++ b/web/js/os.js @@ -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(``); - run = 0; - } - } - }); - return ``; -} - -/* ── 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 - ? `` - : `${glyphSvg("chip")}`; - return ` -
- ${bild} -
- ${state.cfg.user} - ${greeting()} - ${state.cfg.user}@${state.cfg.hostname} -
-
`; -} -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 ` -
- ${glyphSvg(w.glyph)} -
- ${Math.round(w.grad)}° - ${w.text} - ${w.ort} -
-
`; -} - -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)}${Math.round(w.grad)}°`; - 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) => ({ "&": "&", "<": "<", ">": ">" })[c]); -} -function escapeAttr(s) { - return escapeHtml(s).replace(/"/g, """); -} /* ── Startmenü ─────────────────────────────────────────────────────────── */ diff --git a/web/js/widgets/user.js b/web/js/widgets/user.js new file mode 100644 index 0000000..7d1f9f2 --- /dev/null +++ b/web/js/widgets/user.js @@ -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 + ? `` + : `${glyphSvg("chip")}`; + return ` +
+ ${bild} +
+ ${state.cfg.user} + ${greeting()} + ${state.cfg.user}@${state.cfg.hostname} +
+
`; +} + +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; +} diff --git a/web/js/widgets/weather.js b/web/js/widgets/weather.js new file mode 100644 index 0000000..ed2380c --- /dev/null +++ b/web/js/widgets/weather.js @@ -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 ` +
+ ${glyphSvg(w.glyph)} +
+ ${Math.round(w.grad)}° + ${w.text} + ${w.ort} +
+
`; +} + +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)}${Math.round(w.grad)}°`; + bar.title = `${w.ort}: ${w.text}, ${Math.round(w.grad)}°`; + } +} diff --git a/wetter_test.go b/wetter_test.go new file mode 100644 index 0000000..4744b37 --- /dev/null +++ b/wetter_test.go @@ -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) + } +}