Files
HoneyOS/internal/store/store.go
T
marc 0e1c986f90 Reihenfolge mit Pfeilen, Zifferntasten entfernt
Sortiert wird im Programm-Editor mit Pfeilen in der Kopfzeile jeder Zeile —
das Array ist die Reihenfolge, also gilt sie ueberall zugleich: Streifen,
Schublade, Kacheln, Schreibtischsymbole, Startmenue. Neue Apps haengen sich
hinten an, statt eine Taste zu verlangen.

Die Zifferntasten sind ganz weg (Marcs Entscheidung): ein Pflichtfeld je
App, das beim Sortieren nur die Frage aufgeworfen haette, ob es mitwandert.
Damit faellt das Feld 'key' aus dem Datenmodell, aus der Kachel, aus der
Hilfe und aus der Statuszeile. Alte Eintraege verlieren es beim naechsten
Speichern still.

Kein A-Z-Knopf — erstmal zu viel, kommt zurueck wenn er fehlt.
2026-08-29 23:48:04 +02:00

259 lines
6.9 KiB
Go

// Package store hält den ganzen Zustand von HoneyOS: die App-Liste und je
// Nutzer ein paar Einstellungen.
package store
import (
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
)
// Der ganze Zustand von HoneyOS passt in eine JSON-Datei: eine Handvoll Apps
// und pro Nutzer ein paar kosmetische Einstellungen. Eine Datenbank wäre hier
// mehr Betrieb als Nutzen — die Datei lässt sich lesen, sichern und im Zweifel
// mit einem Editor reparieren. Geschrieben wird atomar (tmp + rename), damit
// ein Absturz mitten im Schreiben keine halbe Datei hinterlässt.
// LocalSub ist die ID des einen Nutzers im offenen Betrieb — dann meldet sich
// niemand an, und alles gehört ihm.
const LocalSub = "local"
type App struct {
ID string `json:"id"`
Name string `json:"name"`
Subtitle string `json:"subtitle,omitempty"`
URL string `json:"url"`
Glyph string `json:"glyph,omitempty"`
}
type User struct {
Name string `json:"name,omitempty"`
Picture string `json:"picture,omitempty"`
Settings map[string]string `json:"settings,omitempty"`
}
type data struct {
Apps []App `json:"apps"`
Users map[string]*User `json:"users"`
}
type Store struct {
mu sync.RWMutex
path string
d data
}
const (
maxApps = 24
maxSettings = 40
maxFieldLen = 200
)
var (
idPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`)
knownGlyphs = map[string]bool{"cloud": true, "note": true, "box": true, "chip": true, "folder": true, "gear": true, "globe": true, "disk": true}
settingKeyRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]{0,31}$`)
)
func Open(path string) (*Store, error) {
s := &Store{path: path, d: data{Apps: []App{}, Users: map[string]*User{}}}
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return s, s.save() // gleich anlegen, damit ein Rechteproblem sofort auffällt
}
if err != nil {
return nil, fmt.Errorf("lese %s: %w", path, err)
}
if err := json.Unmarshal(raw, &s.d); err != nil {
return nil, fmt.Errorf("%s ist kein gültiges JSON: %w", path, err)
}
if s.d.Apps == nil {
s.d.Apps = []App{}
}
if s.d.Users == nil {
s.d.Users = map[string]*User{}
}
return s, nil
}
// save schreibt die Datei. Aufrufer hält die Sperre.
func (s *Store) save() error {
raw, err := json.MarshalIndent(s.d, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, append(raw, '\n'), 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
func (s *Store) Apps() []App {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]App, len(s.d.Apps))
copy(out, s.d.Apps)
return out
}
// SetApps ersetzt die ganze Liste. Die Oberfläche schickt sie immer vollständig
// — für ein Dutzend Einträge ist das einfacher und weniger fehleranfällig als
// einzelne Endpunkte für Anlegen, Ändern, Löschen und Umsortieren.
func (s *Store) SetApps(apps []App) error {
if len(apps) > maxApps {
return fmt.Errorf("höchstens %d Apps", maxApps)
}
seen := map[string]bool{}
cleaned := make([]App, 0, len(apps))
for i, a := range apps {
a.ID = strings.ToLower(strings.TrimSpace(a.ID))
a.Name = strings.TrimSpace(a.Name)
a.Subtitle = strings.TrimSpace(a.Subtitle)
a.URL = strings.TrimSpace(a.URL)
a.Glyph = strings.TrimSpace(a.Glyph)
if !idPattern.MatchString(a.ID) {
return fmt.Errorf("App %d: ID muss klein geschrieben sein und mit Buchstabe oder Ziffer beginnen", i+1)
}
if seen[a.ID] {
return fmt.Errorf("ID %q kommt doppelt vor", a.ID)
}
seen[a.ID] = true
if a.Name == "" {
a.Name = strings.ToUpper(a.ID)
}
u, err := url.Parse(a.URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fmt.Errorf("App %q: Adresse muss mit http:// oder https:// beginnen", a.ID)
}
if a.Glyph != "" && !knownGlyphs[a.Glyph] {
a.Glyph = ""
}
for _, f := range []string{a.Name, a.Subtitle, a.URL} {
if len(f) > maxFieldLen {
return fmt.Errorf("App %q: ein Feld ist länger als %d Zeichen", a.ID, maxFieldLen)
}
}
cleaned = append(cleaned, a)
}
s.mu.Lock()
defer s.mu.Unlock()
s.d.Apps = cleaned
return s.save()
}
func (s *Store) User(sub string) User {
s.mu.RLock()
defer s.mu.RUnlock()
if u, ok := s.d.Users[sub]; ok {
cp := *u
cp.Settings = map[string]string{}
for k, v := range u.Settings {
cp.Settings[k] = v
}
return cp
}
return User{Settings: map[string]string{}}
}
// SeenUser hält Name und Bild aus dem Anbieter nach, ohne Einstellungen anzufassen.
func (s *Store) SeenUser(sub, name, picture string) error {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.d.Users[sub]
if !ok {
u = &User{Settings: map[string]string{}}
s.d.Users[sub] = u
}
if u.Name == name && u.Picture == picture {
return nil // nichts zu schreiben
}
u.Name, u.Picture = name, picture
return s.save()
}
func (s *Store) SetSettings(sub string, in map[string]string) error {
if len(in) > maxSettings {
return fmt.Errorf("zu viele Einstellungen")
}
clean := map[string]string{}
for k, v := range in {
if !settingKeyRe.MatchString(k) || len(v) > maxFieldLen {
continue // stillschweigend verwerfen: die Oberfläche schickt nur Bekanntes
}
clean[k] = v
}
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.d.Users[sub]
if !ok {
u = &User{}
s.d.Users[sub] = u
}
u.Settings = clean
return s.save()
}
// SettingsFor liefert das Aussehen für sub — und hilft nach, wenn die Frage
// über eine Site-Grenze kam.
//
// Die HoneyBar in einer fremden App weist sich nur mit dem Schlüssel aus, nicht
// mit einer Sitzung: der Browser schickt das Cookie dorthin nicht mit. Sie
// landet deshalb beim Standardnutzer. Sind die Einstellungen aber unter einem
// Namen aus der Proxy-Kopfzeile gespeichert, wäre der leer — und die HoneyBar
// bliebe bernsteinfarben, während der Schreibtisch grün leuchtet.
//
// Gibt es genau einen Nutzer mit Einstellungen, gilt dessen Aussehen. Bei
// mehreren bleibt es beim Standard: dann ist nicht zu erraten, wer da schaut.
func (s *Store) SettingsFor(sub string) map[string]string {
if own := s.User(sub).Settings; len(own) > 0 {
return own
}
s.mu.RLock()
defer s.mu.RUnlock()
var einziger map[string]string
for _, u := range s.d.Users {
if len(u.Settings) == 0 {
continue
}
if einziger != nil {
return map[string]string{} // mehrere — kein Rateweg
}
einziger = u.Settings
}
out := map[string]string{}
for k, v := range einziger {
out[k] = v
}
return out
}
// AppOrigins nennt die Ursprünge der eingetragenen Apps. Nur sie dürfen die
// HoneyBar mit Anmeldedaten abfragen — die Liste ist damit ihre eigene Freigabe.
func (s *Store) AppOrigins() map[string]bool {
s.mu.RLock()
defer s.mu.RUnlock()
out := map[string]bool{}
for _, a := range s.d.Apps {
if u, err := url.Parse(a.URL); err == nil && u.Host != "" {
out[u.Scheme+"://"+u.Host] = true
}
}
return out
}
// DataPath nennt die eine Datei, in der alles steht.
func DataPath(dir string) string { return filepath.Join(dir, "honeyos.json") }