fix: bind sync state to active vault
This commit is contained in:
parent
b845e8b5e2
commit
18da09e06b
|
|
@ -108,6 +108,9 @@ func NewApp(
|
|||
debug: debugEnabled,
|
||||
activityEvents: make(map[string]bool),
|
||||
}
|
||||
if app.syncSvc == nil {
|
||||
app.rebindSyncService()
|
||||
}
|
||||
app.ensureActivityProviderSubscriptions()
|
||||
app.startFileWatcherForOpenVault()
|
||||
return app
|
||||
|
|
@ -714,6 +717,7 @@ func (a *App) CreateVault(path string) error {
|
|||
if err := a.vault.CreateVault(path); err != nil {
|
||||
return err
|
||||
}
|
||||
a.rebindSyncService()
|
||||
a.secretsSession = nil
|
||||
a.startFileWatcherForOpenVault()
|
||||
return nil
|
||||
|
|
@ -727,6 +731,7 @@ func (a *App) OpenVault(path string) error {
|
|||
if err := a.vault.OpenVault(path); err != nil {
|
||||
return err
|
||||
}
|
||||
a.rebindSyncService()
|
||||
a.secretsSession = nil
|
||||
a.startFileWatcherForOpenVault()
|
||||
return nil
|
||||
|
|
@ -741,6 +746,7 @@ func (a *App) CloseVault() error {
|
|||
a.fileWatcher.Stop()
|
||||
}
|
||||
a.vault.CloseVault()
|
||||
a.syncSvc = nil
|
||||
a.secretsSession = nil
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1656,6 +1662,7 @@ func (a *App) SetCurrentVault(path string) string {
|
|||
a.secretsSession = nil
|
||||
// Save the actual vault path (normalized by OpenVault, includes VerstakVault/)
|
||||
vaultPath := a.vault.GetVaultPath()
|
||||
a.rebindSyncService()
|
||||
if err := a.appSettings.SetCurrentVault(vaultPath); err != nil {
|
||||
return fmt.Sprintf("failed to save app settings: %v", err)
|
||||
}
|
||||
|
|
@ -1685,6 +1692,16 @@ func (a *App) SetCurrentVault(path string) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func (a *App) rebindSyncService() {
|
||||
if a == nil || a.vault == nil || a.vault.GetVaultStatus() != vault.StatusOpen {
|
||||
if a != nil {
|
||||
a.syncSvc = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
a.syncSvc = syncsvc.NewService(a.vault.GetVaultPath(), "")
|
||||
}
|
||||
|
||||
func (a *App) startFileWatcherForOpenVault() {
|
||||
if a == nil || a.vault == nil || a.eventBus == nil {
|
||||
return
|
||||
|
|
@ -2160,7 +2177,9 @@ func (a *App) syncStatus() (*SyncStatusDTO, error) {
|
|||
LastError: cfg.Sync.LastError,
|
||||
}
|
||||
|
||||
if cfg.Sync.DeviceID != "" {
|
||||
if deviceID := a.syncSvc.GetDeviceID(); deviceID != "" {
|
||||
dto.DeviceID = deviceID
|
||||
} else if cfg.Sync.DeviceID != "" {
|
||||
dto.DeviceID = cfg.Sync.DeviceID
|
||||
}
|
||||
|
||||
|
|
@ -2170,10 +2189,13 @@ func (a *App) syncStatus() (*SyncStatusDTO, error) {
|
|||
if deviceToken != "" {
|
||||
client := newSyncClient(serverURL, "", "", vaultPath)
|
||||
client.DeviceToken = deviceToken
|
||||
if cfg.Sync.DeviceID != "" {
|
||||
client.DeviceID = cfg.Sync.DeviceID
|
||||
if dto.DeviceID != "" {
|
||||
client.DeviceID = dto.DeviceID
|
||||
}
|
||||
if info, err := client.GetMe(); err == nil {
|
||||
if info.DeviceID != "" {
|
||||
_ = a.syncSvc.SetDeviceID(info.DeviceID)
|
||||
}
|
||||
dto.DeviceName = info.DeviceName
|
||||
dto.DeviceID = info.DeviceID
|
||||
dto.Connected = true
|
||||
|
|
@ -2224,19 +2246,24 @@ func (a *App) syncConfigure(serverURL, username, password string) error {
|
|||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
meta := a.vault.GetVaultMeta()
|
||||
if meta == nil || strings.TrimSpace(meta.VaultID) == "" {
|
||||
return fmt.Errorf("vault ID is unavailable")
|
||||
}
|
||||
vaultPath := a.vaultPath()
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "unknown"
|
||||
}
|
||||
client := newSyncClient(serverURL, "", "", vaultPath)
|
||||
deviceID, deviceToken, err := client.PairDevice(serverURL, username, password, hostname, "verstak-desktop/v2")
|
||||
deviceID, deviceToken, err := client.PairDevice(serverURL, username, password, hostname, "verstak-desktop/v2", meta.VaultID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pair: %w", err)
|
||||
}
|
||||
if err := syncsvc.SaveDeviceToken(vaultPath, deviceToken); err != nil {
|
||||
return fmt.Errorf("save token: %w", err)
|
||||
}
|
||||
a.syncSvc = syncsvc.NewService(vaultPath, deviceID)
|
||||
if err := a.syncSvc.SetState(serverURL, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -2271,9 +2298,15 @@ func (a *App) syncDisconnect() error {
|
|||
vaultPath := a.vaultPath()
|
||||
deviceToken := syncsvc.LoadDeviceToken(vaultPath)
|
||||
cfg := a.appSettings.Get()
|
||||
serverURL := cfg.Sync.ServerURL
|
||||
if a.syncSvc != nil {
|
||||
if currentServerURL, _, _, _, err := a.syncSvc.GetState(); err == nil && currentServerURL != "" {
|
||||
serverURL = currentServerURL
|
||||
}
|
||||
}
|
||||
|
||||
if deviceToken != "" {
|
||||
client := newSyncClient(cfg.Sync.ServerURL, "", "", vaultPath)
|
||||
if deviceToken != "" && serverURL != "" {
|
||||
client := newSyncClient(serverURL, "", "", vaultPath)
|
||||
client.DeviceToken = deviceToken
|
||||
_ = client.RevokeCurrent()
|
||||
}
|
||||
|
|
@ -2288,6 +2321,9 @@ func (a *App) syncDisconnect() error {
|
|||
if err := a.appSettings.UpdateSync(cfg.Sync); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.syncSvc == nil {
|
||||
return nil
|
||||
}
|
||||
return a.syncSvc.SetState("", "")
|
||||
}
|
||||
|
||||
|
|
@ -2350,6 +2386,7 @@ func (a *App) syncResetKey() error {
|
|||
return err
|
||||
}
|
||||
vaultPath := a.vaultPath()
|
||||
deviceToken := syncsvc.LoadDeviceToken(vaultPath)
|
||||
if err := syncsvc.RemoveDeviceToken(vaultPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
|
@ -2361,7 +2398,14 @@ func (a *App) syncResetKey() error {
|
|||
cfg.Sync.LastStatus = "disconnected"
|
||||
cfg.Sync.LastError = ""
|
||||
if a.syncSvc != nil {
|
||||
if err := a.syncSvc.SetState(cfg.Sync.ServerURL, ""); err != nil {
|
||||
serverURL, _, _, _, err := a.syncSvc.GetState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if serverURL == "" && deviceToken != "" {
|
||||
serverURL = cfg.Sync.ServerURL
|
||||
}
|
||||
if err := a.syncSvc.SetState(serverURL, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -2384,6 +2428,12 @@ func (a *App) syncNow() (map[string]interface{}, error) {
|
|||
return nil, err
|
||||
}
|
||||
vaultPath := a.vaultPath()
|
||||
if a.syncSvc == nil {
|
||||
a.rebindSyncService()
|
||||
}
|
||||
if a.syncSvc == nil {
|
||||
return nil, fmt.Errorf("sync service not initialized")
|
||||
}
|
||||
|
||||
serverURL, apiKey, lastPullSeq, _, err := a.syncSvc.GetState()
|
||||
deviceToken := syncsvc.LoadDeviceToken(vaultPath)
|
||||
|
|
@ -2391,14 +2441,28 @@ func (a *App) syncNow() (map[string]interface{}, error) {
|
|||
return nil, fmt.Errorf("sync not configured")
|
||||
}
|
||||
|
||||
deviceID := ""
|
||||
deviceID := a.syncSvc.GetDeviceID()
|
||||
cfg := a.appSettings.Get()
|
||||
if cfg.Sync.DeviceID != "" {
|
||||
if deviceID == "" && deviceToken == "" && cfg.Sync.DeviceID != "" {
|
||||
deviceID = cfg.Sync.DeviceID
|
||||
}
|
||||
|
||||
client := newSyncClient(serverURL, apiKey, deviceID, vaultPath)
|
||||
client.DeviceToken = deviceToken
|
||||
if deviceID == "" && deviceToken != "" {
|
||||
info, err := client.GetMe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sync identity: %w", err)
|
||||
}
|
||||
if info.DeviceID == "" {
|
||||
return nil, fmt.Errorf("sync identity: server returned an empty device ID")
|
||||
}
|
||||
if err := a.syncSvc.SetDeviceID(info.DeviceID); err != nil {
|
||||
return nil, fmt.Errorf("save sync identity: %w", err)
|
||||
}
|
||||
deviceID = info.DeviceID
|
||||
client.DeviceID = deviceID
|
||||
}
|
||||
|
||||
unpushed, err := a.syncSvc.GetUnpushedOps()
|
||||
if err != nil {
|
||||
|
|
@ -2637,14 +2701,19 @@ func syncPayloadPath(op syncsvc.Op, payload syncFilePayload) string {
|
|||
}
|
||||
|
||||
func (a *App) localSyncDeviceID() string {
|
||||
if a.syncSvc != nil {
|
||||
if deviceID := a.syncSvc.GetDeviceID(); deviceID != "" {
|
||||
return deviceID
|
||||
}
|
||||
}
|
||||
if a.vault != nil && a.vault.GetVaultStatus() == vault.StatusOpen && syncsvc.LoadDeviceToken(a.vaultPath()) != "" {
|
||||
return ""
|
||||
}
|
||||
if a.appSettings != nil {
|
||||
if deviceID := a.appSettings.Get().Sync.DeviceID; deviceID != "" {
|
||||
return deviceID
|
||||
}
|
||||
}
|
||||
if a.syncSvc != nil {
|
||||
return a.syncSvc.GetDeviceID()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1104,6 +1104,174 @@ func TestSyncNowPushesLocalOpsAndAppliesPulledFileOps(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSyncConfigurePairsCurrentVaultID(t *testing.T) {
|
||||
app, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "local-device")
|
||||
meta := app.vault.GetVaultMeta()
|
||||
if meta == nil || meta.VaultID == "" {
|
||||
t.Fatal("test vault must have an ID")
|
||||
}
|
||||
|
||||
var pairedVaultID string
|
||||
server := newLocalHTTPTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/client/pair" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
VaultID string `json:"vault_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pairedVaultID = request.VaultID
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"device_id": "paired-device",
|
||||
"device_token": "paired-token",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := app.syncConfigure(server.URL, "alice", "secret"); err != nil {
|
||||
t.Fatalf("syncConfigure: %v", err)
|
||||
}
|
||||
if pairedVaultID != meta.VaultID {
|
||||
t.Fatalf("paired vault ID = %q, want %q", pairedVaultID, meta.VaultID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncNowHydratesLegacyVaultDeviceID(t *testing.T) {
|
||||
app, root := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, "wrong-global-device")
|
||||
var pushedDeviceID string
|
||||
server := newLocalHTTPTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer vault-token" {
|
||||
http.Error(w, "missing auth", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/client/me":
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"device_id": "vault-device"})
|
||||
case "/api/v1/sync/push":
|
||||
var request struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Ops []struct {
|
||||
OpID string `json:"op_id"`
|
||||
} `json:"ops"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pushedDeviceID = request.DeviceID
|
||||
accepted := make([]string, 0, len(request.Ops))
|
||||
for _, op := range request.Ops {
|
||||
accepted = append(accepted, op.OpID)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"accepted": accepted,
|
||||
"count": len(accepted),
|
||||
"conflicts": []map[string]interface{}{},
|
||||
})
|
||||
case "/api/v1/sync/pull":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"server_sequence": 0,
|
||||
"ops": []map[string]interface{}{},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
app.syncSvc = syncsvc.NewService(root, "")
|
||||
if err := app.syncSvc.SetState(server.URL, ""); err != nil {
|
||||
t.Fatalf("SetState: %v", err)
|
||||
}
|
||||
if err := syncsvc.SaveDeviceToken(root, "vault-token"); err != nil {
|
||||
t.Fatalf("SaveDeviceToken: %v", err)
|
||||
}
|
||||
if err := app.syncSvc.RecordOp(syncsvc.EntityFile, "Docs/one.txt", syncsvc.OpCreate, map[string]string{"path": "Docs/one.txt"}); err != nil {
|
||||
t.Fatalf("RecordOp: %v", err)
|
||||
}
|
||||
|
||||
if _, err := app.syncNow(); err != nil {
|
||||
t.Fatalf("syncNow: %v", err)
|
||||
}
|
||||
if pushedDeviceID != "vault-device" {
|
||||
t.Fatalf("pushed device ID = %q, want vault-device", pushedDeviceID)
|
||||
}
|
||||
if app.syncSvc.GetDeviceID() != "vault-device" {
|
||||
t.Fatalf("persisted device ID = %q, want vault-device", app.syncSvc.GetDeviceID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultTransitionsRebindSyncService(t *testing.T) {
|
||||
settings := appsettings.NewManager(filepath.Join(t.TempDir(), "config.json"))
|
||||
if err := settings.Load(); err != nil {
|
||||
t.Fatalf("settings Load: %v", err)
|
||||
}
|
||||
app := &App{
|
||||
capRegistry: capability.NewRegistry(),
|
||||
vault: vault.NewVault(nil),
|
||||
appSettings: settings,
|
||||
}
|
||||
|
||||
if err := app.CreateVault(t.TempDir()); err != nil {
|
||||
t.Fatalf("CreateVault: %v", err)
|
||||
}
|
||||
if app.syncSvc == nil {
|
||||
t.Fatal("CreateVault must initialize the sync service")
|
||||
}
|
||||
|
||||
vaultB := vault.NewVault(nil)
|
||||
if err := vaultB.CreateVault(t.TempDir()); err != nil {
|
||||
t.Fatalf("Create vault B: %v", err)
|
||||
}
|
||||
primeSyncState(t, vaultB.GetVaultPath(), "device-b", "https://sync-b.example.test", 22)
|
||||
if err := app.OpenVault(vaultB.GetVaultPath()); err != nil {
|
||||
t.Fatalf("OpenVault B: %v", err)
|
||||
}
|
||||
assertSyncState(t, app.syncSvc, "device-b", "https://sync-b.example.test", 22)
|
||||
|
||||
vaultC := vault.NewVault(nil)
|
||||
if err := vaultC.CreateVault(t.TempDir()); err != nil {
|
||||
t.Fatalf("Create vault C: %v", err)
|
||||
}
|
||||
primeSyncState(t, vaultC.GetVaultPath(), "device-c", "https://sync-c.example.test", 33)
|
||||
if errStr := app.SetCurrentVault(vaultC.GetVaultPath()); errStr != "" {
|
||||
t.Fatalf("SetCurrentVault C: %s", errStr)
|
||||
}
|
||||
assertSyncState(t, app.syncSvc, "device-c", "https://sync-c.example.test", 33)
|
||||
}
|
||||
|
||||
func primeSyncState(t *testing.T, vaultRoot, deviceID, serverURL string, lastPullSeq int) {
|
||||
t.Helper()
|
||||
service := syncsvc.NewService(vaultRoot, deviceID)
|
||||
if err := service.SetState(serverURL, ""); err != nil {
|
||||
t.Fatalf("SetState: %v", err)
|
||||
}
|
||||
if err := service.SetLastPullSeq(lastPullSeq); err != nil {
|
||||
t.Fatalf("SetLastPullSeq: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSyncState(t *testing.T, service *syncsvc.Service, wantDeviceID, wantServerURL string, wantLastPullSeq int) {
|
||||
t.Helper()
|
||||
if service == nil {
|
||||
t.Fatal("sync service is nil")
|
||||
}
|
||||
serverURL, _, lastPullSeq, _, err := service.GetState()
|
||||
if err != nil {
|
||||
t.Fatalf("GetState: %v", err)
|
||||
}
|
||||
if service.GetDeviceID() != wantDeviceID || serverURL != wantServerURL || lastPullSeq != wantLastPullSeq {
|
||||
t.Fatalf("sync state = device=%q server=%q cursor=%d, want device=%q server=%q cursor=%d",
|
||||
service.GetDeviceID(), serverURL, lastPullSeq, wantDeviceID, wantServerURL, wantLastPullSeq)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCurrentVaultInitializesWorkspaceWhenMissingAtStartup(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
vaultParent := filepath.Join(tmpDir, "vault-parent")
|
||||
|
|
|
|||
|
|
@ -77,12 +77,13 @@ func NewClient(serverURL, apiKey, deviceID, vaultRoot string) *Client {
|
|||
}
|
||||
|
||||
// PairDevice calls POST /api/client/pair and returns device_id and device_token.
|
||||
func (c *Client) PairDevice(serverURL, username, password, deviceName, clientVersion string) (deviceID, deviceToken string, err error) {
|
||||
func (c *Client) PairDevice(serverURL, username, password, deviceName, clientVersion, vaultID string) (deviceID, deviceToken string, err error) {
|
||||
body := map[string]string{
|
||||
"login": username,
|
||||
"password": password,
|
||||
"device_name": deviceName,
|
||||
"client_version": clientVersion,
|
||||
"vault_id": vaultID,
|
||||
}
|
||||
var resp struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
|
|
|
|||
|
|
@ -86,3 +86,36 @@ func TestPushDoesNotRetryClientErrors(t *testing.T) {
|
|||
t.Fatalf("attempts = %d, want 1", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairDeviceSendsVaultID(t *testing.T) {
|
||||
var pairedVaultID string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/client/pair" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
VaultID string `json:"vault_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pairedVaultID = request.VaultID
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"device_id": "device-1",
|
||||
"device_token": "token-1",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "", "", t.TempDir())
|
||||
_, _, err := client.PairDevice(server.URL, "alice", "secret", "Desktop", "verstak-desktop/v2", "vault-123")
|
||||
if err != nil {
|
||||
t.Fatalf("PairDevice: %v", err)
|
||||
}
|
||||
if pairedVaultID != "vault-123" {
|
||||
t.Fatalf("paired vault ID = %q, want vault-123", pairedVaultID)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,13 @@ type Service struct {
|
|||
|
||||
// NewService creates a sync service.
|
||||
func NewService(vaultRoot, deviceID string) *Service {
|
||||
return &Service{vaultRoot: vaultRoot, deviceID: deviceID}
|
||||
service := &Service{vaultRoot: vaultRoot, deviceID: deviceID}
|
||||
if deviceID == "" {
|
||||
if state, err := service.loadState(); err == nil {
|
||||
service.deviceID = state.DeviceID
|
||||
}
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *Service) syncDir() string {
|
||||
|
|
@ -212,6 +218,9 @@ func (s *Service) SetState(serverURL, apiKey string) error {
|
|||
}
|
||||
st.ServerURL = serverURL
|
||||
st.APIKey = apiKey
|
||||
if s.deviceID != "" {
|
||||
st.DeviceID = s.deviceID
|
||||
}
|
||||
return s.saveState(st)
|
||||
}
|
||||
|
||||
|
|
@ -240,6 +249,23 @@ func (s *Service) GetDeviceID() string {
|
|||
return s.deviceID
|
||||
}
|
||||
|
||||
// SetDeviceID persists the device ID for this vault's sync state.
|
||||
func (s *Service) SetDeviceID(deviceID string) error {
|
||||
if err := s.ensureDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := s.loadState()
|
||||
if err != nil {
|
||||
st = &syncState{}
|
||||
}
|
||||
st.DeviceID = deviceID
|
||||
if err := s.saveState(st); err != nil {
|
||||
return err
|
||||
}
|
||||
s.deviceID = deviceID
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- file helpers ---
|
||||
|
||||
func (s *Service) loadOps() ([]Op, error) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue