settings:configure-sync IPC 핸들러가 `git -C <syncDir> init` 호출 전에 syncDir 디렉토리를 생성하지 않아, sync 첫 설정 시 git 이 chdir 단계에서 `fatal: cannot change to '<profileDir>/sync': No such file or directory` 로 실패하던 문제. SyncService.runSync() 의 동일 패턴 (mkdir recursive) 을 핸들러에도 추가. 연쇄 증상: SyncSection 의 "연결 테스트" 버튼 disabled 조건이 저장된 url state 기반이라, 저장 실패로 url 영영 비어 있어 버튼 활성화 불가 (닭/달걀). mkdir fix 로 자동 해소. 회귀: sync-ipc.test.ts 에 mkdir 호출 순서 검증 1건 추가 (18 pass). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
415 lines
16 KiB
TypeScript
415 lines
16 KiB
TypeScript
import electron from 'electron';
|
|
import type { BrowserWindow } from 'electron';
|
|
import { platform, release, EOL } from 'node:os';
|
|
import { mkdir } from 'node:fs/promises';
|
|
const { ipcMain, app, dialog, Notification, shell, clipboard } = electron;
|
|
import { logger } from '../logger.js';
|
|
import type { BackupService } from '../services/BackupService.js';
|
|
import type { ExportService } from '../services/ExportService.js';
|
|
import type { ImportService } from '../services/ImportService.js';
|
|
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';
|
|
import { refreshVisionCache } from '../services/VisionDetect.js';
|
|
import { DEFAULT_OLLAMA_ENDPOINT } from '../../shared/constants.js';
|
|
|
|
/**
|
|
* 외부 (트레이 / second-instance / 기타 main 프로세스 호출자) 에서 inbox 창에 view 전환을
|
|
* 요청하는 진입점. 창이 숨겨져 있으면 show + focus 후 'inbox:navigate' IPC 이벤트를
|
|
* renderer 로 전달.
|
|
*
|
|
* Task 13 (v0.2.7) — 트레이 "설정..." 메뉴 wiring 은 Task 16 에서 본 함수 호출.
|
|
*/
|
|
export function navigateInbox(view: 'inbox' | 'trash' | 'settings'): void {
|
|
const win = getInboxWindowSingleton();
|
|
if (win && !win.isDestroyed()) {
|
|
if (!win.isVisible()) win.show();
|
|
win.focus();
|
|
win.webContents.send('inbox:navigate', view);
|
|
}
|
|
}
|
|
|
|
export interface SettingsIpcDeps {
|
|
backup: BackupService;
|
|
exportSvc: ExportService;
|
|
importSvc: ImportService;
|
|
syncSvc: SyncService;
|
|
telemetry: TelemetryService;
|
|
settings: SettingsService;
|
|
getInboxWindow: () => BrowserWindow | null;
|
|
syncTimer?: SyncTimer;
|
|
}
|
|
|
|
/**
|
|
* v0.2.7 설정 페이지 IPC 핸들러.
|
|
*
|
|
* - 자동 실행 (Task 22 통일): `settings:autostart-state` (조회) / `settings:autostart-set` (변경).
|
|
* 둘 다 `{ openAtLogin, diagnostic }` 반환 — diagnostic 은 withArgs/noArgs/execPath/registry 진단.
|
|
* args=['--hidden'] 명시 — 자동 실행 시 백그라운드 모드로 시작 (Quick Capture only).
|
|
*
|
|
* - 백업/내보내기/복원/동기화/사용 로그 (Task 10): 기존 `src/main/index.ts` 트레이 callback
|
|
* (runBackup, runExport, runImport, runSync, runExportTelemetry) 본문을 그대로 IPC 핸들러로
|
|
* 복사. 트레이 callback 자체 제거는 Task 16 (Phase 3) — 본 task 에선 잔류 (의도적 중복).
|
|
*/
|
|
export function registerSettingsApi(deps?: SettingsIpcDeps): void {
|
|
// v0.2.7 F12 deeper fix (Task 21~22) — 진단 정보 포함된 autostart 상태 조회/변경.
|
|
// 옛 'settings:get-autostart' / 'settings:set-autostart' 채널은 본 통일에서 제거됨.
|
|
ipcMain.handle('settings:autostart-state', async () => {
|
|
const diag = await collectAutostartState();
|
|
return { openAtLogin: diag.withArgs.openAtLogin, diagnostic: diag };
|
|
});
|
|
|
|
ipcMain.handle('settings:autostart-set', async (_e, open: boolean) => {
|
|
app.setLoginItemSettings({ openAtLogin: open, args: ['--hidden'] });
|
|
const diag = await collectAutostartState();
|
|
return { openAtLogin: diag.withArgs.openAtLogin, diagnostic: diag };
|
|
});
|
|
|
|
// v0.2.7 정보 섹션 (Task 11) — 트레이 showAboutDialog 의 detail 형식 그대로 (clipboard 일관성).
|
|
// 트레이 showAboutDialog 자체 제거는 Task 25 (Phase 6 cleanup) — 본 task 는 추가만.
|
|
ipcMain.handle('settings:get-app-info', () => ({
|
|
version: app.getVersion(),
|
|
electron: process.versions.electron ?? '?',
|
|
node: process.versions.node ?? '?',
|
|
os: `${platform()} ${release()}`,
|
|
profileDir: app.getPath('userData')
|
|
}));
|
|
|
|
ipcMain.handle('settings:open-profile-dir', async () => {
|
|
await shell.openPath(app.getPath('userData'));
|
|
});
|
|
|
|
ipcMain.handle('settings:copy-app-info', () => {
|
|
const v = app.getVersion();
|
|
const detail = [
|
|
`버전: ${v}`,
|
|
`Electron: ${process.versions.electron ?? '?'}`,
|
|
`Node: ${process.versions.node ?? '?'}`,
|
|
`OS: ${platform()} ${release()}`,
|
|
`데이터 위치: ${app.getPath('userData')}`
|
|
].join(EOL);
|
|
clipboard.writeText(`Inkling ${v}${EOL}${detail}`);
|
|
});
|
|
|
|
if (!deps) return;
|
|
const { backup, exportSvc, importSvc, syncSvc, telemetry, settings, getInboxWindow } = deps;
|
|
|
|
// v0.2.9 Cut B Task 12 — settings read + AI/onboarding 토글.
|
|
// 첫 launch 시 OnboardingWizard 분기 (App.tsx) 와 SettingsPage 의 ai_enabled 토글 통합.
|
|
ipcMain.handle('settings:get', async () => settings.getAll());
|
|
|
|
ipcMain.handle('settings:set-ai-enabled', async (_e, enabled: boolean) => {
|
|
await settings.setAiEnabled(enabled);
|
|
return { ok: true as const };
|
|
});
|
|
|
|
ipcMain.handle('settings:set-onboarding-completed', async (_e, completed: boolean) => {
|
|
await settings.setOnboardingCompleted(completed);
|
|
return { ok: true as const };
|
|
});
|
|
|
|
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 };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('settings:run-backup', async () => {
|
|
try {
|
|
const r = await backup.runDaily();
|
|
new Notification({
|
|
title: 'Inkling',
|
|
body: r.snapshotted
|
|
? `백업 완료 — ${r.removed?.length ?? 0}개 정리`
|
|
: `오늘 백업이 이미 있습니다`,
|
|
silent: true
|
|
}).show();
|
|
} catch (e) {
|
|
logger.warn('backup.manual.failed', { reason: String(e) });
|
|
new Notification({
|
|
title: 'Inkling',
|
|
body: '백업을 만들지 못했습니다.',
|
|
silent: true
|
|
}).show();
|
|
}
|
|
return { ok: true } as const;
|
|
});
|
|
|
|
ipcMain.handle('settings:run-export', async () => {
|
|
const win = getInboxWindow();
|
|
const dialogOpts: Electron.OpenDialogOptions = {
|
|
title: '내보낼 폴더 선택',
|
|
message: '선택한 폴더에 노트를 마크다운으로 내보냅니다. 이미지가 함께 포함됩니다. raw_text 가 평문으로 보관되니 비공개 위치를 권장합니다.',
|
|
buttonLabel: '여기에 내보내기',
|
|
properties: ['openDirectory', 'createDirectory']
|
|
};
|
|
const result = win
|
|
? await dialog.showOpenDialog(win, dialogOpts)
|
|
: await dialog.showOpenDialog(dialogOpts);
|
|
if (result.canceled || result.filePaths.length === 0) return { ok: true } as const;
|
|
try {
|
|
const r = await exportSvc.export(result.filePaths[0]!, { includeMedia: true });
|
|
logger.info('export.done', {
|
|
outDir: r.outDir,
|
|
noteCount: r.noteCount,
|
|
mediaCount: r.mediaCount,
|
|
bytes: r.bytes
|
|
});
|
|
new Notification({
|
|
title: 'Inkling',
|
|
body: `내보내기 완료 — 노트 ${r.noteCount}개, 이미지 ${r.mediaCount}개`,
|
|
silent: true
|
|
}).show();
|
|
} catch (e) {
|
|
logger.warn('export.failed', { reason: String(e) });
|
|
new Notification({
|
|
title: 'Inkling',
|
|
body: '내보내기를 완료하지 못했습니다.',
|
|
silent: true
|
|
}).show();
|
|
}
|
|
return { ok: true } as const;
|
|
});
|
|
|
|
ipcMain.handle('settings:run-import', async () => {
|
|
const win = getInboxWindow();
|
|
const dirOpts: Electron.OpenDialogOptions = {
|
|
title: '복원할 백업 폴더 선택',
|
|
message: 'F5 export 형식의 폴더를 선택하세요. notes/ 하위의 마크다운 파일이 적재됩니다.',
|
|
buttonLabel: '여기서 복원',
|
|
properties: ['openDirectory']
|
|
};
|
|
const dirResult = win
|
|
? await dialog.showOpenDialog(win, dirOpts)
|
|
: await dialog.showOpenDialog(dirOpts);
|
|
if (dirResult.canceled || dirResult.filePaths.length === 0) return { ok: true } as const;
|
|
const sourceDir = dirResult.filePaths[0]!;
|
|
let plan;
|
|
try {
|
|
plan = await importSvc.preview(sourceDir);
|
|
} catch (e) {
|
|
logger.warn('import.preview.failed', { reason: String(e) });
|
|
new Notification({
|
|
title: 'Inkling',
|
|
body: '백업 폴더를 읽지 못했습니다.',
|
|
silent: true
|
|
}).show();
|
|
return { ok: true } as const;
|
|
}
|
|
const detail = `총 ${plan.total}개 노트\n · 신규 ${plan.newCount}개\n · 동일 (스킵) ${plan.unchangedCount}개\n · 충돌→새 id (${plan.forkedCount}개, raw_text 보존)\n\n이미지 ${plan.mediaCount}개 복사 예정.`;
|
|
const confirmOpts: Electron.MessageBoxOptions = {
|
|
type: 'question',
|
|
buttons: ['복원', '취소'],
|
|
defaultId: 0,
|
|
cancelId: 1,
|
|
title: 'Inkling 복원',
|
|
message: '복원 미리보기',
|
|
detail
|
|
};
|
|
const confirm = win
|
|
? await dialog.showMessageBox(win, confirmOpts)
|
|
: await dialog.showMessageBox(confirmOpts);
|
|
if (confirm.response !== 0) return { ok: true } as const;
|
|
try {
|
|
const r = await importSvc.run(sourceDir);
|
|
logger.info('import.done', {
|
|
total: r.total,
|
|
new: r.newCount,
|
|
unchanged: r.unchangedCount,
|
|
forked: r.forkedCount,
|
|
media: r.mediaCount
|
|
});
|
|
new Notification({
|
|
title: 'Inkling',
|
|
body: `복원 완료 — 신규 ${r.newCount}개, 스킵 ${r.unchangedCount}개, 충돌 ${r.forkedCount}개`,
|
|
silent: true
|
|
}).show();
|
|
} catch (e) {
|
|
logger.warn('import.run.failed', { reason: String(e) });
|
|
new Notification({
|
|
title: 'Inkling',
|
|
body: '복원을 완료하지 못했습니다.',
|
|
silent: true
|
|
}).show();
|
|
}
|
|
return { ok: true } as const;
|
|
});
|
|
|
|
ipcMain.handle('settings:run-sync', async () => {
|
|
try {
|
|
const r = await syncSvc.sync();
|
|
if (!r.ok) {
|
|
logger.warn('sync.failed', { reason: r.reason });
|
|
const body = r.reason === 'not_configured'
|
|
? `${syncSvc.getSyncDir()} 에서 git init + remote 설정이 필요합니다.`
|
|
: '동기화를 완료하지 못했습니다.';
|
|
new Notification({ title: 'Inkling', body, silent: true }).show();
|
|
return { ok: true } as const;
|
|
}
|
|
if (r.changed) {
|
|
logger.info('sync.done', { sha: r.localSha, pushed: r.pushed });
|
|
new Notification({ title: 'Inkling', body: '동기화 완료', silent: true }).show();
|
|
} else {
|
|
new Notification({ title: 'Inkling', body: '변경 사항 없음', silent: true }).show();
|
|
}
|
|
} catch (e) {
|
|
logger.warn('sync.exception', { reason: String(e) });
|
|
new Notification({ title: 'Inkling', body: '동기화를 완료하지 못했습니다.', silent: true }).show();
|
|
}
|
|
return { ok: true } as const;
|
|
});
|
|
|
|
ipcMain.handle('settings:run-export-telemetry', async () => {
|
|
const win = getInboxWindow();
|
|
const dialogOpts: Electron.OpenDialogOptions = {
|
|
title: '사용 로그를 내보낼 폴더 선택',
|
|
message: '선택한 폴더에 events.jsonl + stats.md 가 생성됩니다. raw_text/요약/제목/태그 이름은 미포함입니다.',
|
|
buttonLabel: '여기로 내보내기',
|
|
properties: ['openDirectory', 'createDirectory']
|
|
};
|
|
const result = win
|
|
? await dialog.showOpenDialog(win, dialogOpts)
|
|
: await dialog.showOpenDialog(dialogOpts);
|
|
if (result.canceled || result.filePaths.length === 0) return { ok: true } as const;
|
|
try {
|
|
const r = await telemetry.exportTo(result.filePaths[0]!);
|
|
logger.info('telemetry.export', { eventCount: r.eventCount, outDir: result.filePaths[0] });
|
|
new Notification({
|
|
title: 'Inkling',
|
|
body: `사용 로그 내보내기 완료 — ${r.eventCount}개 이벤트`,
|
|
silent: true
|
|
}).show();
|
|
} catch (e) {
|
|
logger.warn('telemetry.export.failed', { reason: String(e) });
|
|
new Notification({
|
|
title: 'Inkling',
|
|
body: '사용 로그 내보내기를 완료하지 못했습니다.',
|
|
silent: true
|
|
}).show();
|
|
}
|
|
return { ok: true } as const;
|
|
});
|
|
|
|
// v0.3.0 Cut E — sync IPC.
|
|
|
|
// settings:configure-sync — URL 저장 + git init + remote add (없으면).
|
|
// null URL → 저장만 (init 안 함). 빈 문자열도 null 처리.
|
|
ipcMain.handle('settings:configure-sync', async (_e, url: string | null) => {
|
|
const trimmed = typeof url === 'string' ? url.trim() : '';
|
|
const finalUrl = trimmed.length === 0 ? null : trimmed;
|
|
|
|
try {
|
|
await deps.settings.setSyncRepoUrl(finalUrl);
|
|
} catch (e) {
|
|
return { ok: false as const, reason: `persist failed: ${(e as Error).message}` };
|
|
}
|
|
|
|
if (finalUrl === null) {
|
|
await deps.syncTimer?.reconfigure();
|
|
return { ok: true as const };
|
|
}
|
|
|
|
// git init + remote add origin
|
|
const syncDir = deps.syncSvc.getSyncDir();
|
|
try {
|
|
await mkdir(syncDir, { recursive: true });
|
|
} catch (e) {
|
|
return { ok: false as const, reason: `mkdir failed: ${(e as Error).message}` };
|
|
}
|
|
const git = new GitClient(syncDir);
|
|
|
|
if (!(await git.isRepo())) {
|
|
const init = await git.run(['init']);
|
|
if (init.exitCode !== 0) {
|
|
return { ok: false as const, reason: `git init failed: ${init.stderr}` };
|
|
}
|
|
}
|
|
if (!(await git.hasRemote())) {
|
|
const add = await git.run(['remote', 'add', 'origin', finalUrl]);
|
|
if (add.exitCode !== 0) {
|
|
return { ok: false as const, reason: `remote add failed: ${add.stderr}` };
|
|
}
|
|
} else {
|
|
// remote exists — update URL
|
|
const set = await git.run(['remote', 'set-url', 'origin', finalUrl]);
|
|
if (set.exitCode !== 0) {
|
|
return { ok: false as const, reason: `remote set-url failed: ${set.stderr}` };
|
|
}
|
|
}
|
|
await deps.syncTimer?.reconfigure();
|
|
return { ok: true as const };
|
|
});
|
|
|
|
// settings:test-sync-connection — git ls-remote 결과
|
|
ipcMain.handle('settings:test-sync-connection', async () => {
|
|
const syncDir = deps.syncSvc.getSyncDir();
|
|
const git = new GitClient(syncDir);
|
|
if (!(await git.isRepo())) return { ok: false as const, reason: 'not_initialized' };
|
|
const r = await git.run(['ls-remote', 'origin']);
|
|
if (r.exitCode !== 0) return { ok: false as const, reason: r.stderr || 'connection failed' };
|
|
return { ok: true as const };
|
|
});
|
|
|
|
// sync:list-conflicts — SyncService 캐시 결과
|
|
ipcMain.handle('sync:list-conflicts', () => deps.syncSvc.listConflicts());
|
|
|
|
// sync:resolve-conflict — local/remote 2 choice. path = git index conflict 경로.
|
|
ipcMain.handle('sync:resolve-conflict', async (_e, path: string, choice: 'local' | 'remote') => {
|
|
if (choice !== 'local' && choice !== 'remote') {
|
|
return { ok: false as const, reason: 'invalid choice' };
|
|
}
|
|
return deps.syncSvc.resolveConflict(path, choice);
|
|
});
|
|
|
|
// sync:get-status — lastAt + lastResult + nextAt 계산
|
|
ipcMain.handle('sync:get-status', async () => {
|
|
const last = deps.syncSvc.getLastStatus();
|
|
let nextAt: string | null = null;
|
|
if (await deps.settings.isAutoSyncEnabled()) {
|
|
const intervalMin = await deps.settings.getSyncIntervalMin();
|
|
const baseMs = last.lastAt ? new Date(last.lastAt).getTime() : Date.now();
|
|
nextAt = new Date(baseMs + intervalMin * 60 * 1000).toISOString();
|
|
}
|
|
return { lastAt: last.lastAt, lastResult: last.lastResult, nextAt };
|
|
});
|
|
|
|
// v0.3.1 Cut F — vision IPC
|
|
|
|
ipcMain.handle('settings:get-vision-models', async () => {
|
|
const cache = await deps.settings.getVisionCapableCache();
|
|
const selected = await deps.settings.getVisionModel();
|
|
return { models: cache.models, at: cache.at, selected };
|
|
});
|
|
|
|
ipcMain.handle('settings:set-vision-model', async (_e, value: string | null) => {
|
|
const sanitized = typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
await deps.settings.setVisionModel(sanitized);
|
|
return { ok: true as const };
|
|
});
|
|
|
|
ipcMain.handle('settings:refresh-vision-cache', async () => {
|
|
// Cut F final review fix — index.ts 의 resolvedEndpoint (settings → env → default)
|
|
// 와 동일한 fallback 체인 사용. settings.ollama 미설정 + env / default 만 있는 dev
|
|
// 환경에서도 manual "다시 감지" 가 동작하도록.
|
|
const all = await deps.settings.getAll();
|
|
const endpoint = all.ollama?.endpoint
|
|
?? process.env.INKLING_OLLAMA_ENDPOINT
|
|
?? DEFAULT_OLLAMA_ENDPOINT;
|
|
return refreshVisionCache({ settings: deps.settings, endpoint });
|
|
});
|
|
}
|