두 macOS 한정 버그 묶음: 1. autostart --hidden 으로 spawn 시 quickCapture (NSPanel) 만 떠 있어 dock running indicator (점) 가 표출 안 됨 — NSPanel 은 NSApp main window 로 register 안 됨. inboxWindow 를 hidden 상태로 미리 create + ready-to-show 시점에 showInactive → hide trick 으로 NSApp 에 register, 사용자 화면 깜빡임 없이 dock 점 켜짐. 2. SettingsPage 의 자동실행 mismatch 경고가 macOS 에서 false positive. macOS 13+ 의 SMAppService API 가 args 옵션 무시 + unsigned/Electron 앱에 대해 executableWillLaunchAtLogin 을 자주 false 로 반환 → 정상 등록 상태에서도 경고 떠 있음. AutostartDiagnostic 결과에 platform 필드 추가, willLaunch 신호는 win32 에서만 mismatch 판정에 사용. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
291 lines
12 KiB
TypeScript
291 lines
12 KiB
TypeScript
import electron from 'electron';
|
|
const { app, 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 { refreshVisionCache } from './services/VisionDetect.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 { SyncTimer } from './services/SyncTimer.js';
|
|
import { TelemetryService } from './services/TelemetryService.js';
|
|
import { SettingsService } from './services/SettingsService.js';
|
|
import { collectAutostartState } from './services/AutostartDiagnostic.js';
|
|
import { registerSchemesAsPrivileged, registerInklingMediaProtocol } from './protocol/inklingMedia.js';
|
|
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../shared/constants.js';
|
|
|
|
const HIDDEN_ARG = '--hidden';
|
|
const startedHidden = process.argv.includes(HIDDEN_ARG);
|
|
|
|
// v0.2.8 Cut A — `inkling-media://` custom protocol 스킴은 app.whenReady() 전에
|
|
// privileged 등록 필수 (Electron 표준). 이미지 asset 을 main process 가 직접
|
|
// 서빙해 file:// hack 없이 작동.
|
|
registerSchemesAsPrivileged();
|
|
|
|
// 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');
|
|
|
|
// v0.2.8 Cut A — `inkling-media://` request handler 등록 (profileDir 결정 후).
|
|
registerInklingMediaProtocol(paths.profileDir);
|
|
|
|
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 진단 — startup 로그. 같은 정보가 SettingsPage 진단 패널에도 surface (collectAutostartState single source of truth).
|
|
void collectAutostartState().then((state) => logger.info('autostart.state', { ...state }));
|
|
}
|
|
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);
|
|
|
|
// v0.3.1 Cut F — app launch 시 vision capability cache 갱신 (fire-and-forget).
|
|
// 실패 silent (cache 유지). 사용자가 설정 페이지에서 "다시 감지" manual trigger 가능.
|
|
void refreshVisionCache({ settings: settingsSvc, endpoint: resolvedEndpoint }).catch(() => {});
|
|
|
|
const health = new HealthChecker(providerHolder, {
|
|
// v0.2.9 Cut B Task 14 — AI 비활성 시 health polling skip (Ollama 미설치 환경 무영향).
|
|
isAiEnabled: () => settingsSvc.isAiEnabled(),
|
|
onUpdate: (status) => {
|
|
logger.info('ai.health', { ...status } as Record<string, unknown>);
|
|
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,
|
|
// v0.3.1 Cut F — vision 지원
|
|
settings: settingsSvc,
|
|
mediaStore: store
|
|
});
|
|
|
|
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,
|
|
settings: settingsSvc
|
|
});
|
|
|
|
registerCaptureApi(capture, getQuickCaptureWindow);
|
|
registerInboxApi({
|
|
repo, continuity, capture, health, intent,
|
|
getInboxWindow, settings: settingsSvc, providerHolder,
|
|
paths: { profileDir: paths.profileDir },
|
|
// v0.2.9 Cut B Task 16 — disabled 메모 일괄 재투입 시 in-memory queue 갱신.
|
|
enqueue: (id) => worker.enqueue(id)
|
|
});
|
|
// 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 });
|
|
|
|
// macOS LoginItems autostart 시 startedHidden=true 로 spawn — 그대로 두면 quickCapture
|
|
// (NSPanel) 만 떠 있어 dock running indicator 미표출. inboxWindow 를 hidden 상태로
|
|
// 미리 create 하면 NSApp register → 점 표출 + 사용자가 dock 아이콘 확인으로 앱 살아있음 인지.
|
|
createInboxWindow({ visible: !startedHidden });
|
|
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, importSvc);
|
|
const syncTimer = new SyncTimer(syncSvc, settingsSvc);
|
|
|
|
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) }));
|
|
|
|
// v0.2.7 Task 10 — 설정 페이지 IPC (autostart + backup/export/import/sync/telemetry).
|
|
// backup / exportSvc / importSvc / syncSvc / telemetry 가 모두 준비된 뒤 등록.
|
|
registerSettingsApi({
|
|
backup, exportSvc, importSvc, syncSvc, telemetry, settings: settingsSvc, getInboxWindow,
|
|
syncTimer
|
|
});
|
|
|
|
void syncTimer.start();
|
|
|
|
let backupOnQuitDone = false;
|
|
let trayInterval: NodeJS.Timeout | null = null;
|
|
app.on('before-quit', (e) => {
|
|
// 모든 cleanup 한 곳에 통합 — sync (idempotent) → async backup chain.
|
|
health.stop();
|
|
syncTimer.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();
|
|
});
|
|
});
|
|
|
|
// 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);
|
|
|
|
// F14 (v0.2.7) — macOS dock 클릭 시 hidden inbox 창 show/focus.
|
|
// 기존: BrowserWindow.getAllWindows().length === 0 만 검사 → quickCapture 등이
|
|
// 떠 있으면 inbox 창이 hidden 인 채로 남았음.
|
|
app.on('activate', () => {
|
|
const win = getInboxWindow();
|
|
if (win && !win.isDestroyed()) {
|
|
if (!win.isVisible()) win.show();
|
|
win.focus();
|
|
} else {
|
|
createInboxWindow();
|
|
}
|
|
});
|
|
});
|