Feature: Offline device history with delete and remote uninstall

Persist every device that has ever logged in to the Go server in a small
JSON store (devices.json, overridable with YAMA_DEVICES_FILE and capped by
YAMA_DEVICES_MAX, default 1000 newest). The store follows the users.json
convention: atomic temp-file write with fsync and rename, a 500 ms debounce
for bursts of reconnects, first_seen preserved across logins, and pruning
that evicts offline records before devices that are still connected.
Devices connected at shutdown are stamped offline before the final flush so
last_seen stays accurate across restarts.

The server advertises an offlineDevices capability and device_list now
appends historical devices that are not online, marked with online:false and
last_seen. The web UI shows an Offline checkbox after Total/Showing (state
kept in localStorage), renders offline cards dimmed and dashed, and always
lists them in the screen-wall sidebar. Older servers without the capability
keep the online-only list.

Grid and List cards gain a delete button after the terminal button, admin
only, behind a confirmation dialog. Deleting an offline device removes its
history record; deleting an online device first sends the single-byte
TOKEN_UNINSTALL command (matching the MFC OnOnlineUninstall flow) and then
removes the record too, so the device does not come back as a dead offline
entry. The agent's own disconnect then runs the existing cleanup path:
screen and terminal sub-connections are closed, the hub entry is removed,
and browsers receive device_offline to release their watch sets, wall
sessions, and stream references. Wall mode exposes no delete action.

Tests cover history persistence/pruning/recovery, offline ordering, the
device_list merge, and all three delete outcomes (offline removal, online
uninstall with a single 0x3F byte, and viewer rejection).

Co-Authored-By: deepseek-flash
This commit is contained in:
yuanyuanxianganddeepseek-flash committed 2026-10-18 17:54:30 +02:00
1 parent 1922f87dd2
commit b1ddec1b8a
12 files changed
+1212 -27

No files matched your search

+131
View File
@@ -0,0 +1,131 @@
package web
import (
"encoding/json"
"path/filepath"
"testing"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
)
// deleteTestHub builds the minimal wsHub wiring handleDeleteDevice needs.
func deleteTestHub(t *testing.T) (*wsHub, *hub.Hub, *devicehistory.Store) {
t.Helper()
h := hub.New()
hist, err := devicehistory.Open(filepath.Join(t.TempDir(), "devices.json"), 100, nil)
if err != nil {
t.Fatalf("Open history: %v", err)
}
t.Cleanup(func() { _ = hist.Close() })
h.Subscribe(hist)
auth := wsauth.New()
wh := &wsHub{
auth: auth,
devices: h,
history: hist,
log: logger.New(logger.Config{}),
clients: make(map[*wsClient]struct{}),
}
return wh, h, hist
}
func deleteTestClient(wh *wsHub, role string) (*wsClient, string) {
tok, err := wh.auth.IssueToken("tester", role)
if err != nil {
panic(err)
}
c := &wsClient{send: make(chan wsMsg, 8), closed: make(chan struct{})}
wh.register(c)
return c, tok
}
func deleteReply(t *testing.T, c *wsClient) map[string]any {
t.Helper()
for {
select {
case m := <-c.send:
var out map[string]any
if err := json.Unmarshal(m.data, &out); err != nil {
t.Fatalf("decode reply: %v", err)
}
if out["cmd"] == "delete_device_result" {
return out
}
default:
t.Fatal("no delete_device_result reply")
return nil
}
}
}
func TestDeleteDeviceOfflineRemovesHistoryRecord(t *testing.T) {
wh, h, hist := deleteTestHub(t)
h.Register(&hub.Device{ID: "gone", Name: "Gone"},
&connection.Context{RemoteAddr: "10.0.0.1:1000"})
h.Unregister("gone")
c, tok := deleteTestClient(wh, "admin")
raw, _ := json.Marshal(map[string]any{"token": tok, "id": "gone"})
wh.handleDeleteDevice(c, raw)
rep := deleteReply(t, c)
if rep["ok"] != true || rep["mode"] != "removed" {
t.Fatalf("reply = %+v, want ok/removed", rep)
}
if hist.Remove("gone") {
t.Fatal("history record still present after delete")
}
}
func TestDeleteDeviceOnlineSendsUninstall(t *testing.T) {
wh, h, hist := deleteTestHub(t)
var sent []byte
h.SetSender(func(_ *connection.Context, data []byte) error {
sent = append([]byte(nil), data...)
return nil
})
h.Register(&hub.Device{ID: "live", Name: "Live"},
&connection.Context{RemoteAddr: "10.0.0.2:1000"})
c, tok := deleteTestClient(wh, "admin")
raw, _ := json.Marshal(map[string]any{"token": tok, "id": "live"})
wh.handleDeleteDevice(c, raw)
rep := deleteReply(t, c)
if rep["ok"] != true || rep["mode"] != "uninstall" {
t.Fatalf("reply = %+v, want ok/uninstall", rep)
}
if len(sent) != 1 || sent[0] != protocol.CommandUninstall {
t.Fatalf("sent = %v, want single %d byte", sent, protocol.CommandUninstall)
}
// The history record is dropped along with the uninstall, so the device
// does not reappear as a dead offline entry after it disconnects.
if hist.Remove("live") {
t.Fatal("online device's history record survived the uninstall")
}
}
func TestDeleteDeviceRequiresAdmin(t *testing.T) {
wh, h, hist := deleteTestHub(t)
h.Register(&hub.Device{ID: "gone", Name: "Gone"},
&connection.Context{RemoteAddr: "10.0.0.3:1000"})
h.Unregister("gone")
c, tok := deleteTestClient(wh, "viewer")
raw, _ := json.Marshal(map[string]any{"token": tok, "id": "gone"})
wh.handleDeleteDevice(c, raw)
rep := deleteReply(t, c)
if rep["ok"] != false {
t.Fatalf("reply = %+v, want ok:false", rep)
}
if !hist.Remove("gone") {
t.Fatal("viewer delete removed the history record")
}
}