Files
inkling/src/main/index.ts
altair823 7187aea0a9 hotfix(critical): single-instance lock — multi-process SQLite race 방지
dogfood 발견 — 앱 아이콘 클릭 시마다 새 process 가 떠서 트레이 아이콘 여러 개,
SQLite 동시 접근 + AiWorker 중복 처리 + HealthChecker 중복 polling 등
**데이터 corruption 위험**.

원인: app.requestSingleInstanceLock() 호출 부재. Electron default 가
multi-instance 라 .exe 실행마다 별도 process.

Fix:
- app.requestSingleInstanceLock() 첫 줄에서 호출
- 두 번째 인스턴스 → app.quit() 즉시 종료
- 'second-instance' 이벤트 → 기존 inbox 창 restore + show + focus
  (사용자 의도는 "앱 보기" 라 가정)

게이트: typecheck 0 / 단위 413 / e2e 1
version: 0.2.4 → 0.2.5 (critical hotfix patch)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:42:50 +09:00

406 lines
16 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);
// CRITICAL — single-instance lock. 두 번째 .exe 실행 시 즉시 종료.
// 미적용 시 SQLite 동시 접근 + AiWorker 중복 처리 + HealthChecker 중복 polling 등
// 데이터 corruption 위험. activate (Mac) / second-instance (Win/Linux) 이벤트로
// 기존 inbox 창에 focus.
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on('second-instance', () => {
// 새 .exe 실행 또는 트레이 외 entry 시 기존 inbox 창 보이게
const win = getInboxWindow();
if (win) {
if (win.isMinimized()) win.restore();
if (!win.isVisible()) win.show();
win.focus();
} else {
createInboxWindow();
}
});
}
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();
});
});