# 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<InboxState>` 안 초기 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(<SettingsPage />);
    expect(screen.getByRole('button', { name: /돌아가기/ })).toBeInTheDocument();
  });

  it('renders 4 section headings', () => {
    render(<SettingsPage />);
    expect(screen.getByText('AI 제공자')).toBeInTheDocument();
    expect(screen.getByText('자동 실행')).toBeInTheDocument();
    expect(screen.getByText('백업 / 복원')).toBeInTheDocument();
    expect(screen.getByText('정보')).toBeInTheDocument();
  });

  it('clicking "← 돌아가기" sets showSettings to false', () => {
    render(<SettingsPage />);
    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 (
    <div style={{ padding: 16, maxWidth: 720, margin: '0 auto' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
        <button
          onClick={() => setShowSettings(false)}
          style={{
            background: 'transparent',
            border: 'none',
            fontSize: 14,
            cursor: 'pointer',
            color: '#0a4b80'
          }}
        >
          ← 돌아가기
        </button>
        <h1 style={{ fontSize: 18, margin: 0 }}>설정</h1>
      </div>
      <section style={{ marginBottom: 24 }}>
        <h2 style={{ fontSize: 14, marginBottom: 8 }}>AI 제공자</h2>
        {/* AiProviderSection — Task 8 */}
      </section>
      <section style={{ marginBottom: 24 }}>
        <h2 style={{ fontSize: 14, marginBottom: 8 }}>자동 실행</h2>
        {/* AutostartSection — Task 9 + Task 23/24 */}
      </section>
      <section style={{ marginBottom: 24 }}>
        <h2 style={{ fontSize: 14, marginBottom: 8 }}>백업 / 복원</h2>
        {/* BackupSection — Task 10 */}
      </section>
      <section style={{ marginBottom: 24 }}>
        <h2 style={{ fontSize: 14, marginBottom: 8 }}>정보</h2>
        {/* InfoSection — Task 11 */}
      </section>
    </div>
  );
}
```

- [ ] **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(<AiProviderSection />);
    expect(await screen.findByDisplayValue('http://localhost:11434')).toBeInTheDocument();
    expect(screen.getByDisplayValue('gemma2:2b')).toBeInTheDocument();
  });

  it('rejects invalid endpoint URL', async () => {
    render(<AiProviderSection />);
    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(<AiProviderSection />);
    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<string | null>(null);
  const [saveResult, setSaveResult] = useState<string | null>(null);
  const [recheckResult, setRecheckResult] = useState<string | null>(null);

  useEffect(() => {
    void (async () => {
      const s = await inboxApi.getOllamaSettings();
      setEndpoint(s.endpoint);
      setModel(s.model);
    })();
  }, []);

  async function onSave(): Promise<void> {
    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<void> {
    setRecheckResult('확인 중...');
    const r = await inboxApi.ollamaRecheck();
    setRecheckResult(r.ok ? '✅ 연결됨' : `⚠️ ${r.reason ?? '연결 실패'}`);
  }

  return (
    <div>
      <label style={{ display: 'block', marginBottom: 8 }}>
        Endpoint
        <input
          type="text"
          value={endpoint}
          onChange={(e) => setEndpoint(e.target.value)}
          style={{ display: 'block', width: '100%', padding: 4, marginTop: 2 }}
        />
      </label>
      <label style={{ display: 'block', marginBottom: 8 }}>
        Model
        <input
          type="text"
          value={model}
          onChange={(e) => setModel(e.target.value)}
          style={{ display: 'block', width: '100%', padding: 4, marginTop: 2 }}
        />
      </label>
      {error && <div style={{ color: '#c33', fontSize: 12, marginBottom: 8 }}>{error}</div>}
      {saveResult && <div style={{ fontSize: 12, marginBottom: 8 }}>{saveResult}</div>}
      <div style={{ display: 'flex', gap: 8 }}>
        <button onClick={onSave}>저장</button>
        <button onClick={onRecheck}>지금 재확인</button>
      </div>
      {recheckResult && <div style={{ fontSize: 12, marginTop: 8 }}>{recheckResult}</div>}
    </div>
  );
}
```

- [ ] **Step 4: SettingsPage.tsx 의 AI 섹션 placeholder 를 실 import 로 교체**

`{/* AiProviderSection — Task 8 */}` 를 `<AiProviderSection />` 로 교체. 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(<AutostartSection />);
    const toggle = await screen.findByRole('checkbox');
    expect(toggle).toBeChecked();
  });

  it('clicking toggle calls setAutostart', async () => {
    const { inboxApi } = await import('../../api');
    render(<AutostartSection />);
    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<boolean | null>(null);

  useEffect(() => {
    void (async () => {
      const s = await inboxApi.getAutostart();
      setOpenAtLogin(s.openAtLogin);
    })();
  }, []);

  async function onToggle(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
    const next = e.target.checked;
    const r = await inboxApi.setAutostart(next);
    setOpenAtLogin(r.openAtLogin);
  }

  if (openAtLogin === null) return <div style={{ fontSize: 12 }}>로딩 중...</div>;

  return (
    <div>
      <label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
        <input type="checkbox" checked={openAtLogin} onChange={onToggle} />
        앱 시작 시 자동으로 실행
      </label>
    </div>
  );
}
```

- [ ] **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(<BackupSection />);
    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(<BackupSection />);
    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<string | null>(null);

  async function run(label: string, fn: () => Promise<unknown>): Promise<void> {
    setStatus(`${label}: 진행 중...`);
    try {
      await fn();
      setStatus(`${label}: 완료`);
    } catch (e) {
      setStatus(`${label}: 실패 — ${(e as Error).message}`);
    }
  }

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
      <button onClick={() => run('지금 백업', () => inboxApi.runBackup())}>지금 백업</button>
      <button onClick={() => run('내보내기', () => inboxApi.runExport())}>내보내기...</button>
      <button onClick={() => run('백업에서 복원', () => inboxApi.runImport())}>백업에서 복원...</button>
      <button onClick={() => run('지금 동기화', () => inboxApi.runSync())}>지금 동기화</button>
      <button onClick={() => run('사용 로그 내보내기', () => inboxApi.runExportTelemetry())}>사용 로그 내보내기...</button>
      {status && <div style={{ fontSize: 12 }}>{status}</div>}
    </div>
  );
}
```

- [ ] **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(<InfoSection />);
    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(<InfoSection />);
    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(<InfoSection />);
    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<AppInfo | null>(null);

  useEffect(() => {
    void (async () => {
      setInfo(await inboxApi.getAppInfo());
    })();
  }, []);

  if (!info) return <div style={{ fontSize: 12 }}>로딩 중...</div>;

  return (
    <div>
      <dl style={{ fontSize: 12, lineHeight: 1.6 }}>
        <dt style={{ fontWeight: 600 }}>버전</dt><dd>{info.version}</dd>
        <dt style={{ fontWeight: 600 }}>Electron</dt><dd>{info.electron}</dd>
        <dt style={{ fontWeight: 600 }}>Node</dt><dd>{info.node}</dd>
        <dt style={{ fontWeight: 600 }}>OS</dt><dd>{info.os}</dd>
        <dt style={{ fontWeight: 600 }}>데이터 위치</dt><dd style={{ wordBreak: 'break-all' }}>{info.profileDir}</dd>
      </dl>
      <div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
        <button onClick={() => inboxApi.openProfileDir()}>데이터 위치 열기</button>
        <button onClick={() => inboxApi.copyAppInfo()}>정보 복사</button>
      </div>
    </div>
  );
}
```

- [ ] **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(<App />);
    expect(screen.getByText('설정')).toBeInTheDocument();
    expect(screen.getByText('AI 제공자')).toBeInTheDocument();
  });

  it('header gear icon click sets showSettings=true', () => {
    render(<App />);
    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 />;
```

`SettingsPage` import 추가. 기존 OllamaSettingsModal 관련 state (`ollamaSettingsOpen`) 는 Task 25 에서 제거.

헤더 우측 (ContinuityBadge / IdentityCounter 옆) 에 톱니바퀴 추가:

```tsx
<button
  aria-label="설정 열기"
  onClick={() => setShowSettings(true)}
  style={{
    background: 'transparent',
    border: 'none',
    cursor: 'pointer',
    padding: 4,
    fontSize: 16
  }}
>
  ⚙
</button>
```

- [ ] **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(<App />);
  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<TrayState>) — todayCount 만 영향**

