diff --git a/docs/superpowers/plans/2026-05-07-v027-cross-platform.md b/docs/superpowers/plans/2026-05-07-v027-cross-platform.md new file mode 100644 index 0000000..4a40f6b --- /dev/null +++ b/docs/superpowers/plans/2026-05-07-v027-cross-platform.md @@ -0,0 +1,2364 @@ +# v0.2.7 Cross-Platform 입구 정상화 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Windows 트레이 의존을 끊고 macOS/Linux 사용자에게 동등한 입구 제공 — Linux 앱 빌드 + 설정 페이지 + 트레이 슬림 + macOS dock 클릭 fix + 자동 실행 진단 노출. + +**Architecture:** electron-builder 에 linux target (AppImage + deb) 추가하여 빌드 매트릭스 확장. inbox 윈도우 안에 `showSettings` boolean state 기반 설정 페이지 추가, 4 섹션 (AI 제공자 / 자동 실행 / 백업/복원 / 정보) 으로 트레이 메뉴 8 항목 흡수. 트레이는 4 항목 (한 줄 적기 / 보관함 / 설정 / 종료) 으로 슬림. macOS dock 클릭은 `getInboxWindow().show() + focus()` 분기 추가로 hidden 창 복원. 자동 실행은 main process IPC `settings:autostart-state` 가 withArgs/noArgs/execPath/registry 정보 수집 → 설정 페이지 진단 패널 표시. + +**Tech Stack:** Electron 41 + React 19 + zustand 5 + better-sqlite3 12.9 + electron-builder + vitest 4 + Playwright + +**선행 spec:** `docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md` + +--- + +## File Structure + +### 신규 파일 + +| 경로 | 책임 | +|---|---| +| `src/renderer/inbox/components/SettingsPage.tsx` | 설정 페이지 컨테이너 (4 섹션 vertical scroll + "← 돌아가기" 헤더) | +| `src/renderer/inbox/components/settings/AiProviderSection.tsx` | Ollama endpoint/model + "지금 재확인" + 마지막 ping 결과 (OllamaSettingsModal 흡수) | +| `src/renderer/inbox/components/settings/AutostartSection.tsx` | 토글 + 진단 패널 (펼치기) + "재등록" 버튼 | +| `src/renderer/inbox/components/settings/BackupSection.tsx` | 5 버튼 (백업/내보내기/복원/동기화/telemetry) | +| `src/renderer/inbox/components/settings/InfoSection.tsx` | 버전 + 데이터 위치 + "데이터 위치 열기" + "정보 복사" | +| `src/main/services/AutostartDiagnostic.ts` | autostart-state 정보 수집 (getLoginItemSettings 양쪽 + Windows registry 조회) | +| `src/main/ipc/settingsApi.ts` | settings:autostart-state, settings:autostart-set, inbox:navigate, settings:* (백업/복원/내보내기/동기화/telemetry) IPC 핸들러 | +| 위 항목들의 `.test.ts(x)` | 단위 테스트 (vitest) | + +### 수정 파일 + +| 경로 | 변경 내용 | +|---|---| +| `package.json` | `build.linux` target (AppImage + deb x64) + `dist:linux` script 추가 | +| `src/main/index.ts` | F14 activate 핸들러 5줄 수정, settings IPC 등록, 트레이 callback wiring 슬림화, HealthChecker/AiWorker refreshTray 호출 제거 | +| `src/main/tray.ts` | TrayCallbacks (showInbox/showCapture/showSettings 만), TrayState (todayCount 만), buildMenu 4 항목 | +| `src/renderer/inbox/store.ts` | `showSettings: boolean` + `setShowSettings(b)` action 추가 | +| `src/renderer/inbox/App.tsx` | showSettings 분기 + 헤더 톱니바퀴 아이콘 + 트레이 IPC `inbox:navigate` 구독 | +| `src/renderer/inbox/api.ts` | settings:* IPC 호출 wrapper | +| `src/preload/index.ts` | 신규 IPC 채널 노출 (settings:autostart-state, settings:autostart-set, inbox:navigate, settings:backup/export/import/sync/exportTelemetry) | + +### 제거 파일 + +| 경로 | 이유 | +|---|---| +| `src/renderer/inbox/components/OllamaSettingsModal.tsx` | AiProviderSection 으로 흡수 후 dead code | + +--- + +## Phase 개요 + +``` +Phase 1: Linux 빌드 (Task 1~5) ← Risk-reduction first +Phase 2: 설정 페이지 + IPC (Task 6~13) +Phase 3: 트레이 슬림 (Task 14~17) +Phase 4: F14 dock fix (Task 18) +Phase 5: F12 deeper fix (Task 19~24) +Phase 6: Cleanup + verification (Task 25~27) +``` + +각 task 끝마다 commit. 단위 테스트는 vitest, e2e 는 Playwright. typecheck (`npm run typecheck`) 는 task 별로 step 으로 포함. + +--- + +## Phase 1: Linux 빌드 + +### Task 1: better-sqlite3 linux-x64 prebuild 가용성 검증 + +**Files:** + +- 검증 only — 코드 변경 없음. 결과를 `docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md` §11 Risk 표 끝에 기록. + +- [ ] **Step 1: macOS 호스트에서 brew 도구 설치** + +```bash +brew install dpkg fakeroot +``` + +확인: `which dpkg-deb` 와 `which fakeroot` 둘 다 경로 출력. + +- [ ] **Step 2: better-sqlite3 prebuild 가용성 직접 조회** + +```bash +curl -sI https://github.com/WiseLibs/better-sqlite3/releases/download/v12.9.0/better-sqlite3-v12.9.0-electron-v41.3.0-linux-x64.tar.gz | head -1 +``` + +Expected: `HTTP/2 302` (또는 `200`) — prebuild 존재. `404` 면 node-gyp 로컬 빌드 fallback 필요 (Step 3 에서 확인). + +- [ ] **Step 3: 로컬 prebuild 시도 (linux 타깃)** + +```bash +cd node_modules/better-sqlite3 +./node_modules/.bin/prebuild-install --runtime=electron --target=41.3.0 --platform=linux --arch=x64 --tag-prefix=v --verbose +``` + +Expected: `Successfully installed prebuilt binary` 메시지 + `build/Release/better_sqlite3.node` 파일 존재. + +실패 시: error 로그를 그대로 spec §11 끝에 기록하고 Task 1 종료. node-gyp fallback 은 Task 3 에서 dist:linux 시 자동 시도됨. + +- [ ] **Step 4: spec §11 Risk 표 갱신 (검증 결과 기록)** + +`docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md` 의 §11 표 첫 row 옆에 ✅ 또는 ⚠️ 마킹 + 한 줄 요약. + +- [ ] **Step 5: 커밋** + +```bash +git add docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md +git commit -m "docs(v027): better-sqlite3 linux-x64 prebuild 가용성 검증" +``` + +--- + +### Task 2: package.json — linux target + dist:linux script 추가 + +**Files:** + +- Modify: `package.json` + +- [ ] **Step 1: package.json 의 build 블록 직전 위치 확인** + +```bash +grep -n '"build"' package.json | head -3 +grep -n '"win"' package.json +grep -n '"mac"' package.json +``` + +Expected: `"build"` 키 안에 이미 `"win": {...}` 와 `"mac": {...}` 가 있는 구조. linux 는 그 형제로 추가. + +- [ ] **Step 2: linux target 추가** + +`package.json` 의 `build` 블록 안, `mac` 다음에 추가: + +```json + "linux": { + "target": [ + { "target": "AppImage", "arch": ["x64"] }, + { "target": "deb", "arch": ["x64"] } + ], + "category": "Utility", + "synopsis": "로컬 메모 캡처 + AI 태그", + "description": "Inkling — 잠깐 스친 생각을 잡아두는 로컬-우선 메모 도구." + } +``` + +- [ ] **Step 3: scripts 블록에 dist:linux 추가** + +`package.json` 의 `scripts` 블록 안 `dist:mac` 다음에 추가: + +```json + "predist:linux": "npm run rebuild:electron && npm run build", + "dist:linux": "electron-builder --linux --x64", +``` + +- [ ] **Step 4: typecheck (json 파일이라 syntax 만 검증)** + +```bash +node -e "JSON.parse(require('fs').readFileSync('package.json','utf8'))" +``` + +Expected: 출력 없음 (parse 성공). + +- [ ] **Step 5: 커밋** + +```bash +git add package.json +git commit -m "feat(v027): electron-builder linux target (AppImage + deb x64)" +``` + +--- + +### Task 3: macOS 호스트에서 dist:linux 빌드 실행 + +**Files:** + +- 빌드 산출물 검증 only — 코드 변경 없음. + +- [ ] **Step 1: 빌드 실행** + +```bash +npm run dist:linux +``` + +Expected (성공 시): `dist/Inkling-0.2.7.AppImage` + `dist/inkling_0.2.7_amd64.deb` 산출. 빌드 시간 약 1-3분. + +- [ ] **Step 2: 산출물 존재 확인** + +```bash +ls -la dist/*.AppImage dist/*.deb +``` + +Expected: 두 파일 모두 표시 + AppImage size 약 100-150MB, deb size 약 80-120MB. + +- [ ] **Step 3: AppImage 실행 권한 확인 + 헤더 검증** + +```bash +file dist/Inkling-0.2.7.AppImage +``` + +Expected: `ELF 64-bit LSB executable`. + +- [ ] **Step 4: deb 메타데이터 확인** + +```bash +dpkg-deb -I dist/inkling_0.2.7_amd64.deb +``` + +Expected: `Architecture: amd64`, `Package: inkling`, version `0.2.7`. + +- [ ] **Step 5: 빌드 실패 시 fallback 결정** + +Step 1 실패 시: error 메시지를 spec §11 의 첫 row 에 추가 + 이 task 를 Docker fallback (`docker run --rm -v $(pwd):/project electronuserland/builder npm run dist:linux`) 으로 재시도. 그래도 실패면 v0.2.7 scope 조정 — AppImage 만 (deb 제거) 또는 v0.2.8 로 deb 미루기. + +- [ ] **Step 6: 커밋 (빌드 산출물은 커밋 X — `.gitignore` dist/ 확인)** + +```bash +grep -E '^dist/?$' .gitignore || echo "WARNING: dist not in .gitignore" +git status --short # 변경 없어야 함 +``` + +빌드 검증 자체는 커밋 대상 아님. 다만 spec 변경이 있다면: + +```bash +git add docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md +git commit -m "docs(v027): dist:linux 빌드 검증 — fallback 결정 기록" || echo "no spec change" +``` + +--- + +### Task 4: AppImage Linux VM smoke test + +**Files:** + +- 수동 검증 — 코드 변경 없음. + +- [ ] **Step 1: Linux VM 준비** + +WSL2 Ubuntu 22.04 또는 별도 VM 사용. AppImage 를 VM 안으로 복사 (`scp` 또는 mount). + +```bash +# VM 안에서: +chmod +x ~/Inkling-0.2.7.AppImage +sudo apt-get install -y libfuse2 # AppImage 실행 의존성 +``` + +- [ ] **Step 2: AppImage 실행** + +```bash +~/Inkling-0.2.7.AppImage --no-sandbox 2>&1 | head -30 +``` + +Expected: Inkling 윈도우 등장 + 콘솔에 `migration` 로그 없거나 `applied m003` 정상 출력. 에러 (예: `dlopen: undefined symbol`) 시 — better-sqlite3 ABI 이슈 → spec §11 첫 row 에 기록 + Task 3 Step 5 fallback 진입. + +- [ ] **Step 3: 마이그레이션 + 캡처 1회** + +inbox 윈도우 → "한 줄 적기" → 임의 텍스트 입력 → 저장. 캡처된 노트가 inbox 에 표시되는지 확인. + +- [ ] **Step 4: Ollama 연결 시도** + +설정이 아직 없으니 `INKLING_OLLAMA_ENDPOINT=http://192.168.0.47:11434` env 로 실행 (LAN 서버 - memory 의 dogfood 환경): + +```bash +INKLING_OLLAMA_ENDPOINT=http://192.168.0.47:11434 ~/Inkling-0.2.7.AppImage --no-sandbox +``` + +기존 캡처 노트의 ai_status 가 `pending` → `complete` 로 전이되며 tag 가 표시되는지 확인. + +- [ ] **Step 5: recall 또는 trash 한 사이클** + +inbox 에서 노트 휴지통 → 휴지통 탭 → restore. 동작 정상이면 SQLite write 검증 통과. + +- [ ] **Step 6: 결과 spec §11 갱신 + 커밋** + +성공/실패 + 발견된 이슈를 spec §11 끝 "Linux smoke test 결과" 섹션 (신규) 에 기록. + +```bash +git add docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md +git commit -m "docs(v027): Linux AppImage smoke test 결과 기록" +``` + +--- + +### Task 5: deb Ubuntu VM smoke test + +**Files:** + +- 수동 검증 — 코드 변경 없음. + +- [ ] **Step 1: Ubuntu VM 에 deb 설치** + +```bash +sudo dpkg -i ~/inkling_0.2.7_amd64.deb +sudo apt-get install -f # 의존성 자동 해결 +``` + +Expected: 설치 성공 + `/usr/bin/inkling` 존재. desktop entry (`/usr/share/applications/inkling.desktop`) 확인. + +- [ ] **Step 2: 데스크탑에서 launcher 통해 실행** + +GUI 가능한 VM 이면 application launcher 에서 "Inkling" 검색 → 클릭. 또는 터미널: + +```bash +inkling & +``` + +- [ ] **Step 3: AppImage 와 동일한 검증 (Task 4 Step 3~5 반복)** + +마이그레이션 / 캡처 / Ollama / restore 한 사이클. + +- [ ] **Step 4: 제거 가능 여부 확인** + +```bash +sudo dpkg -r inkling +ls /usr/bin/inkling # 없어야 함 +``` + +데이터는 사용자 홈 (~/.config/Inkling) 에 잔류 — 의도된 동작. + +- [ ] **Step 5: 결과 spec §11 갱신 + 커밋** + +```bash +git add docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md +git commit -m "docs(v027): Linux deb smoke test 결과 기록" +``` + +Phase 1 종료. 만약 Task 1~5 중 어디든 실패면 spec §11 fallback 적용 + Phase 2 진입 보류 결정. + +--- + +## Phase 2: 설정 페이지 + IPC + +### Task 6: store.ts — `showSettings` boolean state + action + +**Files:** + +- Modify: `src/renderer/inbox/store.ts` +- Test: `src/renderer/inbox/store.test.ts` (없으면 신규 — 다만 기존 store 테스트가 컴포넌트 테스트 안에서 간접 검증되는 패턴이면 SettingsPage 테스트 안에서 함께 검증해도 OK) + +- [ ] **Step 1: store 테스트 신규 작성** + +```ts +// src/renderer/inbox/store.test.ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { useInbox } from './store'; + +describe('inbox store — showSettings', () => { + beforeEach(() => { + useInbox.setState({ showSettings: false }); + }); + + it('initial state has showSettings=false', () => { + expect(useInbox.getState().showSettings).toBe(false); + }); + + it('setShowSettings(true) sets state', () => { + useInbox.getState().setShowSettings(true); + expect(useInbox.getState().showSettings).toBe(true); + }); + + it('setShowSettings(false) toggles back', () => { + useInbox.getState().setShowSettings(true); + useInbox.getState().setShowSettings(false); + expect(useInbox.getState().showSettings).toBe(false); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail 확인** + +```bash +npm run rebuild:node && npx vitest run src/renderer/inbox/store.test.ts +``` + +Expected: 3 tests fail with `setShowSettings is not a function`. + +- [ ] **Step 3: store.ts 에 state + action 추가** + +`InboxState` 인터페이스에: + +```ts + showSettings: boolean; + setShowSettings: (open: boolean) => void; +``` + +`create` 안 초기 state: + +```ts + showSettings: false, +``` + +action: + +```ts + setShowSettings(open) { + set({ showSettings: open }); + }, +``` + +- [ ] **Step 4: 테스트 통과 확인** + +```bash +npx vitest run src/renderer/inbox/store.test.ts +``` + +Expected: 3 tests pass. + +- [ ] **Step 5: typecheck** + +```bash +npm run typecheck +``` + +Expected: 0 errors. + +- [ ] **Step 6: 커밋** + +```bash +git add src/renderer/inbox/store.ts src/renderer/inbox/store.test.ts +git commit -m "feat(v027): inbox store 에 showSettings state + setShowSettings action" +``` + +--- + +### Task 7: SettingsPage.tsx scaffold + 빈 4 섹션 placeholder + +**Files:** + +- Create: `src/renderer/inbox/components/SettingsPage.tsx` +- Create: `src/renderer/inbox/components/SettingsPage.test.tsx` + +- [ ] **Step 1: failing test 작성** + +```tsx +// src/renderer/inbox/components/SettingsPage.test.tsx +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SettingsPage } from './SettingsPage'; +import { useInbox } from '../store'; + +describe('SettingsPage', () => { + beforeEach(() => { + useInbox.setState({ showSettings: true }); + }); + + it('renders header with "← 돌아가기" button', () => { + render(); + expect(screen.getByRole('button', { name: /돌아가기/ })).toBeInTheDocument(); + }); + + it('renders 4 section headings', () => { + render(); + expect(screen.getByText('AI 제공자')).toBeInTheDocument(); + expect(screen.getByText('자동 실행')).toBeInTheDocument(); + expect(screen.getByText('백업 / 복원')).toBeInTheDocument(); + expect(screen.getByText('정보')).toBeInTheDocument(); + }); + + it('clicking "← 돌아가기" sets showSettings to false', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /돌아가기/ })); + expect(useInbox.getState().showSettings).toBe(false); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail** + +```bash +npx vitest run src/renderer/inbox/components/SettingsPage.test.tsx +``` + +Expected: import error (SettingsPage 미존재). + +- [ ] **Step 3: SettingsPage.tsx 작성 (4 섹션 placeholder + 헤더)** + +```tsx +// src/renderer/inbox/components/SettingsPage.tsx +import React from 'react'; +import { useInbox } from '../store'; + +export function SettingsPage(): React.ReactElement { + const setShowSettings = useInbox((s) => s.setShowSettings); + return ( +
+
+ +

