// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package tentacle import ( "bytes" "encoding/json" "errors" "os" "path/filepath" "syscall" "git.stormux.org/storm/cthulhu/internal/interaction" ) // PageValues contains only host-validated declarative preferences. Active speech // overrides and quick-setting selections are deliberately never restored here. type PageValues map[string]map[string]map[string]interaction.Value type PageStore struct { Path string original []byte loaded bool } func (store *PageStore) Load() (PageValues, error) { store.loaded = false data, err := read_registry(store.Path) if err != nil { return nil, err } values := PageValues{} if data != nil { if validate_json(data) != nil { return nil, ErrProtocol } if err := json.Unmarshal(data, &values); err != nil || values == nil { return nil, ErrProtocol } } store.original, store.loaded = data, true return values, nil } func (store *PageStore) Save(values PageValues) error { if !store.loaded { return errors.New("tentacle preferences were not loaded successfully") } data, err := json.Marshal(values) if err != nil { return err } if len(data) > MaxFrameBytes { return ErrLimit } directory := filepath.Dir(store.Path) if err := os.MkdirAll(directory, 0700); err != nil { return err } lock, err := os.OpenFile(store.Path+".lock", os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0600) if err != nil { return err } defer lock.Close() if info, err := lock.Stat(); err != nil || !info.Mode().IsRegular() { return errors.New("tentacle preferences lock must be a regular file") } if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { return errors.New("tentacle preferences are being edited") } defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) current, err := read_registry(store.Path) if err != nil { return err } if !bytes.Equal(current, store.original) || (current == nil) != (store.original == nil) { return errors.New("tentacle preferences changed outside the reader") } file, err := os.CreateTemp(directory, ".tentacle-preferences-*") if err != nil { return err } defer os.Remove(file.Name()) defer file.Close() if _, err := file.Write(data); err != nil { return err } if err := file.Sync(); err != nil { return err } if err := file.Close(); err != nil { return err } dir, err := os.Open(directory) if err != nil { return err } defer dir.Close() if err := os.Rename(file.Name(), store.Path); err != nil { return err } store.original = data return dir.Sync() }