Files
inkling/docs/superpowers/plans/2026-05-07-v027-cross-platform.md
altair823 5a605ef98f docs(v027): cross-platform 입구 정상화 implementation plan 작성
27 task / 6 phase. Phase 1 (Linux 빌드 risk-reduction first) → Phase 2
(설정 페이지 + IPC) → Phase 3 (트레이 슬림) → Phase 4 (F14 dock fix) →
Phase 5 (F12 deeper fix) → Phase 6 (cleanup + version bump).

각 task TDD red→green→typecheck→commit 순서. spec coverage / placeholder
/ type consistency self-review 통과.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 00:10:25 +09:00

76 KiB

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 도구 설치

brew install dpkg fakeroot

확인: which dpkg-debwhich fakeroot 둘 다 경로 출력.

  • Step 2: better-sqlite3 prebuild 가용성 직접 조회
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 타깃)
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: 커밋
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 블록 직전 위치 확인

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.jsonbuild 블록 안, mac 다음에 추가:

    "linux": {
      "target": [
        { "target": "AppImage", "arch": ["x64"] },
        { "target": "deb", "arch": ["x64"] }
      ],
      "category": "Utility",
      "synopsis": "로컬 메모 캡처 + AI 태그",
      "description": "Inkling — 잠깐 스친 생각을 잡아두는 로컬-우선 메모 도구."
    }
  • Step 3: scripts 블록에 dist:linux 추가

package.jsonscripts 블록 안 dist:mac 다음에 추가:

    "predist:linux": "npm run rebuild:electron && npm run build",
    "dist:linux": "electron-builder --linux --x64",
  • Step 4: typecheck (json 파일이라 syntax 만 검증)
node -e "JSON.parse(require('fs').readFileSync('package.json','utf8'))"

Expected: 출력 없음 (parse 성공).

  • Step 5: 커밋
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: 빌드 실행

npm run dist:linux

Expected (성공 시): dist/Inkling-0.2.7.AppImage + dist/inkling_0.2.7_amd64.deb 산출. 빌드 시간 약 1-3분.

  • Step 2: 산출물 존재 확인