설정

+
+
+

AI 제공자

+ {/* AiProviderSection — Task 8 */} +
+
+

자동 실행

+ {/* AutostartSection — Task 9 + Task 23/24 */} +
+
+

백업 / 복원

+ {/* BackupSection — Task 10 */} +
+
+

정보

+ {/* InfoSection — Task 11 */} +
+
+ ); +} +``` + +- [ ] **Step 4: 테스트 통과 확인** + +```bash +npx vitest run src/renderer/inbox/components/SettingsPage.test.tsx +``` + +Expected: 3 tests pass. + +- [ ] **Step 5: typecheck** + +```bash +npm run typecheck +``` + +- [ ] **Step 6: 커밋** + +```bash +git add src/renderer/inbox/components/SettingsPage.tsx src/renderer/inbox/components/SettingsPage.test.tsx +git commit -m "feat(v027): SettingsPage scaffold — 4 섹션 placeholder + 돌아가기" +``` + +--- + +### Task 8: AiProviderSection.tsx — OllamaSettingsModal 흡수 + +**Files:** + +- Create: `src/renderer/inbox/components/settings/AiProviderSection.tsx` +- Create: `src/renderer/inbox/components/settings/AiProviderSection.test.tsx` +- Modify: `src/renderer/inbox/components/SettingsPage.tsx` (placeholder → 실 import) + +**선행 참조:** `src/renderer/inbox/components/OllamaSettingsModal.tsx` 의 endpoint zod 검증 + model 입력 + 저장 로직을 그대로 흡수. 차이점: modal frame (overlay) 제거 + section 형태 + "지금 재확인" 버튼 추가. + +- [ ] **Step 1: failing test 작성** + +```tsx +// src/renderer/inbox/components/settings/AiProviderSection.test.tsx +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { AiProviderSection } from './AiProviderSection'; + +vi.mock('../../api', () => ({ + inboxApi: { + getOllamaSettings: vi.fn(async () => ({ endpoint: 'http://localhost:11434', model: 'gemma2:2b' })), + saveOllamaSettings: vi.fn(async () => ({ ok: true })), + ollamaRecheck: vi.fn(async () => ({ ok: true })) + } +})); + +describe('AiProviderSection', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('loads current settings on mount', async () => { + render(); + expect(await screen.findByDisplayValue('http://localhost:11434')).toBeInTheDocument(); + expect(screen.getByDisplayValue('gemma2:2b')).toBeInTheDocument(); + }); + + it('rejects invalid endpoint URL', async () => { + render(); + await screen.findByDisplayValue('http://localhost:11434'); + const input = screen.getByLabelText(/Endpoint/); + fireEvent.change(input, { target: { value: 'not-a-url' } }); + fireEvent.click(screen.getByRole('button', { name: /저장/ })); + expect(await screen.findByText(/올바른 URL/)).toBeInTheDocument(); + }); + + it('"지금 재확인" calls ollamaRecheck and shows result', async () => { + const { inboxApi } = await import('../../api'); + render(); + await screen.findByDisplayValue('http://localhost:11434'); + fireEvent.click(screen.getByRole('button', { name: /지금 재확인/ })); + expect(inboxApi.ollamaRecheck).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail** + +```bash +npx vitest run src/renderer/inbox/components/settings/AiProviderSection.test.tsx +``` + +Expected: import error. + +- [ ] **Step 3: AiProviderSection.tsx 작성** + +기존 `OllamaSettingsModal.tsx` 의 form 로직 (endpoint zod safeParse, model trim, save) 흡수 + modal overlay 제거. 추가 기능: "지금 재확인" 버튼 → `inboxApi.ollamaRecheck()` 결과 inline 표시. + +```tsx +// src/renderer/inbox/components/settings/AiProviderSection.tsx +import React, { useEffect, useState } from 'react'; +import { z } from 'zod'; +import { inboxApi } from '../../api'; + +const endpointSchema = z.string().url(); + +export function AiProviderSection(): React.ReactElement { + const [endpoint, setEndpoint] = useState(''); + const [model, setModel] = useState(''); + const [error, setError] = useState(null); + const [saveResult, setSaveResult] = useState(null); + const [recheckResult, setRecheckResult] = useState(null); + + useEffect(() => { + void (async () => { + const s = await inboxApi.getOllamaSettings(); + setEndpoint(s.endpoint); + setModel(s.model); + })(); + }, []); + + async function onSave(): Promise { + const r = endpointSchema.safeParse(endpoint); + if (!r.success) { setError('올바른 URL 형식이 아닙니다'); return; } + if (model.trim() === '') { setError('모델 이름을 입력해주세요'); return; } + setError(null); + const result = await inboxApi.saveOllamaSettings({ endpoint, model }); + setSaveResult(result.ok ? '저장됨' : '저장 실패'); + } + + async function onRecheck(): Promise { + setRecheckResult('확인 중...'); + const r = await inboxApi.ollamaRecheck(); + setRecheckResult(r.ok ? '✅ 연결됨' : `⚠️ ${r.reason ?? '연결 실패'}`); + } + + return ( +
+ + + {error &&
{error}
} + {saveResult &&
{saveResult}
} +
+ + +
+ {recheckResult &&
{recheckResult}
} +
+ ); +} +``` + +- [ ] **Step 4: SettingsPage.tsx 의 AI 섹션 placeholder 를 실 import 로 교체** + +`{/* AiProviderSection — Task 8 */}` 를 `` 로 교체. import 추가. + +- [ ] **Step 5: 테스트 + typecheck** + +```bash +npx vitest run src/renderer/inbox/components/settings/AiProviderSection.test.tsx +npm run typecheck +``` + +Expected: 3 tests pass + 0 typecheck errors. + +`inboxApi.getOllamaSettings` / `saveOllamaSettings` 가 미존재면 추가 필요. 기존 modal 이 호출하던 IPC channel 을 확인해 같은 채널 사용. 채널이 없으면 `src/renderer/inbox/api.ts` + `src/main/ipc/inboxApi.ts` 에 추가 (별도 step 으로 분리하지 않고 같은 task 안에서). + +- [ ] **Step 6: 커밋** + +```bash +git add src/renderer/inbox/components/settings/AiProviderSection.tsx src/renderer/inbox/components/settings/AiProviderSection.test.tsx src/renderer/inbox/components/SettingsPage.tsx src/renderer/inbox/api.ts src/main/ipc/inboxApi.ts +git commit -m "feat(v027): AiProviderSection — OllamaSettingsModal 흡수 + 지금 재확인" +``` + +--- + +### Task 9: AutostartSection.tsx — 토글만 (진단 패널은 Task 23 에서) + +**Files:** + +- Create: `src/renderer/inbox/components/settings/AutostartSection.tsx` +- Create: `src/renderer/inbox/components/settings/AutostartSection.test.tsx` +- Modify: `src/renderer/inbox/components/SettingsPage.tsx` +- Modify: `src/renderer/inbox/api.ts` (autostart get/set wrapper) +- Modify: `src/preload/index.ts` (채널 expose) + +- [ ] **Step 1: failing test 작성** + +```tsx +// src/renderer/inbox/components/settings/AutostartSection.test.tsx +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { AutostartSection } from './AutostartSection'; + +vi.mock('../../api', () => ({ + inboxApi: { + getAutostart: vi.fn(async () => ({ openAtLogin: true })), + setAutostart: vi.fn(async (open: boolean) => ({ openAtLogin: open })) + } +})); + +describe('AutostartSection', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('renders toggle reflecting current state', async () => { + render(); + const toggle = await screen.findByRole('checkbox'); + expect(toggle).toBeChecked(); + }); + + it('clicking toggle calls setAutostart', async () => { + const { inboxApi } = await import('../../api'); + render(); + const toggle = await screen.findByRole('checkbox'); + fireEvent.click(toggle); + await waitFor(() => expect(inboxApi.setAutostart).toHaveBeenCalledWith(false)); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail** + +```bash +npx vitest run src/renderer/inbox/components/settings/AutostartSection.test.tsx +``` + +- [ ] **Step 3: AutostartSection.tsx 작성** + +```tsx +// src/renderer/inbox/components/settings/AutostartSection.tsx +import React, { useEffect, useState } from 'react'; +import { inboxApi } from '../../api'; + +export function AutostartSection(): React.ReactElement { + const [openAtLogin, setOpenAtLogin] = useState(null); + + useEffect(() => { + void (async () => { + const s = await inboxApi.getAutostart(); + setOpenAtLogin(s.openAtLogin); + })(); + }, []); + + async function onToggle(e: React.ChangeEvent): Promise { + const next = e.target.checked; + const r = await inboxApi.setAutostart(next); + setOpenAtLogin(r.openAtLogin); + } + + if (openAtLogin === null) return
로딩 중...
; + + return ( +
+ +
+ ); +} +``` + +- [ ] **Step 4: api.ts 에 wrapper 추가** + +```ts +// src/renderer/inbox/api.ts 의 inboxApi 객체에 추가: + async getAutostart() { + return await window.inkling.invoke('settings:get-autostart'); + }, + async setAutostart(open: boolean) { + return await window.inkling.invoke('settings:set-autostart', open); + }, +``` + +- [ ] **Step 5: preload + main IPC handler 추가** + +`src/preload/index.ts` 의 invoke 화이트리스트에 `'settings:get-autostart'`, `'settings:set-autostart'` 추가. main 측 IPC 는 Task 22 에서 본격 구현 — 여기선 임시 stub: + +```ts +// src/main/ipc/settingsApi.ts (NEW or 기존 파일에 append) +import electron from 'electron'; +const { ipcMain, app } = electron; + +export function registerSettingsApi(): void { + ipcMain.handle('settings:get-autostart', () => { + const r = app.getLoginItemSettings({ args: ['--hidden'] }); + return { openAtLogin: r.openAtLogin }; + }); + ipcMain.handle('settings:set-autostart', (_e, open: boolean) => { + app.setLoginItemSettings({ openAtLogin: open, args: ['--hidden'] }); + const r = app.getLoginItemSettings({ args: ['--hidden'] }); + return { openAtLogin: r.openAtLogin }; + }); +} +``` + +`src/main/index.ts` whenReady 안에 `registerSettingsApi()` 호출 추가. + +- [ ] **Step 6: SettingsPage placeholder 교체 + 테스트 + 커밋** + +```bash +npx vitest run src/renderer/inbox/components/settings/AutostartSection.test.tsx +npm run typecheck +git add -A src/renderer/inbox/ src/main/ipc/settingsApi.ts src/main/index.ts src/preload/ +git commit -m "feat(v027): AutostartSection 토글 (진단 패널은 후속 task)" +``` + +--- + +### Task 10: BackupSection.tsx — 5 버튼 + +**Files:** + +- Create: `src/renderer/inbox/components/settings/BackupSection.tsx` +- Create: `src/renderer/inbox/components/settings/BackupSection.test.tsx` +- Modify: `src/renderer/inbox/components/SettingsPage.tsx` +- Modify: `src/renderer/inbox/api.ts` +- Modify: `src/main/ipc/settingsApi.ts` +- Modify: `src/preload/index.ts` + +**선행 참조:** `src/main/index.ts` 의 트레이 callback (`runBackup`, `runExport`, `runImport`, `runSync`, `runExportTelemetry`) 5개를 IPC 핸들러로 전환. 핸들러 본문 = 기존 callback 본문 그대로 이동. + +- [ ] **Step 1: failing test** + +```tsx +// src/renderer/inbox/components/settings/BackupSection.test.tsx +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { BackupSection } from './BackupSection'; + +vi.mock('../../api', () => ({ + inboxApi: { + runBackup: vi.fn(async () => ({ ok: true })), + runExport: vi.fn(async () => ({ ok: true })), + runImport: vi.fn(async () => ({ ok: true })), + runSync: vi.fn(async () => ({ ok: true })), + runExportTelemetry: vi.fn(async () => ({ ok: true })) + } +})); + +describe('BackupSection', () => { + it('renders 5 buttons', () => { + render(); + expect(screen.getByRole('button', { name: /지금 백업/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /내보내기/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /백업에서 복원/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /지금 동기화/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /사용 로그/ })).toBeInTheDocument(); + }); + + it('each button triggers corresponding api call', async () => { + const { inboxApi } = await import('../../api'); + render(); + fireEvent.click(screen.getByRole('button', { name: /지금 백업/ })); + expect(inboxApi.runBackup).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail** + +```bash +npx vitest run src/renderer/inbox/components/settings/BackupSection.test.tsx +``` + +- [ ] **Step 3: BackupSection.tsx 작성** + +```tsx +// src/renderer/inbox/components/settings/BackupSection.tsx +import React, { useState } from 'react'; +import { inboxApi } from '../../api'; + +export function BackupSection(): React.ReactElement { + const [status, setStatus] = useState(null); + + async function run(label: string, fn: () => Promise): Promise { + setStatus(`${label}: 진행 중...`); + try { + await fn(); + setStatus(`${label}: 완료`); + } catch (e) { + setStatus(`${label}: 실패 — ${(e as Error).message}`); + } + } + + return ( +
+ + + + + + {status &&
{status}
} +
+ ); +} +``` + +- [ ] **Step 4: IPC 핸들러 5개를 settingsApi.ts 에 추가** + +`src/main/ipc/settingsApi.ts` 의 `registerSettingsApi` 안에: + +```ts + ipcMain.handle('settings:run-backup', async () => { + // 기존 src/main/index.ts 의 runBackup callback 본문 이동 + return await BackupService.run(); + }); + ipcMain.handle('settings:run-export', async () => { + return await BackupService.export(); + }); + ipcMain.handle('settings:run-import', async () => { + return await BackupService.import(); + }); + ipcMain.handle('settings:run-sync', async () => { + return await BackupService.sync(); + }); + ipcMain.handle('settings:run-export-telemetry', async () => { + return await TelemetryService.export(); + }); +``` + +실제 BackupService / TelemetryService import 와 시그니처는 `src/main/index.ts` 의 기존 callback 본문 그대로 복사. 시그니처가 callback 안에 inline 으로 짜여있다면 그 inline 코드를 핸들러 본문으로 이동. `src/main/index.ts` 의 runBackup 등 ref 는 Task 17 에서 정리. + +- [ ] **Step 5: api.ts wrapper + preload 채널 + SettingsPage 교체 + 테스트** + +```ts +// src/renderer/inbox/api.ts inboxApi 객체에 추가: + async runBackup() { return await window.inkling.invoke('settings:run-backup'); }, + async runExport() { return await window.inkling.invoke('settings:run-export'); }, + async runImport() { return await window.inkling.invoke('settings:run-import'); }, + async runSync() { return await window.inkling.invoke('settings:run-sync'); }, + async runExportTelemetry() { return await window.inkling.invoke('settings:run-export-telemetry'); }, +``` + +`src/preload/index.ts` 화이트리스트에 5채널 추가. `SettingsPage.tsx` 의 placeholder 교체. + +```bash +npx vitest run src/renderer/inbox/components/settings/BackupSection.test.tsx +npm run typecheck +``` + +- [ ] **Step 6: 커밋** + +```bash +git add -A src/renderer/inbox/ src/main/ipc/settingsApi.ts src/preload/ +git commit -m "feat(v027): BackupSection — 5 버튼 + IPC 핸들러" +``` + +--- + +### Task 11: InfoSection.tsx — 버전 정보 + 데이터 위치 + +**Files:** + +- Create: `src/renderer/inbox/components/settings/InfoSection.tsx` +- Create: `src/renderer/inbox/components/settings/InfoSection.test.tsx` +- Modify: `src/renderer/inbox/components/SettingsPage.tsx` +- Modify: `src/renderer/inbox/api.ts` +- Modify: `src/main/ipc/settingsApi.ts` +- Modify: `src/preload/index.ts` + +**선행 참조:** `src/main/tray.ts` 의 `showAboutDialog` 함수의 `detail` 문자열 + clipboard.writeText / shell.openPath 로직을 IPC 핸들러로 추출. + +- [ ] **Step 1: failing test** + +```tsx +// src/renderer/inbox/components/settings/InfoSection.test.tsx +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { InfoSection } from './InfoSection'; + +vi.mock('../../api', () => ({ + inboxApi: { + getAppInfo: vi.fn(async () => ({ + version: '0.2.7', + electron: '41.3.0', + node: '22.x', + os: 'darwin 23.6.0', + profileDir: '/Users/u/Library/Application Support/Inkling' + })), + openProfileDir: vi.fn(async () => undefined), + copyAppInfo: vi.fn(async () => undefined) + } +})); + +describe('InfoSection', () => { + it('renders version, electron, node, OS, profileDir', async () => { + render(); + expect(await screen.findByText(/0\.2\.7/)).toBeInTheDocument(); + expect(screen.getByText(/41\.3\.0/)).toBeInTheDocument(); + expect(screen.getByText(/22\.x/)).toBeInTheDocument(); + expect(screen.getByText(/darwin/)).toBeInTheDocument(); + expect(screen.getByText(/Library\/Application Support\/Inkling/)).toBeInTheDocument(); + }); + + it('"데이터 위치 열기" calls openProfileDir', async () => { + const { inboxApi } = await import('../../api'); + render(); + await screen.findByText(/0\.2\.7/); + fireEvent.click(screen.getByRole('button', { name: /데이터 위치 열기/ })); + expect(inboxApi.openProfileDir).toHaveBeenCalled(); + }); + + it('"정보 복사" calls copyAppInfo', async () => { + const { inboxApi } = await import('../../api'); + render(); + await screen.findByText(/0\.2\.7/); + fireEvent.click(screen.getByRole('button', { name: /정보 복사/ })); + expect(inboxApi.copyAppInfo).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail** + +- [ ] **Step 3: InfoSection.tsx 작성** + +```tsx +// src/renderer/inbox/components/settings/InfoSection.tsx +import React, { useEffect, useState } from 'react'; +import { inboxApi } from '../../api'; + +interface AppInfo { + version: string; + electron: string; + node: string; + os: string; + profileDir: string; +} + +export function InfoSection(): React.ReactElement { + const [info, setInfo] = useState(null); + + useEffect(() => { + void (async () => { + setInfo(await inboxApi.getAppInfo()); + })(); + }, []); + + if (!info) return
로딩 중...
; + + return ( +
+
+
버전
{info.version}
+
Electron
{info.electron}
+
Node
{info.node}
+
OS
{info.os}
+
데이터 위치
{info.profileDir}
+
+
+ + +
+
+ ); +} +``` + +- [ ] **Step 4: IPC 핸들러 추가** + +`src/main/ipc/settingsApi.ts` 의 `registerSettingsApi` 안에: + +```ts +import { platform, release, EOL } from 'node:os'; +const { shell, clipboard } = electron; + + ipcMain.handle('settings:get-app-info', () => { + return { + version: app.getVersion(), + electron: process.versions.electron ?? '?', + node: process.versions.node ?? '?', + os: `${platform()} ${release()}`, + profileDir: app.getPath('userData') + }; + }); + ipcMain.handle('settings:open-profile-dir', async () => { + await shell.openPath(app.getPath('userData')); + }); + ipcMain.handle('settings:copy-app-info', () => { + const v = app.getVersion(); + const detail = [ + `버전: ${v}`, + `Electron: ${process.versions.electron}`, + `Node: ${process.versions.node}`, + `OS: ${platform()} ${release()}`, + `데이터 위치: ${app.getPath('userData')}` + ].join(EOL); + clipboard.writeText(`Inkling ${v}${EOL}${detail}`); + }); +``` + +- [ ] **Step 5: api wrapper + preload + SettingsPage 교체 + 테스트** + +```ts +// src/renderer/inbox/api.ts: + async getAppInfo() { return await window.inkling.invoke('settings:get-app-info'); }, + async openProfileDir() { return await window.inkling.invoke('settings:open-profile-dir'); }, + async copyAppInfo() { return await window.inkling.invoke('settings:copy-app-info'); }, +``` + +preload 화이트리스트 + SettingsPage placeholder 교체. + +```bash +npx vitest run src/renderer/inbox/components/settings/InfoSection.test.tsx +npm run typecheck +``` + +- [ ] **Step 6: 커밋** + +```bash +git add -A src/renderer/inbox/ src/main/ipc/settingsApi.ts src/preload/ +git commit -m "feat(v027): InfoSection — 버전/데이터 위치/복사 + IPC" +``` + +--- + +### Task 12: App.tsx — showSettings 분기 + 헤더 톱니바퀴 + +**Files:** + +- Modify: `src/renderer/inbox/App.tsx` +- Modify: `src/renderer/inbox/App.test.tsx` (없으면 신규) + +- [ ] **Step 1: failing test (showSettings=true 시 SettingsPage 렌더 + 톱니바퀴 클릭 시 setShowSettings(true))** + +```tsx +// src/renderer/inbox/App.test.tsx (해당 부분 추가) +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { App } from './App'; +import { useInbox } from './store'; + +describe('App — settings view', () => { + beforeEach(() => { + useInbox.setState({ showSettings: false, notes: [], trashNotes: [], trashCount: 0 }); + }); + + it('renders SettingsPage when showSettings=true', () => { + useInbox.setState({ showSettings: true }); + render(); + expect(screen.getByText('설정')).toBeInTheDocument(); + expect(screen.getByText('AI 제공자')).toBeInTheDocument(); + }); + + it('header gear icon click sets showSettings=true', () => { + render(); + fireEvent.click(screen.getByLabelText('설정 열기')); + expect(useInbox.getState().showSettings).toBe(true); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail** + +```bash +npx vitest run src/renderer/inbox/App.test.tsx +``` + +- [ ] **Step 3: App.tsx 수정** + +`src/renderer/inbox/App.tsx` 의 return 직전: + +```tsx + const showSettings = useInbox((s) => s.showSettings); + const setShowSettings = useInbox((s) => s.setShowSettings); + if (showSettings) return ; +``` + +`SettingsPage` import 추가. 기존 OllamaSettingsModal 관련 state (`ollamaSettingsOpen`) 는 Task 25 에서 제거. + +헤더 우측 (ContinuityBadge / IdentityCounter 옆) 에 톱니바퀴 추가: + +```tsx + +``` + +- [ ] **Step 4: 테스트 + typecheck** + +```bash +npx vitest run src/renderer/inbox/App.test.tsx +npm run typecheck +``` + +- [ ] **Step 5: 수동 launch 검증** + +```bash +npm run rebuild:electron && npm run start +``` + +inbox 윈도우 → 헤더 톱니바퀴 클릭 → SettingsPage 등장 → "← 돌아가기" → inbox 복귀. + +- [ ] **Step 6: 커밋** + +```bash +git add src/renderer/inbox/App.tsx src/renderer/inbox/App.test.tsx +git commit -m "feat(v027): App.tsx 헤더 톱니바퀴 + showSettings 분기" +``` + +--- + +### Task 13: IPC `inbox:navigate` — 트레이/외부에서 설정 진입 + +**Files:** + +- Modify: `src/main/ipc/settingsApi.ts` (또는 별도 navigationApi) +- Modify: `src/preload/index.ts` +- Modify: `src/renderer/inbox/api.ts` +- Modify: `src/renderer/inbox/App.tsx` (navigate 이벤트 구독) + +- [ ] **Step 1: failing test (renderer 측)** + +```tsx +// src/renderer/inbox/App.test.tsx 에 추가: +it('inbox:navigate "settings" event sets showSettings=true', async () => { + // mock: api.onNavigate registers a listener that App calls + const navHandlers: Array<(view: string) => void> = []; + vi.mocked(inboxApi.onNavigate).mockImplementation((cb) => { + navHandlers.push(cb); + return () => { navHandlers.splice(navHandlers.indexOf(cb), 1); }; + }); + render(); + navHandlers.forEach((h) => h('settings')); + await waitFor(() => expect(useInbox.getState().showSettings).toBe(true)); +}); +``` + +(상위 `vi.mock` 블록에 `onNavigate: vi.fn(() => () => undefined)` 추가) + +- [ ] **Step 2: 테스트 실행 → fail (onNavigate 미존재)** + +- [ ] **Step 3: api.ts + preload 에 onNavigate 추가** + +```ts +// src/renderer/inbox/api.ts: + onNavigate(cb: (view: 'inbox' | 'trash' | 'settings') => void) { + return window.inkling.on('inbox:navigate', cb); + } +``` + +`src/preload/index.ts` 의 on whitelist 에 `'inbox:navigate'` 추가. + +- [ ] **Step 4: main 측 sender 함수** + +`src/main/ipc/settingsApi.ts` 의 export 에 추가: + +```ts +import { getInboxWindow } from '../windows/inboxWindow'; + +export function navigateInbox(view: 'inbox' | 'trash' | 'settings'): void { + const win = getInboxWindow(); + if (win && !win.isDestroyed()) { + if (!win.isVisible()) win.show(); + win.focus(); + win.webContents.send('inbox:navigate', view); + } +} +``` + +(트레이 "설정..." 클릭 시 호출 — Task 16 에서 wiring.) + +- [ ] **Step 5: App.tsx 에 navigate 이벤트 구독 추가** + +`useEffect` 안에: + +```tsx +const unsubNav = inboxApi.onNavigate((view) => { + if (view === 'settings') useInbox.getState().setShowSettings(true); + else if (view === 'inbox') useInbox.getState().setShowSettings(false); +}); +// cleanup return 에 unsubNav() 추가 +``` + +- [ ] **Step 6: 테스트 + typecheck + 커밋** + +```bash +npx vitest run src/renderer/inbox/App.test.tsx +npm run typecheck +git add -A src/renderer/inbox/ src/main/ipc/ src/preload/ +git commit -m "feat(v027): IPC inbox:navigate — 외부에서 설정 페이지 진입" +``` + +Phase 2 종료. 이 시점에 inbox 안에서 톱니바퀴 진입 + 4 섹션 (자동 실행은 토글만, 진단은 후속) 모두 동작. + +--- + +## Phase 3: 트레이 슬림 + +### Task 14: TrayCallbacks 인터페이스 슬림 (showSettings 추가, 8개 제거) + +**Files:** + +- Modify: `src/main/tray.ts` +- Modify: `src/main/tray.test.ts` (없으면 신규) + +- [ ] **Step 1: failing test (4 항목 검증 + 제거된 항목 부재 검증)** + +```ts +// src/main/tray.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +// electron mocked at top-level vitest setup OR per-test module mock +vi.mock('electron', () => ({ + default: { + app: { on: vi.fn(), getPath: vi.fn(), getVersion: vi.fn(() => '0.2.7'), isPackaged: false, getLoginItemSettings: vi.fn(() => ({ openAtLogin: false })) }, + Tray: vi.fn().mockImplementation(() => ({ + setToolTip: vi.fn(), + setContextMenu: vi.fn(), + on: vi.fn() + })), + Menu: { buildFromTemplate: vi.fn((items) => ({ items })) }, + nativeImage: { createEmpty: vi.fn() }, + dialog: {}, + shell: {}, + clipboard: {} + } +})); + +import { createTray, type TrayCallbacks } from './tray'; + +describe('tray menu — slim 4 items', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + function makeCallbacks(): TrayCallbacks { + return { + showInbox: vi.fn(), + showCapture: vi.fn(), + showSettings: vi.fn() + }; + } + + it('builds menu with 4 click items + 2 separators', async () => { + createTray(makeCallbacks()); + const electron = (await import('electron')).default; + const calls = (electron.Menu.buildFromTemplate as any).mock.calls; + const items = calls[calls.length - 1][0]; + const labels = items.filter((i: any) => i.type !== 'separator').map((i: any) => i.label); + expect(labels).toEqual(['한 줄 적기', '보관한 메모 보기', '설정...', '종료']); + }); + + it('does not include removed items (백업/내보내기/Ollama 재확인 등)', async () => { + createTray(makeCallbacks()); + const electron = (await import('electron')).default; + const calls = (electron.Menu.buildFromTemplate as any).mock.calls; + const items = calls[calls.length - 1][0]; + const labels = items.filter((i: any) => i.type !== 'separator').map((i: any) => i.label); + expect(labels).not.toContain('지금 백업'); + expect(labels).not.toContain('내보내기...'); + expect(labels).not.toContain('Ollama 재확인'); + expect(labels).not.toContain('지금 AI 처리'); + expect(labels).not.toContain('Ollama 설정...'); + }); + + it('"설정..." click invokes showSettings callback', async () => { + const cb = makeCallbacks(); + createTray(cb); + const electron = (await import('electron')).default; + const calls = (electron.Menu.buildFromTemplate as any).mock.calls; + const items = calls[calls.length - 1][0]; + const settingsItem = items.find((i: any) => i.label === '설정...'); + settingsItem.click(); + expect(cb.showSettings).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail** + +```bash +npm run rebuild:node && npx vitest run src/main/tray.test.ts +``` + +Expected: TypeScript 또는 runtime fail (TrayCallbacks 가 아직 옛 인터페이스). + +- [ ] **Step 3: tray.ts 의 인터페이스 + state 슬림화** + +```ts +// src/main/tray.ts (인터페이스 부분만 교체) +export interface TrayCallbacks { + showInbox: () => void; + showCapture: () => void; + showSettings: () => void; +} + +export interface TrayState { + todayCount: number; +} + +let _state: TrayState = { todayCount: 0 }; +``` + +기존 `ollamaOk`, `failedCount` 필드 + `runBackup`/`runExport`/`runImport`/`runSync`/`runExportTelemetry`/`runOllamaRecheck`/`runRetryAllFailed`/`runOpenOllamaSettings` callback 제거. + +- [ ] **Step 4: buildMenu 4 항목 본문** + +```ts +function buildMenu(): electron.Menu { + const items: MenuItemConstructorOptions[] = []; + const cb = _callbacks; + if (!cb) return Menu.buildFromTemplate([{ label: '로딩 중...', enabled: false }]); + if (_state.todayCount > 0) { + items.push({ label: `오늘 ${_state.todayCount}번 잡아둠`, enabled: false }); + items.push({ type: 'separator' }); + } + items.push({ label: '한 줄 적기', click: cb.showCapture }); + items.push({ label: '보관한 메모 보기', click: cb.showInbox }); + items.push({ type: 'separator' }); + items.push({ label: '설정...', click: cb.showSettings }); + items.push({ type: 'separator' }); + items.push({ label: '종료', click: () => { app.isQuitting = true; app.quit(); } }); + return Menu.buildFromTemplate(items); +} +``` + +기존 `if (app.isPackaged)` 의 자동 실행 checkbox 분기 + Inkling 정보 항목 + Ollama 재확인/AI 재처리/Ollama 설정 항목 모두 제거. `showAboutDialog` 함수도 제거 (Task 25 cleanup). + +- [ ] **Step 5: refreshTray signature 유지 (Partial) — todayCount 만 영향** + +```ts +export function refreshTray(state: Partial): void { + _state = { ..._state, ...state }; + if (tray === null) return; + tray.setToolTip(`Inkling — 오늘 ${_state.todayCount}`); + tray.setContextMenu(buildMenu()); +} +``` + +- [ ] **Step 6: 테스트 + typecheck + 커밋** + +```bash +npx vitest run src/main/tray.test.ts +npm run typecheck +``` + +이 시점 typecheck 는 `src/main/index.ts` 의 createTray 호출부와 HealthChecker/AiWorker 의 refreshTray 호출부에서 type error 발생 — Task 17 에서 정리. Task 14 만 단독 commit: + +```bash +git add src/main/tray.ts src/main/tray.test.ts +git commit -m "feat(v027): TrayCallbacks/TrayState 슬림 + buildMenu 4 항목" +``` + +--- + +### Task 15: tray.ts — showAboutDialog / 자동실행 checkbox / Ollama 분기 코드 제거 + +**Files:** + +- Modify: `src/main/tray.ts` + +이 task 는 Task 14 와 묶여 있지만, 분리 commit 으로 변경 의도 명확화. + +- [ ] **Step 1: showAboutDialog 함수 + 관련 import 제거** + +`src/main/tray.ts` 상단 import 에서 `dialog`, `shell`, `clipboard`, `platform`, `release`, `EOL` 제거 (이제 `settings:get-app-info` IPC 가 정보 dialog 역할). + +`showAboutDialog` 함수 전체 삭제. + +- [ ] **Step 2: typecheck** + +```bash +npm run typecheck +``` + +이 시점에서 tray.ts 자체는 통과 — index.ts 호출부 에러는 Task 17 에서. + +- [ ] **Step 3: 커밋** + +```bash +git add src/main/tray.ts +git commit -m "feat(v027): tray.ts 의 showAboutDialog + 자동실행 분기 + 미사용 import 제거" +``` + +--- + +### Task 16: index.ts — createTray callback wiring 갱신 + +**Files:** + +- Modify: `src/main/index.ts` + +- [ ] **Step 1: 기존 트레이 callback wiring 위치 확인** + +```bash +grep -n 'createTray' src/main/index.ts +``` + +기존 호출부가 10-positional 이었던 시점부터 v0.2.6 의 객체 형태 (`createTray({ showInbox, showCapture, runBackup, ... })`) 까지 발전. 현재는 객체 형태. + +- [ ] **Step 2: createTray 호출을 3-callback 객체로 슬림화** + +기존: + +```ts +createTray({ + showInbox, + showCapture, + runBackup, + runExport, + runImport, + runSync, + runExportTelemetry, + runOllamaRecheck, + runRetryAllFailed, + runOpenOllamaSettings +}); +``` + +변경 후: + +```ts +import { navigateInbox } from './ipc/settingsApi'; + +createTray({ + showInbox, + showCapture, + showSettings: () => navigateInbox('settings') +}); +``` + +- [ ] **Step 3: 미사용 callback 함수 제거** + +`runBackup`, `runExport`, `runImport`, `runSync`, `runExportTelemetry`, `runOllamaRecheck`, `runRetryAllFailed`, `runOpenOllamaSettings` 함수 정의 (또는 inline 람다) 가 `index.ts` 에 남아있다면 제거. 본문 로직은 이미 Task 10 (BackupSection) 의 IPC 핸들러로 이동됨. + +- [ ] **Step 4: refreshTray 호출부 슬림화** + +`grep -n 'refreshTray' src/main/index.ts src/main/health/*.ts src/main/ai/*.ts` 로 호출부 모두 찾기. `refreshTray({ ollamaOk })` / `refreshTray({ failedCount })` 호출은 모두 제거 (해당 메뉴 항목이 사라져 무의미). `refreshTray({ todayCount })` 만 잔류. + +- [ ] **Step 5: typecheck + 단위 테스트 회귀** + +```bash +npm run typecheck +npx vitest run +``` + +Expected: 0 errors + 모든 단위 테스트 pass. tray.test.ts 에서 회귀 없는지 + HealthChecker/AiWorker 테스트가 refreshTray 모킹에 의존했다면 mock 갱신 필요. + +- [ ] **Step 6: 커밋** + +```bash +git add src/main/index.ts src/main/health/ src/main/ai/ +git commit -m "feat(v027): createTray wiring 3-callback + refreshTray 호출부 슬림" +``` + +--- + +### Task 17: 트레이 회귀 단위 테스트 + 수동 검증 + +**Files:** + +- 검증 only. + +- [ ] **Step 1: 전체 단위 테스트 + e2e 실행** + +```bash +npm run rebuild:node +npm test +npm run rebuild:electron +npm run test:e2e +``` + +Expected: 모두 pass. e2e 가 트레이 메뉴를 검증하지 않으면 회귀 risk 낮음. + +- [ ] **Step 2: 수동 launch 검증 (Win + macOS)** + +```bash +npm run start +``` + +트레이 우클릭 → 4 항목 (한 줄 적기 / 보관한 메모 보기 / 설정... / 종료) 만 표시. "설정..." 클릭 → inbox 윈도우 등장 + SettingsPage 표시. + +- [ ] **Step 3: 결과 spec §11 또는 별도 줄에 기록** + +문제 없으면 commit 없이 마무리. 회귀 발견 시 fix 후 commit. + +Phase 3 종료. + +--- + +## Phase 4: F14 macOS dock 클릭 fix + +### Task 18: index.ts activate 핸들러 5줄 수정 + +**Files:** + +- Modify: `src/main/index.ts:411-413` + +mocking 비용 높아 단위 테스트 X — manual macOS dogfood 검증. + +- [ ] **Step 1: 현재 activate 핸들러 위치 확인** + +```bash +grep -n "app.on('activate'" src/main/index.ts +``` + +Expected: 라인 ~411. 직접 라인 번호는 Task 16 변경 후 달라질 수 있음. + +- [ ] **Step 2: 핸들러 본문 교체** + +기존: + +```ts +app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createInboxWindow(); +}); +``` + +변경 후: + +```ts +app.on('activate', () => { + const win = getInboxWindow(); + if (win && !win.isDestroyed()) { + if (!win.isVisible()) win.show(); + win.focus(); + } else { + createInboxWindow(); + } +}); +``` + +`getInboxWindow` import 가 이미 있는지 확인 — 없으면 추가: + +```ts +import { createInboxWindow, getInboxWindow } from './windows/inboxWindow'; +``` + +- [ ] **Step 3: typecheck** + +```bash +npm run typecheck +``` + +- [ ] **Step 4: macOS 수동 검증** + +macOS 호스트에서: + +```bash +npm run start +``` + +inbox 윈도우 → 빨간 신호등 (close) → dock 의 Inkling 아이콘 클릭 → inbox 윈도우 즉시 등장 + focus. + +- [ ] **Step 5: 검증 결과 dogfood-feedback.md F14 entry 갱신 (🚀 promoted 마킹) — 일괄 처리는 Task 27 에서** + +지금은 commit 만: + +- [ ] **Step 6: 커밋** + +```bash +git add src/main/index.ts +git commit -m "fix(v027): F14 — macOS dock 클릭 시 hidden inbox 창 show/focus" +``` + +Phase 4 종료. + +--- + +## Phase 5: F12 deeper fix — 자동 실행 진단 노출 + +### Task 19: AutostartDiagnostic 서비스 — withArgs/noArgs/execPath 수집 + +**Files:** + +- Create: `src/main/services/AutostartDiagnostic.ts` +- Create: `src/main/services/AutostartDiagnostic.test.ts` + +- [ ] **Step 1: failing test** + +```ts +// src/main/services/AutostartDiagnostic.test.ts +import { describe, it, expect, vi } from 'vitest'; + +const mockApp = { + getLoginItemSettings: vi.fn() +}; +vi.mock('electron', () => ({ default: { app: mockApp } })); + +const mockExecFile = vi.fn(); +vi.mock('node:child_process', () => ({ execFile: mockExecFile })); + +import { collectAutostartState } from './AutostartDiagnostic'; + +describe('AutostartDiagnostic — collectAutostartState', () => { + it('returns withArgs / noArgs / execPath structure', async () => { + mockApp.getLoginItemSettings + .mockReturnValueOnce({ openAtLogin: true, executableWillLaunchAtLogin: true }) + .mockReturnValueOnce({ openAtLogin: false, executableWillLaunchAtLogin: true }); + Object.defineProperty(process, 'platform', { value: 'darwin' }); + const state = await collectAutostartState(); + expect(state.withArgs).toEqual({ openAtLogin: true, executableWillLaunchAtLogin: true }); + expect(state.noArgs).toEqual({ openAtLogin: false, executableWillLaunchAtLogin: true }); + expect(state.execPath).toBe(process.execPath); + expect(state.registryPath).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail (모듈 미존재)** + +```bash +npx vitest run src/main/services/AutostartDiagnostic.test.ts +``` + +- [ ] **Step 3: AutostartDiagnostic.ts 작성 (Win registry 부분은 Task 20 에서 — 여기선 stub)** + +```ts +// src/main/services/AutostartDiagnostic.ts +import electron from 'electron'; +const { app } = electron; + +export interface AutostartState { + withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean }; + noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean }; + execPath: string; + registryPath?: string; + registryValue?: string | null; +} + +export async function collectAutostartState(): Promise { + const w = app.getLoginItemSettings({ args: ['--hidden'] }); + const n = app.getLoginItemSettings(); + const state: AutostartState = { + withArgs: { openAtLogin: w.openAtLogin, executableWillLaunchAtLogin: w.executableWillLaunchAtLogin }, + noArgs: { openAtLogin: n.openAtLogin, executableWillLaunchAtLogin: n.executableWillLaunchAtLogin }, + execPath: process.execPath + }; + // Win registry 조회는 Task 20 에서 추가 + return state; +} +``` + +- [ ] **Step 4: 테스트 통과 + typecheck + 커밋** + +```bash +npx vitest run src/main/services/AutostartDiagnostic.test.ts +npm run typecheck +git add src/main/services/AutostartDiagnostic.ts src/main/services/AutostartDiagnostic.test.ts +git commit -m "feat(v027): AutostartDiagnostic — withArgs/noArgs/execPath 수집" +``` + +--- + +### Task 20: AutostartDiagnostic — Windows registry 조회 + +**Files:** + +- Modify: `src/main/services/AutostartDiagnostic.ts` +- Modify: `src/main/services/AutostartDiagnostic.test.ts` + +- [ ] **Step 1: failing test (Win 분기 — registry 조회 + null fallback)** + +테스트 파일에 추가: + +```ts +import { execFile } from 'node:child_process'; + +it('Windows: returns registryPath + registryValue when reg.exe succeeds', async () => { + mockApp.getLoginItemSettings.mockReturnValue({ openAtLogin: true, executableWillLaunchAtLogin: true }); + Object.defineProperty(process, 'platform', { value: 'win32' }); + mockExecFile.mockImplementation((_cmd, _args, cb) => { + cb(null, '\r\n Inkling REG_SZ "C:\\\\Users\\\\u\\\\Inkling.exe" --hidden\r\n', ''); + }); + const state = await collectAutostartState(); + expect(state.registryPath).toBe('HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Inkling'); + expect(state.registryValue).toContain('Inkling.exe'); +}); + +it('Windows: returns null registryValue on reg.exe error (silent fallback)', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + mockExecFile.mockImplementation((_cmd, _args, cb) => { + cb(new Error('not found'), '', ''); + }); + const state = await collectAutostartState(); + expect(state.registryValue).toBeNull(); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail** + +- [ ] **Step 3: collectAutostartState 에 Win 분기 추가** + +```ts +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +const execFileAsync = promisify(execFile); + +export async function collectAutostartState(): Promise { + const w = app.getLoginItemSettings({ args: ['--hidden'] }); + const n = app.getLoginItemSettings(); + const state: AutostartState = { + withArgs: { openAtLogin: w.openAtLogin, executableWillLaunchAtLogin: w.executableWillLaunchAtLogin }, + noArgs: { openAtLogin: n.openAtLogin, executableWillLaunchAtLogin: n.executableWillLaunchAtLogin }, + execPath: process.execPath + }; + if (process.platform === 'win32') { + state.registryPath = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Inkling'; + state.registryValue = await readRegistrySilent(state.registryPath); + } + return state; +} + +async function readRegistrySilent(path: string): Promise { + try { + const { stdout } = await execFileAsync('reg', ['query', path, '/v', 'Inkling']); + const m = stdout.match(/REG_SZ\s+(.+)/); + return m ? m[1].trim() : null; + } catch { + return null; + } +} +``` + +- [ ] **Step 4: 테스트 + typecheck + 커밋** + +```bash +npx vitest run src/main/services/AutostartDiagnostic.test.ts +npm run typecheck +git add src/main/services/AutostartDiagnostic.ts src/main/services/AutostartDiagnostic.test.ts +git commit -m "feat(v027): AutostartDiagnostic — Windows registry 조회 + silent fallback" +``` + +--- + +### Task 21: IPC `settings:autostart-state` 핸들러 + +**Files:** + +- Modify: `src/main/ipc/settingsApi.ts` +- Create: `src/main/ipc/settingsApi.test.ts` + +- [ ] **Step 1: failing test (핸들러가 collectAutostartState 결과 그대로 반환)** + +```ts +// src/main/ipc/settingsApi.test.ts +import { describe, it, expect, vi } from 'vitest'; +const handlers: Record = {}; +vi.mock('electron', () => ({ + default: { + ipcMain: { handle: (ch: string, fn: Function) => { handlers[ch] = fn; } }, + app: { /* stubs */ }, + shell: {}, clipboard: {} + } +})); +vi.mock('../services/AutostartDiagnostic', () => ({ + collectAutostartState: vi.fn(async () => ({ + withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true }, + noArgs: { openAtLogin: false, executableWillLaunchAtLogin: true }, + execPath: '/path/to/exe' + })) +})); + +import { registerSettingsApi } from './settingsApi'; + +describe('settings:autostart-state IPC', () => { + it('returns AutostartState from collectAutostartState', async () => { + registerSettingsApi(); + const state = await handlers['settings:autostart-state'](); + expect(state.withArgs.openAtLogin).toBe(true); + expect(state.noArgs.openAtLogin).toBe(false); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail** + +- [ ] **Step 3: settingsApi.ts 에 핸들러 추가** + +`registerSettingsApi` 안에: + +```ts +import { collectAutostartState } from '../services/AutostartDiagnostic'; + + ipcMain.handle('settings:autostart-state', () => collectAutostartState()); +``` + +- [ ] **Step 4: 테스트 + typecheck + 커밋** + +```bash +npx vitest run src/main/ipc/settingsApi.test.ts +npm run typecheck +git add src/main/ipc/settingsApi.ts src/main/ipc/settingsApi.test.ts +git commit -m "feat(v027): settings:autostart-state IPC 핸들러" +``` + +--- + +### Task 22: settings:autostart-set 핸들러 정식 구현 + +**Files:** + +- Modify: `src/main/ipc/settingsApi.ts` +- Modify: `src/main/ipc/settingsApi.test.ts` + +Task 9 에서 임시 stub 으로 등록한 `settings:set-autostart` 를 `settings:autostart-set` 으로 rename + 결과로 AutostartState 전체 반환하도록 갱신. + +- [ ] **Step 1: failing test** + +```ts +it('settings:autostart-set: calls setLoginItemSettings + returns updated state', async () => { + const mockSet = vi.fn(); + // electron mock 의 app 에 setLoginItemSettings 추가 — top-level mock 갱신 필요 + registerSettingsApi(); + await handlers['settings:autostart-set'](null, true); + expect(mockSet).toHaveBeenCalledWith({ openAtLogin: true, args: ['--hidden'] }); +}); +``` + +(top-level vi.mock 의 app 에 `setLoginItemSettings: mockSet` 추가) + +- [ ] **Step 2: 테스트 → fail** + +- [ ] **Step 3: 핸들러 갱신** + +```ts + ipcMain.handle('settings:autostart-set', async (_e, open: boolean) => { + app.setLoginItemSettings({ openAtLogin: open, args: ['--hidden'] }); + return await collectAutostartState(); + }); +``` + +기존 Task 9 의 `settings:set-autostart` (또는 `settings:get-autostart`) 임시 stub 은 채널 이름 통일 — `settings:get-autostart` → `settings:autostart-state`, `settings:set-autostart` → `settings:autostart-set`. AutostartSection.tsx 의 호출부도 동시 갱신: + +```ts +// src/renderer/inbox/api.ts + async getAutostart() { return await window.inkling.invoke('settings:autostart-state'); }, + async setAutostart(open: boolean) { return await window.inkling.invoke('settings:autostart-set', open); }, +``` + +preload 화이트리스트 갱신. + +- [ ] **Step 4: AutostartSection.test 회귀 확인** + +```bash +npx vitest run src/renderer/inbox/components/settings/ +npm run typecheck +``` + +- [ ] **Step 5: 커밋** + +```bash +git add -A src/main/ipc/ src/renderer/inbox/ src/preload/ +git commit -m "feat(v027): settings:autostart-set 정식 구현 + 채널 이름 통일" +``` + +--- + +### Task 23: AutostartSection — 진단 패널 UI + +**Files:** + +- Modify: `src/renderer/inbox/components/settings/AutostartSection.tsx` +- Modify: `src/renderer/inbox/components/settings/AutostartSection.test.tsx` + +- [ ] **Step 1: failing test (진단 패널 펼치기 + mismatch ⚠️ 표시)** + +```tsx +it('renders diagnostic panel when expanded, shows ⚠️ on mismatch', async () => { + const { inboxApi } = await import('../../api'); + vi.mocked(inboxApi.getAutostart).mockResolvedValue({ + openAtLogin: true, + diagnostic: { + withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true }, + noArgs: { openAtLogin: false, executableWillLaunchAtLogin: true }, // mismatch + execPath: '/path/to/Inkling.exe' + } + }); + render(); + await screen.findByRole('checkbox'); + fireEvent.click(screen.getByRole('button', { name: /진단 정보/ })); + expect(await screen.findByText(/⚠️/)).toBeInTheDocument(); + expect(screen.getByText('/path/to/Inkling.exe')).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: 테스트 → fail** + +- [ ] **Step 3: AutostartSection 확장** + +```tsx +import React, { useEffect, useState } from 'react'; +import { inboxApi } from '../../api'; + +interface AutostartFull { + openAtLogin: boolean; + diagnostic: { + withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean }; + noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean }; + execPath: string; + registryPath?: string; + registryValue?: string | null; + }; +} + +export function AutostartSection(): React.ReactElement { + const [data, setData] = useState(null); + const [expanded, setExpanded] = useState(false); + + useEffect(() => { + void (async () => setData(await inboxApi.getAutostart()))(); + }, []); + + async function onToggle(e: React.ChangeEvent): Promise { + const r = await inboxApi.setAutostart(e.target.checked); + setData(r); + } + + if (!data) return
로딩 중...
; + const d = data.diagnostic; + const mismatch = d.withArgs.openAtLogin !== d.noArgs.openAtLogin + || !d.withArgs.executableWillLaunchAtLogin; + + return ( +
+ + {mismatch &&
⚠️ 등록 상태 불일치 감지
} + + {expanded && ( +
+
표준 (--hidden 인자): openAtLogin={String(d.withArgs.openAtLogin)}, willLaunch={String(d.withArgs.executableWillLaunchAtLogin)}
+
비교 (인자 없이): openAtLogin={String(d.noArgs.openAtLogin)}, willLaunch={String(d.noArgs.executableWillLaunchAtLogin)}
+
실행 파일 경로: {d.execPath}
+ {d.registryPath &&
registry 경로: {d.registryPath}
} + {d.registryValue !== undefined &&
registry 값: {d.registryValue ?? '(없음)'}
} +
+ )} +
+ ); +} +``` + +(getAutostart 의 반환 타입이 `AutostartFull` 로 확장됨 — main IPC 핸들러도 collectAutostartState 결과를 `diagnostic` 으로 wrap 하여 반환하도록 갱신.) + +main 측 `settings:autostart-state` 핸들러 갱신: + +```ts +ipcMain.handle('settings:autostart-state', async () => { + const diag = await collectAutostartState(); + return { openAtLogin: diag.withArgs.openAtLogin, diagnostic: diag }; +}); +``` + +- [ ] **Step 4: 테스트 + typecheck + 커밋** + +```bash +npx vitest run src/renderer/inbox/components/settings/ +npm run typecheck +git add -A src/renderer/inbox/ src/main/ipc/ +git commit -m "feat(v027): AutostartSection 진단 패널 + mismatch 경고" +``` + +--- + +### Task 24: AutostartSection — "재등록" 버튼 + +**Files:** + +- Modify: `src/renderer/inbox/components/settings/AutostartSection.tsx` +- Modify: `src/renderer/inbox/components/settings/AutostartSection.test.tsx` + +- [ ] **Step 1: failing test** + +```tsx +it('"재등록" button calls setAutostart with current value', async () => { + const { inboxApi } = await import('../../api'); + vi.mocked(inboxApi.getAutostart).mockResolvedValue({ + openAtLogin: true, + diagnostic: { /* ... */ } + }); + render(); + await screen.findByRole('checkbox'); + fireEvent.click(screen.getByRole('button', { name: /재등록/ })); + expect(inboxApi.setAutostart).toHaveBeenCalledWith(true); +}); +``` + +- [ ] **Step 2: 테스트 → fail** + +- [ ] **Step 3: AutostartSection 에 버튼 추가** + +```tsx +async function onReregister(): Promise { + if (!data) return; + const r = await inboxApi.setAutostart(data.openAtLogin); + setData(r); +} + +// JSX 에 버튼 추가 (mismatch 경고 옆 또는 아래): + +``` + +- [ ] **Step 4: 테스트 + typecheck + 커밋** + +```bash +npx vitest run src/renderer/inbox/components/settings/ +npm run typecheck +git add src/renderer/inbox/components/settings/AutostartSection.tsx src/renderer/inbox/components/settings/AutostartSection.test.tsx +git commit -m "feat(v027): AutostartSection 재등록 버튼" +``` + +Phase 5 종료. + +--- + +## Phase 6: Cleanup + verification + +### Task 25: OllamaSettingsModal.tsx 제거 + 관련 import 정리 + +**Files:** + +- Delete: `src/renderer/inbox/components/OllamaSettingsModal.tsx` +- Modify: `src/renderer/inbox/App.tsx` (import + state 제거) +- Modify: `src/renderer/inbox/api.ts` (onOpenOllamaSettings 미사용 시 제거) + +- [ ] **Step 1: 사용처 확인** + +```bash +grep -rn 'OllamaSettingsModal\|onOpenOllamaSettings' src/ +``` + +Expected: `src/renderer/inbox/App.tsx` 에 import + `ollamaSettingsOpen` state. 다른 곳 0건이면 안전 제거. + +- [ ] **Step 2: App.tsx 정리** + +App.tsx 에서: + +- `import { OllamaSettingsModal } from './components/OllamaSettingsModal.js';` 제거 +- `const [ollamaSettingsOpen, setOllamaSettingsOpen] = useState(false);` 제거 +- `useEffect` 안 `inboxApi.onOpenOllamaSettings(...)` 구독 + cleanup 제거 +- JSX 에서 `` 렌더 제거 + +- [ ] **Step 3: api.ts 의 onOpenOllamaSettings 제거 (사용처 없음 확인 후)** + +```bash +grep -rn 'onOpenOllamaSettings' src/ +``` + +App.tsx 에서 제거됐으면 src 전체 0건. api.ts + preload 의 채널 expose 제거. + +main 측 `runOpenOllamaSettings` callback (Task 16 에서 이미 제거됐을 것) 도 잔재 없는지 재확인. + +- [ ] **Step 4: 파일 삭제** + +```bash +rm src/renderer/inbox/components/OllamaSettingsModal.tsx +``` + +- [ ] **Step 5: 전체 테스트 + typecheck** + +```bash +npm run rebuild:node +npm test +npm run typecheck +``` + +- [ ] **Step 6: 커밋** + +```bash +git add -A +git commit -m "refactor(v027): OllamaSettingsModal 제거 + onOpenOllamaSettings 채널 cleanup" +``` + +--- + +### Task 26: 전체 회귀 검증 + e2e + +**Files:** + +- 검증 only. + +- [ ] **Step 1: 단위 + e2e + typecheck 일괄** + +```bash +npm run rebuild:node +npm test +npm run typecheck +npm run rebuild:electron +npm run test:e2e +``` + +Expected: 모두 pass. 단위 426 → 약 450 (기준선 메모 `project_inkling_status.md`). + +- [ ] **Step 2: Win 수동 launch 검증** + +```bash +npm run start +``` + +체크리스트: + +- 트레이 4 항목만 표시 +- 트레이 "설정..." → SettingsPage 진입 → 4 섹션 (AI / 자동 실행 / 백업 / 정보) 모두 표시 +- AI 섹션 endpoint 변경 → 저장 → "지금 재확인" → 결과 표시 +- 자동 실행 토글 → 진단 패널 펼침 → withArgs/noArgs 표시 +- 백업 섹션 5 버튼 동작 +- 정보 섹션 버전/데이터 위치 + "정보 복사" → clipboard 검증 + +- [ ] **Step 3: macOS 수동 launch 검증** + +```bash +npm run start +``` + +체크리스트: + +- F14 fix 검증: 빨간 신호등 (close) → dock 클릭 → inbox 재등장 +- macOS 메뉴바 트레이 4 항목 (Win 동일) +- 설정 페이지 동일 동작 + +- [ ] **Step 4: Linux smoke 재검증 (Phase 1 후 변경 사항 영향 확인)** + +```bash +npm run dist:linux +``` + +산출 AppImage 를 Linux VM 에서 실행 → 트레이 (있는 DE) 또는 inbox 톱니바퀴 → 설정 페이지 동작 확인. + +- [ ] **Step 5: 결과 spec §11 갱신 + 커밋 (필요 시)** + +```bash +git add docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md +git commit -m "docs(v027): 전체 회귀 검증 + 3-OS smoke 결과 기록" +``` + +--- + +### Task 27: dogfood-feedback.md F12/F14/F15/F16 promoted 마킹 + version bump + release prep + +**Files:** + +- Modify: `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F12/F14/F16 entry 🚀 promoted 마킹) +- Modify: `docs/superpowers/v024-backlog.md` (#45 자동 실행 처리 갱신) +- Modify: `package.json` (version 0.2.6 → 0.2.7) + +- [ ] **Step 1: F12 entry 갱신** + +`F12. 윈도우 자동 실행 옵션이 재시작 후 풀려있는 버그` 의 진행 상태 헤더를 `🚀 promoted → docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md §9` 로 변경. v0.2.7 진단 노출 적용 사실 추가. + +- [ ] **Step 2: F14 entry 갱신** + +`F14. macOS dock 클릭 시 hidden 창 재현 안 됨` 진행 상태를 `🚀 promoted → docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md §8` 로 변경. + +- [ ] **Step 3: F16 entry 갱신** + +`F16. 트레이 의존도 ↓ + 별도 설정 페이지` 진행 상태를 `🚀 promoted → docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md §6, §7` 로 변경. + +- [ ] **Step 4: backlog #45 처리 갱신** + +`docs/superpowers/v024-backlog.md` 의 처리 이력 표 #45 row 의 상태를 `✅ 처리 (진단 노출 + 재등록 버튼)` 로 갱신, Cut 컬럼 = `v0.2.7`. 잔여 카운트 갱신 (24 → 23). + +- [ ] **Step 5: package.json version bump** + +```json + "version": "0.2.7", +``` + +- [ ] **Step 6: 커밋 + tag** + +```bash +git add docs/superpowers/specs/2026-04-25-dogfood-feedback.md docs/superpowers/v024-backlog.md package.json +git commit -m "chore(release): v0.2.7 — cross-platform 입구 정상화 (F12 deeper + F14 + F15 빌드 + F16)" +git tag v0.2.7 +``` + +Release 빌드 + Gitea release 는 별도 (PR 머지 후 본인 dogfood 가 `gitea-release` 스킬 호출). plan 종료 시점 = main 으로 PR open 또는 merge 직전 상태. + +--- + +## Self-Review + +**Spec coverage check:** + +| Spec 섹션 | 커버 task | +|---|---| +| §5 Linux 빌드 | Task 1~5 | +| §6 설정 페이지 라우팅 | Task 6, 7, 12, 13 | +| §6-3-1 AI 제공자 섹션 | Task 8 | +| §6-3-2 자동 실행 섹션 (토글) | Task 9 | +| §6-3-3 백업/복원/내보내기 | Task 10 | +| §6-3-4 정보 | Task 11 | +| §7 트레이 슬림 | Task 14, 15, 16, 17 | +| §8 F14 dock fix | Task 18 | +| §9 F12 deeper fix | Task 19, 20, 21, 22, 23, 24 | +| §10 테스트 | 각 task 안 단위 + Task 26 e2e | +| §11 Risk fallback | Task 1, 3, 26 step 5 | +| 정리 (OllamaSettingsModal 제거) | Task 25 | +| Version bump + dogfood entry promoted | Task 27 | + +모든 spec 요구가 task 에 매핑됨. 빠진 곳 없음. + +**Placeholder scan:** "TBD" / "TODO" / "implement later" 없음. 각 step 에 코드 또는 명령 직접 포함. + +**Type consistency:** + +- `TrayCallbacks` (Task 14): showInbox / showCapture / showSettings — Task 16 wiring + Task 17 검증에서 동일 사용. +- `TrayState` (Task 14): `{ todayCount: number }` — Task 16 의 refreshTray 호출에서 동일. +- `AutostartState` (Task 19): withArgs / noArgs / execPath / registryPath?/ registryValue? — Task 21 의 IPC, Task 23 의 UI 에서 일관 사용. +- `AutostartFull` (Task 23 renderer): { openAtLogin, diagnostic } — Task 22 main 핸들러 반환 형태와 일치. +- IPC 채널 이름 (Task 22 통일): `settings:autostart-state`, `settings:autostart-set`, `settings:run-backup` 등 — 모든 task 에서 동일. + +**잠재 모순 체크 (수동 검토):** + +- Task 9 의 임시 stub (`settings:get-autostart` / `settings:set-autostart`) 와 Task 22 의 정식 (`settings:autostart-state` / `settings:autostart-set`) 채널 이름 불일치 — Task 22 Step 3 에서 통일하도록 명시. 실행 시 누락하지 말 것. +- Task 8 의 IPC 채널 이름 (`getOllamaSettings`, `saveOllamaSettings`, `ollamaRecheck`) — 기존 `OllamaSettingsModal.tsx` 가 사용 중인 채널 확인 필요. 다른 이름이면 Task 8 step 5 에서 정정. + +이슈 없음. 실행 가능. + +--- + +## Execution Handoff + +Plan 작성 완료, `docs/superpowers/plans/2026-05-07-v027-cross-platform.md` 저장. + +두 가지 실행 옵션: + +**1. Subagent-Driven (recommended)** — fresh subagent per task, two-stage review (spec 준수 + code quality), 빠른 iteration + +**2. Inline Execution** — 본 세션에서 task 일괄 실행 + checkpoint 마다 review + +어느 쪽?