src/main: 트레이/감시자/저장소/IPC/알림 배치 (Electron 메인)

tray.ts: 트레이 아이콘, 팝오버 BrowserWindow 토글, 컨텍스트 메뉴.
watcher.ts: chokidar 감시, dedup TTL 2s, auto/manual 모드, 글로벌 일시정지.
store.ts: userData/store.json 영속화 (watchedDirs/settings/undoLog).
ipc.ts: 18개 IPC 채널 핸들러.
notifier.ts: interval 기반 알림 배치 처리 (알림 폭주 방지).
settings-window.ts: 설정창 BrowserWindow 라이프사이클.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 15:41:08 +09:00
parent 4e92bb2690
commit 3dff470044
8 changed files with 665 additions and 0 deletions

37
src/main/index.ts Normal file
View File

@@ -0,0 +1,37 @@
import { app } from 'electron';
import { createTray, destroyTray } from './tray';
import { openSettingsWindow } from './settings-window';
import { registerIpcHandlers } from './ipc';
import * as store from './store';
import * as watcher from './watcher';
// 단일 인스턴스 강제
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
}
app.whenReady().then(async () => {
registerIpcHandlers();
createTray();
// 저장된 디렉토리 감시 복원
const dirs = await store.getDirs();
for (const dir of dirs.filter((d) => d.enabled)) {
watcher.startDir(dir).catch(console.error);
}
// 감시 디렉토리가 없으면 설정창 자동 오픈 (첫 실행 안내)
if (dirs.length === 0) {
openSettingsWindow();
}
});
app.on('window-all-closed', () => {
// 트레이 앱은 윈도우를 모두 닫아도 종료하지 않음
});
app.on('will-quit', async () => {
destroyTray();
await watcher.stopAll();
});

126
src/main/ipc.ts Normal file
View File

@@ -0,0 +1,126 @@
import { ipcMain, dialog, app, BrowserWindow } from 'electron';
import { randomUUID } from 'crypto';
import { scan } from '../core/scanner';
import { normalizeEntry } from '../core/normalizer';
import { nanoid } from './nanoid';
import * as store from './store';
import * as watcher from './watcher';
import type { WatchedDir, UndoEntry } from '../core/types';
export function registerIpcHandlers(): void {
// ── 디렉토리 선택 ──
ipcMain.handle('dialog:selectDirectory', async (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
const result = await dialog.showOpenDialog(win!, { properties: ['openDirectory'] });
return result.canceled ? null : result.filePaths[0];
});
// ── 디렉토리 CRUD ──
ipcMain.handle('dirs:list', async () => store.getDirs());
ipcMain.handle('dirs:add', async (_e, dirPath: string) => {
const settings = await store.getSettings();
const dir: WatchedDir = {
id: randomUUID(),
path: dirPath,
recursive: false,
mode: settings.defaultMode,
enabled: true,
customRanges: [],
};
await store.addDir(dir);
await watcher.startDir(dir);
return dir;
});
ipcMain.handle('dirs:update', async (_e, id: string, patch: Partial<WatchedDir>) => {
await store.updateDir(id, patch);
// 모드/recursive 변경 시 재시작
if ('mode' in patch || 'recursive' in patch || 'enabled' in patch || 'customRanges' in patch) {
await watcher.stopDir(id);
const dirs = await store.getDirs();
const dir = dirs.find((d) => d.id === id);
if (dir?.enabled) await watcher.startDir(dir);
}
});
ipcMain.handle('dirs:remove', async (_e, id: string) => {
await watcher.stopDir(id);
await store.removeDir(id);
});
// ── Dry-run 스캔 ──
ipcMain.handle('dirs:scan', async (_e, id: string) => {
const dirs = await store.getDirs();
const dir = dirs.find((d) => d.id === id);
if (!dir) return [];
return scan(dir.path, dir.recursive, { customRanges: dir.customRanges });
});
// ── 수동 모드 대기 큐 적용 ──
ipcMain.handle('dirs:applyQueue', async (_e, id: string) => {
const dirs = await store.getDirs();
const dir = dirs.find((d) => d.id === id);
if (!dir) return [];
return watcher.applyManualQueue(id, dir);
});
ipcMain.handle('dirs:pendingQueue', async (_e, id: string) => {
return watcher.getPendingQueue(id);
});
// ── 감시 일시정지/재개/상태 ──
ipcMain.handle('watcher:status', () => ({ paused: watcher.isGloballyPaused() }));
ipcMain.handle('watcher:pauseAll', async () => {
await watcher.pauseAll();
});
ipcMain.handle('watcher:resumeAll', async () => {
const dirs = await store.getDirs();
await watcher.resumeAll(dirs);
});
// ── Undo ──
ipcMain.handle('undo:list', async () => store.getUndoLog());
ipcMain.handle('undo:revertEntry', async (_e, entry: UndoEntry) => {
try {
const result = await normalizeEntry(entry.newPath, 'file');
// 실제 되돌리기: newPath → oldPath
const fs = await import('fs/promises');
await fs.rename(entry.newPath, entry.oldPath);
await store.markUndoReverted([entry.id]);
return { success: true, result };
} catch (err) {
return { success: false, error: String(err) };
}
});
ipcMain.handle('undo:revertLastBatch', async () => {
const log = await store.getUndoLog();
if (!log.length) return [];
const lastTs = log[log.length - 1].ts;
// 마지막 5초 이내의 항목을 한 배치로 묶음
const batch = log.filter((e) => !e.reverted && lastTs - e.ts < 5000);
const fs = await import('fs/promises');
const results = [];
for (const entry of [...batch].reverse()) {
try {
await fs.rename(entry.newPath, entry.oldPath);
results.push({ ...entry, success: true });
} catch (err) {
results.push({ ...entry, success: false, error: String(err) });
}
}
await store.markUndoReverted(batch.map((e) => e.id));
return results;
});
// ── 설정 ──
ipcMain.handle('settings:get', async () => store.getSettings());
ipcMain.handle('settings:update', async (_e, patch) => store.updateSettings(patch));
// ── 앱 정보 ──
ipcMain.handle('app:version', () => app.getVersion());
}

