diff --git a/IDEAS.md b/IDEAS.md index 7637bef..598d5b9 100644 --- a/IDEAS.md +++ b/IDEAS.md @@ -187,7 +187,23 @@ Eines bauen, benutzen, dann entscheiden. - **Speicherplatz** (Aether) — eine Zahl, ein Balken, billig zu bauen - **Letzte Notizen** (InkNote) — braucht 1.3, kommt also später -### Wetter in der Leiste, neben der Uhr — Marcs Idee (25.08.2026) +### Wetter in der Leiste, neben der Uhr — ✅ gebaut (25.08.2026) + +**Steht.** Zwei Schalter in den Einstellungen, unabhängig voneinander: auf dem +Schreibtisch, in der Leiste, beides oder keins. Die Stadt wird einmal gesucht +und mit ihren Koordinaten gespeichert; der Server holt das Wetter und legt es +zehn Minuten beiseite, die Oberflächen zeigen nur, was er hergibt. + +Offen geblieben ist eines mit Absicht: **Die Stadt lässt sich nur auf dem +Schreibtisch ändern, nicht aus der Leiste heraus.** Ein Klick auf das Symbol +führt dorthin. Der Grund ist derselbe wie bei allem anderen in der Leiste: Sie +liest über den Schlüssel und darf nichts schreiben — und über eine Site-Grenze +wüsste sie nicht, für wen sie es täte. Liegt alles unter einer Domain, wäre es +nachrüstbar. + +Die ursprüngliche Notiz, zur Nachvollziehbarkeit: + +#### Wie es geplant war Ein Pixelsymbol neben der Uhrzeit, dazu die Temperatur. Ein Klick öffnet ein kleines Feld, in dem die Stadt eingestellt wird. Alles im Röhren-Look: Sonne, diff --git a/README.md b/README.md index 3ddb9ee..4972396 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,32 @@ in beiden: Menüleiste, Palette, Einstellungen, App-Liste. **Tasten:** `1` `2` App starten · `M` Betriebsart · `S` Einstellungen · `?` Hilfe · `Esc` schließt Startmenü, oberstes Fenster oder Dialog — in dieser Reihenfolge. +## Wetter + +Ein Pixelsymbol mit der Temperatur — wahlweise auf dem Schreibtisch, in der +Leiste, in beidem oder nirgends. Zwei getrennte Schalter unter +*Einstellungen → Wetter*, weil das zwei verschiedene Orte sind: Wer es beim +Arbeiten in einer App sehen will, will es nicht zwangsläufig auch auf einer +Seite, die er selten aufruft. + +Die Stadt wird dort einmal gesucht und **mit ihren Koordinaten** gespeichert; +sonst müsste jede Abfrage den Ort erneut suchen — zwei Anfragen nach draußen +statt einer. Ohne Stadt bleibt das Wetter aus. + +**Der Server holt es, nicht der Browser.** Die Leiste steht auf jeder Seite +jeder App: Fragte sie selbst beim Wetterdienst an, ginge bei jedem Seitenaufruf +aus jeder App eine Anfrage samt IP dorthin, und die CSP jeder App müsste den +fremden Ursprung erlauben. So sieht der Dienst einen Server. Die Antwort gilt +zehn Minuten — das Wetter ändert sich langsamer, als Seiten neu geladen werden. + +Verwendet wird [Open-Meteo](https://open-meteo.com): kein Zugangsschlüssel, kein +Konto, nichts einzurichten. Ist der Dienst nicht erreichbar oder das Gerät +offline, verschwindet das Symbol — lieber keins als eine Zahl von gestern. + +Ändern lässt sich die Stadt nur auf dem Schreibtisch; ein Klick auf das Symbol +in der Leiste führt dorthin. Die Leiste liest über den Schlüssel und darf nichts +schreiben. + ## Die Leiste in Aether / InkNote Eine Zeile ins `frontend/index.html` der App: diff --git a/honeyos b/honeyos new file mode 100755 index 0000000..64d3cf1 Binary files /dev/null and b/honeyos differ diff --git a/main.go b/main.go index f1dd464..5a693fd 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,7 @@ import ( "log" "net/http" "os" + "strconv" "strings" "time" ) @@ -27,7 +28,7 @@ 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 = "0.7" +const Version = "0.8" type Config struct { Addr string @@ -68,9 +69,10 @@ func (c Config) RedirectURL() string { } type Server struct { - cfg Config - auth *Auth - store *Store + cfg Config + auth *Auth + store *Store + wetter *wetterDienst } func main() { @@ -128,7 +130,7 @@ func main() { log.Fatal(err) } - s := &Server{cfg: cfg, auth: NewAuth(cfg), store: store} + s := &Server{cfg: cfg, auth: NewAuth(cfg), store: store, wetter: neuerWetterdienst()} srv := &http.Server{ Addr: cfg.Addr, @@ -161,6 +163,13 @@ func (s *Server) routes() http.Handler { mux.HandleFunc("PUT /api/settings", s.requireAuth(s.handleSetSettings)) mux.HandleFunc("GET /api/apps", s.appsReadable(s.handleApps)) mux.HandleFunc("PUT /api/apps", s.requireAuth(s.handleSetApps)) + // Dieselbe Tür wie die App-Liste: Die Leiste steht in einer fremden App und + // kommt dort nur mit dem Schlüssel an Daten. + mux.HandleFunc("GET /api/weather", s.appsReadable(s.handleWeather)) + // Die Ortssuche dagegen nur für Angemeldete. Sie reicht einen Suchbegriff an + // einen fremden Dienst weiter — offen stünde damit ein Weg, über HoneyOS + // beliebig dort anzufragen. + mux.HandleFunc("GET /api/weather/search", s.requireAuth(s.handleWeatherSearch)) web, err := fs.Sub(webFS, "web") if err != nil { @@ -339,6 +348,39 @@ func (s *Server) handleApps(w http.ResponseWriter, r *http.Request, sub string) }) } +// handleWeather liefert das Wetter für den eingestellten Ort — oder nichts. +// +// „Nichts" ist hier ein 204 und kein Fehler: Kein Ort eingestellt ist der +// Normalfall, solange niemand einen gewählt hat, und die Leiste soll dafür +// nicht rot blinken, sondern das Symbol einfach weglassen. Dasselbe gilt, wenn +// der Dienst nicht erreichbar ist: lieber kein Wetter als eine Zahl von gestern. +func (s *Server) handleWeather(w http.ResponseWriter, r *http.Request, sub string) { + set := s.store.SettingsFor(sub) + lat, err1 := strconv.ParseFloat(set["weatherLat"], 64) + lon, err2 := strconv.ParseFloat(set["weatherLon"], 64) + if set["weatherCity"] == "" || err1 != nil || err2 != nil { + w.WriteHeader(http.StatusNoContent) + return + } + + wetter, err := s.wetter.Jetzt(r.Context(), set["weatherCity"], lat, lon) + if err != nil { + log.Printf("wetter: %v", err) + w.WriteHeader(http.StatusNoContent) + return + } + writeJSON(w, http.StatusOK, wetter) +} + +func (s *Server) handleWeatherSearch(w http.ResponseWriter, r *http.Request, sub string) { + orte, err := s.wetter.Suche(r.Context(), r.URL.Query().Get("q")) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "Ortssuche nicht erreichbar"}) + return + } + writeJSON(w, http.StatusOK, orte) +} + func (s *Server) handleSetApps(w http.ResponseWriter, r *http.Request, sub string) { var in []App if err := decode(r, &in); err != nil { diff --git a/weather.go b/weather.go new file mode 100644 index 0000000..fc19d1b --- /dev/null +++ b/weather.go @@ -0,0 +1,210 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +// Wetter kommt vom Server, nicht aus dem Browser. +// +// Die Leiste läuft auf jeder Seite jeder App. Fragte sie selbst beim +// Wetterdienst an, ginge bei jedem Seitenaufruf aus jeder App eine Anfrage samt +// IP dorthin — und die CSP jeder App müsste den fremden Ursprung erlauben. Holt +// HoneyOS es, sieht der Dienst einen Server, und die Apps bleiben unberührt. +// +// Open-Meteo braucht keinen Schlüssel und keine Anmeldung. Das ist der Grund für +// die Wahl: ein Zugangsschlüssel in der .env wäre eine Sache mehr, die jemand +// einrichten muss, bevor irgendetwas zu sehen ist. +const ( + wetterFrisch = 10 * time.Minute // so lange gilt eine Antwort + wetterTimeout = 6 * time.Second // ein hängender Dienst darf nichts aufhalten +) + +type Wetter struct { + Ort string `json:"ort"` + Grad float64 `json:"grad"` + Code int `json:"code"` + Text string `json:"text"` + Glyph string `json:"glyph"` + Stand string `json:"stand"` +} + +type Ort struct { + Name string `json:"name"` + Land string `json:"land"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` +} + +type wetterDienst struct { + mu sync.Mutex + cache map[string]wetterEintrag + client *http.Client + + // Adressen als Feld, damit der Test einen eigenen Server unterschieben kann. + wetterURL string + sucheURL string +} + +type wetterEintrag struct { + w Wetter + bis time.Time +} + +func neuerWetterdienst() *wetterDienst { + return &wetterDienst{ + cache: map[string]wetterEintrag{}, + client: &http.Client{Timeout: wetterTimeout}, + wetterURL: "https://api.open-meteo.com/v1/forecast", + sucheURL: "https://geocoding-api.open-meteo.com/v1/search", + } +} + +// wmo übersetzt den Wettercode in ein Pixelsymbol und einen kurzen Text. +// Die Skala ist die der Weltorganisation für Meteorologie; Open-Meteo liefert +// sie so. Zusammengefasst auf sechs Symbole — feiner lässt sich in zwölf mal +// zwölf Pixeln ohnehin nichts unterscheiden. +func wmo(code int) (glyph, text string) { + switch { + case code == 0: + return "sonne", "klar" + case code <= 2: + return "heiter", "heiter" + case code == 3: + return "wolke", "bedeckt" + case code == 45 || code == 48: + return "nebel", "nebel" + case code >= 51 && code <= 67, code >= 80 && code <= 82: + return "regen", "regen" + case code >= 71 && code <= 77, code == 85 || code == 86: + return "schnee", "schnee" + case code >= 95: + return "gewitter", "gewitter" + } + return "wolke", "wolken" +} + +// Jetzt liefert das Wetter für einen Ort — aus dem Zwischenspeicher, solange er +// 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) { + schlüssel := fmt.Sprintf("%.3f,%.3f", lat, lon) + + d.mu.Lock() + if e, ok := d.cache[schlüssel]; ok && time.Now().Before(e.bis) { + d.mu.Unlock() + e.w.Ort = ort // der Name gehört den Einstellungen, nicht dem Dienst + return e.w, nil + } + d.mu.Unlock() + + u, _ := url.Parse(d.wetterURL) + q := u.Query() + q.Set("latitude", strconv.FormatFloat(lat, 'f', 4, 64)) + q.Set("longitude", strconv.FormatFloat(lon, 'f', 4, 64)) + q.Set("current", "temperature_2m,weather_code") + q.Set("timezone", "auto") + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return Wetter{}, err + } + res, err := d.client.Do(req) + if err != nil { + return Wetter{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return Wetter{}, fmt.Errorf("wetterdienst antwortet %d", res.StatusCode) + } + + var antwort struct { + Current struct { + Temp float64 `json:"temperature_2m"` + Code int `json:"weather_code"` + } `json:"current"` + } + if err := json.NewDecoder(res.Body).Decode(&antwort); err != nil { + return Wetter{}, err + } + + glyph, text := wmo(antwort.Current.Code) + w := Wetter{ + Ort: ort, + Grad: antwort.Current.Temp, + Code: antwort.Current.Code, + Text: text, + Glyph: glyph, + Stand: time.Now().Format(time.RFC3339), + } + + d.mu.Lock() + d.cache[schlüssel] = wetterEintrag{w: w, bis: time.Now().Add(wetterFrisch)} + d.mu.Unlock() + return w, nil +} + +// 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) { + name = strings.TrimSpace(name) + if name == "" { + return nil, fmt.Errorf("kein Suchbegriff") + } + + u, _ := url.Parse(d.sucheURL) + q := u.Query() + q.Set("name", name) + q.Set("count", "6") + q.Set("language", "de") + q.Set("format", "json") + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + res, err := d.client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ortssuche antwortet %d", res.StatusCode) + } + + var antwort struct { + Results []struct { + Name string `json:"name"` + Country string `json:"country"` + Admin1 string `json:"admin1"` + Lat float64 `json:"latitude"` + Lon float64 `json:"longitude"` + } `json:"results"` + } + if err := json.NewDecoder(res.Body).Decode(&antwort); err != nil { + return nil, err + } + + orte := make([]Ort, 0, len(antwort.Results)) + for _, r := range antwort.Results { + land := r.Country + // „Köln, Nordrhein-Westfalen" trennt zwei Treffer besser als + // „Köln, Deutschland" — davon gibt es mehrere. + if r.Admin1 != "" && r.Admin1 != r.Name { + land = r.Admin1 + ", " + r.Country + } + orte = append(orte, Ort{Name: r.Name, Land: land, Lat: r.Lat, Lon: r.Lon}) + } + return orte, nil +} diff --git a/weather_test.go b/weather_test.go new file mode 100644 index 0000000..b2f58ca --- /dev/null +++ b/weather_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +// 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 { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if aufrufe != nil { + atomic.AddInt32(aufrufe, 1) + } + 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 := neuerWetterdienst() + d.wetterURL = srv.URL + "/forecast" + d.sucheURL = srv.URL + "/search" + return d +} + +func TestWetterKommtAn(t *testing.T) { + d := fakeWetter(t, 61, 12.4, nil) + w, err := d.Jetzt(context.Background(), "Köln", 50.9, 6.9) + if err != nil { + t.Fatalf("unerwartet: %v", err) + } + if w.Ort != "Köln" || w.Grad != 12.4 { + t.Fatalf("falsche Werte: %+v", w) + } + if w.Glyph != "regen" || w.Text != "regen" { + t.Fatalf("Code 61 ist Regen, nicht %q/%q", w.Glyph, w.Text) + } +} + +// Der Zwischenspeicher ist der Grund, warum die Leiste auf jeder Seite jeder App +// stehen darf: Ohne ihn ginge bei jedem Seitenaufruf eine Anfrage nach draußen. +func TestZweiteFrageGehtNichtNachDraussen(t *testing.T) { + var aufrufe int32 + d := fakeWetter(t, 0, 21, &aufrufe) + for i := 0; i < 5; i++ { + if _, err := d.Jetzt(context.Background(), "Köln", 50.9, 6.9); err != nil { + t.Fatalf("unerwartet: %v", err) + } + } + if aufrufe != 1 { + t.Fatalf("%d Anfragen nach draußen, erwartet 1", aufrufe) + } +} + +func TestAndererOrtWirdGetrenntGehalten(t *testing.T) { + var aufrufe int32 + d := fakeWetter(t, 0, 21, &aufrufe) + d.Jetzt(context.Background(), "Köln", 50.9, 6.9) + d.Jetzt(context.Background(), "Wien", 48.2, 16.4) + if aufrufe != 2 { + t.Fatalf("zwei Orte, %d Anfragen", aufrufe) + } +} + +func TestWettercodesWerdenZuSymbolen(t *testing.T) { + fälle := map[int]string{ + 0: "sonne", 2: "heiter", 3: "wolke", 45: "nebel", + 61: "regen", 80: "regen", 73: "schnee", 86: "schnee", 95: "gewitter", + } + for code, will := range fälle { + if g, _ := wmo(code); g != will { + t.Errorf("Code %d → %q, erwartet %q", code, g, will) + } + } +} + +func TestOrtssucheGibtKoordinatenZurueck(t *testing.T) { + d := fakeWetter(t, 0, 20, nil) + orte, err := d.Suche(context.Background(), "Köln") + if err != nil { + t.Fatalf("unerwartet: %v", err) + } + if len(orte) != 1 || orte[0].Lat == 0 || orte[0].Lon == 0 { + t.Fatalf("ohne Koordinaten müsste jede Abfrage neu suchen: %+v", orte) + } + // Bundesland statt Land: „Köln, Deutschland" gibt es mehrfach. + if !strings.Contains(orte[0].Land, "Nordrhein-Westfalen") { + t.Fatalf("Land zu grob: %q", orte[0].Land) + } +} + +func TestSucheOhneBegriffFragtNichtAn(t *testing.T) { + var aufrufe int32 + d := fakeWetter(t, 0, 20, &aufrufe) + if _, err := d.Suche(context.Background(), " "); err == nil { + t.Fatal("leerer Begriff müsste abgelehnt werden") + } + if aufrufe != 0 { + 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/web/honeybar/honeybar.js b/web/honeybar/honeybar.js index 4b6d087..ae084a2 100644 --- a/web/honeybar/honeybar.js +++ b/web/honeybar/honeybar.js @@ -51,6 +51,8 @@ // nur an Ursprünge, die selbst als App eingetragen sind. const appsUrl = d("apps", script ? new URL("../api/apps", script.src).href : ""); const appsKey = d("key", ""); + // Dieselbe Herkunft wie die App-Liste: Wo /api/apps liegt, liegt /api/weather. + const wetterUrl = appsUrl ? appsUrl.replace(/\/apps(\?|$)/, "/weather$1") : ""; let apps = DEFAULT_APPS; let settings = null; @@ -67,6 +69,33 @@ const SAFE = "env(safe-area-inset-top, 0px)"; const HOEHE = "calc(" + BAR_H + "px + " + SAFE + ")"; + /* Wettersymbole als Pixelraster, dieselben Namen wie in weather.go. + Sie stehen auch in os.js — bis die geteilte Symbolquelle (1.3) gebaut ist, + ist das die kleinere Sünde: Die Leiste soll eine einzelne Datei bleiben, + die eine fremde App mit einer Zeile einbindet. */ + const WX = { + sonne: "............;.....##.....;..#..##..#..;...#....#...;....####....;..##....##..;..##....##..;....####....;...#....#...;..#..##..#..;.....##.....;............", + heiter: "............;...##.......;..####..#...;...##..###..;......#####.;...####...#.;..#....##.#.;.##........#;#..........#;.##########.;............;............", + wolke: "............;....####....;...#....#...;..#......##.;.##........#;##.........#;#..........#;#..........#;.##########.;............;............;............", + nebel: "............;....####....;..##....##..;.##.......#.;#..........#;.##########.;............;.#########..;............;..#########.;............;.#########..", + regen: "............;....####....;...#....#...;..#......##.;.##........#;##.........#;.##########.;............;..#..#..#...;.#..#..#....;..#..#..#...;.#..#..#....", + schnee: "............;....####....;...#....#...;..#......##.;.##........#;##.........#;.##########.;............;..#...#...#.;.#.#.#.#.#..;..#...#...#.;............", + gewitter: "............;....####....;...#....#...;..#......##.;.##........#;##.........#;.##########.;......##....;.....##.....;....####....;......##....;.....##.....", + }; + + function wxSvg(name) { + const grid = (WX[name] || WX.wolke).split(";"); + let d = ""; + grid.forEach((row, y) => { + let run = 0; + for (let x = 0; x <= row.length; x++) { + if (row[x] === "#") run++; + else if (run) { d += ``; run = 0; } + } + }); + return ``; + } + const host = document.createElement("honey-bar"); host.style.cssText = [ "position:fixed", "top:0", "left:0", "right:0", @@ -127,6 +156,16 @@ .user { color: var(--p); max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .user[hidden] { display: none; } .clock { color: var(--p-mid); font-variant-numeric: tabular-nums; } + .wx { + display: flex; align-items: center; gap: 5px; color: var(--p-mid); + border-color: transparent; padding: 2px 4px; + } + .wx[hidden] { display: none; } + .wx:hover { color: var(--p); border-color: var(--p-dim); } + .wx svg { width: 14px; height: 14px; display: block; } + /* Am Handy nur das Symbol: 32 px Höhe und die Apps daneben lassen der + Temperatur keinen Platz mehr. */ + @media (max-width: 700px) { .wx .grad { display: none; } } .menu-btn { border-color: var(--p-dim); line-height: 1; padding: 3px 7px; } .menu-btn:hover { background: var(--p-faint); } .menu { @@ -151,6 +190,7 @@
+
@@ -215,6 +255,36 @@ }); } + /** + * Das Wetter neben der Uhr — nur wenn in HoneyOS eingeschaltet und ein Ort + * eingestellt ist. Der Server antwortet sonst mit 204, und dann bleibt hier + * schlicht nichts stehen: lieber kein Symbol als eines ohne Aussage. + */ + async function wetter() { + if (!settings || settings.weatherBar !== "on") return; + let w; + try { + if (!wetterUrl) return; + const res = await fetch(wetterUrl, { + cache: "no-store", + credentials: "include", + headers: appsKey ? { "X-HoneyOS-Key": appsKey } : {}, + }); + if (res.status !== 200) return; + w = await res.json(); + } catch { + return; // offline oder HoneyOS aus — die App geht das nichts an + } + const el = root.getElementById("wx"); + el.innerHTML = wxSvg(w.glyph) + `${Math.round(w.grad)}°`; + el.title = `${w.ort}: ${w.text}, ${Math.round(w.grad)}°`; + el.hidden = false; + // Ein Klick führt dorthin, wo der Ort eingestellt wird. Ändern lässt er + // sich hier nicht: Die Leiste liest über den Schlüssel und darf nichts + // schreiben — und über eine Site-Grenze wüsste sie ohnehin nicht, für wen. + el.onclick = () => window.open(homeUrl, "_blank", "noopener"); + } + /** * Zeigt an, wer angemeldet ist. Die Leiste steckt in der App selbst, der * Aufruf geht also an denselben Ursprung — kein CORS, keine Freigabe nötig. @@ -411,6 +481,7 @@ else document.addEventListener("DOMContentLoaded", mount, { once: true }); showUser(); + wetter(); } start(); diff --git a/web/index.html b/web/index.html index 811e4da..9c6baa5 100644 --- a/web/index.html +++ b/web/index.html @@ -53,6 +53,13 @@ + + +
@@ -61,6 +68,8 @@