feat: add sync backend methods
This commit is contained in:
parent
5c979174f1
commit
ed69746332
|
|
@ -22,6 +22,7 @@ import (
|
|||
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
||||
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
||||
"github.com/verstak/verstak-desktop/internal/core/storage"
|
||||
syncsvc "github.com/verstak/verstak-desktop/internal/core/sync"
|
||||
"github.com/verstak/verstak-desktop/internal/core/vault"
|
||||
coreworkbench "github.com/verstak/verstak-desktop/internal/core/workbench"
|
||||
"github.com/verstak/verstak-desktop/internal/core/workspace"
|
||||
|
|
@ -43,6 +44,7 @@ type App struct {
|
|||
pluginState *pluginstate.Manager
|
||||
workbench *coreworkbench.Router
|
||||
workspace *workspace.Manager
|
||||
syncSvc *syncsvc.Service
|
||||
debug bool
|
||||
}
|
||||
|
||||
|
|
@ -59,6 +61,7 @@ func NewApp(
|
|||
appSettingsMgr *appsettings.Manager,
|
||||
pluginStateMgr *pluginstate.Manager,
|
||||
workspaceMgr *workspace.Manager,
|
||||
syncService *syncsvc.Service,
|
||||
debugEnabled bool,
|
||||
) *App {
|
||||
return &App{
|
||||
|
|
@ -74,6 +77,7 @@ func NewApp(
|
|||
pluginState: pluginStateMgr,
|
||||
workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)),
|
||||
workspace: workspaceMgr,
|
||||
syncSvc: syncService,
|
||||
debug: debugEnabled,
|
||||
}
|
||||
}
|
||||
|
|
@ -950,7 +954,105 @@ func (a *App) SetCurrentVault(path string) string {
|
|||
|
||||
// ─── Workspace API ─────────────────────────────────────────
|
||||
|
||||
// GetWorkspaceTree returns the full workspace tree.
|
||||
// ListWorkspaces returns top-level physical workspace folders.
|
||||
func (a *App) ListWorkspaces() ([]workspace.Workspace, string) {
|
||||
if a.workspace == nil {
|
||||
return nil, "workspace not initialized"
|
||||
}
|
||||
workspaces, err := a.workspace.ListWorkspaces()
|
||||
if err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
return workspaces, ""
|
||||
}
|
||||
|
||||
// CreateWorkspace creates a top-level physical workspace folder.
|
||||
func (a *App) CreateWorkspace(name, templateID string) (workspace.Workspace, string) {
|
||||
if a.workspace == nil {
|
||||
return workspace.Workspace{}, "workspace not initialized"
|
||||
}
|
||||
ws, err := a.workspace.CreateWorkspace(name, templateID)
|
||||
if err != nil {
|
||||
return workspace.Workspace{}, err.Error()
|
||||
}
|
||||
return ws, ""
|
||||
}
|
||||
|
||||
// RenameWorkspace physically renames a top-level workspace folder.
|
||||
func (a *App) RenameWorkspace(oldName, newName string) string {
|
||||
if a.workspace == nil {
|
||||
return "workspace not initialized"
|
||||
}
|
||||
if err := a.workspace.RenameWorkspace(oldName, newName); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TrashWorkspace moves a top-level workspace folder to internal trash.
|
||||
func (a *App) TrashWorkspace(name string) (workspace.TrashResult, string) {
|
||||
if a.workspace == nil {
|
||||
return workspace.TrashResult{}, "workspace not initialized"
|
||||
}
|
||||
result, err := a.workspace.TrashWorkspace(name)
|
||||
if err != nil {
|
||||
return workspace.TrashResult{}, err.Error()
|
||||
}
|
||||
return result, ""
|
||||
}
|
||||
|
||||
// GetWorkspaceMetadata returns metadata or a generic fallback for a workspace.
|
||||
func (a *App) GetWorkspaceMetadata(name string) (workspace.Metadata, string) {
|
||||
if a.workspace == nil {
|
||||
return workspace.Metadata{}, "workspace not initialized"
|
||||
}
|
||||
meta, err := a.workspace.GetWorkspaceMetadata(name)
|
||||
if err != nil {
|
||||
return workspace.Metadata{}, err.Error()
|
||||
}
|
||||
return meta, ""
|
||||
}
|
||||
|
||||
// UpdateWorkspaceMetadata merges metadata for an existing workspace.
|
||||
func (a *App) UpdateWorkspaceMetadata(name string, patch workspace.MetadataPatch) (workspace.Metadata, string) {
|
||||
if a.workspace == nil {
|
||||
return workspace.Metadata{}, "workspace not initialized"
|
||||
}
|
||||
meta, err := a.workspace.UpdateWorkspaceMetadata(name, patch)
|
||||
if err != nil {
|
||||
return workspace.Metadata{}, err.Error()
|
||||
}
|
||||
return meta, ""
|
||||
}
|
||||
|
||||
// GetCurrentWorkspace returns the currently selected top-level workspace.
|
||||
func (a *App) GetCurrentWorkspace() map[string]interface{} {
|
||||
if a.workspace == nil {
|
||||
return map[string]interface{}{"status": "not initialized"}
|
||||
}
|
||||
node, err := a.workspace.GetCurrentNode()
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error()}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"name": node.Name,
|
||||
"rootPath": node.RootPath,
|
||||
}
|
||||
}
|
||||
|
||||
// SetCurrentWorkspace stores the selected top-level workspace name as UI state.
|
||||
func (a *App) SetCurrentWorkspace(name string) string {
|
||||
if a.workspace == nil {
|
||||
return "workspace not initialized"
|
||||
}
|
||||
if err := a.workspace.SetCurrentNode(name); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Deprecated: compatibility wrapper over the flat top-level folder workspace
|
||||
// model. Prefer ListWorkspaces.
|
||||
func (a *App) GetWorkspaceTree() map[string]interface{} {
|
||||
if a.workspace == nil || !a.workspace.IsInitialized() {
|
||||
return map[string]interface{}{"status": "not initialized"}
|
||||
|
|
@ -964,7 +1066,8 @@ func (a *App) GetWorkspaceTree() map[string]interface{} {
|
|||
}
|
||||
}
|
||||
|
||||
// CreateWorkspaceNode creates a new workspace node.
|
||||
// Deprecated: compatibility wrapper over the flat top-level folder workspace
|
||||
// model. Prefer CreateWorkspace.
|
||||
func (a *App) CreateWorkspaceNode(parentID, nodeType, title string) map[string]interface{} {
|
||||
if a.workspace == nil {
|
||||
return map[string]interface{}{"error": "workspace not initialized"}
|
||||
|
|
@ -978,6 +1081,8 @@ func (a *App) CreateWorkspaceNode(parentID, nodeType, title string) map[string]i
|
|||
"parentId": node.ParentID,
|
||||
"type": string(node.Type),
|
||||
"title": node.Title,
|
||||
"name": node.Name,
|
||||
"rootPath": node.RootPath,
|
||||
"status": string(node.Status),
|
||||
"order": node.Order,
|
||||
"createdAt": node.CreatedAt,
|
||||
|
|
@ -985,7 +1090,8 @@ func (a *App) CreateWorkspaceNode(parentID, nodeType, title string) map[string]i
|
|||
}
|
||||
}
|
||||
|
||||
// RenameWorkspaceNode renames a workspace node.
|
||||
// Deprecated: compatibility wrapper over the flat top-level folder workspace
|
||||
// model. Prefer RenameWorkspace.
|
||||
func (a *App) RenameWorkspaceNode(id, title string) string {
|
||||
if a.workspace == nil {
|
||||
return "workspace not initialized"
|
||||
|
|
@ -996,7 +1102,8 @@ func (a *App) RenameWorkspaceNode(id, title string) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// MoveWorkspaceNode moves a node to a new parent.
|
||||
// Deprecated: compatibility wrapper retained only to reject old nested tree
|
||||
// moves. The corrected workspace model is top-level folders only.
|
||||
func (a *App) MoveWorkspaceNode(id, newParentID string) string {
|
||||
if a.workspace == nil {
|
||||
return "workspace not initialized"
|
||||
|
|
@ -1007,7 +1114,8 @@ func (a *App) MoveWorkspaceNode(id, newParentID string) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// ArchiveWorkspaceNode archives a workspace node.
|
||||
// Deprecated: compatibility wrapper over the flat top-level folder workspace
|
||||
// model. Prefer TrashWorkspace.
|
||||
func (a *App) ArchiveWorkspaceNode(id string) string {
|
||||
if a.workspace == nil {
|
||||
return "workspace not initialized"
|
||||
|
|
@ -1018,7 +1126,8 @@ func (a *App) ArchiveWorkspaceNode(id string) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// GetCurrentWorkspaceNode returns the currently selected node.
|
||||
// Deprecated: compatibility wrapper over the flat top-level folder workspace
|
||||
// model. Prefer GetCurrentWorkspace.
|
||||
func (a *App) GetCurrentWorkspaceNode() map[string]interface{} {
|
||||
if a.workspace == nil {
|
||||
return map[string]interface{}{"status": "not initialized"}
|
||||
|
|
@ -1031,11 +1140,14 @@ func (a *App) GetCurrentWorkspaceNode() map[string]interface{} {
|
|||
"id": node.ID,
|
||||
"type": string(node.Type),
|
||||
"title": node.Title,
|
||||
"name": node.Name,
|
||||
"rootPath": node.RootPath,
|
||||
"status": string(node.Status),
|
||||
}
|
||||
}
|
||||
|
||||
// SetCurrentWorkspaceNode sets the currently selected node.
|
||||
// Deprecated: compatibility wrapper over the flat top-level folder workspace
|
||||
// model. Prefer SetCurrentWorkspace.
|
||||
func (a *App) SetCurrentWorkspaceNode(id string) string {
|
||||
if a.workspace == nil {
|
||||
return "workspace not initialized"
|
||||
|
|
@ -1201,3 +1313,318 @@ func (a *App) GetPluginAssetContent(pluginID, assetPath string) (string, string)
|
|||
}
|
||||
return string(data), ""
|
||||
}
|
||||
|
||||
// ─── Sync API ──────────────────────────────────────────────
|
||||
|
||||
func (a *App) requireVault() error {
|
||||
if a.vault == nil || a.vault.GetVaultStatus() != vault.StatusOpen {
|
||||
return fmt.Errorf("vault not open")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) vaultPath() string {
|
||||
if a.vault == nil {
|
||||
return ""
|
||||
}
|
||||
return a.vault.GetVaultPath()
|
||||
}
|
||||
|
||||
// SyncStatusDTO holds sync status information for the frontend.
|
||||
type SyncStatusDTO struct {
|
||||
Configured bool `json:"configured"`
|
||||
ServerURL string `json:"serverUrl"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
Connected bool `json:"connected"`
|
||||
Revoked bool `json:"revoked"`
|
||||
TokenStored bool `json:"tokenStored"`
|
||||
UnpushedOps int `json:"unpushedOps"`
|
||||
LastSyncAt string `json:"lastSyncAt"`
|
||||
SyncInterval int `json:"syncInterval"`
|
||||
LastError string `json:"lastError"`
|
||||
StatusLabel string `json:"statusLabel"`
|
||||
}
|
||||
|
||||
// SyncStatus returns the current sync status.
|
||||
func (a *App) SyncStatus() (*SyncStatusDTO, error) {
|
||||
if a.vault == nil || a.vault.GetVaultStatus() != vault.StatusOpen {
|
||||
return &SyncStatusDTO{}, nil
|
||||
}
|
||||
|
||||
vaultPath := a.vaultPath()
|
||||
if a.syncSvc == nil {
|
||||
return &SyncStatusDTO{}, nil
|
||||
}
|
||||
|
||||
serverURL, apiKey, _, lastSyncAt, err := a.syncSvc.GetState()
|
||||
if err != nil {
|
||||
return &SyncStatusDTO{}, nil
|
||||
}
|
||||
|
||||
cfg := a.appSettings.Get()
|
||||
deviceToken := syncsvc.LoadDeviceToken(vaultPath)
|
||||
|
||||
dto := &SyncStatusDTO{
|
||||
Configured: serverURL != "" && (apiKey != "" || deviceToken != ""),
|
||||
ServerURL: serverURL,
|
||||
LastSyncAt: lastSyncAt,
|
||||
UnpushedOps: 0,
|
||||
TokenStored: deviceToken != "",
|
||||
SyncInterval: cfg.Sync.SyncInterval,
|
||||
LastError: cfg.Sync.LastError,
|
||||
}
|
||||
|
||||
if cfg.Sync.DeviceID != "" {
|
||||
dto.DeviceID = cfg.Sync.DeviceID
|
||||
}
|
||||
|
||||
unpushed, _ := a.syncSvc.GetUnpushedOps()
|
||||
dto.UnpushedOps = len(unpushed)
|
||||
|
||||
if deviceToken != "" {
|
||||
client := syncsvc.NewClient(serverURL, "", "", vaultPath)
|
||||
client.DeviceToken = deviceToken
|
||||
if cfg.Sync.DeviceID != "" {
|
||||
client.DeviceID = cfg.Sync.DeviceID
|
||||
}
|
||||
if info, err := client.GetMe(); err == nil {
|
||||
dto.DeviceName = info.DeviceName
|
||||
dto.DeviceID = info.DeviceID
|
||||
dto.Connected = true
|
||||
if info.RevokedAt != "" {
|
||||
dto.Revoked = true
|
||||
dto.Connected = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case dto.Revoked:
|
||||
dto.StatusLabel = "revoked"
|
||||
case dto.Connected:
|
||||
dto.StatusLabel = "connected"
|
||||
case dto.Configured:
|
||||
dto.StatusLabel = "disconnected"
|
||||
default:
|
||||
dto.StatusLabel = "disabled"
|
||||
}
|
||||
|
||||
if cfg.Sync.LastSyncAt != lastSyncAt || cfg.Sync.LastStatus != dto.StatusLabel {
|
||||
cfg.Sync.LastSyncAt = lastSyncAt
|
||||
cfg.Sync.LastStatus = dto.StatusLabel
|
||||
_ = a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync})
|
||||
}
|
||||
|
||||
return dto, nil
|
||||
}
|
||||
|
||||
// SyncConfigure pairs the device with a sync server.
|
||||
func (a *App) SyncConfigure(serverURL, username, password string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
vaultPath := a.vaultPath()
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "unknown"
|
||||
}
|
||||
client := syncsvc.NewClient(serverURL, "", "", vaultPath)
|
||||
deviceID, deviceToken, err := client.PairDevice(serverURL, username, password, hostname, "verstak-desktop/v2")
|
||||
if err != nil {
|
||||
return fmt.Errorf("pair: %w", err)
|
||||
}
|
||||
if err := syncsvc.SaveDeviceToken(vaultPath, deviceToken); err != nil {
|
||||
return fmt.Errorf("save token: %w", err)
|
||||
}
|
||||
if err := a.syncSvc.SetState(serverURL, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg := a.appSettings.Get()
|
||||
cfg.Sync.Enabled = true
|
||||
cfg.Sync.ServerURL = serverURL
|
||||
cfg.Sync.DeviceID = deviceID
|
||||
cfg.Sync.DeviceName = hostname
|
||||
cfg.Sync.LastStatus = "connected"
|
||||
_ = a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncDisconnect disconnects from the sync server and revokes the device token.
|
||||
func (a *App) SyncDisconnect() error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
vaultPath := a.vaultPath()
|
||||
deviceToken := syncsvc.LoadDeviceToken(vaultPath)
|
||||
cfg := a.appSettings.Get()
|
||||
|
||||
if deviceToken != "" {
|
||||
client := syncsvc.NewClient(cfg.Sync.ServerURL, "", "", vaultPath)
|
||||
client.DeviceToken = deviceToken
|
||||
_ = client.RevokeCurrent()
|
||||
}
|
||||
_ = syncsvc.RemoveDeviceToken(vaultPath)
|
||||
|
||||
cfg.Sync.Enabled = false
|
||||
cfg.Sync.ServerURL = ""
|
||||
cfg.Sync.DeviceID = ""
|
||||
cfg.Sync.DeviceName = ""
|
||||
cfg.Sync.LastStatus = "disabled"
|
||||
cfg.Sync.LastError = ""
|
||||
if err := a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync}); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.syncSvc.SetState("", "")
|
||||
}
|
||||
|
||||
// SyncTestConnection tests the connection to a sync server with the given credentials.
|
||||
func (a *App) SyncTestConnection(serverURL, username, password string) error {
|
||||
vaultPath := a.vaultPath()
|
||||
if vaultPath == "" {
|
||||
vaultPath = "/tmp"
|
||||
}
|
||||
client := syncsvc.NewClient(serverURL, "", "", vaultPath)
|
||||
return client.TestAuth(serverURL, username, password)
|
||||
}
|
||||
|
||||
// SyncSetInterval sets the auto-sync interval in minutes.
|
||||
func (a *App) SyncSetInterval(minutes int) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := a.appSettings.Get()
|
||||
cfg.Sync.SyncInterval = minutes
|
||||
if cfg.Sync.DeviceID == "" && a.syncSvc != nil {
|
||||
cfg.Sync.DeviceID = a.syncSvc.GetDeviceID()
|
||||
}
|
||||
return a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync})
|
||||
}
|
||||
|
||||
// SyncNow triggers an immediate sync cycle (push local ops, pull remote ops).
|
||||
func (a *App) SyncNow() (map[string]interface{}, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vaultPath := a.vaultPath()
|
||||
|
||||
serverURL, apiKey, lastPullSeq, _, err := a.syncSvc.GetState()
|
||||
deviceToken := syncsvc.LoadDeviceToken(vaultPath)
|
||||
if err != nil || serverURL == "" || (apiKey == "" && deviceToken == "") {
|
||||
return nil, fmt.Errorf("sync not configured")
|
||||
}
|
||||
|
||||
deviceID := ""
|
||||
cfg := a.appSettings.Get()
|
||||
if cfg.Sync.DeviceID != "" {
|
||||
deviceID = cfg.Sync.DeviceID
|
||||
}
|
||||
|
||||
client := syncsvc.NewClient(serverURL, apiKey, deviceID, vaultPath)
|
||||
client.DeviceToken = deviceToken
|
||||
|
||||
unpushed, err := a.syncSvc.GetUnpushedOps()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get ops: %w", err)
|
||||
}
|
||||
for i := range unpushed {
|
||||
unpushed[i].LastSeenServerSeq = lastPullSeq
|
||||
}
|
||||
pushResult := &syncsvc.PushResponse{}
|
||||
if len(unpushed) > 0 {
|
||||
pushResult, err = client.Push(unpushed)
|
||||
if err != nil {
|
||||
_ = a.updateSyncError(fmt.Sprintf("push: %v", err))
|
||||
return nil, fmt.Errorf("push: %w", err)
|
||||
}
|
||||
if err := a.syncSvc.MarkPushed(pushResult.Accepted); err != nil {
|
||||
return nil, fmt.Errorf("mark pushed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
pullResult, err := client.Pull(lastPullSeq)
|
||||
if err != nil {
|
||||
_ = a.updateSyncError(fmt.Sprintf("pull: %v", err))
|
||||
return nil, fmt.Errorf("pull: %w", err)
|
||||
}
|
||||
|
||||
var applyErrors []string
|
||||
for _, op := range pullResult.Ops {
|
||||
if err := a.applyRemoteOp(op); err != nil {
|
||||
applyErrors = append(applyErrors, fmt.Sprintf("%s/%s: %v", op.EntityType, op.OpID, err))
|
||||
}
|
||||
_ = a.syncSvc.RecordRemoteOp(op)
|
||||
}
|
||||
if len(pullResult.Ops) > 0 {
|
||||
opIDs := make([]string, len(pullResult.Ops))
|
||||
for i, op := range pullResult.Ops {
|
||||
opIDs[i] = op.OpID
|
||||
}
|
||||
_ = a.syncSvc.MarkApplied(opIDs)
|
||||
}
|
||||
|
||||
if len(pushResult.Conflicts) > 0 {
|
||||
log.Printf("[sync] %d conflict(s) detected on push", len(pushResult.Conflicts))
|
||||
for _, c := range pushResult.Conflicts {
|
||||
log.Printf("[sync] conflict: op=%v entity=%v/%v",
|
||||
c["op_id"], c["entity_type"], c["entity_id"])
|
||||
}
|
||||
}
|
||||
|
||||
if pullResult.ServerSequence > lastPullSeq {
|
||||
_ = a.syncSvc.SetLastPullSeq(pullResult.ServerSequence)
|
||||
}
|
||||
_ = a.syncSvc.SetLastSyncAt(time.Now().UTC().Format(time.RFC3339))
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
a.updateSyncSuccess(now)
|
||||
|
||||
result := map[string]interface{}{
|
||||
"pushed": len(pushResult.Accepted),
|
||||
"pulled": len(pullResult.Ops),
|
||||
"serverSequence": pullResult.ServerSequence,
|
||||
}
|
||||
if len(applyErrors) > 0 {
|
||||
result["applyErrors"] = applyErrors
|
||||
}
|
||||
if len(pushResult.Conflicts) > 0 {
|
||||
result["conflicts"] = pushResult.Conflicts
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ResetSyncKey clears the device token and resets sync state.
|
||||
func (a *App) ResetSyncKey() error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = syncsvc.RemoveDeviceToken(a.vaultPath())
|
||||
cfg := a.appSettings.Get()
|
||||
cfg.Sync.LastStatus = "disabled"
|
||||
cfg.Sync.LastError = ""
|
||||
return a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync})
|
||||
}
|
||||
|
||||
func (a *App) updateSyncError(errMsg string) error {
|
||||
cfg := a.appSettings.Get()
|
||||
cfg.Sync.LastError = errMsg
|
||||
cfg.Sync.LastStatus = "error"
|
||||
return a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync})
|
||||
}
|
||||
|
||||
func (a *App) updateSyncSuccess(lastSyncAt string) error {
|
||||
cfg := a.appSettings.Get()
|
||||
cfg.Sync.LastError = ""
|
||||
cfg.Sync.LastStatus = "connected"
|
||||
cfg.Sync.LastSyncAt = lastSyncAt
|
||||
return a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync})
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteOp(op syncsvc.Op) error {
|
||||
if a.debug {
|
||||
log.Printf("[sync] applyRemoteOp: type=%s entity=%s/%s", op.OpType, op.EntityType, op.EntityID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ type Config struct {
|
|||
DevMode bool `json:"devMode"`
|
||||
UserPluginsDir string `json:"userPluginsDir"`
|
||||
Workbench WorkbenchPreferences `json:"workbench,omitempty"`
|
||||
Sync SyncSettings `json:"sync,omitempty"`
|
||||
WindowState *WindowState `json:"windowState,omitempty"`
|
||||
LastOpenedAt string `json:"lastOpenedAt"`
|
||||
}
|
||||
|
|
@ -31,6 +32,18 @@ type WorkbenchPreferences struct {
|
|||
DefaultNotesMarkdownEditorProvider string `json:"defaultNotesMarkdownEditorProvider,omitempty"`
|
||||
}
|
||||
|
||||
// SyncSettings holds sync configuration for the current vault.
|
||||
type SyncSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ServerURL string `json:"serverUrl"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
SyncInterval int `json:"syncInterval"`
|
||||
LastStatus string `json:"lastStatus"`
|
||||
LastSyncAt string `json:"lastSyncAt"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
}
|
||||
|
||||
// WindowState stores the last window position and size.
|
||||
type WindowState struct {
|
||||
Width int `json:"width"`
|
||||
|
|
@ -234,6 +247,7 @@ func copyConfig(c *Config) *Config {
|
|||
DevMode: c.DevMode,
|
||||
UserPluginsDir: c.UserPluginsDir,
|
||||
Workbench: c.Workbench,
|
||||
Sync: c.Sync,
|
||||
LastOpenedAt: c.LastOpenedAt,
|
||||
}
|
||||
if c.WindowState != nil {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,336 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DeviceTokenPath returns the path to the device_token file inside the vault.
|
||||
func DeviceTokenPath(vaultRoot string) string {
|
||||
return filepath.Join(vaultRoot, ".verstak", "device_token.json")
|
||||
}
|
||||
|
||||
// SaveDeviceToken writes the device token to a file with 0600 perms.
|
||||
func SaveDeviceToken(vaultRoot, token string) error {
|
||||
path := DeviceTokenPath(vaultRoot)
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
data := fmt.Sprintf(`{"device_token":%q}`, token)
|
||||
return os.WriteFile(path, []byte(data), 0o600)
|
||||
}
|
||||
|
||||
// LoadDeviceToken reads the device token from the vault.
|
||||
func LoadDeviceToken(vaultRoot string) string {
|
||||
path := DeviceTokenPath(vaultRoot)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var v struct {
|
||||
DeviceToken string `json:"device_token"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return ""
|
||||
}
|
||||
return v.DeviceToken
|
||||
}
|
||||
|
||||
// RemoveDeviceToken deletes the device token file.
|
||||
func RemoveDeviceToken(vaultRoot string) error {
|
||||
path := DeviceTokenPath(vaultRoot)
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
// Client communicates with the Verstak Sync Server.
|
||||
type Client struct {
|
||||
ServerURL string
|
||||
APIKey string
|
||||
DeviceToken string
|
||||
DeviceID string
|
||||
VaultRoot string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a sync client.
|
||||
func NewClient(serverURL, apiKey, deviceID, vaultRoot string) *Client {
|
||||
return &Client{
|
||||
ServerURL: serverURL,
|
||||
APIKey: apiKey,
|
||||
DeviceID: deviceID,
|
||||
VaultRoot: vaultRoot,
|
||||
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
body := map[string]string{
|
||||
"login": username,
|
||||
"password": password,
|
||||
"device_name": deviceName,
|
||||
"client_version": clientVersion,
|
||||
}
|
||||
var resp struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceToken string `json:"device_token"`
|
||||
}
|
||||
savedURL := c.ServerURL
|
||||
c.ServerURL = serverURL
|
||||
err = c.post("/api/client/pair", body, &resp)
|
||||
c.ServerURL = savedURL
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return resp.DeviceID, resp.DeviceToken, nil
|
||||
}
|
||||
|
||||
// DeviceInfo holds device information from the server.
|
||||
type DeviceInfo struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
DeviceName string `json:"device_name"`
|
||||
ClientVersion string `json:"client_version"`
|
||||
LastSeen string `json:"last_seen"`
|
||||
RevokedAt string `json:"revoked_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// GetMe calls GET /api/client/me and returns device info.
|
||||
func (c *Client) GetMe() (*DeviceInfo, error) {
|
||||
var resp DeviceInfo
|
||||
if err := c.get("/api/client/me", &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// RevokeCurrent calls POST /api/client/revoke-current.
|
||||
func (c *Client) RevokeCurrent() error {
|
||||
var resp struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
return c.post("/api/client/revoke-current", nil, &resp)
|
||||
}
|
||||
|
||||
// TestAuth checks credentials without creating a device.
|
||||
func (c *Client) TestAuth(serverURL, username, password string) error {
|
||||
body := map[string]string{"username": username, "password": password}
|
||||
savedURL := c.ServerURL
|
||||
savedKey := c.APIKey
|
||||
c.ServerURL = serverURL
|
||||
c.APIKey = ""
|
||||
err := c.post("/api/auth/test", body, nil)
|
||||
c.ServerURL = savedURL
|
||||
c.APIKey = savedKey
|
||||
return err
|
||||
}
|
||||
|
||||
// PushRequest is the payload for POST /sync/push.
|
||||
type PushRequest struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
Ops []PushOp `json:"ops"`
|
||||
}
|
||||
|
||||
// PushOp is a single operation in a push request.
|
||||
type PushOp struct {
|
||||
OpID string `json:"op_id"`
|
||||
EntityType string `json:"entity_type"`
|
||||
EntityID string `json:"entity_id"`
|
||||
OpType string `json:"op_type"`
|
||||
PayloadJSON string `json:"payload_json"`
|
||||
ClientSequence int `json:"client_sequence"`
|
||||
LastSeenServerSeq int `json:"last_seen_server_seq"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// PushResponse is the response from POST /sync/push.
|
||||
type PushResponse struct {
|
||||
Accepted []string `json:"accepted"`
|
||||
Count int `json:"count"`
|
||||
Conflicts []map[string]interface{} `json:"conflicts"`
|
||||
}
|
||||
|
||||
// Push sends local operations to the server.
|
||||
func (c *Client) Push(ops []Op) (*PushResponse, error) {
|
||||
pushOps := make([]PushOp, len(ops))
|
||||
for i, op := range ops {
|
||||
pushOps[i] = PushOp{
|
||||
OpID: op.OpID,
|
||||
EntityType: op.EntityType,
|
||||
EntityID: op.EntityID,
|
||||
OpType: op.OpType,
|
||||
PayloadJSON: op.PayloadJSON,
|
||||
ClientSequence: op.ClientSequence,
|
||||
LastSeenServerSeq: op.LastSeenServerSeq,
|
||||
CreatedAt: op.CreatedAt,
|
||||
}
|
||||
}
|
||||
req := PushRequest{DeviceID: c.DeviceID, Ops: pushOps}
|
||||
var resp PushResponse
|
||||
if err := c.post("/api/v1/sync/push", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// PullRequest is the payload for POST /sync/pull.
|
||||
type PullRequest struct {
|
||||
SinceSequence int `json:"since_sequence"`
|
||||
}
|
||||
|
||||
// PullResponse is the response from POST /sync/pull.
|
||||
type PullResponse struct {
|
||||
ServerSequence int `json:"server_sequence"`
|
||||
Ops []Op `json:"ops"`
|
||||
}
|
||||
|
||||
// Pull fetches remote operations since a given sequence.
|
||||
func (c *Client) Pull(sinceSequence int) (*PullResponse, error) {
|
||||
req := PullRequest{SinceSequence: sinceSequence}
|
||||
var resp PullResponse
|
||||
if err := c.post("/api/v1/sync/pull", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// UploadBlob uploads a file to the server and returns its SHA-256.
|
||||
func (c *Client) UploadBlob(localPath string) (sha256 string, err error) {
|
||||
var b bytes.Buffer
|
||||
w := multipart.NewWriter(&b)
|
||||
fw, err := w.CreateFormFile("file", filepath.Base(localPath))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
f, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := io.Copy(fw, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
w.Close()
|
||||
|
||||
req, err := http.NewRequest("POST", c.ServerURL+"/api/v1/blobs/", &b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
SHA256 string `json:"sha256"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.SHA256, nil
|
||||
}
|
||||
|
||||
// DownloadBlob downloads a blob by SHA-256 hash.
|
||||
func (c *Client) DownloadBlob(sha256, destPath string) error {
|
||||
req, err := http.NewRequest("GET", c.ServerURL+"/api/v1/blobs/"+sha256, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("download blob: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) bearerToken() string {
|
||||
if c.DeviceToken != "" {
|
||||
return c.DeviceToken
|
||||
}
|
||||
return c.APIKey
|
||||
}
|
||||
|
||||
func (c *Client) post(path string, body, result interface{}) error {
|
||||
var b bytes.Buffer
|
||||
if body != nil {
|
||||
if err := json.NewEncoder(&b).Encode(body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequest("POST", c.ServerURL+path, &b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("http: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("server %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
return json.NewDecoder(resp.Body).Decode(result)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) get(path string, result interface{}) error {
|
||||
req, err := http.NewRequest("GET", c.ServerURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("http: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("server %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
return json.NewDecoder(resp.Body).Decode(result)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
EntityNode = "node"
|
||||
EntityNote = "note"
|
||||
EntityFile = "file"
|
||||
EntityFolder = "folder"
|
||||
EntityAction = "action"
|
||||
EntityWorklog = "worklog"
|
||||
)
|
||||
|
||||
const (
|
||||
OpCreate = "create"
|
||||
OpUpdate = "update"
|
||||
OpDelete = "delete"
|
||||
OpMove = "move"
|
||||
)
|
||||
|
||||
// Op represents a sync operation.
|
||||
type Op struct {
|
||||
ID string `json:"id"`
|
||||
OpID string `json:"op_id"`
|
||||
ServerSequence int `json:"server_sequence,omitempty"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
EntityType string `json:"entity_type"`
|
||||
EntityID string `json:"entity_id"`
|
||||
OpType string `json:"op_type"`
|
||||
PayloadJSON string `json:"payload_json"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
PushedAt *string `json:"pushed_at,omitempty"`
|
||||
AppliedAt *string `json:"applied_at,omitempty"`
|
||||
ClientSequence int `json:"client_sequence,omitempty"`
|
||||
LastSeenServerSeq int `json:"last_seen_server_seq,omitempty"`
|
||||
}
|
||||
|
||||
// syncState persists connection state to JSON file.
|
||||
type syncState struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
DeviceID string `json:"device_id"`
|
||||
LastPullSeq int `json:"last_pull_seq"`
|
||||
LastSyncAt string `json:"last_sync_at"`
|
||||
}
|
||||
|
||||
// Service records and manages sync operations using JSON file storage.
|
||||
type Service struct {
|
||||
vaultRoot string
|
||||
deviceID string
|
||||
}
|
||||
|
||||
// NewService creates a sync service.
|
||||
func NewService(vaultRoot, deviceID string) *Service {
|
||||
return &Service{vaultRoot: vaultRoot, deviceID: deviceID}
|
||||
}
|
||||
|
||||
func (s *Service) syncDir() string {
|
||||
return filepath.Join(s.vaultRoot, ".verstak", "sync")
|
||||
}
|
||||
|
||||
func (s *Service) opsPath() string {
|
||||
return filepath.Join(s.syncDir(), "ops.json")
|
||||
}
|
||||
|
||||
func (s *Service) statePath() string {
|
||||
return filepath.Join(s.syncDir(), "state.json")
|
||||
}
|
||||
|
||||
func (s *Service) ensureDir() error {
|
||||
return os.MkdirAll(s.syncDir(), 0o755)
|
||||
}
|
||||
|
||||
// RecordOp writes a sync operation to the local ops file.
|
||||
func (s *Service) RecordOp(entityType, entityID, opType string, payload interface{}) error {
|
||||
if err := s.ensureDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
id := uuid.New().String()
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
var payloadStr string
|
||||
if payload != nil {
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payloadStr = string(b)
|
||||
}
|
||||
|
||||
op := Op{
|
||||
ID: id,
|
||||
OpID: id,
|
||||
DeviceID: s.deviceID,
|
||||
EntityType: entityType,
|
||||
EntityID: entityID,
|
||||
OpType: opType,
|
||||
PayloadJSON: payloadStr,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
ops, err := s.loadOps()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ops = append(ops, op)
|
||||
return s.saveOps(ops)
|
||||
}
|
||||
|
||||
// RecordRemoteOp writes a remote op to the local ops file.
|
||||
func (s *Service) RecordRemoteOp(op Op) error {
|
||||
if err := s.ensureDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
ops, err := s.loadOps()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
remoteID := op.OpID + "-remote"
|
||||
for _, existing := range ops {
|
||||
if existing.ID == remoteID {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
op.ID = remoteID
|
||||
op.PushedAt = &now
|
||||
op.AppliedAt = &now
|
||||
ops = append(ops, op)
|
||||
return s.saveOps(ops)
|
||||
}
|
||||
|
||||
// GetUnpushedOps returns ops that have not been pushed yet.
|
||||
func (s *Service) GetUnpushedOps() ([]Op, error) {
|
||||
ops, err := s.loadOps()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var unpushed []Op
|
||||
for _, op := range ops {
|
||||
if op.PushedAt == nil {
|
||||
unpushed = append(unpushed, op)
|
||||
}
|
||||
}
|
||||
return unpushed, nil
|
||||
}
|
||||
|
||||
// MarkPushed marks ops as pushed to server.
|
||||
func (s *Service) MarkPushed(opIDs []string) error {
|
||||
ops, err := s.loadOps()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
idSet := make(map[string]bool, len(opIDs))
|
||||
for _, id := range opIDs {
|
||||
idSet[id] = true
|
||||
}
|
||||
for i := range ops {
|
||||
if idSet[ops[i].OpID] {
|
||||
ops[i].PushedAt = &now
|
||||
}
|
||||
}
|
||||
return s.saveOps(ops)
|
||||
}
|
||||
|
||||
// MarkApplied marks remote ops as applied locally.
|
||||
func (s *Service) MarkApplied(opIDs []string) error {
|
||||
ops, err := s.loadOps()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
idSet := make(map[string]bool, len(opIDs))
|
||||
for _, id := range opIDs {
|
||||
idSet[id] = true
|
||||
}
|
||||
for i := range ops {
|
||||
if idSet[ops[i].OpID] {
|
||||
ops[i].AppliedAt = &now
|
||||
}
|
||||
}
|
||||
return s.saveOps(ops)
|
||||
}
|
||||
|
||||
// GetState returns the current sync state.
|
||||
func (s *Service) GetState() (serverURL, apiKey string, lastPullSeq int, lastSyncAt string, err error) {
|
||||
st, err := s.loadState()
|
||||
if err != nil {
|
||||
return "", "", 0, "", err
|
||||
}
|
||||
return st.ServerURL, st.APIKey, st.LastPullSeq, st.LastSyncAt, nil
|
||||
}
|
||||
|
||||
// SetState saves sync connection state.
|
||||
func (s *Service) SetState(serverURL, apiKey string) error {
|
||||
if err := s.ensureDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := s.loadState()
|
||||
if err != nil {
|
||||
st = &syncState{}
|
||||
}
|
||||
st.ServerURL = serverURL
|
||||
st.APIKey = apiKey
|
||||
return s.saveState(st)
|
||||
}
|
||||
|
||||
// SetLastPullSeq updates the last pulled server sequence.
|
||||
func (s *Service) SetLastPullSeq(seq int) error {
|
||||
st, err := s.loadState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st.LastPullSeq = seq
|
||||
return s.saveState(st)
|
||||
}
|
||||
|
||||
// SetLastSyncAt updates the last sync timestamp.
|
||||
func (s *Service) SetLastSyncAt(t string) error {
|
||||
st, err := s.loadState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st.LastSyncAt = t
|
||||
return s.saveState(st)
|
||||
}
|
||||
|
||||
// GetDeviceID returns the device ID used by this service.
|
||||
func (s *Service) GetDeviceID() string {
|
||||
return s.deviceID
|
||||
}
|
||||
|
||||
// --- file helpers ---
|
||||
|
||||
func (s *Service) loadOps() ([]Op, error) {
|
||||
data, err := os.ReadFile(s.opsPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read ops: %w", err)
|
||||
}
|
||||
var ops []Op
|
||||
if err := json.Unmarshal(data, &ops); err != nil {
|
||||
return nil, fmt.Errorf("parse ops: %w", err)
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
func (s *Service) saveOps(ops []Op) error {
|
||||
data, err := json.MarshalIndent(ops, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal ops: %w", err)
|
||||
}
|
||||
return os.WriteFile(s.opsPath(), data, 0o644)
|
||||
}
|
||||
|
||||
func (s *Service) loadState() (*syncState, error) {
|
||||
data, err := os.ReadFile(s.statePath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &syncState{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read state: %w", err)
|
||||
}
|
||||
var st syncState
|
||||
if err := json.Unmarshal(data, &st); err != nil {
|
||||
return nil, fmt.Errorf("parse state: %w", err)
|
||||
}
|
||||
return &st, nil
|
||||
}
|
||||
|
||||
func (s *Service) saveState(st *syncState) error {
|
||||
data, err := json.MarshalIndent(st, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal state: %w", err)
|
||||
}
|
||||
return os.WriteFile(s.statePath(), data, 0o644)
|
||||
}
|
||||
7
main.go
7
main.go
|
|
@ -21,6 +21,7 @@ import (
|
|||
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
||||
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
||||
"github.com/verstak/verstak-desktop/internal/core/storage"
|
||||
syncsvc "github.com/verstak/verstak-desktop/internal/core/sync"
|
||||
"github.com/verstak/verstak-desktop/internal/core/vault"
|
||||
"github.com/verstak/verstak-desktop/internal/core/workspace"
|
||||
"github.com/verstak/verstak-desktop/internal/shell/debug"
|
||||
|
|
@ -245,7 +246,11 @@ func main() {
|
|||
// Create the App struct
|
||||
storageService := storage.New(vaultService)
|
||||
filesService := corefiles.NewService(vaultService)
|
||||
app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, debugEnabled)
|
||||
var syncService *syncsvc.Service
|
||||
if vaultService.GetVaultStatus() == vault.StatusOpen {
|
||||
syncService = syncsvc.NewService(vaultService.GetVaultPath(), "")
|
||||
}
|
||||
app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, debugEnabled)
|
||||
|
||||
// ─── Wails App ───────────────────────────────────────────
|
||||
err := wails.Run(&options.App{
|
||||
|
|
|
|||
Loading…
Reference in New Issue