- #2: 24*60*60*1000 magic number → 모듈 상단 const DAY_MS cleanupOldFiles + readAllRecent 두 callsite 통일 - #6: gc.run() 의 .catch 누락 → backup.runDaily 패턴 통일 실패 시 logger.warn('media.gc.failed', { reason }) Note: backlog #1 (now() 2번 호출) 은 PR #13 round 1 review 시 이미 fix — backlog 항목 stale. v0.2.5 brainstorm 시 backlog 정리. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
385 lines
15 KiB
TypeScript
385 lines
15 KiB
TypeScript
import electron from 'electron';
|
|
const { app, BrowserWindow, Notification, dialog } = electron;
|
|
import '@shared/types';
|
|
import { existsSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { initLogger, logger } from './logger.js';
|
|
import { resolveProfilePaths } from './paths.js';
|
|
import { openDb } from './db/index.js';
|
|
import { NoteRepository } from './repository/NoteRepository.js';
|
|
import { MediaStore } from './services/MediaStore.js';
|
|
import { ContinuityService } from './services/ContinuityService.js';
|
|
import { CaptureService } from './services/CaptureService.js';
|
|
import { NotificationService } from './services/NotificationService.js';
|
|
import { HotkeyService } from './services/HotkeyService.js';
|
|
import { IntentService } from './services/IntentService.js';
|
|
import { HealthChecker } from './services/HealthChecker.js';
|
|
import { LocalOllamaProvider } from './ai/LocalOllamaProvider.js';
|
|
import { ProviderHolder } from './ai/ProviderHolder.js';
|
|
import { AiWorker } from './ai/AiWorker.js';
|
|
import { registerCaptureApi } from './ipc/captureApi.js';
|
|
import { registerInboxApi, pushNoteUpdated, pushOllamaStatus } from './ipc/inboxApi.js';
|
|
import { createInboxWindow, getInboxWindow } from './windows/inboxWindow.js';
|
|
import {
|
|
createQuickCaptureWindow, showQuickCapture, getQuickCaptureWindow
|
|
} from './windows/quickCaptureWindow.js';
|
|
import { createTray, refreshTray, refreshTrayOllama, refreshTrayFailedCount } from './tray.js';
|
|
import { MediaGc } from './services/MediaGc.js';
|
|
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 { TelemetryService } from './services/TelemetryService.js';
|
|
import { SettingsService } from './services/SettingsService.js';
|
|
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../shared/constants.js';
|
|
|
|
const HIDDEN_ARG = '--hidden';
|
|
const startedHidden = process.argv.includes(HIDDEN_ARG);
|
|
|
|
app.whenReady().then(async () => {
|
|
initLogger();
|
|
logger.info('app.start', {
|
|
platform: process.platform,
|
|
version: app.getVersion(),
|
|
packaged: app.isPackaged,
|
|
hidden: startedHidden
|
|
});
|
|
|
|
const paths = resolveProfilePaths('default');
|
|
|
|
const telemetry = new TelemetryService(join(paths.profileDir, 'telemetry'), () => new Date(), 14, { silent: true });
|
|
void telemetry.cleanupOldFiles()
|
|
.then((r) => logger.info('telemetry.cleanup', { removed: r.removed.length }))
|
|
.catch((e) => logger.warn('telemetry.cleanup.failed', { reason: String(e) }));
|
|
|
|
if (app.isPackaged && process.platform === 'win32') {
|
|
const initFlag = join(paths.profileDir, '.autostart-init');
|
|
if (!existsSync(initFlag)) {
|
|
app.setLoginItemSettings({ openAtLogin: true, args: [HIDDEN_ARG] });
|
|
writeFileSync(initFlag, new Date().toISOString());
|
|
logger.info('autostart.enabled.firstRun');
|
|
}
|
|
}
|
|
const db = openDb(paths.dbFile);
|
|
const repo = new NoteRepository(db);
|
|
const store = new MediaStore(paths.profileDir);
|
|
const continuity = new ContinuityService(db);
|
|
const intent = new IntentService(repo);
|
|
|
|
const settingsSvc = new SettingsService(paths.profileDir);
|
|
const settings = await settingsSvc.load();
|
|
|
|
const resolvedEndpoint = settings.ollama?.endpoint
|
|
?? process.env.INKLING_OLLAMA_ENDPOINT
|
|
?? DEFAULT_OLLAMA_ENDPOINT;
|
|
const resolvedModel = settings.ollama?.model ?? DEFAULT_OLLAMA_MODEL;
|
|
|
|
logger.info('ai.endpoint', {
|
|
endpoint: resolvedEndpoint,
|
|
model: resolvedModel,
|
|
source: settings.ollama?.endpoint
|
|
? 'settings'
|
|
: (process.env.INKLING_OLLAMA_ENDPOINT ? 'env' : 'default')
|
|
});
|
|
|
|
const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint, model: resolvedModel });
|
|
const providerHolder = new ProviderHolder(provider);
|
|
const health = new HealthChecker(providerHolder, {
|
|
onUpdate: (status) => {
|
|
logger.info('ai.health', { ...status } as Record<string, unknown>);
|
|
pushOllamaStatus(getInboxWindow, status);
|
|
refreshTrayOllama(status.ok);
|
|
},
|
|
onTelemetry: (ev) => {
|
|
if (ev.kind === 'ollama_unreachable') {
|
|
void telemetry.emit({ kind: 'ollama_unreachable', payload: { reason: ev.reason } }).catch(() => {});
|
|
} else if (ev.kind === 'ollama_recovered') {
|
|
void telemetry.emit({ kind: 'ollama_recovered', payload: { downtimeMs: ev.downtimeMs } }).catch(() => {});
|
|
} else if (ev.kind === 'ollama_recheck_manual') {
|
|
void telemetry.emit({ kind: 'ollama_recheck_manual', payload: {} }).catch(() => {});
|
|
}
|
|
}
|
|
});
|
|
health.start();
|
|
|
|
const worker = new AiWorker(repo, providerHolder, {
|
|
onUpdate: (note) => {
|
|
pushNoteUpdated(getInboxWindow, note);
|
|
// F4-C: AI 처리 완료 = 새 캡처가 inbox 에 합류한 시점, tray 도 즉시 갱신.
|
|
refreshTray(repo.countToday());
|
|
refreshTrayFailedCount(repo.countFailed());
|
|
},
|
|
logger,
|
|
telemetry
|
|
});
|
|
|
|
const notify = new NotificationService({
|
|
isSupported: () => Notification.isSupported(),
|
|
send: (body) => {
|
|
new Notification({ title: 'Inkling', body, silent: false }).show();
|
|
}
|
|
});
|
|
|
|
const capture = new CaptureService(repo, store, {
|
|
enqueue: (id) => worker.enqueue(id),
|
|
celebrate: (id) => notify.celebrate(id),
|
|
telemetry
|
|
});
|
|
|
|
registerCaptureApi(capture, getQuickCaptureWindow);
|
|
registerInboxApi({
|
|
repo, continuity, capture, health, intent,
|
|
getInboxWindow, settings: settingsSvc, providerHolder
|
|
});
|
|
|
|
const hotkeys = new HotkeyService();
|
|
const reg = hotkeys.register({
|
|
accelerator: process.platform === 'darwin' ? 'Cmd+Shift+J' : 'Ctrl+Shift+J',
|
|
onTrigger: () => showQuickCapture()
|
|
});
|
|
if (!reg.ok) logger.warn('hotkey.register.failed', { reason: reg.reason });
|
|
|
|
if (!startedHidden) {
|
|
createInboxWindow();
|
|
}
|
|
createQuickCaptureWindow();
|
|
await worker.loadFromDb();
|
|
|
|
const gc = new MediaGc(db, store);
|
|
void gc.run()
|
|
.then((r) => logger.info('media.gc', { ...r } as Record<string, unknown>))
|
|
.catch((e) => logger.warn('media.gc.failed', { reason: String(e) }));
|
|
|
|
const exportSvc = new ExportService(repo, store);
|
|
const importSvc = new ImportService(repo, store);
|
|
const syncSvc = new SyncService(paths.profileDir, exportSvc);
|
|
|
|
const backup = new BackupService(db, join(paths.profileDir, 'backups'));
|
|
void backup.runDaily()
|
|
.then((r) => logger.info('backup.daily', { ...r } as Record<string, unknown>))
|
|
.catch((e) => logger.warn('backup.daily.failed', { reason: String(e) }));
|
|
|
|
let backupOnQuitDone = false;
|
|
let trayInterval: NodeJS.Timeout | null = null;
|
|
app.on('before-quit', (e) => {
|
|
// 모든 cleanup 한 곳에 통합 — sync (idempotent) → async backup chain.
|
|
health.stop();
|
|
if (trayInterval !== null) {
|
|
clearInterval(trayInterval);
|
|
trayInterval = null;
|
|
}
|
|
if (backupOnQuitDone) return;
|
|
e.preventDefault();
|
|
backup.runDaily()
|
|
.then((r) => logger.info('backup.beforeQuit', { ...r } as Record<string, unknown>))
|
|
.catch((e2) => logger.warn('backup.beforeQuit.failed', { reason: String(e2) }))
|
|
.then(() => syncSvc.isConfigured().then((cfg) => {
|
|
if (!cfg) return;
|
|
return syncSvc.sync()
|
|
.then((r) => logger.info('sync.beforeQuit', {
|
|
ok: r.ok,
|
|
changed: r.changed ?? false,
|
|
pushed: r.pushed ?? false,
|
|
reason: r.reason
|
|
} as Record<string, unknown>))
|
|
.catch((e3) => logger.warn('sync.beforeQuit.failed', { reason: String(e3) }));
|
|
}))
|
|
.finally(() => {
|
|
backupOnQuitDone = true;
|
|
app.isQuitting = true;
|
|
app.quit();
|
|
});
|
|
});
|
|
|
|
createTray(
|
|
() => createInboxWindow(),
|
|
() => showQuickCapture(),
|
|
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();
|
|
}
|
|
},
|
|
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;
|
|
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();
|
|
}
|
|
},
|
|
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;
|
|
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;
|
|
}
|
|
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;
|
|
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();
|
|
}
|
|
},
|
|
async () => {
|
|
// runSync — 트레이 "지금 동기화"
|
|
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;
|
|
}
|
|
if (r.changed) {
|
|
logger.info('sync.done', { sha: r.sha, 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();
|
|
}
|
|
},
|
|
/* runExportTelemetry */ 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;
|
|
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();
|
|
}
|
|
},
|
|
/* runOllamaRecheck */ () => { void health.runOnce({ manual: true }); },
|
|
/* runRetryAllFailed */ () => { void capture.retryAllFailed(); },
|
|
/* runOpenOllamaSettings */ () => {
|
|
const win = getInboxWindow();
|
|
if (win) win.webContents.send('inbox:openOllamaSettings');
|
|
}
|
|
);
|
|
|
|
// F4-C 환경 앵커 — tray tooltip + 메뉴 첫 항목을 오늘 KST 캡처 수로 갱신.
|
|
// 초기 1회 + 60s interval. AiWorker.onUpdate 도 별도 갱신 트리거.
|
|
// cleanup 은 위 통합 before-quit 핸들러에서 처리.
|
|
refreshTray(repo.countToday());
|
|
refreshTrayFailedCount(repo.countFailed());
|
|
trayInterval = setInterval(() => {
|
|
refreshTray(repo.countToday());
|
|
}, 60_000);
|
|
|
|
app.on('activate', () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) createInboxWindow();
|
|
});
|
|
});
|