summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKyren223 <Kyren223@proton.me>2025-07-11 16:07:15 +0300
committerKyren223 <Kyren223@proton.me>2025-07-19 18:32:12 +0300
commitdc6c10155c33033b386665e7882b91be9837f55a (patch)
tree64ca15fdb2aec5a17c93bae99508f70e61b951a6
parent86c3f3f730dd8867f4d5df6fef428df36ff2d193 (diff)
Added anonymous_device_analytics option on the client and added cache
dir to store things like device ID and TOS hashes (only storing device ID currently)
-rw-r--r--internal/client/client.go7
-rw-r--r--internal/client/config/cache.go126
-rw-r--r--internal/client/config/config.go60
-rw-r--r--internal/client/gateway/gateway.go8
-rw-r--r--internal/client/ui/auth/auth.go4
-rw-r--r--internal/client/ui/core/core.go40
-rw-r--r--internal/client/ui/viminput/viminput.go2
7 files changed, 195 insertions, 52 deletions
diff --git a/internal/client/client.go b/internal/client/client.go
index 0ba3530..57e1bb1 100644
--- a/internal/client/client.go
+++ b/internal/client/client.go
@@ -37,11 +37,16 @@ func Run() {
log.Println("client started")
- err := config.Load()
+ err := config.LoadConfig()
if err != nil {
fmt.Printf("Config file at '%v' was unable to load successfully\n%v\n", config.ConfigFile, err)
os.Exit(1)
}
+ err = config.LoadCache()
+ if err != nil {
+ fmt.Printf("Cache file at '%v' was unable to load successfully\n%v\n", config.CacheFile, err)
+ os.Exit(1)
+ }
program := tea.NewProgram(initialModel(dump), tea.WithAltScreen())
assert.AddFlush(BubbleTeaCloser{program})
diff --git a/internal/client/config/cache.go b/internal/client/config/cache.go
new file mode 100644
index 0000000..a78425b
--- /dev/null
+++ b/internal/client/config/cache.go
@@ -0,0 +1,126 @@
+package config
+
+import (
+ "crypto/rand"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "reflect"
+
+ "github.com/kyren223/eko/pkg/assert"
+)
+
+type Cache struct {
+ // Key is a server URL like "eko.kyren.codes" and value is the base 16 hash
+ TosHashes map[string]string `json:"tos_hashes"`
+ DeviceID string `json:"device_id"`
+}
+
+func DefaultCache() Cache {
+ return Cache{
+ TosHashes: map[string]string{},
+ DeviceID: "",
+ }
+}
+
+func VerifyAndFixCache(cache *Cache) error {
+ if cache.DeviceID == "" {
+ cache.DeviceID = GenerateDeviceID()
+ }
+ return nil
+}
+
+func GenerateDeviceID() string {
+ // Generate a random 32-byte device ID
+ // Gurantees uniqueness, so it's not reverseable and it is annonymized
+ // For legal reasons: this may still be logged on the server, so associated with an IP address
+ deviceId := [32]byte{}
+ _, err := rand.Read(deviceId[:])
+ assert.NoError(err, "random never fails")
+ return fmt.Sprintf("%x", deviceId)
+}
+
+var (
+ cache = Cache{}
+ CacheDir string
+ CacheFile string
+)
+
+func LoadCache() error {
+ userCacheDir, err := os.UserCacheDir()
+ if err != nil {
+ return err
+ }
+
+ CacheDir = filepath.Join(userCacheDir, "eko")
+ err = os.MkdirAll(CacheDir, 0o750)
+ if err != nil {
+ return err
+ }
+
+ CacheFile = filepath.Join(CacheDir, "cache.json")
+ contents, err := os.ReadFile(CacheFile) // #nosec 304
+ if errors.Is(err, os.ErrNotExist) {
+ cache = DefaultCache()
+ return writeCache()
+ }
+ if err != nil {
+ return err
+ }
+
+ var rawMap map[string]json.RawMessage
+ if err := json.Unmarshal(contents, &rawMap); err != nil {
+ return err
+ }
+
+ defaultVal := reflect.ValueOf(DefaultCache())
+ finalCache := reflect.New(defaultVal.Type()).Elem()
+ finalCache.Set(defaultVal)
+
+ for i := 0; i < defaultVal.NumField(); i++ {
+ field := defaultVal.Type().Field(i)
+ jsonTag := field.Tag.Get("json")
+ if jsonTag == "" {
+ jsonTag = field.Name
+ }
+
+ rawValue, found := rawMap[jsonTag]
+ fieldValue := finalCache.Field(i)
+ if !found || !fieldValue.CanAddr() {
+ continue
+ }
+
+ err := json.Unmarshal(rawValue, fieldValue.Addr().Interface())
+ if err != nil {
+ return fmt.Errorf("error unmarshaling field %s: %w", field.Name, err)
+ }
+ }
+
+ cache = finalCache.Interface().(Cache)
+
+ err = VerifyAndFixCache(&cache)
+ if err != nil {
+ return err
+ }
+
+ return writeCache()
+}
+
+func writeCache() error {
+ b, err := json.MarshalIndent(cache, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(CacheFile, b, 0o600)
+}
+
+func ReadCache() Cache {
+ return cache
+}
+
+func UseCache(f func(cache *Cache)) error {
+ f(&cache)
+ return writeCache()
+}
diff --git a/internal/client/config/config.go b/internal/client/config/config.go
index 4ceea6b..2de0ea4 100644
--- a/internal/client/config/config.go
+++ b/internal/client/config/config.go
@@ -12,28 +12,30 @@ import (
)
type Config struct {
- ServerName string `json:"server_name"`
- PrivateKeyPath string `json:"private_key_path"`
- InsertModeTabToSpace bool `json:"insert_mode_tab_to_space"`
- InsertModeSpacesPerTab uint8 `json:"insert_mode_spaces_per_tab"`
- InsecureDebugMode bool `json:"insecure_debug_mode"`
- Colors []string `json:"colors"`
+ ServerName string `json:"server_name"`
+ PrivateKeyPath string `json:"private_key_path"`
+ InsertModeTabToSpace bool `json:"insert_mode_tab_to_space"`
+ InsertModeSpacesPerTab uint8 `json:"insert_mode_spaces_per_tab"`
+ InsecureDebugMode bool `json:"insecure_debug_mode"`
+ Colors []string `json:"colors"`
+ AnonymousDeviceAnalytics bool `json:"anonymous_device_analytics"`
}
-func Default() Config {
+func DefaultConfig() Config {
return Config{
- ServerName: "eko.kyren.codes",
- PrivateKeyPath: "",
- InsertModeTabToSpace: true,
- InsertModeSpacesPerTab: 4,
- InsecureDebugMode: false,
- Colors: nil,
+ ServerName: "eko.kyren.codes",
+ PrivateKeyPath: "",
+ InsertModeTabToSpace: true,
+ InsertModeSpacesPerTab: 4,
+ InsecureDebugMode: false,
+ Colors: nil,
+ AnonymousDeviceAnalytics: true,
}
}
-func VerifyAndFix(config *Config) error {
+func VerifyAndFixConfig(config *Config) error {
if config.ServerName == "" {
- config.ServerName = Default().ServerName
+ config.ServerName = DefaultConfig().ServerName
}
if config.Colors != nil {
@@ -58,27 +60,27 @@ func VerifyAndFix(config *Config) error {
var (
config = Config{}
- Dir string
+ ConfigDir string
ConfigFile string
)
-func Load() error {
+func LoadConfig() error {
userConfigDir, err := os.UserConfigDir()
if err != nil {
return err
}
- Dir = filepath.Join(userConfigDir, "eko")
- err = os.MkdirAll(Dir, 0o750)
+ ConfigDir = filepath.Join(userConfigDir, "eko")
+ err = os.MkdirAll(ConfigDir, 0o750)
if err != nil {
return err
}
- ConfigFile = filepath.Join(Dir, "config.json")
+ ConfigFile = filepath.Join(ConfigDir, "config.json")
contents, err := os.ReadFile(ConfigFile) // #nosec 304
if errors.Is(err, os.ErrNotExist) {
- config = Default()
- return write()
+ config = DefaultConfig()
+ return writeConfig()
}
if err != nil {
return err
@@ -89,7 +91,7 @@ func Load() error {
return err
}
- defaultVal := reflect.ValueOf(Default())
+ defaultVal := reflect.ValueOf(DefaultConfig())
finalConfig := reflect.New(defaultVal.Type()).Elem()
finalConfig.Set(defaultVal)
@@ -114,15 +116,15 @@ func Load() error {
config = finalConfig.Interface().(Config)
- err = VerifyAndFix(&config)
+ err = VerifyAndFixConfig(&config)
if err != nil {
return err
}
- return write()
+ return writeConfig()
}
-func write() error {
+func writeConfig() error {
b, err := json.MarshalIndent(config, "", " ")
if err != nil {
return err
@@ -130,11 +132,11 @@ func write() error {
return os.WriteFile(ConfigFile, b, 0o600)
}
-func Read() Config {
+func ReadConfig() Config {
return config
}
-func Use(f func(config *Config)) error {
+func UseConfig(f func(config *Config)) error {
f(&config)
- return write()
+ return writeConfig()
}
diff --git a/internal/client/gateway/gateway.go b/internal/client/gateway/gateway.go
index 20ca777..ef4f31a 100644
--- a/internal/client/gateway/gateway.go
+++ b/internal/client/gateway/gateway.go
@@ -63,16 +63,16 @@ func connect(ctx context.Context) error {
tlsConfig := &tls.Config{
RootCAs: certPool,
- ServerName: config.Read().ServerName,
+ ServerName: config.ReadConfig().ServerName,
MinVersion: tls.VersionTLS12,
// This is fine, it's always false by default
// The user may change the config, the name should be clear enough
// that this is insecure (valid use cases are for testing purposes)
- InsecureSkipVerify: config.Read().InsecureDebugMode, // #nosec 402
+ InsecureSkipVerify: config.ReadConfig().InsecureDebugMode, // #nosec 402
}
- address := config.Read().ServerName
- if config.Read().InsecureDebugMode {
+ address := config.ReadConfig().ServerName
+ if config.ReadConfig().InsecureDebugMode {
address = "localhost"
}
diff --git a/internal/client/ui/auth/auth.go b/internal/client/ui/auth/auth.go
index 8e6dffb..47a3f89 100644
--- a/internal/client/ui/auth/auth.go
+++ b/internal/client/ui/auth/auth.go
@@ -378,7 +378,7 @@ func (m *Model) SetSignup(signup bool) tea.Cmd {
m.focusIndex = -1
m.CycleForward()
- privateKey := config.Read().PrivateKeyPath
+ privateKey := config.ReadConfig().PrivateKeyPath
if !m.signup && privateKey != "" {
m.fields[privateKeyField].Input.SetValue(privateKey)
}
@@ -544,7 +544,7 @@ func (m *Model) signin() tea.Cmd {
}
if m.remember {
- _ = config.Use(func(config *config.Config) {
+ _ = config.UseConfig(func(config *config.Config) {
config.PrivateKeyPath = privateKeyFilepath
})
}
diff --git a/internal/client/ui/core/core.go b/internal/client/ui/core/core.go
index 5a0f8db..11772fa 100644
--- a/internal/client/ui/core/core.go
+++ b/internal/client/ui/core/core.go
@@ -5,6 +5,8 @@ import (
"fmt"
"log"
"math"
+ "os"
+ "runtime"
"slices"
"time"
@@ -13,6 +15,7 @@ import (
"github.com/charmbracelet/bubbles/timer"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
+ "github.com/kyren223/eko/internal/client/config"
"github.com/kyren223/eko/internal/client/gateway"
"github.com/kyren223/eko/internal/client/ui"
"github.com/kyren223/eko/internal/client/ui/colors"
@@ -294,25 +297,11 @@ func (m *Model) updateConnected(message tea.Msg) tea.Cmd {
combined := msg.Tos + "\n" + msg.PrivacyPolicy
tos := tosscreen.New(combined)
m.tos = &tos
- // var setName tea.Cmd
- // if m.name != "" {
- // setName = gateway.Send(&packet.SetUserData{
- // Data: nil,
- // User: &data.User{
- // Name: m.name,
- // Description: "",
- // IsPublicDM: true,
- // },
- // })
- // m.name = ""
- // }
- //
- // return tea.Batch(m.timer.Stop(), setName)
case *packet.Error:
if m.state == ConnectedAcceptedTos {
if msg.Error == "success" {
- return gateway.Send(&packet.GetNonce{})
+ return tea.Batch(gateway.Send(&packet.GetNonce{}), SendAnalytics())
} else {
log.Println("received error:", msg.Error)
gateway.Disconnect()
@@ -408,6 +397,7 @@ func (m *Model) updateAuthenticated(message tea.Msg) tea.Cmd {
case *packet.Error:
err := "new connection from another location, closing this one"
if err == msg.Error {
+ gateway.Disconnect()
return ui.Transition(ui.NewAuth())
}
@@ -945,3 +935,23 @@ func getSignalNotification(signal snowflake.ID) int {
func (m Model) ViewTos() string {
return "TODO TOS"
}
+
+func SendAnalytics() tea.Cmd {
+ if !config.ReadConfig().AnonymousDeviceAnalytics {
+ return nil // User disabled analytics
+ }
+ assert.Assert(config.ReadConfig().AnonymousDeviceAnalytics, "who is paranoid")
+
+ osName := runtime.GOOS
+ arch := runtime.GOARCH
+ term := os.Getenv("TERM")
+ colorterm := os.Getenv("COLORTERM")
+
+ return gateway.Send(&packet.DeviceAnalytics{
+ DeviceID: config.ReadCache().DeviceID,
+ OS: osName,
+ Arch: arch,
+ Term: term,
+ Colorterm: colorterm,
+ })
+}
diff --git a/internal/client/ui/viminput/viminput.go b/internal/client/ui/viminput/viminput.go
index 6d1f01b..5c4b6b2 100644
--- a/internal/client/ui/viminput/viminput.go
+++ b/internal/client/ui/viminput/viminput.go
@@ -649,7 +649,7 @@ func (m *Model) handleInsertModeKeys(key tea.KeyMsg) {
}
if key.Type == tea.KeyTab || key.Type == tea.KeyShiftTab {
- conf := config.Read()
+ conf := config.ReadConfig()
var runes []rune
if conf.InsertModeTabToSpace {
runes = []rune(strings.Repeat(" ", int(conf.InsertModeSpacesPerTab)))