feat(app): wire NotificationService, IntentService, ContinuityService into main

Task 30 of the slice plan. Replaces the Task 2 placeholder main
entry with the final whenReady wiring:
- profile path resolution + better-sqlite3 open + migrations.
- repo / store / continuity / intent / health constructed
  against the open db.
- LocalOllamaProvider reads INKLING_OLLAMA_ENDPOINT for LAN
  dogfood, falls back to localhost:11434 otherwise.
- AiWorker registers an onUpdate that fans note:updated through
  pushNoteUpdated(getInboxWindow, note).
- NotificationService is plumbed to electron.Notification with
  isSupported gating; CaptureService gets the worker.enqueue +
  notify.celebrate hooks.
- IPC bindings for both capture and inbox surfaces.
- HotkeyService registers Ctrl/Cmd+Shift+J -> showQuickCapture;
  failure logged but not fatal.
- Tray menu '구출한 메모 보기' / '기억 구출하기' / '종료'
  with click-to-show-inbox.
- worker.loadFromDb() resumes pending jobs at startup.
- MediaGc runs once and logs the count of removed orphan dirs.
- Two logger.info(..., {...obj} as Record<string, unknown>)
  casts at the health and gc result sites to satisfy the
  index-signature requirement on logger meta — the result
  objects (HealthResult, {removed: number}) are assignment-
  compatible with Record<string, unknown> at runtime but TS
  refuses without the spread + cast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-04-25 12:19:53 +09:00
parent 58b85ff5c3
commit 71aafa2337
2 changed files with 95 additions and 3 deletions

View File

@@ -1,12 +1,86 @@
import { app, BrowserWindow } from 'electron';
import { app, BrowserWindow, Notification } from 'electron';
import '@shared/types';
import { createInboxWindow } from './windows/inboxWindow.js';
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 { AiWorker } from './ai/AiWorker.js';
import { registerCaptureApi } from './ipc/captureApi.js';
import { registerInboxApi, pushNoteUpdated } from './ipc/inboxApi.js';
import { createInboxWindow, getInboxWindow } from './windows/inboxWindow.js';
import {
createQuickCaptureWindow, showQuickCapture, getQuickCaptureWindow
} from './windows/quickCaptureWindow.js';
import { createTray } from './tray.js';
import { MediaGc } from './services/MediaGc.js';
app.whenReady().then(() => {
app.whenReady().then(async () => {
initLogger();
logger.info('app.start', { platform: process.platform, version: app.getVersion() });
const paths = resolveProfilePaths('default');
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 provider = new LocalOllamaProvider({
endpoint: process.env.INKLING_OLLAMA_ENDPOINT
});
const health = new HealthChecker(provider);
void health.runOnce().then((h) => logger.info('ai.health', { ...h } as Record<string, unknown>));
const worker = new AiWorker(repo, provider, {
onUpdate: (note) => pushNoteUpdated(getInboxWindow, note),
logger
});
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)
});
registerCaptureApi(capture, getQuickCaptureWindow);
registerInboxApi({
repo, continuity, capture, health, intent,
getInboxWindow
});
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 });
createInboxWindow();
createQuickCaptureWindow();
createTray(
() => createInboxWindow(),
() => showQuickCapture()
);
await worker.loadFromDb();
const gc = new MediaGc(db, store);
void gc.run().then((r) => logger.info('media.gc', { ...r } as Record<string, unknown>));
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createInboxWindow();
});

18
src/main/tray.ts Normal file
View File

@@ -0,0 +1,18 @@
import { app, Tray, Menu, nativeImage } from 'electron';
let tray: Tray | null = null;
export function createTray(showInbox: () => void, showCapture: () => void): Tray {
const icon = nativeImage.createEmpty();
tray = new Tray(icon);
tray.setToolTip('Inkling');
const menu = Menu.buildFromTemplate([
{ label: '구출한 메모 보기', click: showInbox },
{ label: '기억 구출하기', click: showCapture },
{ type: 'separator' },
{ label: '종료', click: () => { app.isQuitting = true; app.quit(); } }
]);
tray.setContextMenu(menu);
tray.on('click', showInbox);
return tray;
}