Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env-dev
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ ENVIRONMENT=DEV

CFW=
BASE_PATH=

DEVICE_IP_ADDRESS=
PRIVATE_KEY_PATH=
SSH_PASSWORD=
2 changes: 2 additions & 0 deletions app/transitions.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ func transitionSaveSync(ctx *transitionContext, result any) (router.Screen, any)
Items: conflicts,
AllItems: r.Items,
ConflictIndices: r.ConflictIndices,
SessionID: r.SessionID,
}
}

Expand All @@ -303,6 +304,7 @@ func transitionSaveConflict(ctx *transitionContext, result any) (router.Screen,
Config: ctx.state.Config,
Host: ctx.state.Host,
ResolvedItems: r.AllItems,
SessionID: r.SessionID,
}
}

Expand Down
20 changes: 20 additions & 0 deletions internal/fileutil/fileutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package fileutil
import (
"archive/zip"
"bufio"
"crypto/md5"
"crypto/sha1"
"fmt"
"hash/crc32"
Expand Down Expand Up @@ -305,6 +306,25 @@ func FilterHiddenDirectories(entries []os.DirEntry) []os.DirEntry {
return result
}

// ComputeMD5 computes the MD5 hash of a file and returns it as a lowercase hex string.
// This matches the server's content hash algorithm (8192-byte chunks, md5, hex output).
func ComputeMD5(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()

hash := md5.New()
buffer := make([]byte, 8192)

if _, err := io.CopyBuffer(hash, file, buffer); err != nil {
return "", fmt.Errorf("failed to compute hash: %w", err)
}

return fmt.Sprintf("%x", hash.Sum(nil)), nil
}

// ComputeCRC32 computes the CRC32 hash of a file and returns it as an uppercase hex string
func ComputeCRC32(filePath string) (string, error) {
file, err := os.Open(filePath)
Expand Down
7 changes: 4 additions & 3 deletions romm/endpoints.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ const (
endpointSaveByID = "/api/saves/%d"
endpointSaveSummary = "/api/saves/summary"
endpointSaveContent = "/api/saves/%d/content"
endpointSaveDownloaded = "/api/saves/%d/downloaded"
endpointDevices = "/api/devices"
endpointDeviceByID = "/api/devices/%s"

endpointDevices = "/api/devices"
endpointDeviceByID = "/api/devices/%s"
endpointSyncNegotiate = "/api/sync/negotiate"
endpointSyncSessionComplete = "/api/sync/sessions/%d/complete"
)
29 changes: 6 additions & 23 deletions romm/saves.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ type UploadSaveQuery struct {
Overwrite bool `qs:"overwrite,omitempty"`
Autocleanup bool `qs:"autocleanup,omitempty"`
AutocleanupLimit int `qs:"autocleanup_limit,omitempty"`
SessionID int `qs:"session_id,omitempty"`
}

func (uq UploadSaveQuery) Valid() bool {
Expand All @@ -96,16 +97,13 @@ func (uq UploadSaveQuery) Valid() bool {
type SaveContentQuery struct {
DeviceID string `qs:"device_id,omitempty"`
Optimistic bool `qs:"optimistic,omitempty"`
SessionID int `qs:"session_id,omitempty"`
}

func (scq SaveContentQuery) Valid() bool {
return scq.DeviceID != ""
}

type SaveDeviceBody struct {
DeviceID string `json:"device_id"`
}

type SaveSummaryQuery struct {
RomID int `qs:"rom_id"`
}
Expand All @@ -124,26 +122,18 @@ func (c *Client) DownloadSave(downloadPath string) ([]byte, error) {
return c.doRequestRaw("GET", downloadPath, nil)
}

func (c *Client) DownloadSaveByID(saveID int, deviceID string, optimistic bool) ([]byte, error) {
func (c *Client) DownloadSaveByID(saveID int, deviceID string, optimistic bool, sessionID ...int) ([]byte, error) {
path := fmt.Sprintf(endpointSaveContent, saveID)
query := SaveContentQuery{
DeviceID: deviceID,
Optimistic: optimistic,
}
if len(sessionID) > 0 {
query.SessionID = sessionID[0]
}
return c.doRequestRawWithQuery("GET", path, query)
}

func (c *Client) ConfirmSaveDownloaded(saveID int, deviceID string) error {
path := fmt.Sprintf(endpointSaveDownloaded, saveID)
body := SaveDeviceBody{DeviceID: deviceID}
return c.doRequest("POST", path, nil, body, nil)
}

// MarkDeviceSynced confirms this device has the latest save state.
// Used after both uploads and downloads.
func (c *Client) MarkDeviceSynced(saveID int, deviceID string) error {
return c.ConfirmSaveDownloaded(saveID, deviceID)
}

func (c *Client) GetSaveSummary(romID int) (SaveSummary, error) {
var summary SaveSummary
Expand Down Expand Up @@ -187,13 +177,6 @@ func (c *Client) UpdateSave(saveID int, savePath string) (Save, error) {
return res, nil
}

func (c *Client) UploadSave(romID int, savePath string, emulator string) (Save, error) {
return c.UploadSaveWithQuery(UploadSaveQuery{
RomID: romID,
Emulator: emulator,
}, savePath)
}

func (c *Client) UploadSaveWithQuery(query UploadSaveQuery, savePath string) (Save, error) {
file, err := os.Open(savePath)
if err != nil {
Expand Down
81 changes: 81 additions & 0 deletions romm/sync.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package romm

import (
"fmt"
"time"
)

// ClientSaveState represents the state of a single save file on the client device.
type ClientSaveState struct {
RomID int `json:"rom_id"`
FileName string `json:"file_name"`
Slot string `json:"slot,omitempty"`
Emulator string `json:"emulator,omitempty"`
ContentHash string `json:"content_hash,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
FileSizeBytes int64 `json:"file_size_bytes"`
}

// SyncNegotiatePayload is the request body for POST /api/sync/negotiate.
type SyncNegotiatePayload struct {
DeviceID string `json:"device_id"`
Saves []ClientSaveState `json:"saves"`
}

// SyncOperationSchema describes a single sync operation returned by the server.
type SyncOperationSchema struct {
Action string `json:"action"` // "upload", "download", "conflict", "no_op"
RomID int `json:"rom_id"`
SaveID *int `json:"save_id"` // nil for new uploads
FileName string `json:"file_name"`
Slot *string `json:"slot,omitempty"`
Emulator string `json:"emulator,omitempty"`
Reason string `json:"reason"`
ServerUpdatedAt *time.Time `json:"server_updated_at,omitempty"`
ServerContentHash *string `json:"server_content_hash,omitempty"`
}

// SyncNegotiateResponse is the response from POST /api/sync/negotiate.
type SyncNegotiateResponse struct {
SessionID int `json:"session_id"`
Operations []SyncOperationSchema `json:"operations"`
TotalUpload int `json:"total_upload"`
TotalDownload int `json:"total_download"`
TotalConflict int `json:"total_conflict"`
TotalNoOp int `json:"total_no_op"`
}

// SyncCompletePayload is the request body for POST /api/sync/sessions/{id}/complete.
type SyncCompletePayload struct {
OperationsCompleted int `json:"operations_completed"`
OperationsFailed int `json:"operations_failed"`
}

// SyncSessionSchema is the response from sync session endpoints.
type SyncSessionSchema struct {
ID int `json:"id"`
DeviceID string `json:"device_id"`
UserID int `json:"user_id"`
Status string `json:"status"`
InitiatedAt time.Time `json:"initiated_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
OperationsPlanned int `json:"operations_planned"`
OperationsCompleted int `json:"operations_completed"`
OperationsFailed int `json:"operations_failed"`
ErrorMessage *string `json:"error_message,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

// Negotiate sends the client's save state to the server and receives sync operations.
func (c *Client) Negotiate(payload SyncNegotiatePayload) (SyncNegotiateResponse, error) {
var resp SyncNegotiateResponse
err := c.doRequest("POST", endpointSyncNegotiate, nil, payload, &resp)
return resp, err
}

// CompleteSession marks a sync session as completed.
func (c *Client) CompleteSession(sessionID int, payload SyncCompletePayload) error {
path := fmt.Sprintf(endpointSyncSessionComplete, sessionID)
return c.doRequest("POST", path, nil, payload, nil)
}
Loading