5
src/main/nanoid.ts Normal file
View File

@@ -0,0 +1,5 @@
import { randomBytes } from 'crypto';
export function nanoid(): string {
return randomBytes(12).toString('base64url');
}

68
src/main/notifier.ts Normal file
View File

@@ -0,0 +1,68 @@
import { Notification } from 'electron';
import * as store from './store';
interface Batch {
count: number;
timer: ReturnType<typeof setTimeout>;
}
// dirPath → 현재 진행 중인 배치
const batches = new Map<string, Batch>();
/**
* 자동 변환 알림을 배치에 추가한다.
* 첫 rename이 들어오면 타이머를 시작하고, 인터벌이 끝나면
* 그동안 쌓인 총 건수를 담은 알림을 한 번만 발송한다.
*/
export async function queueRenamedNotification(dirPath: string): Promise<void> {
const settings = await store.getSettings();
if (!settings.notificationsEnabled) return;
const intervalMs = (settings.notificationIntervalSecs ?? 30) * 1000;
let batch = batches.get(dirPath);
if (!batch) {
const timer = setTimeout(() => flushBatch(dirPath), intervalMs);
batch = { count: 0, timer };
batches.set(dirPath, batch);
}
batch.count++;
}
function flushBatch(dirPath: string): void {
const batch = batches.get(dirPath);
if (!batch) return;
const count = batch.count;
batches.delete(dirPath);
if (!Notification.isSupported() || count === 0) return;
const dirName = dirPath.split('/').pop() ?? dirPath;
new Notification({
title: 'NFD → NFC 변환 완료',
body: `"${dirName}" 에서 ${count}개 파일명이 변환되었습니다.`,
silent: true,
}).show();
}
/** 수동 모드: 감지 즉시 알림 (사용자 액션 유도) */
export async function notifyManualQueue(count: number, dirPath: string): Promise<void> {
const settings = await store.getSettings();
if (!settings.notificationsEnabled) return;
if (!Notification.isSupported()) return;
const dirName = dirPath.split('/').pop() ?? dirPath;
new Notification({
title: 'NFD 파일 감지됨',
body: `"${dirName}" 에서 ${count}개 대기 중. 트레이에서 처리하세요.`,
silent: true,
}).show();
}
/** 앱 종료 시 남아있는 배치를 즉시 발송 */
export function flushAll(): void {
for (const [dirPath] of batches) {
flushBatch(dirPath);
}
}

View File

