feat: add sync backend methods

This commit is contained in:
mirivlad 2026-06-20 02:25:25 +08:00
parent 5c979174f1
commit ed69746332
5 changed files with 1083 additions and 12 deletions

View File

@ -22,6 +22,7 @@ import (
"github.com/verstak/verstak-desktop/internal/core/plugin" "github.com/verstak/verstak-desktop/internal/core/plugin"
"github.com/verstak/verstak-desktop/internal/core/pluginstate" "github.com/verstak/verstak-desktop/internal/core/pluginstate"
"github.com/verstak/verstak-desktop/internal/core/storage" "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/vault"
coreworkbench "github.com/verstak/verstak-desktop/internal/core/workbench" coreworkbench "github.com/verstak/verstak-desktop/internal/core/workbench"
"github.com/verstak/verstak-desktop/internal/core/workspace" "github.com/verstak/verstak-desktop/internal/core/workspace"
@ -43,6 +44,7 @@ type App struct {
pluginState *pluginstate.Manager pluginState *pluginstate.Manager
workbench *coreworkbench.Router workbench *coreworkbench.Router
workspace *workspace.Manager workspace *workspace.Manager
syncSvc *syncsvc.Service
debug bool debug bool
} }
@ -59,6 +61,7 @@ func NewApp(
appSettingsMgr *appsettings.Manager, appSettingsMgr *appsettings.Manager,
pluginStateMgr *pluginstate.Manager, pluginStateMgr *pluginstate.Manager,
workspaceMgr *workspace.Manager, workspaceMgr *workspace.Manager,
syncService *syncsvc.Service,
debugEnabled bool, debugEnabled bool,
) *App { ) *App {
return &App{ return &App{
@ -74,6 +77,7 @@ func NewApp(
pluginState: pluginStateMgr, pluginState: pluginStateMgr,
workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)), workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)),
workspace: workspaceMgr, workspace: workspaceMgr,
syncSvc: syncService,
debug: debugEnabled, debug: debugEnabled,
} }
} }
@ -950,7 +954,105 @@ func (a *App) SetCurrentVault(path string) string {
// ─── Workspace API ───────────────────────────────────────── // ─── 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{} { func (a *App) GetWorkspaceTree() map[string]interface{} {
if a.workspace == nil || !a.workspace.IsInitialized() { if a.workspace == nil || !a.workspace.IsInitialized() {
return map[string]interface{}{"status": "not initialized"} 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{} { func (a *App) CreateWorkspaceNode(parentID, nodeType, title string) map[string]interface{} {
if a.workspace == nil { if a.workspace == nil {
return map[string]interface{}{"error": "workspace not initialized"} 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, "parentId": node.ParentID,
"type": string(node.Type), "type": string(node.Type),
"title": node.Title, "title": node.Title,
"name": node.Name,
"rootPath": node.RootPath,
"status": string(node.Status), "status": string(node.Status),
"order": node.Order, "order": node.Order,
"createdAt": node.CreatedAt, "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 { func (a *App) RenameWorkspaceNode(id, title string) string {
if a.workspace == nil { if a.workspace == nil {
return "workspace not initialized" return "workspace not initialized"
@ -996,7 +1102,8 @@ func (a *App) RenameWorkspaceNode(id, title string) string {
return "" 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 { func (a *App) MoveWorkspaceNode(id, newParentID string) string {
if a.workspace == nil { if a.workspace == nil {
return "workspace not initialized" return "workspace not initialized"
@ -1007,7 +1114,8 @@ func (a *App) MoveWorkspaceNode(id, newParentID string) string {
return "" 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 { func (a *App) ArchiveWorkspaceNode(id string) string {
if a.workspace == nil { if a.workspace == nil {
return "workspace not initialized" return "workspace not initialized"
@ -1018,7 +1126,8 @@ func (a *App) ArchiveWorkspaceNode(id string) string {
return "" 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{} { func (a *App) GetCurrentWorkspaceNode() map[string]interface{} {
if a.workspace == nil { if a.workspace == nil {
return map[string]interface{}{"status": "not initialized"} return map[string]interface{}{"status": "not initialized"}
@ -1028,14 +1137,17 @@ func (a *App) GetCurrentWorkspaceNode() map[string]interface{} {
return map[string]interface{}{"error": err.Error()} return map[string]interface{}{"error": err.Error()}
} }
return map[string]interface{}{ return map[string]interface{}{
"id": node.ID, "id": node.ID,
"type": string(node.Type), "type": string(node.Type),
"title": node.Title, "title": node.Title,
"status": string(node.Status), "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 { func (a *App) SetCurrentWorkspaceNode(id string) string {
if a.workspace == nil { if a.workspace == nil {
return "workspace not initialized" return "workspace not initialized"
@ -1201,3 +1313,318 @@ func (a *App) GetPluginAssetContent(pluginID, assetPath string) (string, string)
} }
return string(data), "" 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
}

View File

@ -21,6 +21,7 @@ type Config struct {
DevMode bool `json:"devMode"` DevMode bool `json:"devMode"`
UserPluginsDir string `json:"userPluginsDir"` UserPluginsDir string `json:"userPluginsDir"`
Workbench WorkbenchPreferences `json:"workbench,omitempty"` Workbench WorkbenchPreferences `json:"workbench,omitempty"`
Sync SyncSettings `json:"sync,omitempty"`
WindowState *WindowState `json:"windowState,omitempty"` WindowState *WindowState `json:"windowState,omitempty"`
LastOpenedAt string `json:"lastOpenedAt"` LastOpenedAt string `json:"lastOpenedAt"`
} }
@ -31,6 +32,18 @@ type WorkbenchPreferences struct {
DefaultNotesMarkdownEditorProvider string `json:"defaultNotesMarkdownEditorProvider,omitempty"` 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. // WindowState stores the last window position and size.
type WindowState struct { type WindowState struct {
Width int `json:"width"` Width int `json:"width"`
@ -234,6 +247,7 @@ func copyConfig(c *Config) *Config {
DevMode: c.DevMode, DevMode: c.DevMode,
UserPluginsDir: c.UserPluginsDir, UserPluginsDir: c.UserPluginsDir,
Workbench: c.Workbench, Workbench: c.Workbench,
Sync: c.Sync,
LastOpenedAt: c.LastOpenedAt, LastOpenedAt: c.LastOpenedAt,
} }
if c.WindowState != nil { if c.WindowState != nil {

View File

@ -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
}

View File

@ -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)
}

View File

@ -21,6 +21,7 @@ import (
"github.com/verstak/verstak-desktop/internal/core/plugin" "github.com/verstak/verstak-desktop/internal/core/plugin"
"github.com/verstak/verstak-desktop/internal/core/pluginstate" "github.com/verstak/verstak-desktop/internal/core/pluginstate"
"github.com/verstak/verstak-desktop/internal/core/storage" "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/vault"
"github.com/verstak/verstak-desktop/internal/core/workspace" "github.com/verstak/verstak-desktop/internal/core/workspace"
"github.com/verstak/verstak-desktop/internal/shell/debug" "github.com/verstak/verstak-desktop/internal/shell/debug"
@ -245,7 +246,11 @@ func main() {
// Create the App struct // Create the App struct
storageService := storage.New(vaultService) storageService := storage.New(vaultService)
filesService := corefiles.NewService(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 ─────────────────────────────────────────── // ─── Wails App ───────────────────────────────────────────
err := wails.Run(&options.App{ err := wails.Run(&options.App{