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:
1 parent
d08b0a229c
commit
52deffa30e
20 files changed
+780
-624
No files matched your search
@@ -1,3 +1,5 @@
|
|||||||
|
# Gebautes Binary — gehört nicht ins Repo (lag dort 9,8 MB lang)
|
||||||
|
/honeyos
|
||||||
devdata/
|
devdata/
|
||||||
.env
|
.env
|
||||||
*.tmp
|
*.tmp
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ WORKDIR /src
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY *.go ./
|
COPY *.go ./
|
||||||
|
COPY internal/ ./internal/
|
||||||
COPY web/ ./web/
|
COPY web/ ./web/
|
||||||
RUN CGO_ENABLED=0 go build -buildvcs=false -ldflags="-s -w" -o /honeyos .
|
RUN CGO_ENABLED=0 go build -buildvcs=false -ldflags="-s -w" -o /honeyos .
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
## Entwickeln
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+49
-34
@@ -1,6 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"honeyos/internal/auth"
|
||||||
|
"honeyos/internal/config"
|
||||||
|
"honeyos/internal/store"
|
||||||
|
"io/fs"
|
||||||
|
|
||||||
"crypto"
|
"crypto"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
@@ -98,18 +103,18 @@ func (f *fakeIdP) idToken(t *testing.T) string {
|
|||||||
|
|
||||||
func newTestServer(t *testing.T, issuer string) *Server {
|
func newTestServer(t *testing.T, issuer string) *Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
store, err := OpenStore(dataPath(t.TempDir()))
|
st, err := store.Open(store.DataPath(t.TempDir()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
cfg := Config{
|
cfg := config.Config{
|
||||||
PublicURL: "https://os.example.test",
|
PublicURL: "https://os.example.test",
|
||||||
SessionSecret: strings.Repeat("k", 40),
|
SessionSecret: strings.Repeat("k", 40),
|
||||||
OIDCIssuer: issuer,
|
OIDCIssuer: issuer,
|
||||||
OIDCClientID: "honeyos",
|
OIDCClientID: "honeyos",
|
||||||
OIDCClientSecret: "geheim",
|
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,
|
// TestAnmeldeablauf geht den Weg einmal ganz durch: Umleitung zum Anbieter,
|
||||||
@@ -140,7 +145,7 @@ func TestAnmeldeablauf(t *testing.T) {
|
|||||||
|
|
||||||
var stateCk *http.Cookie
|
var stateCk *http.Cookie
|
||||||
for _, c := range rec.Result().Cookies() {
|
for _, c := range rec.Result().Cookies() {
|
||||||
if c.Name == stateCookie {
|
if c.Name == auth.StateCookie {
|
||||||
stateCk = c
|
stateCk = c
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,9 +168,9 @@ func TestAnmeldeablauf(t *testing.T) {
|
|||||||
var sess, csrf *http.Cookie
|
var sess, csrf *http.Cookie
|
||||||
for _, c := range rec.Result().Cookies() {
|
for _, c := range rec.Result().Cookies() {
|
||||||
switch c.Name {
|
switch c.Name {
|
||||||
case sessionCookie:
|
case auth.SessionCookie:
|
||||||
sess = c
|
sess = c
|
||||||
case csrfCookie:
|
case auth.CSRFCookie:
|
||||||
csrf = c
|
csrf = c
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,7 +220,7 @@ func TestGefälschtesCookieZähltNicht(t *testing.T) {
|
|||||||
h := newTestServer(t, idp.URL).routes()
|
h := newTestServer(t, idp.URL).routes()
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/me", nil)
|
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()
|
rec := httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, req)
|
h.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusUnauthorized {
|
if rec.Code != http.StatusUnauthorized {
|
||||||
@@ -315,7 +320,7 @@ func TestOffenerBetrieb(t *testing.T) {
|
|||||||
if rec.Code != http.StatusNoContent {
|
if rec.Code != http.StatusNoContent {
|
||||||
t.Fatalf("Schreiben im offenen Betrieb: %d", rec.Code)
|
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")
|
t.Error("Einstellung im offenen Betrieb nicht gespeichert")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,7 +331,7 @@ func TestLeisteNurFürEingetrageneApps(t *testing.T) {
|
|||||||
idp := newFakeIdP(t, "honeyos")
|
idp := newFakeIdP(t, "honeyos")
|
||||||
s := newTestServer(t, idp.URL)
|
s := newTestServer(t, idp.URL)
|
||||||
h := s.routes()
|
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)
|
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"))
|
loc, _ := url.Parse(rec.Header().Get("Location"))
|
||||||
var stateCk *http.Cookie
|
var stateCk *http.Cookie
|
||||||
for _, c := range rec.Result().Cookies() {
|
for _, c := range rec.Result().Cookies() {
|
||||||
if c.Name == stateCookie {
|
if c.Name == auth.StateCookie {
|
||||||
stateCk = c
|
stateCk = c
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,9 +380,9 @@ func signIn(t *testing.T, h http.Handler) (sess, csrf *http.Cookie) {
|
|||||||
h.ServeHTTP(rec, req)
|
h.ServeHTTP(rec, req)
|
||||||
for _, c := range rec.Result().Cookies() {
|
for _, c := range rec.Result().Cookies() {
|
||||||
switch c.Name {
|
switch c.Name {
|
||||||
case sessionCookie:
|
case auth.SessionCookie:
|
||||||
sess = c
|
sess = c
|
||||||
case csrfCookie:
|
case auth.CSRFCookie:
|
||||||
csrf = c
|
csrf = c
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -390,18 +395,18 @@ func signIn(t *testing.T, h http.Handler) (sess, csrf *http.Cookie) {
|
|||||||
// ── Betrieb hinter einem Anmelde-Proxy ─────────────────────────────────────
|
// ── Betrieb hinter einem Anmelde-Proxy ─────────────────────────────────────
|
||||||
// Kein eigener Anmeldeschirm: Pangolin o. ä. hat die Tür schon zugemacht.
|
// 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()
|
t.Helper()
|
||||||
store, err := OpenStore(dataPath(t.TempDir()))
|
st, err := store.Open(store.DataPath(t.TempDir()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
cfg.SessionSecret = strings.Repeat("k", 40)
|
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) {
|
func TestProxyBetriebLässtDurch(t *testing.T) {
|
||||||
h := proxyServer(t, Config{ProxyAuth: true}).routes()
|
h := proxyServer(t, config.Config{ProxyAuth: true}).routes()
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/me", nil))
|
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.
|
// Nennt der Proxy einen Namen, bekommt jeder Name seine eigenen Einstellungen.
|
||||||
func TestProxyKopfzeileTrenntEinstellungen(t *testing.T) {
|
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()
|
h := s.routes()
|
||||||
|
|
||||||
put := func(user, body string) {
|
put := func(user, body string) {
|
||||||
@@ -451,7 +456,7 @@ func TestAppListeBrauchtDenSchlüssel(t *testing.T) {
|
|||||||
idp := newFakeIdP(t, "honeyos")
|
idp := newFakeIdP(t, "honeyos")
|
||||||
s := newTestServer(t, idp.URL)
|
s := newTestServer(t, idp.URL)
|
||||||
s.cfg.AppsKey = "geheim123"
|
s.cfg.AppsKey = "geheim123"
|
||||||
s.auth = NewAuth(s.cfg)
|
s.auth = auth.New(s.cfg)
|
||||||
h := s.routes()
|
h := s.routes()
|
||||||
|
|
||||||
get := func(mod func(*http.Request)) int {
|
get := func(mod func(*http.Request)) int {
|
||||||
@@ -483,7 +488,7 @@ func TestSchlüsselDarfNichtSchreiben(t *testing.T) {
|
|||||||
idp := newFakeIdP(t, "honeyos")
|
idp := newFakeIdP(t, "honeyos")
|
||||||
s := newTestServer(t, idp.URL)
|
s := newTestServer(t, idp.URL)
|
||||||
s.cfg.AppsKey = "geheim123"
|
s.cfg.AppsKey = "geheim123"
|
||||||
s.auth = NewAuth(s.cfg)
|
s.auth = auth.New(s.cfg)
|
||||||
h := s.routes()
|
h := s.routes()
|
||||||
|
|
||||||
req := httptest.NewRequest("PUT", "/api/apps", strings.NewReader(`[{"id":"x","url":"https://a.test"}]`))
|
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.
|
// Die Oberfläche muss den Schlüssel mitbekommen, sonst fragt sie sich selbst aus.
|
||||||
func TestStartseiteTrägtDenSchlüssel(t *testing.T) {
|
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()
|
rec := httptest.NewRecorder()
|
||||||
s.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
|
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.
|
// Ohne Schlüssel darf auch nichts drinstehen.
|
||||||
s2 := proxyServer(t, Config{ProxyAuth: true})
|
s2 := proxyServer(t, config.Config{ProxyAuth: true})
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
s2.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
|
s2.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
|
||||||
if strings.Contains(rec.Body.String(), "honeyos-key") {
|
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
|
// 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.
|
// zurückfallen, sonst gibt der freigegebene Pfad die Liste an jeden heraus.
|
||||||
func TestSchlüsselGiltAuchHinterDemProxy(t *testing.T) {
|
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 {
|
get := func(key string) int {
|
||||||
req := httptest.NewRequest("GET", "/api/apps", nil)
|
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.
|
// Die Leiste stellt genau eine Anfrage — sie muss Liste und Aussehen bringen.
|
||||||
func TestAppListeBringtDasAussehenMit(t *testing.T) {
|
func TestAppListeBringtDasAussehenMit(t *testing.T) {
|
||||||
s := proxyServer(t, Config{ProxyAuth: true, AppsKey: "geheim123"})
|
s := proxyServer(t, config.Config{ProxyAuth: true, AppsKey: "geheim123"})
|
||||||
if err := s.store.SetSettings(localSub, map[string]string{"phosphor": "green", "glow": "off"}); err != nil {
|
if err := s.store.SetSettings(store.LocalSub, map[string]string{"phosphor": "green", "glow": "off"}); err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,7 +562,7 @@ func TestAppListeBringtDasAussehenMit(t *testing.T) {
|
|||||||
s.routes().ServeHTTP(rec, req)
|
s.routes().ServeHTTP(rec, req)
|
||||||
|
|
||||||
var got struct {
|
var got struct {
|
||||||
Apps []App
|
Apps []store.App
|
||||||
Settings map[string]string
|
Settings map[string]string
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
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
|
// 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.
|
// os.js und blieb bei 0.2 stehen, während die Images schon 0.4 hiessen.
|
||||||
func TestVersionKommtVomServer(t *testing.T) {
|
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"} {
|
for _, pfad := range []string{"/api/public", "/api/me"} {
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -586,14 +591,24 @@ func TestVersionKommtVomServer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Und in der Oberfläche darf keine zweite fest eingetragen sein.
|
// Und in der Oberfläche darf keine zweite fest eingetragen sein — in keiner
|
||||||
raw, err := webFS.ReadFile("web/os.js")
|
// 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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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
|
// 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
|
// also beim Standardnutzer — die Einstellungen können aber unter einem Namen
|
||||||
// aus der Proxy-Kopfzeile liegen.
|
// aus der Proxy-Kopfzeile liegen.
|
||||||
func TestLeisteFindetDasAussehenAuchÜberDieSiteGrenze(t *testing.T) {
|
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()
|
h := s.routes()
|
||||||
|
|
||||||
// Am Rechner, durch den Proxy: grün.
|
// 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.
|
// Bei mehreren Nutzern darf nicht geraten werden, wessen Farbe gilt.
|
||||||
func TestBeiMehrerenNutzernWirdNichtGeraten(t *testing.T) {
|
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"} {
|
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 {
|
if err := s.store.SetSettings("proxy:"+name, map[string]string{"phosphor": farbe}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
|
"honeyos/internal/config"
|
||||||
|
"honeyos/internal/store"
|
||||||
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
@@ -25,9 +32,11 @@ import (
|
|||||||
// nichts zu verlieren, ein Neustart wirft niemanden hinaus.
|
// nichts zu verlieren, ein Neustart wirft niemanden hinaus.
|
||||||
|
|
||||||
const (
|
const (
|
||||||
sessionCookie = "honeyos_session"
|
// Exportiert, weil die Server-Tests im Wurzelpaket dieselben Namen brauchen
|
||||||
csrfCookie = "honeyos_csrf"
|
// — sonst stünden die Zeichenketten zweimal da und liefen auseinander.
|
||||||
stateCookie = "honeyos_state"
|
SessionCookie = "honeyos_session"
|
||||||
|
CSRFCookie = "honeyos_csrf"
|
||||||
|
StateCookie = "honeyos_state"
|
||||||
sessionTTL = 30 * 24 * time.Hour
|
sessionTTL = 30 * 24 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,7 +47,7 @@ type Identity struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Auth struct {
|
type Auth struct {
|
||||||
cfg Config
|
cfg config.Config
|
||||||
secret []byte
|
secret []byte
|
||||||
|
|
||||||
once sync.Once
|
once sync.Once
|
||||||
@@ -48,7 +57,7 @@ type Auth struct {
|
|||||||
verifier *gooidc.IDTokenVerifier
|
verifier *gooidc.IDTokenVerifier
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuth(cfg Config) *Auth {
|
func New(cfg config.Config) *Auth {
|
||||||
return &Auth{cfg: cfg, secret: []byte(cfg.SessionSecret)}
|
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) {
|
func (a *Auth) issueSession(w http.ResponseWriter, r *http.Request, sub string) {
|
||||||
payload := base64.RawURLEncoding.EncodeToString([]byte(sub)) + "." +
|
payload := base64.RawURLEncoding.EncodeToString([]byte(sub)) + "." +
|
||||||
strconv.FormatInt(time.Now().Add(sessionTTL).Unix(), 10)
|
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
|
// Zweites Cookie, absichtlich lesbar: der Browser schickt es als Kopfzeile
|
||||||
// zurück, und nur wer die Seite wirklich geöffnet hat, kann das.
|
// zurück, und nur wer die Seite wirklich geöffnet hat, kann das.
|
||||||
tok := randomString(24)
|
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) {
|
func (a *Auth) ClearSession(w http.ResponseWriter, r *http.Request) {
|
||||||
setCookie(w, r, sessionCookie, "", -1, true)
|
setCookie(w, r, SessionCookie, "", -1, true)
|
||||||
setCookie(w, r, csrfCookie, "", -1, false)
|
setCookie(w, r, CSRFCookie, "", -1, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubFromRequest liest die Sitzung. Zweiter Rückgabewert sagt, ob sie gilt.
|
// 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 "proxy:" + u, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return localSub, true
|
return store.LocalSub, true
|
||||||
}
|
}
|
||||||
c, err := r.Cookie(sessionCookie)
|
c, err := r.Cookie(SessionCookie)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -145,11 +154,12 @@ func (a *Auth) SubFromRequest(r *http.Request) (string, bool) {
|
|||||||
return string(sub), true
|
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() {
|
if !a.Enabled() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
c, err := r.Cookie(csrfCookie)
|
c, err := r.Cookie(CSRFCookie)
|
||||||
if err != nil || c.Value == "" {
|
if err != nil || c.Value == "" {
|
||||||
return errors.New("kein CSRF-Cookie")
|
return errors.New("kein CSRF-Cookie")
|
||||||
}
|
}
|
||||||
@@ -172,14 +182,14 @@ func (a *Auth) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
verifier := oauth2.GenerateVerifier()
|
verifier := oauth2.GenerateVerifier()
|
||||||
// State und Prüfwort reisen zusammen im selben Cookie — sie gehören zum
|
// State und Prüfwort reisen zusammen im selben Cookie — sie gehören zum
|
||||||
// selben Versuch und laufen zusammen ab.
|
// 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)
|
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) {
|
fail := func(reason string) {
|
||||||
setCookie(w, r, stateCookie, "", -1, true)
|
setCookie(w, r, StateCookie, "", -1, true)
|
||||||
http.Redirect(w, r, "/?fehler="+reason, http.StatusFound)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c, err := r.Cookie(stateCookie)
|
c, err := r.Cookie(StateCookie)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail("abgelaufen")
|
fail("abgelaufen")
|
||||||
return
|
return
|
||||||
@@ -199,7 +209,7 @@ func (a *Auth) Callback(w http.ResponseWriter, r *http.Request, store *Store) {
|
|||||||
fail("state")
|
fail("state")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setCookie(w, r, stateCookie, "", -1, true)
|
setCookie(w, r, StateCookie, "", -1, true)
|
||||||
|
|
||||||
code := r.URL.Query().Get("code")
|
code := r.URL.Query().Get("code")
|
||||||
if code == "" {
|
if code == "" {
|
||||||
@@ -243,7 +253,7 @@ func (a *Auth) Callback(w http.ResponseWriter, r *http.Request, store *Store) {
|
|||||||
return
|
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)
|
log.Printf("konnte Nutzer nicht merken: %v", err)
|
||||||
}
|
}
|
||||||
a.issueSession(w, r, claims.Sub)
|
a.issueSession(w, r, claims.Sub)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -17,6 +19,10 @@ import (
|
|||||||
// mit einem Editor reparieren. Geschrieben wird atomar (tmp + rename), damit
|
// mit einem Editor reparieren. Geschrieben wird atomar (tmp + rename), damit
|
||||||
// ein Absturz mitten im Schreiben keine halbe Datei hinterlässt.
|
// 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 {
|
type App struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -55,7 +61,7 @@ var (
|
|||||||
settingKeyRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]{0,31}$`)
|
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{}}}
|
s := &Store{path: path, d: data{Apps: []App{}, Users: map[string]*User{}}}
|
||||||
|
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path)
|
||||||
@@ -253,4 +259,5 @@ func (s *Store) AppOrigins() map[string]bool {
|
|||||||
return out
|
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") }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -37,13 +39,13 @@ type Wetter struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Ort struct {
|
type Ort struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Land string `json:"land"`
|
Land string `json:"land"`
|
||||||
Lat float64 `json:"lat"`
|
Lat float64 `json:"lat"`
|
||||||
Lon float64 `json:"lon"`
|
Lon float64 `json:"lon"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type wetterDienst struct {
|
type Dienst struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cache map[string]wetterEintrag
|
cache map[string]wetterEintrag
|
||||||
client *http.Client
|
client *http.Client
|
||||||
@@ -58,8 +60,8 @@ type wetterEintrag struct {
|
|||||||
bis time.Time
|
bis time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func neuerWetterdienst() *wetterDienst {
|
func Neu() *Dienst {
|
||||||
return &wetterDienst{
|
return &Dienst{
|
||||||
cache: map[string]wetterEintrag{},
|
cache: map[string]wetterEintrag{},
|
||||||
client: &http.Client{Timeout: wetterTimeout},
|
client: &http.Client{Timeout: wetterTimeout},
|
||||||
wetterURL: "https://api.open-meteo.com/v1/forecast",
|
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;
|
// frisch ist. Das Wetter ändert sich langsamer, als Seiten neu geladen werden;
|
||||||
// ohne diesen Speicher stellte jeder Seitenaufruf in jeder App eine Anfrage
|
// ohne diesen Speicher stellte jeder Seitenaufruf in jeder App eine Anfrage
|
||||||
// nach draußen.
|
// 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)
|
schlüssel := fmt.Sprintf("%.3f,%.3f", lat, lon)
|
||||||
|
|
||||||
d.mu.Lock()
|
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
|
// 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
|
// Ort wird mit seinen Koordinaten gespeichert, damit die spätere Abfrage nicht
|
||||||
// jedes Mal erneut sucht.
|
// 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)
|
name = strings.TrimSpace(name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return nil, fmt.Errorf("kein Suchbegriff")
|
return nil, fmt.Errorf("kein Suchbegriff")
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package weather
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
// Ein erfundener Wetterdienst. Kein Test in diesem Projekt fragt je wirklich
|
// 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
|
// bei Open-Meteo an: Das wäre langsam, unzuverlässig und würde einen fremden
|
||||||
// Dienst für unsere Testläufe benutzen.
|
// 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()
|
t.Helper()
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if aufrufe != nil {
|
if aufrufe != nil {
|
||||||
@@ -32,7 +32,7 @@ func fakeWetter(t *testing.T, code int, grad float64, aufrufe *int32) *wetterDie
|
|||||||
}))
|
}))
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
d := neuerWetterdienst()
|
d := Neu()
|
||||||
d.wetterURL = srv.URL + "/forecast"
|
d.wetterURL = srv.URL + "/forecast"
|
||||||
d.sucheURL = srv.URL + "/search"
|
d.sucheURL = srv.URL + "/search"
|
||||||
return d
|
return d
|
||||||
@@ -114,37 +114,3 @@ func TestSucheOhneBegriffFragtNichtAn(t *testing.T) {
|
|||||||
t.Fatalf("leerer Begriff ging trotzdem nach draußen (%d)", aufrufe)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
|
"honeyos/internal/auth"
|
||||||
|
"honeyos/internal/config"
|
||||||
|
"honeyos/internal/store"
|
||||||
|
"honeyos/internal/weather"
|
||||||
|
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"embed"
|
"embed"
|
||||||
@@ -22,76 +28,35 @@ import (
|
|||||||
//go:embed all:web
|
//go:embed all:web
|
||||||
var webFS embed.FS
|
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
|
// 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.
|
// 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.
|
// Vorher stand sie zusätzlich in os.js und lief mit den Image-Tags auseinander.
|
||||||
const Version = "1.0"
|
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 {
|
type Server struct {
|
||||||
cfg Config
|
cfg config.Config
|
||||||
auth *Auth
|
auth *auth.Auth
|
||||||
store *Store
|
store *store.Store
|
||||||
wetter *wetterDienst
|
wetter *weather.Dienst
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
log.SetFlags(log.LstdFlags)
|
log.SetFlags(log.LstdFlags)
|
||||||
|
|
||||||
cfg := Config{
|
cfg := config.Config{
|
||||||
Addr: env("HONEYOS_ADDR", ":80"),
|
Addr: config.Env("HONEYOS_ADDR", ":80"),
|
||||||
PublicURL: env("HONEYOS_PUBLIC_URL", ""),
|
PublicURL: config.Env("HONEYOS_PUBLIC_URL", ""),
|
||||||
DataDir: env("HONEYOS_DATA_DIR", "/data"),
|
DataDir: config.Env("HONEYOS_DATA_DIR", "/data"),
|
||||||
SessionSecret: env("HONEYOS_SESSION_SECRET", ""),
|
SessionSecret: config.Env("HONEYOS_SESSION_SECRET", ""),
|
||||||
OIDCIssuer: strings.TrimRight(env("HONEYOS_OIDC_ISSUER", ""), "/"),
|
OIDCIssuer: strings.TrimRight(config.Env("HONEYOS_OIDC_ISSUER", ""), "/"),
|
||||||
OIDCClientID: env("HONEYOS_OIDC_CLIENT_ID", ""),
|
OIDCClientID: config.Env("HONEYOS_OIDC_CLIENT_ID", ""),
|
||||||
OIDCClientSecret: env("HONEYOS_OIDC_CLIENT_SECRET", ""),
|
OIDCClientSecret: config.Env("HONEYOS_OIDC_CLIENT_SECRET", ""),
|
||||||
OIDCRedirect: env("HONEYOS_OIDC_REDIRECT_URL", ""),
|
OIDCRedirect: config.Env("HONEYOS_OIDC_REDIRECT_URL", ""),
|
||||||
ButtonLabel: env("HONEYOS_BUTTON_LABEL", "Anmelden"),
|
ButtonLabel: config.Env("HONEYOS_BUTTON_LABEL", "Anmelden"),
|
||||||
AllowedUsers: splitList(env("HONEYOS_ALLOWED_USERS", "")),
|
AllowedUsers: splitList(config.Env("HONEYOS_ALLOWED_USERS", "")),
|
||||||
ProxyAuth: env("HONEYOS_PROXY_AUTH", "") == "true",
|
ProxyAuth: config.Env("HONEYOS_PROXY_AUTH", "") == "true",
|
||||||
UserHeader: env("HONEYOS_USER_HEADER", ""),
|
UserHeader: config.Env("HONEYOS_USER_HEADER", ""),
|
||||||
AppsKey: env("HONEYOS_APPS_KEY", ""),
|
AppsKey: config.Env("HONEYOS_APPS_KEY", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.OIDCIssuer != "" {
|
if cfg.OIDCIssuer != "" {
|
||||||
@@ -125,12 +90,12 @@ func main() {
|
|||||||
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
|
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
|
||||||
log.Fatalf("Datenverzeichnis %s: %v", cfg.DataDir, err)
|
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 {
|
if err != nil {
|
||||||
log.Fatal(err)
|
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{
|
srv := &http.Server{
|
||||||
Addr: cfg.Addr,
|
Addr: cfg.Addr,
|
||||||
@@ -139,7 +104,7 @@ func main() {
|
|||||||
IdleTimeout: 60 * time.Second,
|
IdleTimeout: 60 * time.Second,
|
||||||
}
|
}
|
||||||
log.Printf("HoneyOS %s lauscht auf %s (Daten: %s, Anmeldung: %s)",
|
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())
|
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")
|
given = r.URL.Query().Get("key")
|
||||||
}
|
}
|
||||||
if subtle.ConstantTimeCompare([]byte(given), []byte(key)) == 1 {
|
if subtle.ConstantTimeCompare([]byte(given), []byte(key)) == 1 {
|
||||||
next(w, r, localSub)
|
next(w, r, store.LocalSub)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Ohne eigene Anmeldung gibt es keine Sitzung, die den Schlüssel
|
// 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
|
return
|
||||||
}
|
}
|
||||||
if r.Method != http.MethodGet {
|
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()})
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
|
||||||
return
|
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) {
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
s.auth.clearSession(w, r)
|
s.auth.ClearSession(w, r)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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)
|
u := s.store.User(sub)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"sub": sub,
|
"sub": sub,
|
||||||
"name": firstNonEmpty(u.Name, "marc"),
|
"name": cmp.Or(u.Name, "marc"),
|
||||||
"picture": u.Picture,
|
"picture": u.Picture,
|
||||||
"settings": u.Settings,
|
"settings": u.Settings,
|
||||||
"oidc": s.auth.Enabled(),
|
"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) {
|
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 {
|
if err := decode(r, &in); err != nil {
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -414,13 +379,6 @@ func writeJSON(w http.ResponseWriter, code int, v any) {
|
|||||||
_ = json.NewEncoder(w).Encode(v)
|
_ = 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 {
|
func splitList(s string) []string {
|
||||||
var out []string
|
var out []string
|
||||||
for _, p := range strings.Split(s, ",") {
|
for _, p := range strings.Split(s, ",") {
|
||||||
|
|||||||
File renamed without changes.
+2
-2
@@ -6,7 +6,7 @@
|
|||||||
<meta name="theme-color" content="#0a0705" />
|
<meta name="theme-color" content="#0a0705" />
|
||||||
<title>HoneyOS</title>
|
<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="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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Bildschirm: alles liegt darin, damit Scanlines/Vignette darüber passen -->
|
<!-- Bildschirm: alles liegt darin, damit Scanlines/Vignette darüber passen -->
|
||||||
@@ -110,6 +110,6 @@
|
|||||||
<div class="flicker" aria-hidden="true"></div>
|
<div class="flicker" aria-hidden="true"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="./os.js"></script>
|
<script type="module" src="./js/os.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+120
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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.
|
* HoneyOS — Desktop-Logik.
|
||||||
*
|
*
|
||||||
@@ -16,308 +25,8 @@
|
|||||||
* gemeinsamen Teile hier oben und die beiden Ansichten weiter unten getrennt.
|
* 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 ─────────────────────────────────────────────────── */
|
/* ── Laden & Speichern ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -336,46 +45,8 @@ async function loadFromServer() {
|
|||||||
|
|
||||||
/** Einstellungen vorab aus localStorage, damit die Röhre nicht erst weiß
|
/** Einstellungen vorab aus localStorage, damit die Röhre nicht erst weiß
|
||||||
* aufblitzt und dann umspringt. */
|
* 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 ──────────────────────────────────────────────────────────────── */
|
/* ── Boot ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -436,13 +107,6 @@ function runBoot() {
|
|||||||
|
|
||||||
/* ── Gemeinsames: Menüleiste, Uhr, Stand, Start ────────────────────────── */
|
/* ── 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() {
|
function renderShell() {
|
||||||
$("desktop").hidden = false;
|
$("desktop").hidden = false;
|
||||||
@@ -497,99 +161,7 @@ function renderMode() {
|
|||||||
paintProbes();
|
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) {
|
function setMode(mode) {
|
||||||
if (state.settings.mode === mode) return;
|
if (state.settings.mode === mode) return;
|
||||||
@@ -1219,12 +791,6 @@ function openAbout() {
|
|||||||
|
|
||||||
/* ── Hilfsmittel ───────────────────────────────────────────────────────── */
|
/* ── Hilfsmittel ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
function escapeHtml(s) {
|
|
||||||
return String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
|
||||||
}
|
|
||||||
function escapeAttr(s) {
|
|
||||||
return escapeHtml(s).replace(/"/g, """);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Startmenü ─────────────────────────────────────────────────────────── */
|
/* ── Startmenü ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)}°`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user