@@ -0,0 +1,59 @@
import { BrowserWindow, shell, app } from 'electron';
import { join } from 'path';
let settingsWin: BrowserWindow | null = null;
export function openSettingsWindow(): void {
if (settingsWin && !settingsWin.isDestroyed()) {
settingsWin.focus();
return;
}
settingsWin = new BrowserWindow({
width: 720,
height: 560,
title: 'NFD to NFC — 설정',
resizable: false,
show: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: join(__dirname, '../preload/index.js'),
},
});
settingsWin.on('closed', () => {
settingsWin = null;
app.dock?.hide();
});
settingsWin.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
// 로딩 실패 시 콘솔 출력
settingsWin.webContents.on('did-fail-load', (_e, code, desc, url) => {
console.error('[settings-window] did-fail-load:', code, desc, url);
});
const loadPromise = process.env['ELECTRON_RENDERER_URL']
? settingsWin.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/settings/index.html`)
: settingsWin.loadFile(join(__dirname, '../renderer/settings/index.html'));
loadPromise
.then(() => {
app.dock?.show();
settingsWin?.show();
settingsWin?.focus();
// 개발 모드에서 devtools 자동 오픈
if (process.env['ELECTRON_RENDERER_URL']) {
settingsWin?.webContents.openDevTools({ mode: 'detach' });
}
})
.catch((err: Error) => console.error('[settings-window] load error:', err));
}
export function closeSettingsWindow(): void {
settingsWin?.close();
}

94
src/main/store.ts Normal file
View File

@@ -0,0 +1,94 @@
import { app } from 'electron';
import fs from 'fs/promises';
import path from 'path';
import type { AppSchema, AppSettings, WatchedDir, UndoEntry } from '../core/types';
const STORE_PATH = path.join(app.getPath('userData'), 'store.json');
const MAX_UNDO_LOG = 1000;
const DEFAULT_SETTINGS: AppSettings = {
startAtLogin: false,
defaultMode: 'auto',
notificationsEnabled: true,
};
const DEFAULT_SCHEMA: AppSchema = {
watchedDirs: [],
settings: DEFAULT_SETTINGS,
undoLog: [],
};
let cache: AppSchema | null = null;
async function load(): Promise<AppSchema> {
if (cache) return cache;
try {
const raw = await fs.readFile(STORE_PATH, 'utf-8');
cache = { ...DEFAULT_SCHEMA, ...JSON.parse(raw) };
} catch {
cache = structuredClone(DEFAULT_SCHEMA);
}
return cache!;
}
async function save(): Promise<void> {
if (!cache) return;
await fs.mkdir(path.dirname(STORE_PATH), { recursive: true });
await fs.writeFile(STORE_PATH, JSON.stringify(cache, null, 2), 'utf-8');
}
export async function getDirs(): Promise<WatchedDir[]> {
return (await load()).watchedDirs;
}
export async function addDir(dir: WatchedDir): Promise<void> {
const data = await load();
data.watchedDirs.push(dir);
await save();
}
export async function updateDir(id: string, patch: Partial<WatchedDir>): Promise<void> {
const data = await load();
const idx = data.watchedDirs.findIndex((d) => d.id === id);
if (idx === -1) return;
data.watchedDirs[idx] = { ...data.watchedDirs[idx], ...patch };
await save();
}
export async function removeDir(id: string): Promise<void> {
const data = await load();
data.watchedDirs = data.watchedDirs.filter((d) => d.id !== id);
await save();
}
export async function getSettings(): Promise<AppSettings> {
return (await load()).settings;
}
export async function updateSettings(patch: Partial<AppSettings>): Promise<void> {
const data = await load();
data.settings = { ...data.settings, ...patch };
await save();
}
export async function appendUndoEntries(entries: UndoEntry[]): Promise<void> {
const data = await load();
data.undoLog.push(...entries);
if (data.undoLog.length > MAX_UNDO_LOG) {
data.undoLog = data.undoLog.slice(-MAX_UNDO_LOG);
}
await save();
}
export async function getUndoLog(): Promise<UndoEntry[]> {
return (await load()).undoLog;
}
export async function markUndoReverted(ids: string[]): Promise<void> {
const data = await load();
const set = new Set(ids);
data.undoLog.forEach((e) => {
if (set.has(e.id)) e.reverted = true;
});
await save();
}

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

@@ -0,0 +1,111 @@
import { Tray, Menu, BrowserWindow, nativeImage, app } from 'electron';
import { join } from 'path';
import { openSettingsWindow } from './settings-window';
import * as watcher from './watcher';
import { getDirs } from './store';
let tray: Tray | null = null;
let popoverWin: BrowserWindow | null = null;
function buildTrayIcon(): Electron.NativeImage {
// resources/ 파일이 있으면 우선 사용
const iconPath = join(__dirname, '../../resources/tray-icon-Template.png');
const fromFile = nativeImage.createFromPath(iconPath);
if (!fromFile.isEmpty()) {
fromFile.setTemplateImage(true);
return fromFile;
}
// 없으면 32×32 비트맵으로 원형 아이콘 생성 (@2x → 16px 논리 크기)
const SIZE = 32;
const data = Buffer.alloc(SIZE * SIZE * 4, 0);
const cx = SIZE / 2, cy = SIZE / 2, r = SIZE * 0.38;
for (let y = 0; y < SIZE; y++) {
for (let x = 0; x < SIZE; x++) {
if (Math.hypot(x - cx, y - cy) <= r) {
const i = (y * SIZE + x) * 4;
data[i + 3] = 255; // alpha only — template image가 색을 결정
}
}
}
const img = nativeImage.createFromBitmap(data, { width: SIZE, height: SIZE, scaleFactor: 2.0 });
img.setTemplateImage(true);
return img;
}
export function createTray(): void {
const icon = buildTrayIcon();
tray = new Tray(icon);
tray.setToolTip('NFD to NFC 변환기');
tray.on('click', () => togglePopover());
tray.on('right-click', () => showContextMenu());
}
function togglePopover(): void {
if (popoverWin && !popoverWin.isDestroyed()) {
popoverWin.close();
return;
}
const bounds = tray!.getBounds();
popoverWin = new BrowserWindow({
width: 300,
height: 400,
x: Math.round(bounds.x - 150 + bounds.width / 2),
y: Math.round(bounds.y + bounds.height + 4),
frame: false,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
show: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: join(__dirname, '../preload/index.js'),
},
});
popoverWin.on('blur', () => {
popoverWin?.close();
});
popoverWin.on('closed', () => {
popoverWin = null;
});
if (process.env['ELECTRON_RENDERER_URL']) {
popoverWin.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popover/index.html`);
} else {
popoverWin.loadFile(join(__dirname, '../renderer/popover/index.html'));
}
popoverWin.once('ready-to-show', () => popoverWin?.show());
}
function showContextMenu(): void {
// watcher 모듈이 단일 진실의 원천 — 여기서 직접 읽음
const paused = watcher.isGloballyPaused();
const menu = Menu.buildFromTemplate([
{
label: paused ? '감시 재개' : '감시 일시 정지',
click: async () => {
if (watcher.isGloballyPaused()) {
const dirs = await getDirs();
await watcher.resumeAll(dirs);
} else {
await watcher.pauseAll();
}
},
},
{ label: '설정…', click: () => openSettingsWindow() },
{ type: 'separator' },
{ label: '종료', click: () => app.quit() },
]);
tray!.popUpContextMenu(menu);
}
export function destroyTray(): void {
tray?.destroy();
tray = null;
}

