// Package devicehistory persists the set of devices that have ever logged in // to the Go server, so the web UI can keep listing them after they go offline. // // It implements hub.EventHandler and is subscribed to the hub, which means the // only wiring needed at startup is hub.Subscribe(store). Online events upsert // the record (refreshing the display fields the client reports at login) and // offline events merely refresh last_seen. // // Storage is a small JSON file, mirroring the wsauth users.json convention: // atomic temp-file + fsync + rename, loaded once at startup and written back // on a short debounce so a burst of reconnects doesn't rewrite it per device. package devicehistory import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "sync" "time" "github.com/yuanyuanxiang/SimpleRemoter/server/go/hub" "github.com/yuanyuanxiang/SimpleRemoter/server/go/logger" ) const ( // fileVersion lets a future release migrate the on-disk shape. Unknown // versions are still parsed best-effort; only structurally invalid JSON // makes Open fail. fileVersion = 1 // flushDelay coalesces updates. Device logins/disconnects are sparse, but // a server restart makes every agent reconnect at once; without the // debounce that would be one file write per device. flushDelay = 500 * time.Millisecond ) // Record is one persisted device. The fields mirror hub.DeviceInfo so the // offline list can be rendered with the same front-end card code; only the // fields that are stable across sessions are kept (RTT / active window are // live-only and would be stale noise on an offline card). type Record struct { ID string `json:"id"` Name string `json:"name"` Group string `json:"group,omitempty"` Version string `json:"version,omitempty"` Capability string `json:"capability,omitempty"` OS string `json:"os,omitempty"` CPU string `json:"cpu,omitempty"` FilePath string `json:"file_path,omitempty"` InstallTime string `json:"install_time,omitempty"` Location string `json:"location,omitempty"` PeerIP string `json:"peer_ip,omitempty"` IP string `json:"ip,omitempty"` Screen string `json:"screen,omitempty"` FirstSeen int64 `json:"first_seen"` LastSeen int64 `json:"last_seen"` } type fileShape struct { Version int `json:"version"` Devices map[string]*Record `json:"devices"` } // Store is the in-memory device history plus its backing file. Safe for // concurrent use. type Store struct { path string max int // 0 = unlimited log *logger.Logger mu sync.Mutex devices map[string]*Record // online is derived from hub events and used only by pruning: a device // that is still connected must keep its record even if its last login is // older than the 1000th offline device's. Not persisted — after a restart // every device is offline until it reconnects. online map[string]bool dirty bool timer *time.Timer } // Open loads the history at path. A missing file is an empty history, not an // error. If the file exists but cannot be parsed, the returned store is still // usable (empty) along with the error, so callers can log and continue — the // next flush will replace the corrupt file. func Open(path string, max int, log *logger.Logger) (*Store, error) { s := newStore(path, max, log) data, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return s, nil } return s, err } var f fileShape if err := json.Unmarshal(data, &f); err != nil { return s, fmt.Errorf("parse %s: %w", path, err) } for id, r := range f.Devices { if r == nil { continue } // The map key is authoritative; tolerate records written by a // future/older shape that omitted or disagreed with the id field. r.ID = id s.devices[id] = r } s.pruneLocked() return s, nil } // New returns an empty store for path without touching disk. Useful when Open // failed and the caller still wants live recording to work. func New(path string, max int, log *logger.Logger) *Store { return newStore(path, max, log) } func newStore(path string, max int, log *logger.Logger) *Store { return &Store{ path: path, max: max, log: log, devices: make(map[string]*Record), online: make(map[string]bool), } } // OnDeviceOnline records a device that just logged in. It refreshes every // display field because the agent reports its current name/group/version at // each login, and preserves first_seen across reconnects. func (s *Store) OnDeviceOnline(d hub.DeviceInfo) { s.upsertAt(d, time.Now()) } func (s *Store) upsertAt(d hub.DeviceInfo, at time.Time) { if d.ID == "" { return } now := at.Unix() s.mu.Lock() rec, ok := s.devices[d.ID] if !ok { rec = &Record{ID: d.ID, FirstSeen: now} s.devices[d.ID] = rec } if rec.FirstSeen == 0 { rec.FirstSeen = now } rec.Name = d.Name rec.Group = d.Group rec.Version = d.Version rec.Capability = d.Capability rec.OS = d.OS rec.CPU = d.CPU rec.FilePath = d.FilePath rec.InstallTime = d.InstallTime rec.Location = d.Location rec.PeerIP = d.PeerIP rec.IP = d.IP rec.Screen = d.Screen rec.LastSeen = now s.online[d.ID] = true s.pruneLocked() s.markDirtyLocked() s.mu.Unlock() } // OnDeviceOffline refreshes last_seen. The record was created when the device // logged in; an unknown ID is ignored (e.g. a device rejected by signing never // reaches the hub). func (s *Store) OnDeviceOffline(id string) { if id == "" { return } s.offlineAt(id, time.Now()) } func (s *Store) offlineAt(id string, at time.Time) { s.mu.Lock() delete(s.online, id) if rec, ok := s.devices[id]; ok { rec.LastSeen = at.Unix() s.markDirtyLocked() } s.mu.Unlock() } // The remaining EventHandler methods are not relevant to device history. func (s *Store) OnDeviceUpdate(string, int, string) {} func (s *Store) OnScreenFrame(string, []byte, bool) {} func (s *Store) OnResolutionChange(string, int, int) {} func (s *Store) OnCursorChange(string, byte) {} func (s *Store) OnClipboardText(string, string) {} func (s *Store) OnTerminalReady(string, bool) {} func (s *Store) OnTerminalData(string, []byte) {} func (s *Store) OnTerminalClosed(string, string) {} // Offline returns the records whose IDs are not in online, newest last_seen // first, converted to the hub's device payload shape with Online=false. // // Callers pass the hub's current online snapshot; the store deliberately does // not know which device is online, so a reconnect that re-registers the same // ID can never produce a duplicate row. func (s *Store) Offline(online map[string]struct{}) []hub.DeviceInfo { s.mu.Lock() out := make([]hub.DeviceInfo, 0, len(s.devices)) for id, rec := range s.devices { if _, ok := online[id]; ok { continue } out = append(out, hub.DeviceInfo{ ID: rec.ID, Name: rec.Name, Group: rec.Group, Version: rec.Version, Capability: rec.Capability, OS: rec.OS, CPU: rec.CPU, FilePath: rec.FilePath, InstallTime: rec.InstallTime, Location: rec.Location, IP: rec.IP, PeerIP: rec.PeerIP, Screen: rec.Screen, LastSeen: rec.LastSeen, Online: false, }) } s.mu.Unlock() sort.Slice(out, func(i, j int) bool { if out[i].LastSeen != out[j].LastSeen { return out[i].LastSeen > out[j].LastSeen } return out[i].ID < out[j].ID }) return out } // Remove deletes the persisted record for id and reports whether one existed. // This is the web UI's explicit "delete" for an offline device: an offline // record has no live agent to uninstall, so forgetting it is all "delete" can // mean. The caller decides when removal is appropriate; the store does not // refuse to drop a record that happens to be marked online. func (s *Store) Remove(id string) bool { if id == "" { return false } s.mu.Lock() _, existed := s.devices[id] if existed { delete(s.devices, id) delete(s.online, id) s.markDirtyLocked() } s.mu.Unlock() return existed } // Flush writes the current state synchronously. Normally the debounced timer // does this; Flush exists for tests and graceful shutdown. func (s *Store) Flush() error { s.mu.Lock() if s.timer != nil { s.timer.Stop() s.timer = nil } if !s.dirty { s.mu.Unlock() return nil } s.dirty = false data, err := s.marshalLocked() s.mu.Unlock() if err != nil { return err } if err := writeAtomic(s.path, data); err != nil { s.mu.Lock() s.dirty = true s.mu.Unlock() return err } return nil } // Close stops the debounce timer and flushes pending changes. func (s *Store) Close() error { return s.Flush() } func (s *Store) markDirtyLocked() { s.dirty = true if s.timer == nil { s.timer = time.AfterFunc(flushDelay, s.flush) } } func (s *Store) flush() { if err := s.Flush(); err != nil && s.log != nil { s.log.Error("Failed to persist device history to %s: %v", s.path, err) } } func (s *Store) marshalLocked() ([]byte, error) { f := fileShape{Version: fileVersion, Devices: s.devices} return json.MarshalIndent(f, "", " ") } // pruneLocked keeps the max most-recently-seen records. It runs after every // online upsert, which is the only path that can grow the map; the O(n log n) // sort only happens when the cap is actually exceeded. func (s *Store) pruneLocked() { if s.max <= 0 || len(s.devices) <= s.max { return } ids := make([]string, 0, len(s.devices)) for id := range s.devices { ids = append(ids, id) } sort.Slice(ids, func(i, j int) bool { oi, oj := s.online[ids[i]], s.online[ids[j]] if oi != oj { return !oi // evict offline records before online ones } return s.devices[ids[i]].LastSeen < s.devices[ids[j]].LastSeen }) for _, id := range ids[:len(ids)-s.max] { delete(s.devices, id) } } // writeAtomic mirrors wsauth.writeUsersFile: temp file in the same directory, // fsync, then rename over the destination. func writeAtomic(path string, data []byte) error { dir := filepath.Dir(path) if dir != "" { if err := os.MkdirAll(dir, 0o755); err != nil { return err } } tmp, err := os.CreateTemp(dir, "devices-*.json.tmp") if err != nil { return err } tmpName := tmp.Name() if _, err := tmp.Write(data); err != nil { _ = tmp.Close() _ = os.Remove(tmpName) return err } if err := tmp.Sync(); err != nil { _ = tmp.Close() _ = os.Remove(tmpName) return err } if err := tmp.Close(); err != nil { _ = os.Remove(tmpName) return err } if err := os.Rename(tmpName, path); err != nil { _ = os.Remove(tmpName) return err } return nil }