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 { registerSettingsApi, navigateInbox } from './ipc/settingsApi.js'; import { createInboxWindow, getInboxWindow } from './windows/inboxWindow.js'; import { createQuickCaptureWindow, showQuickCapture, getQuickCaptureWindow } from './windows/quickCaptureWindow.js'; import { createTray, refreshTray } 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 + hidden-flag 전달 (v0.2.6 #46). // 두 번째 .exe 가 hidden 으로 spawn 됐다면 (autostart) 첫 instance 의 inbox 창 // 띄우지 않음 — 사용자가 명시적으로 클릭한 게 아니므로. const additionalData = { hidden: startedHidden }; const gotLock = app.requestSingleInstanceLock(additionalData); if (!gotLock) { app.quit(); } else { app.on('second-instance', (_e, _argv, _cwd, secondData) => { const data = secondData as { hidden?: boolean } | undefined; // 두 번째가 hidden 으로 spawn (autostart 등) — UI 띄우지 않음 if (data?.hidden === true) return; // 사용자가 명시적으로 .exe / 단축키 / 트레이로 띄움 → 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'); } // v0.2.6 #45 진단 — 실제 LoginItem 상태 확인 (args 전달 vs 미전달 차이) const withArgs = app.getLoginItemSettings({ args: [HIDDEN_ARG] }); const noArgs = app.getLoginItemSettings(); logger.info('autostart.state', { withArgs: { openAtLogin: withArgs.openAtLogin, executableWillLaunchAtLogin: withArgs.executableWillLaunchAtLogin }, noArgs: { openAtLogin: noArgs.openAtLogin, executableWillLaunchAtLogin: noArgs.executableWillLaunchAtLogin }, expectedArgs: [HIDDEN_ARG] }); } 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); pushOllamaStatus(getInboxWindow, status); }, 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 도 즉시 갱신. // v0.2.7 Phase 3 — failedCount 메뉴 항목 제거됨 → todayCount 만 갱신. refreshTray({ todayCount: repo.countToday() }); }, 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 }); // registerSettingsApi 는 backup / exportSvc / importSvc / syncSvc / telemetry 가 // 생성된 뒤에 호출 (Task 10) — 아래 BackupService/ExportService/... 초기화 직후로 이동. 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)) .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)) .catch((e) => logger.warn('backup.daily.failed', { reason: String(e) })); // v0.2.7 Task 10 — 설정 페이지 IPC (autostart + backup/export/import/sync/telemetry). // backup / exportSvc / importSvc / syncSvc / telemetry 가 모두 준비된 뒤 등록. registerSettingsApi({ backup, exportSvc, importSvc, syncSvc, telemetry, getInboxWindow }); 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)) .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)) .catch((e3) => logger.warn('sync.beforeQuit.failed', { reason: String(e3) })); })) .finally(() => { backupOnQuitDone = true; app.isQuitting = true; app.quit(); }); }); // v0.2.7 Phase 3 (Task 16) — TrayCallbacks 슬림: 10 → 3. // 백업/내보내기/복원/동기화/사용 로그/Ollama 재확인/AI 재처리/Ollama 설정/정보 → // 모두 설정 페이지로 이전 (registerSettingsApi 의 IPC 핸들러가 본문 보유). createTray({ showInbox: () => createInboxWindow(), showCapture: () => showQuickCapture(), showSettings: () => navigateInbox('settings') }); // F4-C 환경 앵커 — tray tooltip + 메뉴 첫 항목을 오늘 KST 캡처 수로 갱신. // 초기 1회 + 60s interval. AiWorker.onUpdate 도 별도 갱신 트리거. // cleanup 은 위 통합 before-quit 핸들러에서 처리. // v0.2.7 Phase 3 — failedCount 메뉴 항목 제거됨 → todayCount 만 갱신. refreshTray({ todayCount: repo.countToday() }); trayInterval = setInterval(() => { refreshTray({ todayCount: repo.countToday() }); }, 60_000); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createInboxWindow(); }); });