From e3f6c711a77306316b81a67458724dd1daab317f Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 03:59:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(v030):=20SyncTimer=20=E2=80=94=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=A3=BC=EA=B8=B0=20sync=20(settings=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=8B=9C=20reconfigure)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/main/index.ts | 8 +++- src/main/ipc/settingsApi.ts | 10 ++++- src/main/services/SyncTimer.ts | 49 +++++++++++++++++++++++ tests/unit/SyncTimer.test.ts | 72 ++++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 src/main/services/SyncTimer.ts create mode 100644 tests/unit/SyncTimer.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index d93c027..b249fd5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -30,6 +30,7 @@ import { BackupService } from './services/BackupService.js'; import { ExportService } from './services/ExportService.js'; import { ImportService } from './services/ImportService.js'; import { SyncService } from './services/SyncService.js'; +import { SyncTimer } from './services/SyncTimer.js'; import { TelemetryService } from './services/TelemetryService.js'; import { SettingsService } from './services/SettingsService.js'; import { collectAutostartState } from './services/AutostartDiagnostic.js'; @@ -197,6 +198,7 @@ app.whenReady().then(async () => { const exportSvc = new ExportService(repo, store); const importSvc = new ImportService(repo, store); const syncSvc = new SyncService(paths.profileDir, exportSvc, importSvc); + const syncTimer = new SyncTimer(syncSvc, settingsSvc); const backup = new BackupService(db, join(paths.profileDir, 'backups')); void backup.runDaily() @@ -206,14 +208,18 @@ app.whenReady().then(async () => { // v0.2.7 Task 10 — 설정 페이지 IPC (autostart + backup/export/import/sync/telemetry). // backup / exportSvc / importSvc / syncSvc / telemetry 가 모두 준비된 뒤 등록. registerSettingsApi({ - backup, exportSvc, importSvc, syncSvc, telemetry, settings: settingsSvc, getInboxWindow + backup, exportSvc, importSvc, syncSvc, telemetry, settings: settingsSvc, getInboxWindow, + syncTimer }); + void syncTimer.start(); + let backupOnQuitDone = false; let trayInterval: NodeJS.Timeout | null = null; app.on('before-quit', (e) => { // 모든 cleanup 한 곳에 통합 — sync (idempotent) → async backup chain. health.stop(); + syncTimer.stop(); if (trayInterval !== null) { clearInterval(trayInterval); trayInterval = null; diff --git a/src/main/ipc/settingsApi.ts b/src/main/ipc/settingsApi.ts index 1f30f55..8086b20 100644 --- a/src/main/ipc/settingsApi.ts +++ b/src/main/ipc/settingsApi.ts @@ -10,6 +10,7 @@ import type { SyncService } from '../services/SyncService.js'; import { GitClient } from '../services/GitClient.js'; import type { TelemetryService } from '../services/TelemetryService.js'; import type { SettingsService } from '../services/SettingsService.js'; +import type { SyncTimer } from '../services/SyncTimer.js'; import { collectAutostartState } from '../services/AutostartDiagnostic.js'; import { getInboxWindow as getInboxWindowSingleton } from '../windows/inboxWindow.js'; @@ -37,6 +38,7 @@ export interface SettingsIpcDeps { telemetry: TelemetryService; settings: SettingsService; getInboxWindow: () => BrowserWindow | null; + syncTimer?: SyncTimer; } /** @@ -109,12 +111,14 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void { ipcMain.handle('settings:set-sync-auto-enabled', async (_e, value: boolean) => { await deps.settings.setAutoSyncEnabled(value); + await deps.syncTimer?.reconfigure(); return { ok: true as const }; }); ipcMain.handle('settings:set-sync-interval-min', async (_e, value: number) => { try { await deps.settings.setSyncIntervalMin(value); + await deps.syncTimer?.reconfigure(); return { ok: true as const }; } catch (e) { return { ok: false as const, reason: (e as Error).message }; @@ -311,7 +315,10 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void { return { ok: false as const, reason: `persist failed: ${(e as Error).message}` }; } - if (finalUrl === null) return { ok: true as const }; + if (finalUrl === null) { + await deps.syncTimer?.reconfigure(); + return { ok: true as const }; + } // git init + remote add origin const syncDir = deps.syncSvc.getSyncDir(); @@ -335,6 +342,7 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void { return { ok: false as const, reason: `remote set-url failed: ${set.stderr}` }; } } + await deps.syncTimer?.reconfigure(); return { ok: true as const }; }); diff --git a/src/main/services/SyncTimer.ts b/src/main/services/SyncTimer.ts new file mode 100644 index 0000000..203be6d --- /dev/null +++ b/src/main/services/SyncTimer.ts @@ -0,0 +1,49 @@ +import type { SyncService } from './SyncService.js'; +import type { SettingsService } from './SettingsService.js'; + +/** + * v0.3.0 Cut E — 자동 주기 sync timer. + * + * - start: settings 의 auto enabled + repo URL 모두 갖춰져야 시작 + * - reconfigure: settings 변경 시 stop + start (새 interval 적용) + * - stop: clearInterval (idempotent) + * + * sync 결과는 무시 (interval mode = silent). conflict 발생 시 다음 manual sync / + * 충돌 UI 진입 시 처리됨 — 사용자가 settings 페이지의 SyncSection 에서 확인 가능. + */ +export class SyncTimer { + private handle: NodeJS.Timeout | null = null; + + constructor( + private syncSvc: SyncService, + private settings: SettingsService + ) {} + + async start(): Promise { + if (this.handle !== null) return; // idempotent + const enabled = await this.settings.isAutoSyncEnabled(); + if (!enabled) return; + const url = await this.settings.getSyncRepoUrl(); + if (url === null || url.trim().length === 0) return; + const intervalMin = await this.settings.getSyncIntervalMin(); + const ms = Math.max(5, intervalMin) * 60 * 1000; + this.handle = setInterval(() => { + void this.syncSvc.sync().catch(() => { + // silent — interval mode 의 실패는 다음 attempt 또는 사용자 manual 호출이 처리 + }); + }, ms); + } + + stop(): void { + if (this.handle !== null) { + clearInterval(this.handle); + this.handle = null; + } + } + + /** settings 변경 시 호출 — 현재 interval stop 후 새 값으로 start. */ + async reconfigure(): Promise { + this.stop(); + await this.start(); + } +} diff --git a/tests/unit/SyncTimer.test.ts b/tests/unit/SyncTimer.test.ts new file mode 100644 index 0000000..d2971cf --- /dev/null +++ b/tests/unit/SyncTimer.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { SyncTimer } from '../../src/main/services/SyncTimer.js'; + +describe('SyncTimer', () => { + let syncSvc: { sync: ReturnType }; + let settings: { + isAutoSyncEnabled: ReturnType; + getSyncIntervalMin: ReturnType; + getSyncRepoUrl: ReturnType; + }; + let timer: SyncTimer; + + beforeEach(() => { + vi.useFakeTimers(); + syncSvc = { sync: vi.fn(async () => ({ ok: true })) }; + settings = { + isAutoSyncEnabled: vi.fn(async () => true), + getSyncIntervalMin: vi.fn(async () => 5), + getSyncRepoUrl: vi.fn(async () => 'git@host:u/r.git') + }; + timer = new SyncTimer(syncSvc as never, settings as never); + }); + + afterEach(() => { + timer.stop(); + vi.useRealTimers(); + }); + + it('start — interval 마다 syncSvc.sync 호출', async () => { + await timer.start(); + expect(syncSvc.sync).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(syncSvc.sync).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(syncSvc.sync).toHaveBeenCalledTimes(2); + }); + + it('auto disabled → 시작 안 함 (sync 0회)', async () => { + settings.isAutoSyncEnabled.mockResolvedValueOnce(false); + await timer.start(); + await vi.advanceTimersByTimeAsync(60 * 60 * 1000); + expect(syncSvc.sync).not.toHaveBeenCalled(); + }); + + it('repo URL 미설정 → 시작 안 함', async () => { + settings.getSyncRepoUrl.mockResolvedValueOnce(null); + await timer.start(); + await vi.advanceTimersByTimeAsync(60 * 60 * 1000); + expect(syncSvc.sync).not.toHaveBeenCalled(); + }); + + it('reconfigure — stop + 새 interval 로 start', async () => { + await timer.start(); + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(syncSvc.sync).toHaveBeenCalledTimes(1); + + settings.getSyncIntervalMin.mockResolvedValueOnce(10); + await timer.reconfigure(); + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + // not enough time for new interval — still 1 call + expect(syncSvc.sync).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(syncSvc.sync).toHaveBeenCalledTimes(2); + }); + + it('stop — 호출 후 더 이상 sync 발생 안 함', async () => { + await timer.start(); + timer.stop(); + await vi.advanceTimersByTimeAsync(60 * 60 * 1000); + expect(syncSvc.sync).not.toHaveBeenCalled(); + }); +});