```ts
export function refreshTray(state: Partial<TrayState>): 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<AutostartState> {
  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<AutostartState> {
  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<string | null> {
  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<string, Function> = {};
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(<AutostartSection />);
  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<AutostartFull | null>(null);
  const [expanded, setExpanded] = useState(false);

  useEffect(() => {
    void (async () => setData(await inboxApi.getAutostart()))();
  }, []);

  async function onToggle(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
    const r = await inboxApi.setAutostart(e.target.checked);
    setData(r);
  }

  if (!data) return <div style={{ fontSize: 12 }}>로딩 중...</div>;
  const d = data.diagnostic;
  const mismatch = d.withArgs.openAtLogin !== d.noArgs.openAtLogin
    || !d.withArgs.executableWillLaunchAtLogin;

  return (
    <div>
      <label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
        <input type="checkbox" checked={data.openAtLogin} onChange={onToggle} />
        앱 시작 시 자동으로 실행
      </label>
      {mismatch && <div style={{ color: '#c33', fontSize: 12, marginTop: 4 }}>⚠️ 등록 상태 불일치 감지</div>}
      <button
        onClick={() => setExpanded(!expanded)}
        style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#0a4b80', marginTop: 4 }}
      >
        {expanded ? '▾' : '▸'} 진단 정보
      </button>
      {expanded && (
        <div style={{ fontSize: 11, lineHeight: 1.6, marginTop: 4, fontFamily: 'monospace', background: '#f5f5f5', padding: 8 }}>
          <div>표준 (--hidden 인자): openAtLogin={String(d.withArgs.openAtLogin)}, willLaunch={String(d.withArgs.executableWillLaunchAtLogin)}</div>
          <div>비교 (인자 없이): openAtLogin={String(d.noArgs.openAtLogin)}, willLaunch={String(d.noArgs.executableWillLaunchAtLogin)}</div>
          <div>실행 파일 경로: {d.execPath}</div>
          {d.registryPath && <div>registry 경로: {d.registryPath}</div>}
          {d.registryValue !== undefined && <div>registry 값: {d.registryValue ?? '(없음)'}</div>}
        </div>
      )}
    </div>
  );
}
```

(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(<AutostartSection />);
  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<void> {
  if (!data) return;
  const r = await inboxApi.setAutostart(data.openAtLogin);
  setData(r);
}

// JSX 에 버튼 추가 (mismatch 경고 옆 또는 아래):
<button onClick={onReregister} style={{ marginTop: 8 }}>재등록</button>
```

- [ ] **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 에서 `<OllamaSettingsModal ... />` 렌더 제거

- [ ] **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

어느 쪽?
