feat(wetter): Wetter auf dem Schreibtisch und in der Leiste

Zwei Schalter, unabhaengig voneinander: Schreibtisch, Leiste, beides oder
keins. Die Stadt wird einmal gesucht und mit ihren Koordinaten gespeichert,
sonst suchte jede Abfrage den Ort erneut.

Geholt wird serverseitig und zehn Minuten zwischengespeichert. Der Grund ist
nicht Sparsamkeit: Die Leiste steht auf jeder Seite jeder App, und fragte sie
selbst an, ginge bei jedem Seitenaufruf eine Anfrage samt IP an einen Fremden —
und die CSP jeder App muesste ihn erlauben.

Kein Ort eingestellt oder Dienst nicht erreichbar: 204, und das Symbol bleibt
weg. Eine Zahl von gestern waere schlechter als keine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-25 13:12:09 +02:00
1 parent 52b11efe06
commit 4e38ccdec1
10 files changed
+823 -6

No files matched your search

+17 -1
View File
@@ -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,
+26
View File
@@ -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:
Executable
BIN
View File
Binary file not shown.
+47 -5
View File
@@ -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 {
+210
View File
@@ -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
}
+150
View File
@@ -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)
}
}
+71
View File
@@ -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 += `<rect x="${x - run}" y="${y}" width="${run}" height="1"/>`; run = 0; }
}
});
return `<svg viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">${d}</svg>`;
}
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 @@
<nav class="apps" id="apps"></nav>
<div class="right">
<span class="user" id="user" hidden></span>
<button class="wx" id="wx" hidden title="Wetter"></button>
<span class="clock" id="clock"></span>
<button class="menu-btn" id="menuBtn" aria-haspopup="true" aria-expanded="false">&#9662;</button>
</div>
@@ -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) + `<span class="grad">${Math.round(w.grad)}°</span>`;
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();
+9
View File
@@ -53,6 +53,13 @@
</div>
</section>
<!-- Wetter. Steht in beiden Betriebsarten zur Verfügung, wird aber
nur gezeigt, wenn ein Ort eingestellt und das Widget an ist. -->
<section class="win win-weather" id="wxTile" hidden>
<div class="win-bar"><span class="win-title">wetter</span><span class="win-dots">&#9679;&#9679;&#9679;</span></div>
<div class="win-body wx-body"></div>
</section>
<!-- App-Kacheln -->
<section class="tiles" id="tiles" aria-label="Apps starten"></section>
</main>
@@ -61,6 +68,8 @@
<div class="deskspace" id="deskspace" hidden>
<div class="desk-icons" id="deskIcons"></div>
<div class="windows" id="windows"></div>
<!-- Dasselbe Wetter, im Schreibtisch-Modus als kleines Feld oben rechts -->
<div class="deskwidget" id="wxDesk" hidden></div>
</div>
<footer class="taskbar" id="taskbar" hidden>
+224
View File
@@ -26,11 +26,20 @@ const DEFAULT_SETTINGS = {
flicker: "on",
glow: "on",
openIn: "tab", // tab | same
// Wetter. Zwei Schalter statt einem: Der Schreibtisch und die Leiste 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"
@@ -47,6 +56,107 @@ const isTouch = () => window.matchMedia("(pointer: coarse)").matches;
* im Retro-Raster statt aus einer Icon-Bibliothek zu kommen.
*/
const GLYPHS = {
// Wettersymbole. Dieselben Namen wie in weather.go (wmo) und in der Leiste —
// solange 1.3 (geteilte Symbolquelle) nicht gebaut ist, stehen sie zweimal da.
sonne: [
"............",
".....##.....",
"..#..##..#..",
"...#....#...",
"....####....",
"..##....##..",
"..##....##..",
"....####....",
"...#....#...",
"..#..##..#..",
".....##.....",
"............",
],
heiter: [
"............",
"...##.......",
"..####..#...",
"...##..###..",
"......#####.",
"...####...#.",
"..#....##.#.",
".##........#",
"#..........#",
".##########.",
"............",
"............",
],
wolke: [
"............",
"....####....",
"...#....#...",
"..#......##.",
".##........#",
"##.........#",
"#..........#",
"#..........#",
".##########.",
"............",
"............",
"............",
],
nebel: [
"............",
"....####....",
"..##....##..",
".##.......#.",
"#..........#",
".##########.",
"............",
".#########..",
"............",
"..#########.",
"............",
".#########..",
],
regen: [
"............",
"....####....",
"...#....#...",
"..#......##.",
".##........#",
"##.........#",
".##########.",
"............",
"..#..#..#...",
".#..#..#....",
"..#..#..#...",
".#..#..#....",
],
schnee: [
"............",
"....####....",
"...#....#...",
"..#......##.",
".##........#",
"##.........#",
".##########.",
"............",
"..#...#...#.",
".#.#.#.#.#..",
"..#...#...#.",
"............",
],
gewitter: [
"............",
"....####....",
"...#....#...",
"..#......##.",
".##........#",
"##.........#",
".##########.",
"......##....",
".....##.....",
"....####....",
"......##....",
".....##.....",
],
cloud: [
"............",
"....####....",
@@ -346,6 +456,11 @@ function renderShell() {
renderMenubarApps();
renderMode();
tickClock();
// Nicht abgewartet: Das Wetter kommt von außen und darf den Aufbau des
// Schreibtischs nicht verzögern. Danach alle 10 Minuten, im selben Takt,
// in dem der Server seinen Zwischenspeicher erneuert.
loadWeather();
setInterval(loadWeather, 10 * 60 * 1000);
}
function renderMenubarApps() {
@@ -383,9 +498,57 @@ function renderMode() {
renderTiles();
}
renderStats();
renderWeather();
paintProbes();
}
/* ── 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 an = state.settings.weatherDesk === "on" && state.weather;
const tile = $("wxTile");
const desk = $("wxDesk");
const desktopMode = state.settings.mode === "desktop";
tile.hidden = !an || desktopMode;
desk.hidden = !an || !desktopMode;
if (!an) return;
const html = weatherHtml(state.weather);
tile.querySelector(".wx-body").innerHTML = html;
desk.innerHTML = html;
}
function setMode(mode) {
if (state.settings.mode === mode) return;
state.settings.mode = mode;
@@ -875,6 +1038,19 @@ function openSettings() {
${seg("openIn", [["tab", "neuer tab"], ["same", "gleiches fenster"]], s.openIn)}
</div>
<div class="field">
<span class="label">Wetter</span>
${seg("weatherDesk", [["on", "auf dem schreibtisch"], ["off", "aus"]], s.weatherDesk)}
<div style="height:6px"></div>
${seg("weatherBar", [["on", "in der leiste"], ["off", "aus"]], s.weatherBar)}
<div class="wx-city-row">
<input id="wxQuery" type="text" placeholder="Stadt suchen …" autocomplete="off" />
<button class="btn" id="btnWxSearch">suchen</button>
</div>
<div id="wxResults" class="wx-results"></div>
<p class="ae-hint" id="wxCurrent" style="margin-left:0"></p>
</div>
<div class="field">
<span class="label">Programme</span>
<button class="btn" id="btnApps">${state.cfg.apps.length} eingetragen — verwalten …</button>
@@ -902,10 +1078,58 @@ function openSettings() {
state.settings[key] = btn.dataset.v;
saveSettings();
renderStats();
if (key.startsWith("weather")) loadWeather();
if (key === "openIn" && state.settings.mode === "terminal") renderTiles();
});
});
const zeigeOrt = () => {
root.querySelector("#wxCurrent").textContent = state.settings.weatherCity
? `eingestellt: ${state.settings.weatherCity}`
: "kein Ort eingestellt — ohne Ort bleibt das Wetter aus.";
};
zeigeOrt();
const suchen = async () => {
const q = root.querySelector("#wxQuery").value.trim();
const out = root.querySelector("#wxResults");
if (!q) return;
out.textContent = "suche …";
let orte;
try {
orte = await api("/api/weather/search?q=" + encodeURIComponent(q));
} catch {
out.textContent = "Ortssuche nicht erreichbar.";
return;
}
if (!orte || !orte.length) {
out.textContent = "nichts gefunden.";
return;
}
out.innerHTML = orte
.map((o, i) => `<button type="button" class="wx-hit" data-i="${i}">${o.name}<span>${o.land}</span></button>`)
.join("");
out.querySelectorAll(".wx-hit").forEach((b) =>
b.addEventListener("click", () => {
const o = orte[Number(b.dataset.i)];
// Koordinaten mitspeichern: Sonst müsste jede Wetterabfrage den Ort
// erneut suchen — zwei Anfragen nach draußen statt einer.
state.settings.weatherCity = o.name;
state.settings.weatherLat = String(o.lat);
state.settings.weatherLon = String(o.lon);
saveSettings();
out.innerHTML = "";
root.querySelector("#wxQuery").value = "";
zeigeOrt();
loadWeather();
})
);
};
root.querySelector("#btnWxSearch").addEventListener("click", suchen);
root.querySelector("#wxQuery").addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); suchen(); }
});
root.querySelector("#btnApps").addEventListener("click", openApps);
},
420
+69
View File
@@ -885,3 +885,72 @@ body {
color: var(--p-mid);
}
.saveline[data-kind="err"] { color: #ff7a5c; }
/* ── Wetter ────────────────────────────────────────────────────────────────
Zweimal dasselbe Feld: als Fenster im Terminal-Modus, als kleines Schild
oben rechts auf dem Schreibtisch. Ein Aufbau, zwei Rahmen. */
.wx {
display: flex;
align-items: center;
gap: 12px;
}
.wx-glyph {
display: block;
width: 44px;
height: 44px;
flex: 0 0 44px;
color: var(--p);
}
.wx-glyph svg { width: 100%; height: 100%; display: block; }
.wx-text { display: flex; flex-direction: column; line-height: 1.25; min-width: 0; }
.wx-temp { font-size: 22px; color: var(--p); }
.wx-desc { color: var(--p-mid); }
.wx-city {
color: var(--p-dim);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Terminal-Modus: ein Fenster wie die anderen, aber schmal. Über die volle
Breite gezogen sähe eine Temperatur nach Bekanntmachung aus, nicht nach
Widget. */
.win-weather { max-width: 320px; align-self: start; }
/* Schreibtisch-Modus: schwebendes Schild, rechts unter der Menüleiste. Nicht
verschiebbar wie ein Fenster — ein Widget, das man erst suchen muss, ist
keins. */
.deskwidget {
position: absolute;
top: 14px;
right: 14px;
z-index: 5;
padding: 10px 14px;
background: var(--bg);
border: 1px solid var(--p-faint);
box-shadow: 0 0 0 1px var(--bg), 4px 4px 0 rgba(0, 0, 0, 0.5);
}
@media (max-width: 620px) {
/* Am Handy stünde es über der Symbolspalte. */
.deskwidget { position: static; margin: 10px; }
}
/* Ortssuche in den Einstellungen */
.wx-city-row { display: flex; gap: 6px; margin-top: 8px; }
.wx-city-row input { flex: 1; min-width: 0; }
.wx-results { display: flex; flex-direction: column; gap: 2px; margin-top: 6px; }
.wx-hit {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 5px 8px;
background: transparent;
border: 1px solid transparent;
color: var(--p-mid);
font: inherit;
text-align: left;
cursor: pointer;
}
.wx-hit span { color: var(--p-dim); font-size: 12px; }
.wx-hit:hover { border-color: var(--p-faint); color: var(--p); }