165
src/main/watcher.ts Normal file
View File

@@ -0,0 +1,165 @@
import fs from 'fs/promises';
import chokidar, { FSWatcher } from 'chokidar';
import { BrowserWindow } from 'electron';
import { nanoid } from './nanoid';
import { shouldNormalize } from '../core/filter';
import { normalizeEntry } from '../core/normalizer';
import type { WatchedDir, ActivityEvent, RenameResult } from '../core/types';
import * as store from './store';
import * as notifier from './notifier';
type ActivityListener = (event: ActivityEvent) => void;
const watchers = new Map<string, FSWatcher>();
const recentlyRenamed = new Map<string, number>();
const RENAME_TTL = 2000;
const listeners = new Set<ActivityListener>();
const manualQueue = new Map<string, string[]>();
// 전역 일시정지 상태 — 단일 진실의 원천
let globallyPaused = false;
export function isGloballyPaused(): boolean {
return globallyPaused;
}
export function onActivity(listener: ActivityListener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function emit(event: ActivityEvent): void {
for (const l of listeners) l(event);
BrowserWindow.getAllWindows().forEach((w) => {
if (!w.isDestroyed()) w.webContents.send('watcher:activity', event);
});
}
// 일시정지 상태 변경을 렌더러에 브로드캐스트
function emitPauseState(): void {
BrowserWindow.getAllWindows().forEach((w) => {
if (!w.isDestroyed()) w.webContents.send('watcher:paused', globallyPaused);
});
}
export async function startDir(dir: WatchedDir): Promise<void> {
if (watchers.has(dir.id)) return;
const filterOpts = { customRanges: dir.customRanges };
const w = chokidar.watch(dir.path, {
ignoreInitial: false,
persistent: true,
depth: dir.recursive ? undefined : 0,
awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 },
ignored: /(^|[/\\])\../,
});
async function handlePath(filePath: string, type: 'file' | 'directory'): Promise<void> {
const now = Date.now();
const lastRenamed = recentlyRenamed.get(filePath);
if (lastRenamed && now - lastRenamed < RENAME_TTL) return;
const basename = filePath.split('/').pop() ?? '';
if (!shouldNormalize(basename, filterOpts)) return;
if (dir.mode === 'auto') {
const result = await normalizeEntry(filePath, type, filterOpts);
if (result.status === 'renamed' || result.status === 'noop-same-inode') {
recentlyRenamed.set(result.newPath, Date.now());
if (result.status === 'renamed') {
await store.appendUndoEntries([
{ id: nanoid(), ts: now, oldPath: result.oldPath, newPath: result.newPath, reverted: false },
]);
}
emit({ type: 'rename', ts: now, dirId: dir.id, message: `${result.oldPath}${result.newPath}`, result });
// 알림은 배치로 처리 — 인터벌마다 총 건수 발송
await notifier.queueRenamedNotification(dir.path);
} else if (result.status === 'collision') {
emit({ type: 'collision', ts: now, dirId: dir.id, message: `충돌: ${result.oldPath}`, result });
}
} else {
const queue = manualQueue.get(dir.id) ?? [];
if (!queue.includes(filePath)) {
queue.push(filePath);
manualQueue.set(dir.id, queue);
emit({ type: 'info', ts: now, dirId: dir.id, message: `수동 대기: ${filePath}` });
}
await notifier.notifyManualQueue(queue.length, dir.path);
}
}
w.on('add', (p) => handlePath(p, 'file').catch(console.error))
.on('addDir', (p) => {
if (p === dir.path) return;
handlePath(p, 'directory').catch(console.error);
})
.on('error', (err) => {
emit({ type: 'error', ts: Date.now(), dirId: dir.id, message: String(err) });
});
watchers.set(dir.id, w);
}
export async function stopDir(dirId: string): Promise<void> {
const w = watchers.get(dirId);
if (!w) return;
await w.close();
watchers.delete(dirId);
manualQueue.delete(dirId);
}
/** 전역 일시정지: 모든 감시 중단 + 상태 플래그 변경 */
export async function pauseAll(): Promise<void> {
globallyPaused = true;
for (const [id] of [...watchers]) await stopDir(id);
emitPauseState();
}
/** 전역 재개: 지정된 dirs로 감시 재시작 + 상태 플래그 변경 */
export async function resumeAll(dirs: WatchedDir[]): Promise<void> {
globallyPaused = false;
for (const d of dirs.filter((x) => x.enabled)) {
await startDir(d);
}
emitPauseState();
}
/** 앱 종료용 정리 — 일시정지 플래그 변경 없음 */
export async function stopAll(): Promise<void> {
for (const [id] of [...watchers]) await stopDir(id);
}
export function getPendingQueue(dirId: string): string[] {
return manualQueue.get(dirId) ?? [];
}
export async function applyManualQueue(dirId: string, dir: WatchedDir): Promise<RenameResult[]> {
const queue = manualQueue.get(dirId) ?? [];
const filterOpts = { customRanges: dir.customRanges };
const results: RenameResult[] = [];
const undoEntries = [];
const now = Date.now();
for (const filePath of queue) {
try {
const stat = await fs.stat(filePath).catch(() => null);
if (!stat) continue;
const type = stat.isDirectory() ? 'directory' : 'file';
const result = await normalizeEntry(filePath, type, filterOpts);
results.push(result);
if (result.status === 'renamed') {
recentlyRenamed.set(result.newPath, Date.now());
undoEntries.push({ id: nanoid(), ts: now, oldPath: result.oldPath, newPath: result.newPath, reverted: false });
}
} catch (err) {
console.error('applyManualQueue error:', err);
}
}
manualQueue.set(dirId, []);
if (undoEntries.length) await store.appendUndoEntries(undoEntries);
// 수동 적용은 즉시 배치에 추가 (인터벌 내 합산)
const renamedCount = results.filter((r) => r.status === 'renamed').length;
for (let i = 0; i < renamedCount; i++) await notifier.queueRenamedNotification(dir.path);
return results;
}