feat(desktop): two-way cloud sync with mobile and web

Rewrites the desktop mirror as services/sync/SyncEngine: a persistent
outbox, per-account server-clock keyset cursors with paging, pulls that never
overwrite unsent local edits, deletions both ways through sync_tombstones and
per-row failure isolation. It now covers history titles and favorites,
dictionary, every meeting's memos and documents, memo tags, user commands and
dictation/meeting templates, and registers the desktop as a device that the
phone can disconnect.

Fixes shipped defects: the first pull after sign-in fetched nothing, only
the first meeting's children were pushed, team meetings leaked into the
personal database and lost team_id on re-push, and Realtime never connected
because Electron's Node 20 has no global WebSocket (ws is now the transport).
Anonymous local-mode records are imported into the first account that signs
in. The settings sync section is translated and shows pending/rejected
changes; synced screens reload on app:dataChanged.
This commit is contained in:
Yun Chan 2026-09-27 14:04:49 +09:00
parent b5c9ff9f31
commit 0a4f5aee64
49 changed files with 3940 additions and 1033 deletions

View file

@ -6,7 +6,6 @@ import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ipcSuccess, ipcError, ErrorCode, D3ROError } from '@d3ro/core/errors'
import { getCloudSyncService } from '../services/CloudSyncService'
import { getLogger } from '../services/LoggerService'
import { configSet } from '../services/ConfigService'
const logger = getLogger('cloud-sync-handlers')
@ -71,7 +70,6 @@ export function registerCloudSyncHandlers(): void {
ipcMain.handle(IPC_CHANNELS.CLOUD_SYNC.PUSH_ALL, async () => {
try {
const result = await sync.pushAll()
configSet('cloudSyncLastAt', Date.now())
return ipcSuccess(result)
} catch (e) {
if (e instanceof D3ROError) {
@ -85,7 +83,6 @@ export function registerCloudSyncHandlers(): void {
ipcMain.handle(IPC_CHANNELS.CLOUD_SYNC.PULL_ALL, async () => {
try {
const result = await sync.pullAll()
configSet('cloudSyncLastAt', Date.now())
return ipcSuccess(result)
} catch (e) {
if (e instanceof D3ROError) {
@ -117,6 +114,10 @@ export function registerCloudSyncHandlers(): void {
sync.on('sync-error', (payload) => {
broadcast(IPC_CHANNELS.CLOUD_SYNC.SYNC_ERROR, payload)
})
// 다른 기기의 변경이 반영되면 앱 공통 새로고침 신호로 알린다 (이력·사전·회의·명령 화면이 구독).
sync.on('data-changed', (payload) => {
broadcast(IPC_CHANNELS.APP.DATA_CHANGED, { type: 'cloud-sync', entities: payload.entities })
})
logger.info('Cloud Sync IPC handlers registered')
}

View file

@ -8,7 +8,8 @@ import type {
HistoryQueryParams,
HistoryGetByIdParams,
HistoryDeleteParams,
HistorySearchParams
HistorySearchParams,
HistorySetFavoriteParams
} from '@d3ro/core/types'
export function registerHistoryHandlers(): void {
@ -53,6 +54,19 @@ export function registerHistoryHandlers(): void {
}
})
ipcMain.handle(IPC_CHANNELS.HISTORY.SET_FAVORITE, async (_event, params: HistorySetFavoriteParams) => {
try {
const entry = getHistoryService().setFavorite(params.id, params.isFavorite === true)
if (!entry) {
return ipcError(ErrorCode.HistoryNotFound, `History entry not found: ${params.id}`)
}
return ipcSuccess(entry)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return ipcError(ErrorCode.DBWriteFailed, `Failed to update favorite: ${message}`)
}
})
ipcMain.handle(IPC_CHANNELS.HISTORY.SEARCH, async (_event, params: HistorySearchParams) => {
try {
return ipcSuccess(getHistoryService().search(params))