ls -la dist/*.AppImage dist/*.deb

Expected: 두 파일 모두 표시 + AppImage size 약 100-150MB, deb size 약 80-120MB.

  • Step 3: AppImage 실행 권한 확인 + 헤더 검증
file dist/Inkling-0.2.7.AppImage

Expected: ELF 64-bit LSB executable.

  • Step 4: deb 메타데이터 확인
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/ 확인)
grep -E '^dist/?$' .gitignore || echo "WARNING: dist not in .gitignore"
git status --short  # 변경 없어야 함

빌드 검증 자체는 커밋 대상 아님. 다만 spec 변경이 있다면:

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).

# VM 안에서:
chmod +x ~/Inkling-0.2.7.AppImage
sudo apt-get install -y libfuse2  # AppImage 실행 의존성
  • Step 2: AppImage 실행
~/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 환경):

INKLING_OLLAMA_ENDPOINT=http://192.168.0.47:11434 ~/Inkling-0.2.7.AppImage --no-sandbox

기존 캡처 노트의 ai_status 가 pendingcomplete 로 전이되며 tag 가 표시되는지 확인.

  • Step 5: recall 또는 trash 한 사이클

inbox 에서 노트 휴지통 → 휴지통 탭 → restore. 동작 정상이면 SQLite write 검증 통과.

  • Step 6: 결과 spec §11 갱신 + 커밋

성공/실패 + 발견된 이슈를 spec §11 끝 "Linux smoke test 결과" 섹션 (신규) 에 기록.

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 설치

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" 검색 → 클릭. 또는 터미널:

inkling &
  • Step 3: AppImage 와 동일한 검증 (Task 4 Step 3~5 반복)

마이그레이션 / 캡처 / Ollama / restore 한 사이클.

  • Step 4: 제거 가능 여부 확인
sudo dpkg -r inkling
ls /usr/bin/inkling  # 없어야 함

데이터는 사용자 홈 (~/.config/Inkling) 에 잔류 — 의도된 동작.

  • Step 5: 결과 spec §11 갱신 + 커밋
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 테스트 신규 작성

// 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 확인
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 인터페이스에:

  showSettings: boolean;
  setShowSettings: (open: boolean) => void;

create<InboxState> 안 초기 state:

  showSettings: false,

action:

  setShowSettings(open) {
    set({ showSettings: open });
  },
  • Step 4: 테스트 통과 확인
npx vitest run src/renderer/inbox/store.test.ts

Expected: 3 tests pass.

  • Step 5: typecheck
npm run typecheck

Expected: 0 errors.

  • Step 6: 커밋
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 작성

// 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
npx vitest run src/renderer/inbox/components/SettingsPage.test.tsx

Expected: import error (SettingsPage 미존재).

  • Step 3: SettingsPage.tsx 작성 (4 섹션 placeholder + 헤더)
// 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: 테스트 통과 확인
npx vitest run src/renderer/inbox/components/SettingsPage.test.tsx

Expected: 3 tests pass.

  • Step 5: typecheck
npm run typecheck
  • Step 6: 커밋
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 작성
// 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
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 표시.

// 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
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: 커밋
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 작성

// 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
npx vitest run src/renderer/inbox/components/settings/AutostartSection.test.tsx
  • Step 3: AutostartSection.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 추가
// 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:

// 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 교체 + 테스트 + 커밋
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
// 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
npx vitest run src/renderer/inbox/components/settings/BackupSection.test.tsx
  • Step 3: BackupSection.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.tsregisterSettingsApi 안에:

  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 교체 + 테스트
// 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 교체.

npx vitest run src/renderer/inbox/components/settings/BackupSection.test.tsx
npm run typecheck
  • Step 6: 커밋
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.tsshowAboutDialog 함수의 detail 문자열 + clipboard.writeText / shell.openPath 로직을 IPC 핸들러로 추출.

  • Step 1: failing test
// 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 작성

// 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.tsregisterSettingsApi 안에:

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 교체 + 테스트
// 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 교체.

npx vitest run src/renderer/inbox/components/settings/InfoSection.test.tsx
npm run typecheck
  • Step 6: 커밋
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))

// 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
npx vitest run src/renderer/inbox/App.test.tsx
  • Step 3: App.tsx 수정

src/renderer/inbox/App.tsx 의 return 직전:

  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 옆) 에 톱니바퀴 추가:

<button
  aria-label="설정 열기"
  onClick={() => setShowSettings(true)}
  style={{
    background: 'transparent',
    border: 'none',
    cursor: 'pointer',
    padding: 4,
    fontSize: 16
  }}
>
  
</button>
  • Step 4: 테스트 + typecheck
npx vitest run src/renderer/inbox/App.test.tsx
npm run typecheck
  • Step 5: 수동 launch 검증
npm run rebuild:electron && npm run start

inbox 윈도우 → 헤더 톱니바퀴 클릭 → SettingsPage 등장 → "← 돌아가기" → inbox 복귀.

  • Step 6: 커밋
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 측)

// 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 추가

// 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 에 추가:

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 안에:

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 + 커밋
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 항목 검증 + 제거된 항목 부재 검증)

// 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
npm run rebuild:node && npx vitest run src/main/tray.test.ts

Expected: TypeScript 또는 runtime fail (TrayCallbacks 가 아직 옛 인터페이스).

  • Step 3: tray.ts 의 인터페이스 + state 슬림화
// 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 항목 본문
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 만 영향
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 + 커밋
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:

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
npm run typecheck

이 시점에서 tray.ts 자체는 통과 — index.ts 호출부 에러는 Task 17 에서.

  • Step 3: 커밋
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 위치 확인

grep -n 'createTray' src/main/index.ts

기존 호출부가 10-positional 이었던 시점부터 v0.2.6 의 객체 형태 (createTray({ showInbox, showCapture, runBackup, ... })) 까지 발전. 현재는 객체 형태.

  • Step 2: createTray 호출을 3-callback 객체로 슬림화

기존:

createTray({
  showInbox,
  showCapture,
  runBackup,
  runExport,
  runImport,
  runSync,
  runExportTelemetry,
  runOllamaRecheck,
  runRetryAllFailed,
  runOpenOllamaSettings
});

변경 후:

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 + 단위 테스트 회귀
npm run typecheck
npx vitest run

Expected: 0 errors + 모든 단위 테스트 pass. tray.test.ts 에서 회귀 없는지 + HealthChecker/AiWorker 테스트가 refreshTray 모킹에 의존했다면 mock 갱신 필요.

  • Step 6: 커밋
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 실행

npm run rebuild:node
npm test
npm run rebuild:electron
npm run test:e2e

Expected: 모두 pass. e2e 가 트레이 메뉴를 검증하지 않으면 회귀 risk 낮음.

  • Step 2: 수동 launch 검증 (Win + macOS)
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 핸들러 위치 확인
grep -n "app.on('activate'" src/main/index.ts

Expected: 라인 ~411. 직접 라인 번호는 Task 16 변경 후 달라질 수 있음.

  • Step 2: 핸들러 본문 교체

기존:

app.on('activate', () => {
  if (BrowserWindow.getAllWindows().length === 0) createInboxWindow();
});

변경 후:

app.on('activate', () => {
  const win = getInboxWindow();
  if (win && !win.isDestroyed()) {
    if (!win.isVisible()) win.show();
    win.focus();
  } else {
    createInboxWindow();
  }
});

getInboxWindow import 가 이미 있는지 확인 — 없으면 추가:

import { createInboxWindow, getInboxWindow } from './windows/inboxWindow';
  • Step 3: typecheck
npm run typecheck
  • Step 4: macOS 수동 검증

macOS 호스트에서:

npm run start

inbox 윈도우 → 빨간 신호등 (close) → dock 의 Inkling 아이콘 클릭 → inbox 윈도우 즉시 등장 + focus.

  • Step 5: 검증 결과 dogfood-feedback.md F14 entry 갱신 (🚀 promoted 마킹) — 일괄 처리는 Task 27 에서

지금은 commit 만:

  • Step 6: 커밋
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

// 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 (모듈 미존재)
npx vitest run src/main/services/AutostartDiagnostic.test.ts
  • Step 3: AutostartDiagnostic.ts 작성 (Win registry 부분은 Task 20 에서 — 여기선 stub)
// 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 + 커밋
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)

테스트 파일에 추가:

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 분기 추가

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 + 커밋
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 결과 그대로 반환)

// 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 안에:

import { collectAutostartState } from '../services/AutostartDiagnostic';

  ipcMain.handle('settings:autostart-state', () => collectAutostartState());
  • Step 4: 테스트 + typecheck + 커밋
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-autostartsettings:autostart-set 으로 rename + 결과로 AutostartState 전체 반환하도록 갱신.

  • Step 1: failing test
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: 핸들러 갱신

  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-autostartsettings:autostart-state, settings:set-autostartsettings:autostart-set. AutostartSection.tsx 의 호출부도 동시 갱신:

// 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 회귀 확인
npx vitest run src/renderer/inbox/components/settings/
npm run typecheck
  • Step 5: 커밋
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 ⚠️ 표시)

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 확장

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 핸들러 갱신:

ipcMain.handle('settings:autostart-state', async () => {
  const diag = await collectAutostartState();
  return { openAtLogin: diag.withArgs.openAtLogin, diagnostic: diag };
});
  • Step 4: 테스트 + typecheck + 커밋
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

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 에 버튼 추가

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 + 커밋
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: 사용처 확인

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); 제거

  • useEffectinboxApi.onOpenOllamaSettings(...) 구독 + cleanup 제거

  • JSX 에서 <OllamaSettingsModal ... /> 렌더 제거

  • Step 3: api.ts 의 onOpenOllamaSettings 제거 (사용처 없음 확인 후)

grep -rn 'onOpenOllamaSettings' src/

App.tsx 에서 제거됐으면 src 전체 0건. api.ts + preload 의 채널 expose 제거.

main 측 runOpenOllamaSettings callback (Task 16 에서 이미 제거됐을 것) 도 잔재 없는지 재확인.

  • Step 4: 파일 삭제
rm src/renderer/inbox/components/OllamaSettingsModal.tsx
  • Step 5: 전체 테스트 + typecheck
npm run rebuild:node
npm test
npm run typecheck
  • Step 6: 커밋
git add -A
git commit -m "refactor(v027): OllamaSettingsModal 제거 + onOpenOllamaSettings 채널 cleanup"

Task 26: 전체 회귀 검증 + e2e

Files:

  • 검증 only.

  • Step 1: 단위 + e2e + typecheck 일괄

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 검증
npm run start

체크리스트:

  • 트레이 4 항목만 표시

  • 트레이 "설정..." → SettingsPage 진입 → 4 섹션 (AI / 자동 실행 / 백업 / 정보) 모두 표시

  • AI 섹션 endpoint 변경 → 저장 → "지금 재확인" → 결과 표시

  • 자동 실행 토글 → 진단 패널 펼침 → withArgs/noArgs 표시

  • 백업 섹션 5 버튼 동작

  • 정보 섹션 버전/데이터 위치 + "정보 복사" → clipboard 검증

  • Step 3: macOS 수동 launch 검증

npm run start

체크리스트:

  • F14 fix 검증: 빨간 신호등 (close) → dock 클릭 → inbox 재등장

  • macOS 메뉴바 트레이 4 항목 (Win 동일)

  • 설정 페이지 동일 동작

  • Step 4: Linux smoke 재검증 (Phase 1 후 변경 사항 영향 확인)

npm run dist:linux

산출 AppImage 를 Linux VM 에서 실행 → 트레이 (있는 DE) 또는 inbox 톱니바퀴 → 설정 페이지 동작 확인.

  • Step 5: 결과 spec §11 갱신 + 커밋 (필요 시)
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
  "version": "0.2.7",
  • Step 6: 커밋 + tag
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

어느 쪽?