From c2be1350319ff9016d1826a456fe1f03300c3e0e Mon Sep 17 00:00:00 2001 From: altair823 Date: Wed, 6 May 2026 23:59:39 +0900 Subject: [PATCH 01/27] =?UTF-8?q?docs(v027):=20cross-platform=20=EC=9E=85?= =?UTF-8?q?=EA=B5=AC=20=EC=A0=95=EC=83=81=ED=99=94=20design=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F12 deeper fix + F14 + F15 (Linux 빌드만, CLI 제거) + F16 4묶음 — v0.2.7 brainstorm 결과. dogfood-feedback.md F15 entry promoted/rejected 표시. F12/F14/F16 promoted 마킹은 design 확정 후 일괄 처리. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-04-25-dogfood-feedback.md | 189 ++++++++- .../2026-05-06-v027-cross-platform-design.md | 368 ++++++++++++++++++ 2 files changed, 556 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md diff --git a/docs/superpowers/specs/2026-04-25-dogfood-feedback.md b/docs/superpowers/specs/2026-04-25-dogfood-feedback.md index af6d190..ad9efe1 100644 --- a/docs/superpowers/specs/2026-04-25-dogfood-feedback.md +++ b/docs/superpowers/specs/2026-04-25-dogfood-feedback.md @@ -1120,8 +1120,195 @@ Round 1 reviewer 의 발견 가치 = **production path 와 unit test 가 갈라 --- +## F14. macOS dock 클릭 시 hidden 창 재현 안 됨 (🌱 raw — v0.2.7 후보) + +**진행 상태:** 🌱 raw — 2026-05-05 v0.2.6 dogfood 발견. v0.2.7 brainstorm 후보. + +**발견:** 2026-05-05 김태현 macOS dogfood 도중. + +### 관찰 + +- macOS 에서 inbox 창 닫음 (빨간 신호등) → 창 사라짐. +- Dock 에는 Inkling 아이콘이 "켜져 있음" 표시 (점) 으로 그대로. +- Dock 아이콘 클릭 → **창이 안 뜸**. 클릭이 그냥 무시됨. +- 트레이 메뉴 "보관한 메모 보기" 로는 정상 호출 가능 (별 이슈 없음). +- 앱을 완전히 종료 후 재실행 해야만 창이 다시 뜸. + +### 제안 방향 + +추정 원인 (코드 확인 결과): + +- [src/main/windows/inboxWindow.ts:39-44](src/main/windows/inboxWindow.ts#L39-L44) — close 이벤트에서 `app.isQuitting === false` 면 `e.preventDefault()` + `inboxWindow.hide()`. 즉 창은 살아있고 단지 hidden. +- [src/main/index.ts:411-413](src/main/index.ts#L411-L413) — `app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createInboxWindow(); })`. 하지만 hidden 창은 destroy 되지 않았으므로 `getAllWindows().length === 1`. 분기 안 타고 no-op. + +따라서 fix 방향은 단순: + +```ts +app.on('activate', () => { + const win = getInboxWindow(); + if (win && !win.isDestroyed()) { + win.show(); + win.focus(); + } else { + createInboxWindow(); + } +}); +``` + +(B4 #46 second-instance 핸들러의 windows-restore 로직과 동일 패턴 — 거기서는 macOS dock 이 아닌 Windows multi-launch 경로지만 의도가 같음.) + +### 결정 대기 + +- macOS 에서 트레이 (menubar extra) 도 사용 중인가? Windows 에서 트레이가 핵심 surface 인 반면 macOS 에서는 dock 클릭이 자연스러운 1차 surface. +- 현재 macOS 빌드는 "기본 동작 유지" 수준 (메모리 우선순위 정책). dogfood 1차 타깃이 Windows 라 macOS 마찰을 어디까지 잡을지 cost-benefit 결정 필요. + +### 가설·측정 + +- Fix 후 macOS dogfood 시 dock click → 창 즉시 등장 (1 클릭, < 100ms 체감). +- regression 측정: Windows 에서 close→tray 메뉴 경로 영향 없음 (activate 이벤트는 macOS 전용 흐름이라 Windows 무관해야 함). + +### 범위 + +- 1-file edit (`src/main/index.ts` activate 핸들러 5줄). +- 단위 테스트 추가 어려움 (BrowserWindow + activate 이벤트 mocking 비용 ↑) — manual dogfood 검증 으로 충분. +- v0.2.7 telemetry export 와 함께 묶어 cut. + +### 영향 + +- macOS dogfood UX 마찰. "앱이 떠 있는데 안 보임 + 클릭 무반응" 은 사용자가 "고장났나?" 의심하게 만드는 첫 신호 — 신뢰 손상 큼. +- Windows 는 트레이 중심이라 동일 증상 없음. macOS 단독 이슈. +- v0.2.7 우선순위: F12 autostart 풀림 (Windows) 와 동급의 "잊지 않고 매일 쓰는" 흐름 마찰. + +--- + +## F15. Linux 앱 빌드 (🚀 promoted → v0.2.7 / CLI 부분 거부) + +**진행 상태:** 🚀 promoted (Linux 빌드만) → `docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md` §5. CLI 부분은 ❌ rejected (DB/Ollama 동시접근 race + monorepo 부담 대비 본인 dogfood metric 직접 기여 적음). + +**발견:** 2026-05-05 dogfood 외부 피드백 + 본인 후속 결정. + +### 관찰 + +- Linux 터미널 사용자가 "Inkling 을 터미널에서 쓰고 싶다" 요청. +- 본인 macOS 흐름에서도 GUI quick-capture 띄우는 비용 > `ink "한 줄"` 한 방 비용. +- 현재 빌드 타겟: Windows NSIS x64 + macOS DMG arm64. Linux 타겟 자체 없음 → Linux 사용자에게 입구 자체 부재. + +### 결정된 방향 (narrow scope) + +**범위 (v0.2.7 narrow):** +- CLI **capture-only**: `inkling capture "한 줄"` 만 노출. search/recall/restore/edit 등 v0.3+ 로 deferred. +- 플랫폼: **Linux + macOS** (Windows CLI 제외 — 본인 Windows 는 GUI 흐름). +- Linux 앱 빌드 추가: **AppImage + deb** 1차. rpm 은 외부 demand 후. + +**아키텍처 — race-free 설계:** +- CLI = SQLite WAL writer 로 `notes` INSERT (raw_text) + `pending_jobs` INSERT (note_id) 만 수행. +- AI 워커는 **Electron 메인 단일 보유**. CLI 는 워커 안 띄움. +- Electron 떠있으면 → 워커가 즉시 pending_jobs poll → AI 처리. +- Electron 안 떠있으면 → 다음 launch 시 처리 (기존 pending_jobs 큐 모델 그대로). +- 동일 DB 파일 동시 접근은 SQLite WAL 로 안전 (1 writer + N readers). Single-instance lock 은 **Electron 인스턴스 끼리만** 적용 — CLI 는 짧은 transaction 만 수행 후 종료, lock 우회 무관. + +**플랫폼별 build 비용:** + +| 타겟 | 빌드 비용 | 커버 | +|---|---|---| +| AppImage | 매우 낮음 (Mac/Linux cross-build 가능) | 모든 desktop linux 배포판 (1-file portable) | +| deb | 낮음 (Mac brew `dpkg` `fakeroot`) | Debian/Ubuntu/Mint | +| rpm | 중간 (Linux 호스트 또는 Docker) — **defer** | Fedora/RHEL/openSUSE | + +**CLI 패키징:** +- pkg / nexe 로 Node CLI → 단일 바이너리. Linux + macOS 각각 빌드. +- 또는 Electron 앱 안에 `inkling` 심볼릭 링크 노출 (Mac: `/Applications/Inkling.app/Contents/MacOS/inkling`, Linux: AppImage 내 path 노출 / deb postinst hook). +- 결정 대기 — v0.2.7 brainstorm 때 spike. + +### 결정 대기 (v0.2.7 brainstorm) + +- CLI 패키징: 별도 단일 바이너리 vs Electron 앱 번들 내 노출 — install UX 영향 큼. +- AI 처리 시점 표기: capture 시 즉시 stdout 으로 "queued" 만 회신할지, `--wait` 플래그로 처리 완료 대기할지. +- AppImage + deb 동시 vs 단계적. + +### 가설·측정 + +- 본인 macOS 흐름에서 quick-capture window 호출 vs `ink "..."` time-to-capture 비교. 후자가 의미 있게 빠르면 본인 Aha metric (7일/3일) 에도 기여 가능. +- 외부 Linux 사용자 1주 soak 후 capture 발생 빈도 (telemetry) — 채널 살아있는지 확인. + +### 범위 + +- Linux 앱 빌드 + AppImage + deb: 1~2일. +- CLI capture-only (Mac/Linux 양쪽): 2~3일. +- better-sqlite3 prebuild 매트릭스 확장 (linux-x64): 부수 작업, 0.5일. +- 합 약 4~5일 spike — v0.2.7 cut 1개로 가능. + +### 영향 + +- 본인 macOS dogfood capture 가속 (직접 효용). +- Linux 사용자 입구 제공 (외부 확장 첫걸음). +- v0.4 slice 자기 종료 조건 (본인 2주 완주) 와 병행 가능 — capture 만 노출하므로 v0.4 핵심 흐름 (recall, AI tag, due) 변경 없음. +- Risk: Linux native ABI 첫 진입 — better-sqlite3 prebuild 가 linux-x64 에서 깔끔히 떨어질지 dogfood 검증 필요. + +--- + +## F16. 트레이 의존도 ↓ + 별도 설정 페이지 (🌱 raw — v0.2.7 후보, 광범위 영향) + +**진행 상태:** 🌱 raw — 본인 결정: "트레이 사용 최소화하자". v0.2.7 brainstorm 시 트레이 deemphasis 정책 + 설정 페이지 spec 합의 필요. + +**발견:** 2026-05-05 dogfood 후속 결정. + +### 관찰 + +- 현재 [src/main/tray.ts](src/main/tray.ts) 가 **13개 항목** 보유: 보관함 열기, 한 줄 적기, 백업, 내보내기, 복원, 동기화, telemetry export, Ollama 재확인, AI 재처리, **Ollama 설정...**, **자동 실행 토글**, 정보, 종료. +- Windows: SysTray 잘 보임 → 핵심 surface 로 기능했음. +- **macOS**: menubar extra 로 뜨지만 사용자가 메뉴바 가득찼을 때 가려짐. dock 사용자 흐름과 분리되어 발견성 ↓. +- **Linux**: 모던 GNOME 등 일부 DE 는 system tray 자체 없음 (extension 필요). KDE/Cinnamon 은 보임. +- 결과: macOS / Linux 사용자가 **Ollama 설정 / 자동 실행 토글 등 핵심 설정에 접근 불가**. + +### 결정된 방향 (정책 변화) + +**1. 트레이 = 최소 surface 로 강등.** +- 잔류: 한 줄 적기 (quick capture), 보관함 열기, 종료. +- 이동: Ollama 설정, 자동 실행, 백업/복원/내보내기, telemetry export, AI 재처리, Ollama 재확인, 정보 → **설정 페이지** 또는 inbox window 안 메뉴. + +**2. 별도 설정 페이지 신설 (inbox window 안 또는 별도 윈도우).** +- 예상 섹션: AI provider (Ollama endpoint/model — 기존 OllamaSettingsModal 흡수), 자동 실행, 백업/복원, 내보내기/가져오기, 텔레메트리, 정보. +- 현재 OllamaSettingsModal 은 inbox 안 modal — 그대로 활용하거나 페이지화 검토. + +**3. 1차 액션 진입점 재설계.** +- macOS: dock 클릭 (F14 fix 와 동시) → inbox 창 → 메뉴 또는 톱니바퀴 → 설정. +- Linux: 앱 launcher 또는 CLI (`inkling settings` 후보) → inbox 창 → 설정. +- Windows: 트레이 우클릭 "한 줄 적기" + "보관함 열기" 만 잔류 + 트레이에서 "설정..." 한 항목 추가 (inbox 안 설정 페이지로 라우팅). + +### 결정 대기 (v0.2.7 brainstorm) + +- 설정 surface 형태: + - (a) inbox 안 별도 라우트 (e.g., `/settings`) — SPA 내부 페이지 + - (b) 별도 BrowserWindow — 독립 윈도우 + - (c) 메뉴바 (Application Menu) 사용 — macOS 표준이지만 Win/Linux 와 일관성 깨짐 + - 추천 잠정: (a) — 최소 비용, 기존 inbox UI 연속성, OllamaSettingsModal 흡수 자연스러움. +- 트레이 잔류 항목: "설정..." 1줄 잔류 vs 완전 제거 (Windows 사용자 경험 시 trade-off). +- 자동 실행 토글의 실제 동작 — F12 deeper fix 가 설정 페이지 안에서 더 명확한 진단 노출 가능 (registry path 확인, args 미스매치 표시 등). + +### 가설·측정 + +- 새 사용자 (macOS) 가 Ollama endpoint 변경 작업 완료까지 클릭 수: 현재 (트레이 메뉴에 가려짐) vs 설정 페이지 (inbox → 설정) — 후자가 의미 있게 짧으면 채택. +- 트레이 메뉴 항목별 사용 빈도 telemetry — v0.2.7 export 후 실제 어떤 항목이 자주 클릭되는지 보고 잔류 우선순위 결정. + +### 범위 + +- 1~2일: OllamaSettingsModal 을 설정 페이지 섹션으로 흡수. +- 1~2일: 자동 실행 / 백업 / 복원 / 내보내기 등 트레이 click 핸들러를 설정 페이지 버튼으로 이동 (메인 IPC 는 그대로). +- 0.5일: 트레이 메뉴 슬림화 (3~4 항목으로). +- 합 3~5일. CLI (F15) 와 묶으면 v0.2.7 한 cut 가능. + +### 영향 + +- **macOS / Linux 입구 정상화** — F14 (dock 클릭) + F15 (CLI 입구) 와 정합. 외부 Linux 사용자 + 본인 macOS 모두 설정 접근 가능. +- **F12 autostart 영향**: 자동 실행 토글이 설정 페이지로 이동하면 진단 정보 (registry path, args 비교 결과) 노출 가능 → deeper fix 자연스럽게 결합. +- **트레이 코드 단순화**: 13 항목 → 3~4 항목. v0.2.6 의 TrayCallbacks 객체화 (C2) 효과 가시화. +- Risk: Windows 사용자 흐름 변경 — 트레이 한 클릭으로 끝나던 동작이 inbox 열기 → 설정 → 항목 클릭 으로 늘어남. 단, 빈도 낮은 동작 (Ollama 설정 변경, 백업 등) 만 이동하고 자주 쓰는 캡처/보관함 은 트레이 잔류 → 체감 마찰 ↓ 예상. + +--- + ## (다음 항목 자리) -새 피드백 추가 시 `## F14. 짧은 제목 (🌱 raw)` 헤더로 시작. 표준 슬롯 6개 채우거나 비워둔 채 시작 가능. +새 피드백 추가 시 `## F17. 짧은 제목 (🌱 raw)` 헤더로 시작. 표준 슬롯 6개 채우거나 비워둔 채 시작 가능. dogfood ≥1주 soak (v0.2.6 release 후) 동안 새 발견 항목들 여기 누적 → v0.2.7 brainstorm 트리거. diff --git a/docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md b/docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md new file mode 100644 index 0000000..27a5a5a --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md @@ -0,0 +1,368 @@ +# v0.2.7 — Cross-Platform 입구 정상화 (Design) + +**작성일:** 2026-05-06 +**저자:** 김태현 (dlsrks0734@gmail.com) +**선행 문서:** + +- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F12, F14, F15, F16) +- `docs/superpowers/v024-backlog.md` (잔여 24건) +- `docs/superpowers/strategy/dogfood-strategy.md` (운영안) + +**Cut 라벨:** v0.2.7 (semver 엄밀히는 MINOR — 새 플랫폼 + 새 surface — 이지만 본 프로젝트 관습상 v0.2.x 를 feature lane 으로 사용 중이므로 v0.2.7 라벨 유지) + +--- + +## 1. Cut 정체성 + +**"Cross-platform 입구 정상화" cut.** F12 / F14 / F15 / F16 4개 항목을 한 묶음으로 처리. 핵심 동기: + +> Windows 트레이 의존을 끊고 macOS / Linux 사용자에게 동등한 입구를 제공한다. + +현재 13개 트레이 메뉴 항목이 macOS / Linux (특히 모던 GNOME) 에서 발견 / 접근성이 떨어져 핵심 설정 (Ollama endpoint, 자동 실행 등) 진입이 막히는 구조적 문제. 트레이를 deemphasis 하고 inbox 윈도우 안에 통합 설정 페이지를 둔다. 동시에 macOS dock 동작 정상화 (F14) + Linux 앱 빌드 추가 (F15 축소판) + 자동 실행 진단 노출 (F12 deeper fix) 까지 함께 처리한다. + +**의도적으로 빠진 것:** + +- ~~CLI (`inkling capture` 등)~~ — DB / Ollama 동시접근 race + monorepo 재구성 부담 대비 본인 dogfood metric 직접 기여 적음. v0.2.7 에서 제외. 외부 demand 누적 시 v0.3+ 재거론. + +--- + +## 2. 범위 + +| 항목 | 출처 | 작업 | +|---|---|---| +| **F15 (축소판)** | dogfood F15 | Linux 앱 빌드 (AppImage + deb x64) + better-sqlite3 prebuild linux-x64 매트릭스 | +| **F16** | dogfood F16 | 트레이 슬림 (13 → 4) + inbox 안 설정 페이지 (4 섹션) | +| **F14** | dogfood F14 | macOS dock 클릭 시 hidden 창 show/focus (activate 핸들러 5줄 수정) | +| **F12 deeper fix** | dogfood F12 (v0.2.6 진단 fallback 후속) | 설정 페이지 "자동 실행" 섹션 안에 진단 패널 노출 (withArgs vs noArgs / executableWillLaunchAtLogin / registry path) | + +--- + +## 3. Architecture 변화 + +| 영역 | 현재 (v0.2.6) | v0.2.7 | +|---|---|---| +| 설정 진입 | 트레이 메뉴 13개 항목 | 트레이 4개 + 설정 페이지 (inbox 내부 라우트) | +| Ollama 설정 | OllamaSettingsModal (트레이에서만 진입) | 설정 페이지 안 "AI 제공자" 섹션 (modal 흡수) | +| 자동 실행 | 트레이 checkbox + args 명시 | 설정 페이지 안 섹션 + 진단 패널 | +| macOS dock 클릭 | activate 핸들러 no-op (length===0 분기 못 탐) | `getInboxWindow().show() + focus()` 분기 추가 | +| Linux 배포 | 없음 | AppImage + deb 산출물 | +| 빌드 매트릭스 | win-x64 + mac-arm64 | + linux-x64 | + +--- + +## 4. 구현 순서 (Approach 2: Risk-reduction first) + +``` +1. Linux 빌드 (가장 unknown — better-sqlite3 prebuild linux-x64 검증) + ↓ AppImage + deb 산출 + Linux VM/WSL2 smoke test +2. 설정 페이지 (inbox 내부 라우트 + 4 섹션) + ↓ OllamaSettingsModal 흡수 +3. 트레이 슬림 (13 → 4) + ↓ 제거된 click 핸들러 → 설정 페이지 버튼으로 이동 +4. F14 macOS dock 클릭 fix + ↓ activate 핸들러 5줄 +5. F12 deeper fix (자동 실행 진단 노출) + ↓ IPC settings:autostart-state + 진단 panel UI +``` + +Linux 빌드를 먼저 두는 이유: native ABI 트랩 (메모 `project_inkling_status.md`) 이 linux-x64 에서 재발할 수 있음. 만약 prebuild 가 깔끔히 떨어지지 않으면 v0.2.7 scope 조정 (예: AppImage 만, deb 는 v0.2.8) 여유. 설정 페이지 / 트레이 슬림 / F14 / F12 는 모두 코드 작성 risk 가 낮은 영역이라 후순위로 안전. + +--- + +## 5. Linux 빌드 디테일 + +### 5-1. electron-builder config 추가 + +```json +"linux": { + "target": [ + { "target": "AppImage", "arch": ["x64"] }, + { "target": "deb", "arch": ["x64"] } + ], + "category": "Utility", + "synopsis": "로컬 메모 캡처 + AI 태그", + "description": "Inkling — 잠깐 스친 생각을 잡아두는 로컬-우선 메모 도구." +} +``` + +### 5-2. npm scripts 추가 + +```json +"predist:linux": "npm run rebuild:electron && npm run build", +"dist:linux": "electron-builder --linux --x64" +``` + +`rebuild:electron` 의 `--target=41.3.0` 그대로. `prebuild-install` 이 linux-x64 prebuild 를 npm 레지스트리에서 받아오는지 검증. 없으면 `node-gyp` fallback 으로 로컬 컴파일. + +### 5-3. 빌드 호스트 전략 + +**1차: macOS 호스트** (이미 DMG 빌드 호스트). brew 로 도구 설치: + +```bash +brew install dpkg fakeroot +``` + +electron-builder 가 cross-build 지원. AppImage 는 Mac 에서 직접 빌드 가능 (Linux 유저랜드 도구만 필요한 부분은 electron-builder 내장 + AppImageKit 자동 다운로드). deb 는 dpkg-deb 필요. + +**Fallback (1차 실패 시): Docker on Mac/Windows.** `electronuserland/builder` 이미지로 Linux 빌드 환경 격리. v0.2.7 scope 안에서 결정. + +### 5-4. Smoke test + +`dist/` 산출물: + +- `Inkling-0.2.7.AppImage` (x64) — Linux VM 또는 WSL2 에서 chmod +x → 실행 → 마이그레이션 통과 확인 → capture / recall 한 사이클. +- `inkling_0.2.7_amd64.deb` — Ubuntu/Debian VM 또는 WSL2 에서 `sudo dpkg -i` → `inkling` 실행 → 동일 검증. + +검증 항목: + +1. better-sqlite3 native module 로드 성공 (마이그레이션 0 → m003 통과) +2. Ollama 연결 시도 (settings.json 의 endpoint 또는 `INKLING_OLLAMA_ENDPOINT` env) — 본인 LAN 서버 `http://192.168.0.47:11434` 사용 +3. capture 한 줄 → AI 처리 → tag 표시 +4. 트레이 (KDE/Cinnamon DE 가정) 4 항목 표시 +5. 트레이 없는 DE (모던 GNOME) — launcher 에서 앱 실행 → inbox 윈도우 → 톱니바퀴 → 설정 페이지 진입 + +--- + +## 6. 설정 페이지 디테일 + +### 6-1. 라우팅 방식 + +React Router 도입 안 함 (의존성 + 학습 비용). zustand store 의 `view: 'inbox' | 'trash' | 'settings'` state + 조건부 렌더 — 기존 trash view 와 동일 패턴. 새 의존성 0. + +### 6-2. 진입점 + +| 진입 | 동작 | +|---|---| +| 트레이 "설정..." 클릭 | main → IPC `inbox:navigate` 'settings' → renderer store action `setView('settings')` + inbox 윈도우 show/focus | +| inbox 헤더 톱니바퀴 아이콘 | renderer store action `setView('settings')` | +| 설정 페이지 안 "← 돌아가기" 버튼 | `setView('inbox')` | + +### 6-3. 섹션 4개 + +#### 6-3-1. AI 제공자 + +흡수 대상: OllamaSettingsModal 전체 + 트레이 "Ollama 재확인". + +UI 요소: + +- Endpoint URL 입력 (zod 검증 — 기존 modal 의 `safeParse` 재활용) +- Model 입력 (빈 값 guard) +- "지금 재확인" 버튼 → ProviderHolder 의 health check trigger +- 마지막 ping 결과 표시 (성공 시각 또는 실패 사유) +- "기본값으로 되돌리기" 버튼 + +저장: 기존 SettingsService (atomic temp+rename + zod) 그대로. + +#### 6-3-2. 자동 실행 + +흡수 대상: 트레이 "윈도우 시작 시 자동 실행" checkbox. + +UI 요소: + +- 토글 ("앱 시작 시 자동으로 실행") +- 진단 패널 (펼치기 가능 — 평소엔 접혀 있음) +- "재등록" 버튼 (setLoginItemSettings 강제 재호출) + +진단 패널 디테일은 §9 (F12 deeper fix) 참조. + +#### 6-3-3. 백업 / 복원 / 내보내기 + +흡수 대상: 트레이의 5개 항목 — "지금 백업" / "내보내기..." / "백업에서 복원..." / "지금 동기화" / "사용 로그 내보내기...". + +UI 요소: 5개 버튼 + 각 작업 마지막 실행 시각 (가능하면) + 결과 toast. + +IPC 핸들러는 기존 그대로 — 트레이 click 핸들러였던 함수를 IPC 핸들러로 등록 + renderer 에서 invoke. + +#### 6-3-4. 정보 + +흡수 대상: 트레이 "Inkling 정보..." dialog. + +UI 요소: 버전 / Electron / Node / OS / 데이터 위치 텍스트 + "데이터 위치 열기" 버튼 + "정보 복사" 버튼. + +기존 `showAboutDialog` 의 detail 문자열 그대로 활용 — clipboard.writeText / shell.openPath 호출도 동일. + +### 6-4. 제외 항목 + +- "지금 AI 처리 (실패 N건)" — 이미 inbox FailedBanner 가 surface. 트레이 / 설정 둘 다 제거. +- "Ollama 재확인" 트레이 메뉴 단독 — OllamaBanner (끊김 시) + 설정 페이지 AI 섹션 "지금 재확인" 버튼이 surface. 트레이 단독 메뉴 제거. + +--- + +## 7. 트레이 슬림 디테일 + +### 7-1. 잔류 4개 (Win / Mac / Linux 동일) + +```ts +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(); } }); +``` + +todayCount tooltip (`Inkling — 오늘 N`) 잔류. F4-C 의 "오늘 N번 잡아둠" 비활성 라벨도 잔류 (정체성 신호). + +### 7-2. TrayCallbacks / TrayState 갱신 + +```ts +export interface TrayCallbacks { + showInbox: () => void; + showCapture: () => void; + showSettings: () => void; // NEW — IPC 'inbox:navigate' 'settings' 송출 +} + +// 메뉴 영향 state 슬림 +export interface TrayState { + todayCount: number; +} +``` + +**제거 대상:** + +- `runBackup`, `runExport`, `runImport`, `runSync`, `runExportTelemetry` callback (5개) → 설정 페이지 버튼으로 이동 +- `runOllamaRecheck`, `runRetryAllFailed`, `runOpenOllamaSettings` callback (3개) → 설정 페이지 또는 banner 로 이동 +- `ollamaOk`, `failedCount` state field (2개) → 트레이 메뉴 영향 사라짐 (banner 가 surface) +- `refreshTray({ ollamaOk })`, `refreshTray({ failedCount })` 호출부 (HealthChecker, AiWorker) → 제거. todayCount 만 남음. + +v0.2.6 의 Partial 패턴 그대로 활용 — 인터페이스 좁아질 뿐. + +### 7-3. 자동 실행 토글 트레이 잔류 X + +기존 트레이 안 checkbox (`type: 'checkbox'`) 는 제거. 설정 페이지 "자동 실행" 섹션 토글이 단일 진입점. + +이유: 자동 실행 토글은 빈도 낮은 액션 + F12 진단이 같은 자리에 있어야 의미. 트레이 잔류 시 두 surface mismatch 위험. + +--- + +## 8. F14 macOS dock 클릭 fix + +[src/main/index.ts:411-413](src/main/index.ts#L411-L413) 수정: + +```ts +app.on('activate', () => { + const win = getInboxWindow(); + if (win && !win.isDestroyed()) { + if (!win.isVisible()) win.show(); + win.focus(); + } else { + createInboxWindow(); + } +}); +``` + +second-instance 핸들러 (B4 #46) 와 패턴 일치 — 양쪽 모두 "살아있으면 show, 죽었으면 create". + +테스트: BrowserWindow + activate 이벤트 mocking 비용 ↑ → manual dogfood 검증으로 충분 (macOS 빨간 신호등 → dock 클릭 → 즉시 창 등장). + +--- + +## 9. F12 deeper fix — 자동 실행 진단 노출 + +### 9-1. 정보 모델 + +```ts +// 신규 IPC: settings:autostart-state +interface AutostartState { + withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean }; + noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean }; + execPath: string; // process.execPath + registryPath?: string; // Windows only + registryValue?: string; // Windows only — null 또는 string +} +``` + +### 9-2. main process 핸들러 + +```ts +ipcMain.handle('settings:autostart-state', async () => { + const withArgs = app.getLoginItemSettings({ args: ['--hidden'] }); + const noArgs = app.getLoginItemSettings(); + const state: AutostartState = { + withArgs: { + openAtLogin: withArgs.openAtLogin, + executableWillLaunchAtLogin: withArgs.executableWillLaunchAtLogin + }, + noArgs: { + openAtLogin: noArgs.openAtLogin, + executableWillLaunchAtLogin: noArgs.executableWillLaunchAtLogin + }, + execPath: process.execPath + }; + if (process.platform === 'win32') { + state.registryPath = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Inkling'; + state.registryValue = await readRegistryValueSilent(state.registryPath); + } + return state; +}); +``` + +`readRegistryValueSilent`: `child_process.execFile('reg', ['query', path, '/v', 'Inkling'])` 1회. 실패 시 null 반환 (silent fallback — 사용자에 에러 노출 X). + +새 dependency 추가 X (`winreg` 등 X) — built-in `child_process` + Windows `reg.exe` 만 활용. + +### 9-3. UI + +설정 페이지 "자동 실행" 섹션: + +``` +[ ] 앱 시작 시 자동으로 실행 + 상태: ✅ 등록됨 / ⚠️ 등록 안 됨 / ⚠️ args 미스매치 + ▾ 진단 정보 (펼치기) + - 표준 조회 (args 명시): openAtLogin=true, willLaunch=true + - 비교 조회 (args 없이): openAtLogin=false, willLaunch=true ← mismatch ⚠️ + - 실행 파일 경로: /Applications/Inkling.app/Contents/MacOS/Inkling + - registry 경로 (Windows): HKCU\...\Run\Inkling + - registry 값: "C:\Users\...\Inkling.exe" --hidden + [ 재등록 ] 버튼 +``` + +### 9-4. dogfood 시나리오 + +1. 토글 ON → 재시작 → 풀려있으면 진단 패널 펼침. +2. withArgs vs noArgs mismatch 보임 → args canonicalization 문제 확인. +3. registry 값 vs execPath 비교 — 다르면 path canonicalization 문제 (NSIS 재설치 시 path 바뀜). +4. "재등록" 버튼 → setLoginItemSettings 재호출 → 다시 재시작 → 효과 측정. + +수집된 데이터로 v0.2.8 root cause fix 작성. + +--- + +## 10. 테스트 전략 + +| 영역 | 단위 | e2e | Manual dogfood | +|---|---|---|---| +| Linux 빌드 (F15) | - | - | AppImage + deb 산출 + Linux VM 실행 + 마이그레이션/캡처/recall 한 사이클 | +| 설정 페이지 라우팅 | zustand store action `setView('settings')` 단위 | (선택) 트레이 "설정..." → IPC → view 전환 e2e | 실제 클릭 흐름 | +| Ollama 섹션 흡수 | 기존 OllamaSettingsModal 단위 + 흡수 후 회귀 | - | 1회 | +| 자동 실행 진단 IPC | autostart-state 핸들러 단위 (mock electron API + child_process) | - | Win 토글 → 재시작 → 진단 패널 mismatch 검출 | +| 트레이 슬림 | tray.ts buildMenu 단위 (4 항목 검증 + 제거된 항목 부재) | - | - | +| F14 dock fix | (mock 비용 ↑) — manual 만 | - | macOS dock 클릭 | +| F12 진단 UI | mismatch 시 ⚠️ 렌더 단위 | - | F12 시나리오 재현 | + +**목표**: 단위 426 → 약 450 (+24), e2e 1 유지 또는 +1. + +--- + +## 11. Risk / Known unknowns + +| Risk | 발생 시 대응 | +|---|---| +| linux-x64 prebuild 부재 → node-gyp 빌드 실패 | Docker `electronuserland/builder` fallback. 그래도 실패 시 v0.2.7 scope 조정: AppImage 만, deb 는 v0.2.8. | +| ELECTRON_RUN_AS_NODE 함정 (메모) 가 Linux 환경에서 재현 | smoke test launch env 에서 strip — 기존 e2e 의 strip 패턴 그대로 | +| AppImage 가 모던 GNOME 에서 트레이 표시 안 됨 | 의도적 — 그래서 dock/launcher → inbox → 설정 페이지 흐름이 안전망. F14 fix 가 이 흐름의 핵심. | +| 설정 페이지 라우팅이 inbox 의 keyboard shortcut / hotkey 와 충돌 | view='settings' 시 inbox-only shortcut 비활성. zustand state 분기. | +| 자동 실행 진단 패널이 Mac/Linux 에선 의미 없는 정보 노출 | 플랫폼 분기 — Mac/Linux 는 registry 행 숨김 + executableWillLaunchAtLogin 만 표시 | +| 트레이 callback 8개 제거 시 import 그래프에서 dead code 잔존 | 제거 후 typecheck + grep 으로 검증 | + +--- + +## 12. v0.2.7 후 + +**잔여 backlog (24건)** — `docs/superpowers/v024-backlog.md`: + +- v0.2.6 final reviewer minor cleanup 6건 — kstDate 의미 정정 / NoteRepository.test.ts as any / store.ts trashCount race 등 +- telemetry data-dependent 14건 — VOCAB_TOP_N 튜닝, recallBanner 임계값 등 — v0.2.8 후보 (v0.2.7 dogfood soak ≥1주 후 telemetry export 누적) +- v0.2.7 본 cut 안 신규 발견 — F12 root cause 가 진단 데이터 누적 후 결정될 가능성 + +**v0.2.8 트리거**: v0.2.7 release 후 dogfood ≥1주 soak + telemetry export + F12 진단 데이터 → v0.2.8 brainstorm. From 5a605ef98f18c65eec17906d7c173ac5e5fcb627 Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 00:10:25 +0900 Subject: [PATCH 02/27] =?UTF-8?q?docs(v027):=20cross-platform=20=EC=9E=85?= =?UTF-8?q?=EA=B5=AC=20=EC=A0=95=EC=83=81=ED=99=94=20implementation=20plan?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../plans/2026-05-07-v027-cross-platform.md | 2364 +++++++++++++++++ 1 file changed, 2364 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-07-v027-cross-platform.md diff --git a/docs/superpowers/plans/2026-05-07-v027-cross-platform.md b/docs/superpowers/plans/2026-05-07-v027-cross-platform.md new file mode 100644 index 0000000..4a40f6b --- /dev/null +++ b/docs/superpowers/plans/2026-05-07-v027-cross-platform.md @@ -0,0 +1,2364 @@ +# v0.2.7 Cross-Platform 입구 정상화 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Windows 트레이 의존을 끊고 macOS/Linux 사용자에게 동등한 입구 제공 — Linux 앱 빌드 + 설정 페이지 + 트레이 슬림 + macOS dock 클릭 fix + 자동 실행 진단 노출. + +**Architecture:** electron-builder 에 linux target (AppImage + deb) 추가하여 빌드 매트릭스 확장. inbox 윈도우 안에 `showSettings` boolean state 기반 설정 페이지 추가, 4 섹션 (AI 제공자 / 자동 실행 / 백업/복원 / 정보) 으로 트레이 메뉴 8 항목 흡수. 트레이는 4 항목 (한 줄 적기 / 보관함 / 설정 / 종료) 으로 슬림. macOS dock 클릭은 `getInboxWindow().show() + focus()` 분기 추가로 hidden 창 복원. 자동 실행은 main process IPC `settings:autostart-state` 가 withArgs/noArgs/execPath/registry 정보 수집 → 설정 페이지 진단 패널 표시. + +**Tech Stack:** Electron 41 + React 19 + zustand 5 + better-sqlite3 12.9 + electron-builder + vitest 4 + Playwright + +**선행 spec:** `docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md` + +--- + +## File Structure + +### 신규 파일 + +| 경로 | 책임 | +|---|---| +| `src/renderer/inbox/components/SettingsPage.tsx` | 설정 페이지 컨테이너 (4 섹션 vertical scroll + "← 돌아가기" 헤더) | +| `src/renderer/inbox/components/settings/AiProviderSection.tsx` | Ollama endpoint/model + "지금 재확인" + 마지막 ping 결과 (OllamaSettingsModal 흡수) | +| `src/renderer/inbox/components/settings/AutostartSection.tsx` | 토글 + 진단 패널 (펼치기) + "재등록" 버튼 | +| `src/renderer/inbox/components/settings/BackupSection.tsx` | 5 버튼 (백업/내보내기/복원/동기화/telemetry) | +| `src/renderer/inbox/components/settings/InfoSection.tsx` | 버전 + 데이터 위치 + "데이터 위치 열기" + "정보 복사" | +| `src/main/services/AutostartDiagnostic.ts` | autostart-state 정보 수집 (getLoginItemSettings 양쪽 + Windows registry 조회) | +| `src/main/ipc/settingsApi.ts` | settings:autostart-state, settings:autostart-set, inbox:navigate, settings:* (백업/복원/내보내기/동기화/telemetry) IPC 핸들러 | +| 위 항목들의 `.test.ts(x)` | 단위 테스트 (vitest) | + +### 수정 파일 + +| 경로 | 변경 내용 | +|---|---| +| `package.json` | `build.linux` target (AppImage + deb x64) + `dist:linux` script 추가 | +| `src/main/index.ts` | F14 activate 핸들러 5줄 수정, settings IPC 등록, 트레이 callback wiring 슬림화, HealthChecker/AiWorker refreshTray 호출 제거 | +| `src/main/tray.ts` | TrayCallbacks (showInbox/showCapture/showSettings 만), TrayState (todayCount 만), buildMenu 4 항목 | +| `src/renderer/inbox/store.ts` | `showSettings: boolean` + `setShowSettings(b)` action 추가 | +| `src/renderer/inbox/App.tsx` | showSettings 분기 + 헤더 톱니바퀴 아이콘 + 트레이 IPC `inbox:navigate` 구독 | +| `src/renderer/inbox/api.ts` | settings:* IPC 호출 wrapper | +| `src/preload/index.ts` | 신규 IPC 채널 노출 (settings:autostart-state, settings:autostart-set, inbox:navigate, settings:backup/export/import/sync/exportTelemetry) | + +### 제거 파일 + +| 경로 | 이유 | +|---|---| +| `src/renderer/inbox/components/OllamaSettingsModal.tsx` | AiProviderSection 으로 흡수 후 dead code | + +--- + +## Phase 개요 + +``` +Phase 1: Linux 빌드 (Task 1~5) ← Risk-reduction first +Phase 2: 설정 페이지 + IPC (Task 6~13) +Phase 3: 트레이 슬림 (Task 14~17) +Phase 4: F14 dock fix (Task 18) +Phase 5: F12 deeper fix (Task 19~24) +Phase 6: Cleanup + verification (Task 25~27) +``` + +각 task 끝마다 commit. 단위 테스트는 vitest, e2e 는 Playwright. typecheck (`npm run typecheck`) 는 task 별로 step 으로 포함. + +--- + +## Phase 1: Linux 빌드 + +### Task 1: better-sqlite3 linux-x64 prebuild 가용성 검증 + +**Files:** + +- 검증 only — 코드 변경 없음. 결과를 `docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md` §11 Risk 표 끝에 기록. + +- [ ] **Step 1: macOS 호스트에서 brew 도구 설치** + +```bash +brew install dpkg fakeroot +``` + +확인: `which dpkg-deb` 와 `which fakeroot` 둘 다 경로 출력. + +- [ ] **Step 2: better-sqlite3 prebuild 가용성 직접 조회** + +```bash +curl -sI https://github.com/WiseLibs/better-sqlite3/releases/download/v12.9.0/better-sqlite3-v12.9.0-electron-v41.3.0-linux-x64.tar.gz | head -1 +``` + +Expected: `HTTP/2 302` (또는 `200`) — prebuild 존재. `404` 면 node-gyp 로컬 빌드 fallback 필요 (Step 3 에서 확인). + +- [ ] **Step 3: 로컬 prebuild 시도 (linux 타깃)** + +```bash +cd node_modules/better-sqlite3 +./node_modules/.bin/prebuild-install --runtime=electron --target=41.3.0 --platform=linux --arch=x64 --tag-prefix=v --verbose +``` + +Expected: `Successfully installed prebuilt binary` 메시지 + `build/Release/better_sqlite3.node` 파일 존재. + +실패 시: error 로그를 그대로 spec §11 끝에 기록하고 Task 1 종료. node-gyp fallback 은 Task 3 에서 dist:linux 시 자동 시도됨. + +- [ ] **Step 4: spec §11 Risk 표 갱신 (검증 결과 기록)** + +`docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md` 의 §11 표 첫 row 옆에 ✅ 또는 ⚠️ 마킹 + 한 줄 요약. + +- [ ] **Step 5: 커밋** + +```bash +git add docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md +git commit -m "docs(v027): better-sqlite3 linux-x64 prebuild 가용성 검증" +``` + +--- + +### Task 2: package.json — linux target + dist:linux script 추가 + +**Files:** + +- Modify: `package.json` + +- [ ] **Step 1: package.json 의 build 블록 직전 위치 확인** + +```bash +grep -n '"build"' package.json | head -3 +grep -n '"win"' package.json +grep -n '"mac"' package.json +``` + +Expected: `"build"` 키 안에 이미 `"win": {...}` 와 `"mac": {...}` 가 있는 구조. linux 는 그 형제로 추가. + +- [ ] **Step 2: linux target 추가** + +`package.json` 의 `build` 블록 안, `mac` 다음에 추가: + +```json + "linux": { + "target": [ + { "target": "AppImage", "arch": ["x64"] }, + { "target": "deb", "arch": ["x64"] } + ], + "category": "Utility", + "synopsis": "로컬 메모 캡처 + AI 태그", + "description": "Inkling — 잠깐 스친 생각을 잡아두는 로컬-우선 메모 도구." + } +``` + +- [ ] **Step 3: scripts 블록에 dist:linux 추가** + +`package.json` 의 `scripts` 블록 안 `dist:mac` 다음에 추가: + +```json + "predist:linux": "npm run rebuild:electron && npm run build", + "dist:linux": "electron-builder --linux --x64", +``` + +- [ ] **Step 4: typecheck (json 파일이라 syntax 만 검증)** + +```bash +node -e "JSON.parse(require('fs').readFileSync('package.json','utf8'))" +``` + +Expected: 출력 없음 (parse 성공). + +- [ ] **Step 5: 커밋** + +```bash +git add package.json +git commit -m "feat(v027): electron-builder linux target (AppImage + deb x64)" +``` + +--- + +### Task 3: macOS 호스트에서 dist:linux 빌드 실행 + +**Files:** + +- 빌드 산출물 검증 only — 코드 변경 없음. + +- [ ] **Step 1: 빌드 실행** + +```bash +npm run dist:linux +``` + +Expected (성공 시): `dist/Inkling-0.2.7.AppImage` + `dist/inkling_0.2.7_amd64.deb` 산출. 빌드 시간 약 1-3분. + +- [ ] **Step 2: 산출물 존재 확인** + +```bash +ls -la dist/*.AppImage dist/*.deb +``` + +Expected: 두 파일 모두 표시 + AppImage size 약 100-150MB, deb size 약 80-120MB. + +- [ ] **Step 3: AppImage 실행 권한 확인 + 헤더 검증** + +```bash +file dist/Inkling-0.2.7.AppImage +``` + +Expected: `ELF 64-bit LSB executable`. + +- [ ] **Step 4: deb 메타데이터 확인** + +```bash +dpkg-deb -I dist/inkling_0.2.7_amd64.deb +``` + +Expected: `Architecture: amd64`, `Package: inkling`, version `0.2.7`. + +- [ ] **Step 5: 빌드 실패 시 fallback 결정** + +Step 1 실패 시: error 메시지를 spec §11 의 첫 row 에 추가 + 이 task 를 Docker fallback (`docker run --rm -v $(pwd):/project electronuserland/builder npm run dist:linux`) 으로 재시도. 그래도 실패면 v0.2.7 scope 조정 — AppImage 만 (deb 제거) 또는 v0.2.8 로 deb 미루기. + +- [ ] **Step 6: 커밋 (빌드 산출물은 커밋 X — `.gitignore` dist/ 확인)** + +```bash +grep -E '^dist/?$' .gitignore || echo "WARNING: dist not in .gitignore" +git status --short # 변경 없어야 함 +``` + +빌드 검증 자체는 커밋 대상 아님. 다만 spec 변경이 있다면: + +```bash +git add docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md +git commit -m "docs(v027): dist:linux 빌드 검증 — fallback 결정 기록" || echo "no spec change" +``` + +--- + +### Task 4: AppImage Linux VM smoke test + +**Files:** + +- 수동 검증 — 코드 변경 없음. + +- [ ] **Step 1: Linux VM 준비** + +WSL2 Ubuntu 22.04 또는 별도 VM 사용. AppImage 를 VM 안으로 복사 (`scp` 또는 mount). + +```bash +# VM 안에서: +chmod +x ~/Inkling-0.2.7.AppImage +sudo apt-get install -y libfuse2 # AppImage 실행 의존성 +``` + +- [ ] **Step 2: AppImage 실행** + +```bash +~/Inkling-0.2.7.AppImage --no-sandbox 2>&1 | head -30 +``` + +Expected: Inkling 윈도우 등장 + 콘솔에 `migration` 로그 없거나 `applied m003` 정상 출력. 에러 (예: `dlopen: undefined symbol`) 시 — better-sqlite3 ABI 이슈 → spec §11 첫 row 에 기록 + Task 3 Step 5 fallback 진입. + +- [ ] **Step 3: 마이그레이션 + 캡처 1회** + +inbox 윈도우 → "한 줄 적기" → 임의 텍스트 입력 → 저장. 캡처된 노트가 inbox 에 표시되는지 확인. + +- [ ] **Step 4: Ollama 연결 시도** + +설정이 아직 없으니 `INKLING_OLLAMA_ENDPOINT=http://192.168.0.47:11434` env 로 실행 (LAN 서버 - memory 의 dogfood 환경): + +```bash +INKLING_OLLAMA_ENDPOINT=http://192.168.0.47:11434 ~/Inkling-0.2.7.AppImage --no-sandbox +``` + +기존 캡처 노트의 ai_status 가 `pending` → `complete` 로 전이되며 tag 가 표시되는지 확인. + +- [ ] **Step 5: recall 또는 trash 한 사이클** + +inbox 에서 노트 휴지통 → 휴지통 탭 → restore. 동작 정상이면 SQLite write 검증 통과. + +- [ ] **Step 6: 결과 spec §11 갱신 + 커밋** + +성공/실패 + 발견된 이슈를 spec §11 끝 "Linux smoke test 결과" 섹션 (신규) 에 기록. + +```bash +git add docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md +git commit -m "docs(v027): Linux AppImage smoke test 결과 기록" +``` + +--- + +### Task 5: deb Ubuntu VM smoke test + +**Files:** + +- 수동 검증 — 코드 변경 없음. + +- [ ] **Step 1: Ubuntu VM 에 deb 설치** + +```bash +sudo dpkg -i ~/inkling_0.2.7_amd64.deb +sudo apt-get install -f # 의존성 자동 해결 +``` + +Expected: 설치 성공 + `/usr/bin/inkling` 존재. desktop entry (`/usr/share/applications/inkling.desktop`) 확인. + +- [ ] **Step 2: 데스크탑에서 launcher 통해 실행** + +GUI 가능한 VM 이면 application launcher 에서 "Inkling" 검색 → 클릭. 또는 터미널: + +```bash +inkling & +``` + +- [ ] **Step 3: AppImage 와 동일한 검증 (Task 4 Step 3~5 반복)** + +마이그레이션 / 캡처 / Ollama / restore 한 사이클. + +- [ ] **Step 4: 제거 가능 여부 확인** + +```bash +sudo dpkg -r inkling +ls /usr/bin/inkling # 없어야 함 +``` + +데이터는 사용자 홈 (~/.config/Inkling) 에 잔류 — 의도된 동작. + +- [ ] **Step 5: 결과 spec §11 갱신 + 커밋** + +```bash +git add docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md +git commit -m "docs(v027): Linux deb smoke test 결과 기록" +``` + +Phase 1 종료. 만약 Task 1~5 중 어디든 실패면 spec §11 fallback 적용 + Phase 2 진입 보류 결정. + +--- + +## Phase 2: 설정 페이지 + IPC + +### Task 6: store.ts — `showSettings` boolean state + action + +**Files:** + +- Modify: `src/renderer/inbox/store.ts` +- Test: `src/renderer/inbox/store.test.ts` (없으면 신규 — 다만 기존 store 테스트가 컴포넌트 테스트 안에서 간접 검증되는 패턴이면 SettingsPage 테스트 안에서 함께 검증해도 OK) + +- [ ] **Step 1: store 테스트 신규 작성** + +```ts +// src/renderer/inbox/store.test.ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { useInbox } from './store'; + +describe('inbox store — showSettings', () => { + beforeEach(() => { + useInbox.setState({ showSettings: false }); + }); + + it('initial state has showSettings=false', () => { + expect(useInbox.getState().showSettings).toBe(false); + }); + + it('setShowSettings(true) sets state', () => { + useInbox.getState().setShowSettings(true); + expect(useInbox.getState().showSettings).toBe(true); + }); + + it('setShowSettings(false) toggles back', () => { + useInbox.getState().setShowSettings(true); + useInbox.getState().setShowSettings(false); + expect(useInbox.getState().showSettings).toBe(false); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail 확인** + +```bash +npm run rebuild:node && npx vitest run src/renderer/inbox/store.test.ts +``` + +Expected: 3 tests fail with `setShowSettings is not a function`. + +- [ ] **Step 3: store.ts 에 state + action 추가** + +`InboxState` 인터페이스에: + +```ts + showSettings: boolean; + setShowSettings: (open: boolean) => void; +``` + +`create` 안 초기 state: + +```ts + showSettings: false, +``` + +action: + +```ts + setShowSettings(open) { + set({ showSettings: open }); + }, +``` + +- [ ] **Step 4: 테스트 통과 확인** + +```bash +npx vitest run src/renderer/inbox/store.test.ts +``` + +Expected: 3 tests pass. + +- [ ] **Step 5: typecheck** + +```bash +npm run typecheck +``` + +Expected: 0 errors. + +- [ ] **Step 6: 커밋** + +```bash +git add src/renderer/inbox/store.ts src/renderer/inbox/store.test.ts +git commit -m "feat(v027): inbox store 에 showSettings state + setShowSettings action" +``` + +--- + +### Task 7: SettingsPage.tsx scaffold + 빈 4 섹션 placeholder + +**Files:** + +- Create: `src/renderer/inbox/components/SettingsPage.tsx` +- Create: `src/renderer/inbox/components/SettingsPage.test.tsx` + +- [ ] **Step 1: failing test 작성** + +```tsx +// src/renderer/inbox/components/SettingsPage.test.tsx +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SettingsPage } from './SettingsPage'; +import { useInbox } from '../store'; + +describe('SettingsPage', () => { + beforeEach(() => { + useInbox.setState({ showSettings: true }); + }); + + it('renders header with "← 돌아가기" button', () => { + render(); + expect(screen.getByRole('button', { name: /돌아가기/ })).toBeInTheDocument(); + }); + + it('renders 4 section headings', () => { + render(); + expect(screen.getByText('AI 제공자')).toBeInTheDocument(); + expect(screen.getByText('자동 실행')).toBeInTheDocument(); + expect(screen.getByText('백업 / 복원')).toBeInTheDocument(); + expect(screen.getByText('정보')).toBeInTheDocument(); + }); + + it('clicking "← 돌아가기" sets showSettings to false', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /돌아가기/ })); + expect(useInbox.getState().showSettings).toBe(false); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail** + +```bash +npx vitest run src/renderer/inbox/components/SettingsPage.test.tsx +``` + +Expected: import error (SettingsPage 미존재). + +- [ ] **Step 3: SettingsPage.tsx 작성 (4 섹션 placeholder + 헤더)** + +```tsx +// src/renderer/inbox/components/SettingsPage.tsx +import React from 'react'; +import { useInbox } from '../store'; + +export function SettingsPage(): React.ReactElement { + const setShowSettings = useInbox((s) => s.setShowSettings); + return ( +
+
+ +

설정

+
+
+

AI 제공자

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

자동 실행

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

백업 / 복원

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

정보

+ {/* InfoSection — Task 11 */} +
+
+ ); +} +``` + +- [ ] **Step 4: 테스트 통과 확인** + +```bash +npx vitest run src/renderer/inbox/components/SettingsPage.test.tsx +``` + +Expected: 3 tests pass. + +- [ ] **Step 5: typecheck** + +```bash +npm run typecheck +``` + +- [ ] **Step 6: 커밋** + +```bash +git add src/renderer/inbox/components/SettingsPage.tsx src/renderer/inbox/components/SettingsPage.test.tsx +git commit -m "feat(v027): SettingsPage scaffold — 4 섹션 placeholder + 돌아가기" +``` + +--- + +### Task 8: AiProviderSection.tsx — OllamaSettingsModal 흡수 + +**Files:** + +- Create: `src/renderer/inbox/components/settings/AiProviderSection.tsx` +- Create: `src/renderer/inbox/components/settings/AiProviderSection.test.tsx` +- Modify: `src/renderer/inbox/components/SettingsPage.tsx` (placeholder → 실 import) + +**선행 참조:** `src/renderer/inbox/components/OllamaSettingsModal.tsx` 의 endpoint zod 검증 + model 입력 + 저장 로직을 그대로 흡수. 차이점: modal frame (overlay) 제거 + section 형태 + "지금 재확인" 버튼 추가. + +- [ ] **Step 1: failing test 작성** + +```tsx +// src/renderer/inbox/components/settings/AiProviderSection.test.tsx +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { AiProviderSection } from './AiProviderSection'; + +vi.mock('../../api', () => ({ + inboxApi: { + getOllamaSettings: vi.fn(async () => ({ endpoint: 'http://localhost:11434', model: 'gemma2:2b' })), + saveOllamaSettings: vi.fn(async () => ({ ok: true })), + ollamaRecheck: vi.fn(async () => ({ ok: true })) + } +})); + +describe('AiProviderSection', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('loads current settings on mount', async () => { + render(); + expect(await screen.findByDisplayValue('http://localhost:11434')).toBeInTheDocument(); + expect(screen.getByDisplayValue('gemma2:2b')).toBeInTheDocument(); + }); + + it('rejects invalid endpoint URL', async () => { + render(); + await screen.findByDisplayValue('http://localhost:11434'); + const input = screen.getByLabelText(/Endpoint/); + fireEvent.change(input, { target: { value: 'not-a-url' } }); + fireEvent.click(screen.getByRole('button', { name: /저장/ })); + expect(await screen.findByText(/올바른 URL/)).toBeInTheDocument(); + }); + + it('"지금 재확인" calls ollamaRecheck and shows result', async () => { + const { inboxApi } = await import('../../api'); + render(); + await screen.findByDisplayValue('http://localhost:11434'); + fireEvent.click(screen.getByRole('button', { name: /지금 재확인/ })); + expect(inboxApi.ollamaRecheck).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail** + +```bash +npx vitest run src/renderer/inbox/components/settings/AiProviderSection.test.tsx +``` + +Expected: import error. + +- [ ] **Step 3: AiProviderSection.tsx 작성** + +기존 `OllamaSettingsModal.tsx` 의 form 로직 (endpoint zod safeParse, model trim, save) 흡수 + modal overlay 제거. 추가 기능: "지금 재확인" 버튼 → `inboxApi.ollamaRecheck()` 결과 inline 표시. + +```tsx +// src/renderer/inbox/components/settings/AiProviderSection.tsx +import React, { useEffect, useState } from 'react'; +import { z } from 'zod'; +import { inboxApi } from '../../api'; + +const endpointSchema = z.string().url(); + +export function AiProviderSection(): React.ReactElement { + const [endpoint, setEndpoint] = useState(''); + const [model, setModel] = useState(''); + const [error, setError] = useState(null); + const [saveResult, setSaveResult] = useState(null); + const [recheckResult, setRecheckResult] = useState(null); + + useEffect(() => { + void (async () => { + const s = await inboxApi.getOllamaSettings(); + setEndpoint(s.endpoint); + setModel(s.model); + })(); + }, []); + + async function onSave(): Promise { + const r = endpointSchema.safeParse(endpoint); + if (!r.success) { setError('올바른 URL 형식이 아닙니다'); return; } + if (model.trim() === '') { setError('모델 이름을 입력해주세요'); return; } + setError(null); + const result = await inboxApi.saveOllamaSettings({ endpoint, model }); + setSaveResult(result.ok ? '저장됨' : '저장 실패'); + } + + async function onRecheck(): Promise { + setRecheckResult('확인 중...'); + const r = await inboxApi.ollamaRecheck(); + setRecheckResult(r.ok ? '✅ 연결됨' : `⚠️ ${r.reason ?? '연결 실패'}`); + } + + return ( +
+ + + {error &&
{error}
} + {saveResult &&
{saveResult}
} +
+ + +
+ {recheckResult &&
{recheckResult}
} +
+ ); +} +``` + +- [ ] **Step 4: SettingsPage.tsx 의 AI 섹션 placeholder 를 실 import 로 교체** + +`{/* AiProviderSection — Task 8 */}` 를 `` 로 교체. import 추가. + +- [ ] **Step 5: 테스트 + typecheck** + +```bash +npx vitest run src/renderer/inbox/components/settings/AiProviderSection.test.tsx +npm run typecheck +``` + +Expected: 3 tests pass + 0 typecheck errors. + +`inboxApi.getOllamaSettings` / `saveOllamaSettings` 가 미존재면 추가 필요. 기존 modal 이 호출하던 IPC channel 을 확인해 같은 채널 사용. 채널이 없으면 `src/renderer/inbox/api.ts` + `src/main/ipc/inboxApi.ts` 에 추가 (별도 step 으로 분리하지 않고 같은 task 안에서). + +- [ ] **Step 6: 커밋** + +```bash +git add src/renderer/inbox/components/settings/AiProviderSection.tsx src/renderer/inbox/components/settings/AiProviderSection.test.tsx src/renderer/inbox/components/SettingsPage.tsx src/renderer/inbox/api.ts src/main/ipc/inboxApi.ts +git commit -m "feat(v027): AiProviderSection — OllamaSettingsModal 흡수 + 지금 재확인" +``` + +--- + +### Task 9: AutostartSection.tsx — 토글만 (진단 패널은 Task 23 에서) + +**Files:** + +- Create: `src/renderer/inbox/components/settings/AutostartSection.tsx` +- Create: `src/renderer/inbox/components/settings/AutostartSection.test.tsx` +- Modify: `src/renderer/inbox/components/SettingsPage.tsx` +- Modify: `src/renderer/inbox/api.ts` (autostart get/set wrapper) +- Modify: `src/preload/index.ts` (채널 expose) + +- [ ] **Step 1: failing test 작성** + +```tsx +// src/renderer/inbox/components/settings/AutostartSection.test.tsx +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { AutostartSection } from './AutostartSection'; + +vi.mock('../../api', () => ({ + inboxApi: { + getAutostart: vi.fn(async () => ({ openAtLogin: true })), + setAutostart: vi.fn(async (open: boolean) => ({ openAtLogin: open })) + } +})); + +describe('AutostartSection', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('renders toggle reflecting current state', async () => { + render(); + const toggle = await screen.findByRole('checkbox'); + expect(toggle).toBeChecked(); + }); + + it('clicking toggle calls setAutostart', async () => { + const { inboxApi } = await import('../../api'); + render(); + const toggle = await screen.findByRole('checkbox'); + fireEvent.click(toggle); + await waitFor(() => expect(inboxApi.setAutostart).toHaveBeenCalledWith(false)); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail** + +```bash +npx vitest run src/renderer/inbox/components/settings/AutostartSection.test.tsx +``` + +- [ ] **Step 3: AutostartSection.tsx 작성** + +```tsx +// src/renderer/inbox/components/settings/AutostartSection.tsx +import React, { useEffect, useState } from 'react'; +import { inboxApi } from '../../api'; + +export function AutostartSection(): React.ReactElement { + const [openAtLogin, setOpenAtLogin] = useState(null); + + useEffect(() => { + void (async () => { + const s = await inboxApi.getAutostart(); + setOpenAtLogin(s.openAtLogin); + })(); + }, []); + + async function onToggle(e: React.ChangeEvent): Promise { + const next = e.target.checked; + const r = await inboxApi.setAutostart(next); + setOpenAtLogin(r.openAtLogin); + } + + if (openAtLogin === null) return
로딩 중...
; + + return ( +
+ +
+ ); +} +``` + +- [ ] **Step 4: api.ts 에 wrapper 추가** + +```ts +// src/renderer/inbox/api.ts 의 inboxApi 객체에 추가: + async getAutostart() { + return await window.inkling.invoke('settings:get-autostart'); + }, + async setAutostart(open: boolean) { + return await window.inkling.invoke('settings:set-autostart', open); + }, +``` + +- [ ] **Step 5: preload + main IPC handler 추가** + +`src/preload/index.ts` 의 invoke 화이트리스트에 `'settings:get-autostart'`, `'settings:set-autostart'` 추가. main 측 IPC 는 Task 22 에서 본격 구현 — 여기선 임시 stub: + +```ts +// src/main/ipc/settingsApi.ts (NEW or 기존 파일에 append) +import electron from 'electron'; +const { ipcMain, app } = electron; + +export function registerSettingsApi(): void { + ipcMain.handle('settings:get-autostart', () => { + const r = app.getLoginItemSettings({ args: ['--hidden'] }); + return { openAtLogin: r.openAtLogin }; + }); + ipcMain.handle('settings:set-autostart', (_e, open: boolean) => { + app.setLoginItemSettings({ openAtLogin: open, args: ['--hidden'] }); + const r = app.getLoginItemSettings({ args: ['--hidden'] }); + return { openAtLogin: r.openAtLogin }; + }); +} +``` + +`src/main/index.ts` whenReady 안에 `registerSettingsApi()` 호출 추가. + +- [ ] **Step 6: SettingsPage placeholder 교체 + 테스트 + 커밋** + +```bash +npx vitest run src/renderer/inbox/components/settings/AutostartSection.test.tsx +npm run typecheck +git add -A src/renderer/inbox/ src/main/ipc/settingsApi.ts src/main/index.ts src/preload/ +git commit -m "feat(v027): AutostartSection 토글 (진단 패널은 후속 task)" +``` + +--- + +### Task 10: BackupSection.tsx — 5 버튼 + +**Files:** + +- Create: `src/renderer/inbox/components/settings/BackupSection.tsx` +- Create: `src/renderer/inbox/components/settings/BackupSection.test.tsx` +- Modify: `src/renderer/inbox/components/SettingsPage.tsx` +- Modify: `src/renderer/inbox/api.ts` +- Modify: `src/main/ipc/settingsApi.ts` +- Modify: `src/preload/index.ts` + +**선행 참조:** `src/main/index.ts` 의 트레이 callback (`runBackup`, `runExport`, `runImport`, `runSync`, `runExportTelemetry`) 5개를 IPC 핸들러로 전환. 핸들러 본문 = 기존 callback 본문 그대로 이동. + +- [ ] **Step 1: failing test** + +```tsx +// src/renderer/inbox/components/settings/BackupSection.test.tsx +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { BackupSection } from './BackupSection'; + +vi.mock('../../api', () => ({ + inboxApi: { + runBackup: vi.fn(async () => ({ ok: true })), + runExport: vi.fn(async () => ({ ok: true })), + runImport: vi.fn(async () => ({ ok: true })), + runSync: vi.fn(async () => ({ ok: true })), + runExportTelemetry: vi.fn(async () => ({ ok: true })) + } +})); + +describe('BackupSection', () => { + it('renders 5 buttons', () => { + render(); + expect(screen.getByRole('button', { name: /지금 백업/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /내보내기/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /백업에서 복원/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /지금 동기화/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /사용 로그/ })).toBeInTheDocument(); + }); + + it('each button triggers corresponding api call', async () => { + const { inboxApi } = await import('../../api'); + render(); + fireEvent.click(screen.getByRole('button', { name: /지금 백업/ })); + expect(inboxApi.runBackup).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail** + +```bash +npx vitest run src/renderer/inbox/components/settings/BackupSection.test.tsx +``` + +- [ ] **Step 3: BackupSection.tsx 작성** + +```tsx +// src/renderer/inbox/components/settings/BackupSection.tsx +import React, { useState } from 'react'; +import { inboxApi } from '../../api'; + +export function BackupSection(): React.ReactElement { + const [status, setStatus] = useState(null); + + async function run(label: string, fn: () => Promise): Promise { + setStatus(`${label}: 진행 중...`); + try { + await fn(); + setStatus(`${label}: 완료`); + } catch (e) { + setStatus(`${label}: 실패 — ${(e as Error).message}`); + } + } + + return ( +
+ + + + + + {status &&
{status}
} +
+ ); +} +``` + +- [ ] **Step 4: IPC 핸들러 5개를 settingsApi.ts 에 추가** + +`src/main/ipc/settingsApi.ts` 의 `registerSettingsApi` 안에: + +```ts + ipcMain.handle('settings:run-backup', async () => { + // 기존 src/main/index.ts 의 runBackup callback 본문 이동 + return await BackupService.run(); + }); + ipcMain.handle('settings:run-export', async () => { + return await BackupService.export(); + }); + ipcMain.handle('settings:run-import', async () => { + return await BackupService.import(); + }); + ipcMain.handle('settings:run-sync', async () => { + return await BackupService.sync(); + }); + ipcMain.handle('settings:run-export-telemetry', async () => { + return await TelemetryService.export(); + }); +``` + +실제 BackupService / TelemetryService import 와 시그니처는 `src/main/index.ts` 의 기존 callback 본문 그대로 복사. 시그니처가 callback 안에 inline 으로 짜여있다면 그 inline 코드를 핸들러 본문으로 이동. `src/main/index.ts` 의 runBackup 등 ref 는 Task 17 에서 정리. + +- [ ] **Step 5: api.ts wrapper + preload 채널 + SettingsPage 교체 + 테스트** + +```ts +// src/renderer/inbox/api.ts inboxApi 객체에 추가: + async runBackup() { return await window.inkling.invoke('settings:run-backup'); }, + async runExport() { return await window.inkling.invoke('settings:run-export'); }, + async runImport() { return await window.inkling.invoke('settings:run-import'); }, + async runSync() { return await window.inkling.invoke('settings:run-sync'); }, + async runExportTelemetry() { return await window.inkling.invoke('settings:run-export-telemetry'); }, +``` + +`src/preload/index.ts` 화이트리스트에 5채널 추가. `SettingsPage.tsx` 의 placeholder 교체. + +```bash +npx vitest run src/renderer/inbox/components/settings/BackupSection.test.tsx +npm run typecheck +``` + +- [ ] **Step 6: 커밋** + +```bash +git add -A src/renderer/inbox/ src/main/ipc/settingsApi.ts src/preload/ +git commit -m "feat(v027): BackupSection — 5 버튼 + IPC 핸들러" +``` + +--- + +### Task 11: InfoSection.tsx — 버전 정보 + 데이터 위치 + +**Files:** + +- Create: `src/renderer/inbox/components/settings/InfoSection.tsx` +- Create: `src/renderer/inbox/components/settings/InfoSection.test.tsx` +- Modify: `src/renderer/inbox/components/SettingsPage.tsx` +- Modify: `src/renderer/inbox/api.ts` +- Modify: `src/main/ipc/settingsApi.ts` +- Modify: `src/preload/index.ts` + +**선행 참조:** `src/main/tray.ts` 의 `showAboutDialog` 함수의 `detail` 문자열 + clipboard.writeText / shell.openPath 로직을 IPC 핸들러로 추출. + +- [ ] **Step 1: failing test** + +```tsx +// src/renderer/inbox/components/settings/InfoSection.test.tsx +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { InfoSection } from './InfoSection'; + +vi.mock('../../api', () => ({ + inboxApi: { + getAppInfo: vi.fn(async () => ({ + version: '0.2.7', + electron: '41.3.0', + node: '22.x', + os: 'darwin 23.6.0', + profileDir: '/Users/u/Library/Application Support/Inkling' + })), + openProfileDir: vi.fn(async () => undefined), + copyAppInfo: vi.fn(async () => undefined) + } +})); + +describe('InfoSection', () => { + it('renders version, electron, node, OS, profileDir', async () => { + render(); + expect(await screen.findByText(/0\.2\.7/)).toBeInTheDocument(); + expect(screen.getByText(/41\.3\.0/)).toBeInTheDocument(); + expect(screen.getByText(/22\.x/)).toBeInTheDocument(); + expect(screen.getByText(/darwin/)).toBeInTheDocument(); + expect(screen.getByText(/Library\/Application Support\/Inkling/)).toBeInTheDocument(); + }); + + it('"데이터 위치 열기" calls openProfileDir', async () => { + const { inboxApi } = await import('../../api'); + render(); + await screen.findByText(/0\.2\.7/); + fireEvent.click(screen.getByRole('button', { name: /데이터 위치 열기/ })); + expect(inboxApi.openProfileDir).toHaveBeenCalled(); + }); + + it('"정보 복사" calls copyAppInfo', async () => { + const { inboxApi } = await import('../../api'); + render(); + await screen.findByText(/0\.2\.7/); + fireEvent.click(screen.getByRole('button', { name: /정보 복사/ })); + expect(inboxApi.copyAppInfo).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail** + +- [ ] **Step 3: InfoSection.tsx 작성** + +```tsx +// src/renderer/inbox/components/settings/InfoSection.tsx +import React, { useEffect, useState } from 'react'; +import { inboxApi } from '../../api'; + +interface AppInfo { + version: string; + electron: string; + node: string; + os: string; + profileDir: string; +} + +export function InfoSection(): React.ReactElement { + const [info, setInfo] = useState(null); + + useEffect(() => { + void (async () => { + setInfo(await inboxApi.getAppInfo()); + })(); + }, []); + + if (!info) return
로딩 중...
; + + return ( +
+
+
버전
{info.version}
+
Electron
{info.electron}
+
Node
{info.node}
+
OS
{info.os}
+
데이터 위치
{info.profileDir}
+
+
+ + +
+
+ ); +} +``` + +- [ ] **Step 4: IPC 핸들러 추가** + +`src/main/ipc/settingsApi.ts` 의 `registerSettingsApi` 안에: + +```ts +import { platform, release, EOL } from 'node:os'; +const { shell, clipboard } = electron; + + ipcMain.handle('settings:get-app-info', () => { + return { + version: app.getVersion(), + electron: process.versions.electron ?? '?', + node: process.versions.node ?? '?', + os: `${platform()} ${release()}`, + profileDir: app.getPath('userData') + }; + }); + ipcMain.handle('settings:open-profile-dir', async () => { + await shell.openPath(app.getPath('userData')); + }); + ipcMain.handle('settings:copy-app-info', () => { + const v = app.getVersion(); + const detail = [ + `버전: ${v}`, + `Electron: ${process.versions.electron}`, + `Node: ${process.versions.node}`, + `OS: ${platform()} ${release()}`, + `데이터 위치: ${app.getPath('userData')}` + ].join(EOL); + clipboard.writeText(`Inkling ${v}${EOL}${detail}`); + }); +``` + +- [ ] **Step 5: api wrapper + preload + SettingsPage 교체 + 테스트** + +```ts +// src/renderer/inbox/api.ts: + async getAppInfo() { return await window.inkling.invoke('settings:get-app-info'); }, + async openProfileDir() { return await window.inkling.invoke('settings:open-profile-dir'); }, + async copyAppInfo() { return await window.inkling.invoke('settings:copy-app-info'); }, +``` + +preload 화이트리스트 + SettingsPage placeholder 교체. + +```bash +npx vitest run src/renderer/inbox/components/settings/InfoSection.test.tsx +npm run typecheck +``` + +- [ ] **Step 6: 커밋** + +```bash +git add -A src/renderer/inbox/ src/main/ipc/settingsApi.ts src/preload/ +git commit -m "feat(v027): InfoSection — 버전/데이터 위치/복사 + IPC" +``` + +--- + +### Task 12: App.tsx — showSettings 분기 + 헤더 톱니바퀴 + +**Files:** + +- Modify: `src/renderer/inbox/App.tsx` +- Modify: `src/renderer/inbox/App.test.tsx` (없으면 신규) + +- [ ] **Step 1: failing test (showSettings=true 시 SettingsPage 렌더 + 톱니바퀴 클릭 시 setShowSettings(true))** + +```tsx +// src/renderer/inbox/App.test.tsx (해당 부분 추가) +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { App } from './App'; +import { useInbox } from './store'; + +describe('App — settings view', () => { + beforeEach(() => { + useInbox.setState({ showSettings: false, notes: [], trashNotes: [], trashCount: 0 }); + }); + + it('renders SettingsPage when showSettings=true', () => { + useInbox.setState({ showSettings: true }); + render(); + expect(screen.getByText('설정')).toBeInTheDocument(); + expect(screen.getByText('AI 제공자')).toBeInTheDocument(); + }); + + it('header gear icon click sets showSettings=true', () => { + render(); + fireEvent.click(screen.getByLabelText('설정 열기')); + expect(useInbox.getState().showSettings).toBe(true); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail** + +```bash +npx vitest run src/renderer/inbox/App.test.tsx +``` + +- [ ] **Step 3: App.tsx 수정** + +`src/renderer/inbox/App.tsx` 의 return 직전: + +```tsx + const showSettings = useInbox((s) => s.showSettings); + const setShowSettings = useInbox((s) => s.setShowSettings); + if (showSettings) return ; +``` + +`SettingsPage` import 추가. 기존 OllamaSettingsModal 관련 state (`ollamaSettingsOpen`) 는 Task 25 에서 제거. + +헤더 우측 (ContinuityBadge / IdentityCounter 옆) 에 톱니바퀴 추가: + +```tsx + +``` + +- [ ] **Step 4: 테스트 + typecheck** + +```bash +npx vitest run src/renderer/inbox/App.test.tsx +npm run typecheck +``` + +- [ ] **Step 5: 수동 launch 검증** + +```bash +npm run rebuild:electron && npm run start +``` + +inbox 윈도우 → 헤더 톱니바퀴 클릭 → SettingsPage 등장 → "← 돌아가기" → inbox 복귀. + +- [ ] **Step 6: 커밋** + +```bash +git add src/renderer/inbox/App.tsx src/renderer/inbox/App.test.tsx +git commit -m "feat(v027): App.tsx 헤더 톱니바퀴 + showSettings 분기" +``` + +--- + +### Task 13: IPC `inbox:navigate` — 트레이/외부에서 설정 진입 + +**Files:** + +- Modify: `src/main/ipc/settingsApi.ts` (또는 별도 navigationApi) +- Modify: `src/preload/index.ts` +- Modify: `src/renderer/inbox/api.ts` +- Modify: `src/renderer/inbox/App.tsx` (navigate 이벤트 구독) + +- [ ] **Step 1: failing test (renderer 측)** + +```tsx +// src/renderer/inbox/App.test.tsx 에 추가: +it('inbox:navigate "settings" event sets showSettings=true', async () => { + // mock: api.onNavigate registers a listener that App calls + const navHandlers: Array<(view: string) => void> = []; + vi.mocked(inboxApi.onNavigate).mockImplementation((cb) => { + navHandlers.push(cb); + return () => { navHandlers.splice(navHandlers.indexOf(cb), 1); }; + }); + render(); + navHandlers.forEach((h) => h('settings')); + await waitFor(() => expect(useInbox.getState().showSettings).toBe(true)); +}); +``` + +(상위 `vi.mock` 블록에 `onNavigate: vi.fn(() => () => undefined)` 추가) + +- [ ] **Step 2: 테스트 실행 → fail (onNavigate 미존재)** + +- [ ] **Step 3: api.ts + preload 에 onNavigate 추가** + +```ts +// src/renderer/inbox/api.ts: + onNavigate(cb: (view: 'inbox' | 'trash' | 'settings') => void) { + return window.inkling.on('inbox:navigate', cb); + } +``` + +`src/preload/index.ts` 의 on whitelist 에 `'inbox:navigate'` 추가. + +- [ ] **Step 4: main 측 sender 함수** + +`src/main/ipc/settingsApi.ts` 의 export 에 추가: + +```ts +import { getInboxWindow } from '../windows/inboxWindow'; + +export function navigateInbox(view: 'inbox' | 'trash' | 'settings'): void { + const win = getInboxWindow(); + if (win && !win.isDestroyed()) { + if (!win.isVisible()) win.show(); + win.focus(); + win.webContents.send('inbox:navigate', view); + } +} +``` + +(트레이 "설정..." 클릭 시 호출 — Task 16 에서 wiring.) + +- [ ] **Step 5: App.tsx 에 navigate 이벤트 구독 추가** + +`useEffect` 안에: + +```tsx +const unsubNav = inboxApi.onNavigate((view) => { + if (view === 'settings') useInbox.getState().setShowSettings(true); + else if (view === 'inbox') useInbox.getState().setShowSettings(false); +}); +// cleanup return 에 unsubNav() 추가 +``` + +- [ ] **Step 6: 테스트 + typecheck + 커밋** + +```bash +npx vitest run src/renderer/inbox/App.test.tsx +npm run typecheck +git add -A src/renderer/inbox/ src/main/ipc/ src/preload/ +git commit -m "feat(v027): IPC inbox:navigate — 외부에서 설정 페이지 진입" +``` + +Phase 2 종료. 이 시점에 inbox 안에서 톱니바퀴 진입 + 4 섹션 (자동 실행은 토글만, 진단은 후속) 모두 동작. + +--- + +## Phase 3: 트레이 슬림 + +### Task 14: TrayCallbacks 인터페이스 슬림 (showSettings 추가, 8개 제거) + +**Files:** + +- Modify: `src/main/tray.ts` +- Modify: `src/main/tray.test.ts` (없으면 신규) + +- [ ] **Step 1: failing test (4 항목 검증 + 제거된 항목 부재 검증)** + +```ts +// src/main/tray.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +// electron mocked at top-level vitest setup OR per-test module mock +vi.mock('electron', () => ({ + default: { + app: { on: vi.fn(), getPath: vi.fn(), getVersion: vi.fn(() => '0.2.7'), isPackaged: false, getLoginItemSettings: vi.fn(() => ({ openAtLogin: false })) }, + Tray: vi.fn().mockImplementation(() => ({ + setToolTip: vi.fn(), + setContextMenu: vi.fn(), + on: vi.fn() + })), + Menu: { buildFromTemplate: vi.fn((items) => ({ items })) }, + nativeImage: { createEmpty: vi.fn() }, + dialog: {}, + shell: {}, + clipboard: {} + } +})); + +import { createTray, type TrayCallbacks } from './tray'; + +describe('tray menu — slim 4 items', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + function makeCallbacks(): TrayCallbacks { + return { + showInbox: vi.fn(), + showCapture: vi.fn(), + showSettings: vi.fn() + }; + } + + it('builds menu with 4 click items + 2 separators', async () => { + createTray(makeCallbacks()); + const electron = (await import('electron')).default; + const calls = (electron.Menu.buildFromTemplate as any).mock.calls; + const items = calls[calls.length - 1][0]; + const labels = items.filter((i: any) => i.type !== 'separator').map((i: any) => i.label); + expect(labels).toEqual(['한 줄 적기', '보관한 메모 보기', '설정...', '종료']); + }); + + it('does not include removed items (백업/내보내기/Ollama 재확인 등)', async () => { + createTray(makeCallbacks()); + const electron = (await import('electron')).default; + const calls = (electron.Menu.buildFromTemplate as any).mock.calls; + const items = calls[calls.length - 1][0]; + const labels = items.filter((i: any) => i.type !== 'separator').map((i: any) => i.label); + expect(labels).not.toContain('지금 백업'); + expect(labels).not.toContain('내보내기...'); + expect(labels).not.toContain('Ollama 재확인'); + expect(labels).not.toContain('지금 AI 처리'); + expect(labels).not.toContain('Ollama 설정...'); + }); + + it('"설정..." click invokes showSettings callback', async () => { + const cb = makeCallbacks(); + createTray(cb); + const electron = (await import('electron')).default; + const calls = (electron.Menu.buildFromTemplate as any).mock.calls; + const items = calls[calls.length - 1][0]; + const settingsItem = items.find((i: any) => i.label === '설정...'); + settingsItem.click(); + expect(cb.showSettings).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail** + +```bash +npm run rebuild:node && npx vitest run src/main/tray.test.ts +``` + +Expected: TypeScript 또는 runtime fail (TrayCallbacks 가 아직 옛 인터페이스). + +- [ ] **Step 3: tray.ts 의 인터페이스 + state 슬림화** + +```ts +// src/main/tray.ts (인터페이스 부분만 교체) +export interface TrayCallbacks { + showInbox: () => void; + showCapture: () => void; + showSettings: () => void; +} + +export interface TrayState { + todayCount: number; +} + +let _state: TrayState = { todayCount: 0 }; +``` + +기존 `ollamaOk`, `failedCount` 필드 + `runBackup`/`runExport`/`runImport`/`runSync`/`runExportTelemetry`/`runOllamaRecheck`/`runRetryAllFailed`/`runOpenOllamaSettings` callback 제거. + +- [ ] **Step 4: buildMenu 4 항목 본문** + +```ts +function buildMenu(): electron.Menu { + const items: MenuItemConstructorOptions[] = []; + const cb = _callbacks; + if (!cb) return Menu.buildFromTemplate([{ label: '로딩 중...', enabled: false }]); + if (_state.todayCount > 0) { + items.push({ label: `오늘 ${_state.todayCount}번 잡아둠`, enabled: false }); + items.push({ type: 'separator' }); + } + items.push({ label: '한 줄 적기', click: cb.showCapture }); + items.push({ label: '보관한 메모 보기', click: cb.showInbox }); + items.push({ type: 'separator' }); + items.push({ label: '설정...', click: cb.showSettings }); + items.push({ type: 'separator' }); + items.push({ label: '종료', click: () => { app.isQuitting = true; app.quit(); } }); + return Menu.buildFromTemplate(items); +} +``` + +기존 `if (app.isPackaged)` 의 자동 실행 checkbox 분기 + Inkling 정보 항목 + Ollama 재확인/AI 재처리/Ollama 설정 항목 모두 제거. `showAboutDialog` 함수도 제거 (Task 25 cleanup). + +- [ ] **Step 5: refreshTray signature 유지 (Partial) — todayCount 만 영향** + +```ts +export function refreshTray(state: Partial): void { + _state = { ..._state, ...state }; + if (tray === null) return; + tray.setToolTip(`Inkling — 오늘 ${_state.todayCount}`); + tray.setContextMenu(buildMenu()); +} +``` + +- [ ] **Step 6: 테스트 + typecheck + 커밋** + +```bash +npx vitest run src/main/tray.test.ts +npm run typecheck +``` + +이 시점 typecheck 는 `src/main/index.ts` 의 createTray 호출부와 HealthChecker/AiWorker 의 refreshTray 호출부에서 type error 발생 — Task 17 에서 정리. Task 14 만 단독 commit: + +```bash +git add src/main/tray.ts src/main/tray.test.ts +git commit -m "feat(v027): TrayCallbacks/TrayState 슬림 + buildMenu 4 항목" +``` + +--- + +### Task 15: tray.ts — showAboutDialog / 자동실행 checkbox / Ollama 분기 코드 제거 + +**Files:** + +- Modify: `src/main/tray.ts` + +이 task 는 Task 14 와 묶여 있지만, 분리 commit 으로 변경 의도 명확화. + +- [ ] **Step 1: showAboutDialog 함수 + 관련 import 제거** + +`src/main/tray.ts` 상단 import 에서 `dialog`, `shell`, `clipboard`, `platform`, `release`, `EOL` 제거 (이제 `settings:get-app-info` IPC 가 정보 dialog 역할). + +`showAboutDialog` 함수 전체 삭제. + +- [ ] **Step 2: typecheck** + +```bash +npm run typecheck +``` + +이 시점에서 tray.ts 자체는 통과 — index.ts 호출부 에러는 Task 17 에서. + +- [ ] **Step 3: 커밋** + +```bash +git add src/main/tray.ts +git commit -m "feat(v027): tray.ts 의 showAboutDialog + 자동실행 분기 + 미사용 import 제거" +``` + +--- + +### Task 16: index.ts — createTray callback wiring 갱신 + +**Files:** + +- Modify: `src/main/index.ts` + +- [ ] **Step 1: 기존 트레이 callback wiring 위치 확인** + +```bash +grep -n 'createTray' src/main/index.ts +``` + +기존 호출부가 10-positional 이었던 시점부터 v0.2.6 의 객체 형태 (`createTray({ showInbox, showCapture, runBackup, ... })`) 까지 발전. 현재는 객체 형태. + +- [ ] **Step 2: createTray 호출을 3-callback 객체로 슬림화** + +기존: + +```ts +createTray({ + showInbox, + showCapture, + runBackup, + runExport, + runImport, + runSync, + runExportTelemetry, + runOllamaRecheck, + runRetryAllFailed, + runOpenOllamaSettings +}); +``` + +변경 후: + +```ts +import { navigateInbox } from './ipc/settingsApi'; + +createTray({ + showInbox, + showCapture, + showSettings: () => navigateInbox('settings') +}); +``` + +- [ ] **Step 3: 미사용 callback 함수 제거** + +`runBackup`, `runExport`, `runImport`, `runSync`, `runExportTelemetry`, `runOllamaRecheck`, `runRetryAllFailed`, `runOpenOllamaSettings` 함수 정의 (또는 inline 람다) 가 `index.ts` 에 남아있다면 제거. 본문 로직은 이미 Task 10 (BackupSection) 의 IPC 핸들러로 이동됨. + +- [ ] **Step 4: refreshTray 호출부 슬림화** + +`grep -n 'refreshTray' src/main/index.ts src/main/health/*.ts src/main/ai/*.ts` 로 호출부 모두 찾기. `refreshTray({ ollamaOk })` / `refreshTray({ failedCount })` 호출은 모두 제거 (해당 메뉴 항목이 사라져 무의미). `refreshTray({ todayCount })` 만 잔류. + +- [ ] **Step 5: typecheck + 단위 테스트 회귀** + +```bash +npm run typecheck +npx vitest run +``` + +Expected: 0 errors + 모든 단위 테스트 pass. tray.test.ts 에서 회귀 없는지 + HealthChecker/AiWorker 테스트가 refreshTray 모킹에 의존했다면 mock 갱신 필요. + +- [ ] **Step 6: 커밋** + +```bash +git add src/main/index.ts src/main/health/ src/main/ai/ +git commit -m "feat(v027): createTray wiring 3-callback + refreshTray 호출부 슬림" +``` + +--- + +### Task 17: 트레이 회귀 단위 테스트 + 수동 검증 + +**Files:** + +- 검증 only. + +- [ ] **Step 1: 전체 단위 테스트 + e2e 실행** + +```bash +npm run rebuild:node +npm test +npm run rebuild:electron +npm run test:e2e +``` + +Expected: 모두 pass. e2e 가 트레이 메뉴를 검증하지 않으면 회귀 risk 낮음. + +- [ ] **Step 2: 수동 launch 검증 (Win + macOS)** + +```bash +npm run start +``` + +트레이 우클릭 → 4 항목 (한 줄 적기 / 보관한 메모 보기 / 설정... / 종료) 만 표시. "설정..." 클릭 → inbox 윈도우 등장 + SettingsPage 표시. + +- [ ] **Step 3: 결과 spec §11 또는 별도 줄에 기록** + +문제 없으면 commit 없이 마무리. 회귀 발견 시 fix 후 commit. + +Phase 3 종료. + +--- + +## Phase 4: F14 macOS dock 클릭 fix + +### Task 18: index.ts activate 핸들러 5줄 수정 + +**Files:** + +- Modify: `src/main/index.ts:411-413` + +mocking 비용 높아 단위 테스트 X — manual macOS dogfood 검증. + +- [ ] **Step 1: 현재 activate 핸들러 위치 확인** + +```bash +grep -n "app.on('activate'" src/main/index.ts +``` + +Expected: 라인 ~411. 직접 라인 번호는 Task 16 변경 후 달라질 수 있음. + +- [ ] **Step 2: 핸들러 본문 교체** + +기존: + +```ts +app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createInboxWindow(); +}); +``` + +변경 후: + +```ts +app.on('activate', () => { + const win = getInboxWindow(); + if (win && !win.isDestroyed()) { + if (!win.isVisible()) win.show(); + win.focus(); + } else { + createInboxWindow(); + } +}); +``` + +`getInboxWindow` import 가 이미 있는지 확인 — 없으면 추가: + +```ts +import { createInboxWindow, getInboxWindow } from './windows/inboxWindow'; +``` + +- [ ] **Step 3: typecheck** + +```bash +npm run typecheck +``` + +- [ ] **Step 4: macOS 수동 검증** + +macOS 호스트에서: + +```bash +npm run start +``` + +inbox 윈도우 → 빨간 신호등 (close) → dock 의 Inkling 아이콘 클릭 → inbox 윈도우 즉시 등장 + focus. + +- [ ] **Step 5: 검증 결과 dogfood-feedback.md F14 entry 갱신 (🚀 promoted 마킹) — 일괄 처리는 Task 27 에서** + +지금은 commit 만: + +- [ ] **Step 6: 커밋** + +```bash +git add src/main/index.ts +git commit -m "fix(v027): F14 — macOS dock 클릭 시 hidden inbox 창 show/focus" +``` + +Phase 4 종료. + +--- + +## Phase 5: F12 deeper fix — 자동 실행 진단 노출 + +### Task 19: AutostartDiagnostic 서비스 — withArgs/noArgs/execPath 수집 + +**Files:** + +- Create: `src/main/services/AutostartDiagnostic.ts` +- Create: `src/main/services/AutostartDiagnostic.test.ts` + +- [ ] **Step 1: failing test** + +```ts +// src/main/services/AutostartDiagnostic.test.ts +import { describe, it, expect, vi } from 'vitest'; + +const mockApp = { + getLoginItemSettings: vi.fn() +}; +vi.mock('electron', () => ({ default: { app: mockApp } })); + +const mockExecFile = vi.fn(); +vi.mock('node:child_process', () => ({ execFile: mockExecFile })); + +import { collectAutostartState } from './AutostartDiagnostic'; + +describe('AutostartDiagnostic — collectAutostartState', () => { + it('returns withArgs / noArgs / execPath structure', async () => { + mockApp.getLoginItemSettings + .mockReturnValueOnce({ openAtLogin: true, executableWillLaunchAtLogin: true }) + .mockReturnValueOnce({ openAtLogin: false, executableWillLaunchAtLogin: true }); + Object.defineProperty(process, 'platform', { value: 'darwin' }); + const state = await collectAutostartState(); + expect(state.withArgs).toEqual({ openAtLogin: true, executableWillLaunchAtLogin: true }); + expect(state.noArgs).toEqual({ openAtLogin: false, executableWillLaunchAtLogin: true }); + expect(state.execPath).toBe(process.execPath); + expect(state.registryPath).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail (모듈 미존재)** + +```bash +npx vitest run src/main/services/AutostartDiagnostic.test.ts +``` + +- [ ] **Step 3: AutostartDiagnostic.ts 작성 (Win registry 부분은 Task 20 에서 — 여기선 stub)** + +```ts +// src/main/services/AutostartDiagnostic.ts +import electron from 'electron'; +const { app } = electron; + +export interface AutostartState { + withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean }; + noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean }; + execPath: string; + registryPath?: string; + registryValue?: string | null; +} + +export async function collectAutostartState(): Promise { + const w = app.getLoginItemSettings({ args: ['--hidden'] }); + const n = app.getLoginItemSettings(); + const state: AutostartState = { + withArgs: { openAtLogin: w.openAtLogin, executableWillLaunchAtLogin: w.executableWillLaunchAtLogin }, + noArgs: { openAtLogin: n.openAtLogin, executableWillLaunchAtLogin: n.executableWillLaunchAtLogin }, + execPath: process.execPath + }; + // Win registry 조회는 Task 20 에서 추가 + return state; +} +``` + +- [ ] **Step 4: 테스트 통과 + typecheck + 커밋** + +```bash +npx vitest run src/main/services/AutostartDiagnostic.test.ts +npm run typecheck +git add src/main/services/AutostartDiagnostic.ts src/main/services/AutostartDiagnostic.test.ts +git commit -m "feat(v027): AutostartDiagnostic — withArgs/noArgs/execPath 수집" +``` + +--- + +### Task 20: AutostartDiagnostic — Windows registry 조회 + +**Files:** + +- Modify: `src/main/services/AutostartDiagnostic.ts` +- Modify: `src/main/services/AutostartDiagnostic.test.ts` + +- [ ] **Step 1: failing test (Win 분기 — registry 조회 + null fallback)** + +테스트 파일에 추가: + +```ts +import { execFile } from 'node:child_process'; + +it('Windows: returns registryPath + registryValue when reg.exe succeeds', async () => { + mockApp.getLoginItemSettings.mockReturnValue({ openAtLogin: true, executableWillLaunchAtLogin: true }); + Object.defineProperty(process, 'platform', { value: 'win32' }); + mockExecFile.mockImplementation((_cmd, _args, cb) => { + cb(null, '\r\n Inkling REG_SZ "C:\\\\Users\\\\u\\\\Inkling.exe" --hidden\r\n', ''); + }); + const state = await collectAutostartState(); + expect(state.registryPath).toBe('HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Inkling'); + expect(state.registryValue).toContain('Inkling.exe'); +}); + +it('Windows: returns null registryValue on reg.exe error (silent fallback)', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + mockExecFile.mockImplementation((_cmd, _args, cb) => { + cb(new Error('not found'), '', ''); + }); + const state = await collectAutostartState(); + expect(state.registryValue).toBeNull(); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail** + +- [ ] **Step 3: collectAutostartState 에 Win 분기 추가** + +```ts +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +const execFileAsync = promisify(execFile); + +export async function collectAutostartState(): Promise { + const w = app.getLoginItemSettings({ args: ['--hidden'] }); + const n = app.getLoginItemSettings(); + const state: AutostartState = { + withArgs: { openAtLogin: w.openAtLogin, executableWillLaunchAtLogin: w.executableWillLaunchAtLogin }, + noArgs: { openAtLogin: n.openAtLogin, executableWillLaunchAtLogin: n.executableWillLaunchAtLogin }, + execPath: process.execPath + }; + if (process.platform === 'win32') { + state.registryPath = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Inkling'; + state.registryValue = await readRegistrySilent(state.registryPath); + } + return state; +} + +async function readRegistrySilent(path: string): Promise { + try { + const { stdout } = await execFileAsync('reg', ['query', path, '/v', 'Inkling']); + const m = stdout.match(/REG_SZ\s+(.+)/); + return m ? m[1].trim() : null; + } catch { + return null; + } +} +``` + +- [ ] **Step 4: 테스트 + typecheck + 커밋** + +```bash +npx vitest run src/main/services/AutostartDiagnostic.test.ts +npm run typecheck +git add src/main/services/AutostartDiagnostic.ts src/main/services/AutostartDiagnostic.test.ts +git commit -m "feat(v027): AutostartDiagnostic — Windows registry 조회 + silent fallback" +``` + +--- + +### Task 21: IPC `settings:autostart-state` 핸들러 + +**Files:** + +- Modify: `src/main/ipc/settingsApi.ts` +- Create: `src/main/ipc/settingsApi.test.ts` + +- [ ] **Step 1: failing test (핸들러가 collectAutostartState 결과 그대로 반환)** + +```ts +// src/main/ipc/settingsApi.test.ts +import { describe, it, expect, vi } from 'vitest'; +const handlers: Record = {}; +vi.mock('electron', () => ({ + default: { + ipcMain: { handle: (ch: string, fn: Function) => { handlers[ch] = fn; } }, + app: { /* stubs */ }, + shell: {}, clipboard: {} + } +})); +vi.mock('../services/AutostartDiagnostic', () => ({ + collectAutostartState: vi.fn(async () => ({ + withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true }, + noArgs: { openAtLogin: false, executableWillLaunchAtLogin: true }, + execPath: '/path/to/exe' + })) +})); + +import { registerSettingsApi } from './settingsApi'; + +describe('settings:autostart-state IPC', () => { + it('returns AutostartState from collectAutostartState', async () => { + registerSettingsApi(); + const state = await handlers['settings:autostart-state'](); + expect(state.withArgs.openAtLogin).toBe(true); + expect(state.noArgs.openAtLogin).toBe(false); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → fail** + +- [ ] **Step 3: settingsApi.ts 에 핸들러 추가** + +`registerSettingsApi` 안에: + +```ts +import { collectAutostartState } from '../services/AutostartDiagnostic'; + + ipcMain.handle('settings:autostart-state', () => collectAutostartState()); +``` + +- [ ] **Step 4: 테스트 + typecheck + 커밋** + +```bash +npx vitest run src/main/ipc/settingsApi.test.ts +npm run typecheck +git add src/main/ipc/settingsApi.ts src/main/ipc/settingsApi.test.ts +git commit -m "feat(v027): settings:autostart-state IPC 핸들러" +``` + +--- + +### Task 22: settings:autostart-set 핸들러 정식 구현 + +**Files:** + +- Modify: `src/main/ipc/settingsApi.ts` +- Modify: `src/main/ipc/settingsApi.test.ts` + +Task 9 에서 임시 stub 으로 등록한 `settings:set-autostart` 를 `settings:autostart-set` 으로 rename + 결과로 AutostartState 전체 반환하도록 갱신. + +- [ ] **Step 1: failing test** + +```ts +it('settings:autostart-set: calls setLoginItemSettings + returns updated state', async () => { + const mockSet = vi.fn(); + // electron mock 의 app 에 setLoginItemSettings 추가 — top-level mock 갱신 필요 + registerSettingsApi(); + await handlers['settings:autostart-set'](null, true); + expect(mockSet).toHaveBeenCalledWith({ openAtLogin: true, args: ['--hidden'] }); +}); +``` + +(top-level vi.mock 의 app 에 `setLoginItemSettings: mockSet` 추가) + +- [ ] **Step 2: 테스트 → fail** + +- [ ] **Step 3: 핸들러 갱신** + +```ts + ipcMain.handle('settings:autostart-set', async (_e, open: boolean) => { + app.setLoginItemSettings({ openAtLogin: open, args: ['--hidden'] }); + return await collectAutostartState(); + }); +``` + +기존 Task 9 의 `settings:set-autostart` (또는 `settings:get-autostart`) 임시 stub 은 채널 이름 통일 — `settings:get-autostart` → `settings:autostart-state`, `settings:set-autostart` → `settings:autostart-set`. AutostartSection.tsx 의 호출부도 동시 갱신: + +```ts +// src/renderer/inbox/api.ts + async getAutostart() { return await window.inkling.invoke('settings:autostart-state'); }, + async setAutostart(open: boolean) { return await window.inkling.invoke('settings:autostart-set', open); }, +``` + +preload 화이트리스트 갱신. + +- [ ] **Step 4: AutostartSection.test 회귀 확인** + +```bash +npx vitest run src/renderer/inbox/components/settings/ +npm run typecheck +``` + +- [ ] **Step 5: 커밋** + +```bash +git add -A src/main/ipc/ src/renderer/inbox/ src/preload/ +git commit -m "feat(v027): settings:autostart-set 정식 구현 + 채널 이름 통일" +``` + +--- + +### Task 23: AutostartSection — 진단 패널 UI + +**Files:** + +- Modify: `src/renderer/inbox/components/settings/AutostartSection.tsx` +- Modify: `src/renderer/inbox/components/settings/AutostartSection.test.tsx` + +- [ ] **Step 1: failing test (진단 패널 펼치기 + mismatch ⚠️ 표시)** + +```tsx +it('renders diagnostic panel when expanded, shows ⚠️ on mismatch', async () => { + const { inboxApi } = await import('../../api'); + vi.mocked(inboxApi.getAutostart).mockResolvedValue({ + openAtLogin: true, + diagnostic: { + withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true }, + noArgs: { openAtLogin: false, executableWillLaunchAtLogin: true }, // mismatch + execPath: '/path/to/Inkling.exe' + } + }); + render(); + await screen.findByRole('checkbox'); + fireEvent.click(screen.getByRole('button', { name: /진단 정보/ })); + expect(await screen.findByText(/⚠️/)).toBeInTheDocument(); + expect(screen.getByText('/path/to/Inkling.exe')).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: 테스트 → fail** + +- [ ] **Step 3: AutostartSection 확장** + +```tsx +import React, { useEffect, useState } from 'react'; +import { inboxApi } from '../../api'; + +interface AutostartFull { + openAtLogin: boolean; + diagnostic: { + withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean }; + noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean }; + execPath: string; + registryPath?: string; + registryValue?: string | null; + }; +} + +export function AutostartSection(): React.ReactElement { + const [data, setData] = useState(null); + const [expanded, setExpanded] = useState(false); + + useEffect(() => { + void (async () => setData(await inboxApi.getAutostart()))(); + }, []); + + async function onToggle(e: React.ChangeEvent): Promise { + const r = await inboxApi.setAutostart(e.target.checked); + setData(r); + } + + if (!data) return
로딩 중...
; + const d = data.diagnostic; + const mismatch = d.withArgs.openAtLogin !== d.noArgs.openAtLogin + || !d.withArgs.executableWillLaunchAtLogin; + + return ( +
+ + {mismatch &&
⚠️ 등록 상태 불일치 감지
} + + {expanded && ( +
+
표준 (--hidden 인자): openAtLogin={String(d.withArgs.openAtLogin)}, willLaunch={String(d.withArgs.executableWillLaunchAtLogin)}
+
비교 (인자 없이): openAtLogin={String(d.noArgs.openAtLogin)}, willLaunch={String(d.noArgs.executableWillLaunchAtLogin)}
+
실행 파일 경로: {d.execPath}
+ {d.registryPath &&
registry 경로: {d.registryPath}
} + {d.registryValue !== undefined &&
registry 값: {d.registryValue ?? '(없음)'}
} +
+ )} +
+ ); +} +``` + +(getAutostart 의 반환 타입이 `AutostartFull` 로 확장됨 — main IPC 핸들러도 collectAutostartState 결과를 `diagnostic` 으로 wrap 하여 반환하도록 갱신.) + +main 측 `settings:autostart-state` 핸들러 갱신: + +```ts +ipcMain.handle('settings:autostart-state', async () => { + const diag = await collectAutostartState(); + return { openAtLogin: diag.withArgs.openAtLogin, diagnostic: diag }; +}); +``` + +- [ ] **Step 4: 테스트 + typecheck + 커밋** + +```bash +npx vitest run src/renderer/inbox/components/settings/ +npm run typecheck +git add -A src/renderer/inbox/ src/main/ipc/ +git commit -m "feat(v027): AutostartSection 진단 패널 + mismatch 경고" +``` + +--- + +### Task 24: AutostartSection — "재등록" 버튼 + +**Files:** + +- Modify: `src/renderer/inbox/components/settings/AutostartSection.tsx` +- Modify: `src/renderer/inbox/components/settings/AutostartSection.test.tsx` + +- [ ] **Step 1: failing test** + +```tsx +it('"재등록" button calls setAutostart with current value', async () => { + const { inboxApi } = await import('../../api'); + vi.mocked(inboxApi.getAutostart).mockResolvedValue({ + openAtLogin: true, + diagnostic: { /* ... */ } + }); + render(); + await screen.findByRole('checkbox'); + fireEvent.click(screen.getByRole('button', { name: /재등록/ })); + expect(inboxApi.setAutostart).toHaveBeenCalledWith(true); +}); +``` + +- [ ] **Step 2: 테스트 → fail** + +- [ ] **Step 3: AutostartSection 에 버튼 추가** + +```tsx +async function onReregister(): Promise { + if (!data) return; + const r = await inboxApi.setAutostart(data.openAtLogin); + setData(r); +} + +// JSX 에 버튼 추가 (mismatch 경고 옆 또는 아래): + +``` + +- [ ] **Step 4: 테스트 + typecheck + 커밋** + +```bash +npx vitest run src/renderer/inbox/components/settings/ +npm run typecheck +git add src/renderer/inbox/components/settings/AutostartSection.tsx src/renderer/inbox/components/settings/AutostartSection.test.tsx +git commit -m "feat(v027): AutostartSection 재등록 버튼" +``` + +Phase 5 종료. + +--- + +## Phase 6: Cleanup + verification + +### Task 25: OllamaSettingsModal.tsx 제거 + 관련 import 정리 + +**Files:** + +- Delete: `src/renderer/inbox/components/OllamaSettingsModal.tsx` +- Modify: `src/renderer/inbox/App.tsx` (import + state 제거) +- Modify: `src/renderer/inbox/api.ts` (onOpenOllamaSettings 미사용 시 제거) + +- [ ] **Step 1: 사용처 확인** + +```bash +grep -rn 'OllamaSettingsModal\|onOpenOllamaSettings' src/ +``` + +Expected: `src/renderer/inbox/App.tsx` 에 import + `ollamaSettingsOpen` state. 다른 곳 0건이면 안전 제거. + +- [ ] **Step 2: App.tsx 정리** + +App.tsx 에서: + +- `import { OllamaSettingsModal } from './components/OllamaSettingsModal.js';` 제거 +- `const [ollamaSettingsOpen, setOllamaSettingsOpen] = useState(false);` 제거 +- `useEffect` 안 `inboxApi.onOpenOllamaSettings(...)` 구독 + cleanup 제거 +- JSX 에서 `` 렌더 제거 + +- [ ] **Step 3: api.ts 의 onOpenOllamaSettings 제거 (사용처 없음 확인 후)** + +```bash +grep -rn 'onOpenOllamaSettings' src/ +``` + +App.tsx 에서 제거됐으면 src 전체 0건. api.ts + preload 의 채널 expose 제거. + +main 측 `runOpenOllamaSettings` callback (Task 16 에서 이미 제거됐을 것) 도 잔재 없는지 재확인. + +- [ ] **Step 4: 파일 삭제** + +```bash +rm src/renderer/inbox/components/OllamaSettingsModal.tsx +``` + +- [ ] **Step 5: 전체 테스트 + typecheck** + +```bash +npm run rebuild:node +npm test +npm run typecheck +``` + +- [ ] **Step 6: 커밋** + +```bash +git add -A +git commit -m "refactor(v027): OllamaSettingsModal 제거 + onOpenOllamaSettings 채널 cleanup" +``` + +--- + +### Task 26: 전체 회귀 검증 + e2e + +**Files:** + +- 검증 only. + +- [ ] **Step 1: 단위 + e2e + typecheck 일괄** + +```bash +npm run rebuild:node +npm test +npm run typecheck +npm run rebuild:electron +npm run test:e2e +``` + +Expected: 모두 pass. 단위 426 → 약 450 (기준선 메모 `project_inkling_status.md`). + +- [ ] **Step 2: Win 수동 launch 검증** + +```bash +npm run start +``` + +체크리스트: + +- 트레이 4 항목만 표시 +- 트레이 "설정..." → SettingsPage 진입 → 4 섹션 (AI / 자동 실행 / 백업 / 정보) 모두 표시 +- AI 섹션 endpoint 변경 → 저장 → "지금 재확인" → 결과 표시 +- 자동 실행 토글 → 진단 패널 펼침 → withArgs/noArgs 표시 +- 백업 섹션 5 버튼 동작 +- 정보 섹션 버전/데이터 위치 + "정보 복사" → clipboard 검증 + +- [ ] **Step 3: macOS 수동 launch 검증** + +```bash +npm run start +``` + +체크리스트: + +- F14 fix 검증: 빨간 신호등 (close) → dock 클릭 → inbox 재등장 +- macOS 메뉴바 트레이 4 항목 (Win 동일) +- 설정 페이지 동일 동작 + +- [ ] **Step 4: Linux smoke 재검증 (Phase 1 후 변경 사항 영향 확인)** + +```bash +npm run dist:linux +``` + +산출 AppImage 를 Linux VM 에서 실행 → 트레이 (있는 DE) 또는 inbox 톱니바퀴 → 설정 페이지 동작 확인. + +- [ ] **Step 5: 결과 spec §11 갱신 + 커밋 (필요 시)** + +```bash +git add docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md +git commit -m "docs(v027): 전체 회귀 검증 + 3-OS smoke 결과 기록" +``` + +--- + +### Task 27: dogfood-feedback.md F12/F14/F15/F16 promoted 마킹 + version bump + release prep + +**Files:** + +- Modify: `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F12/F14/F16 entry 🚀 promoted 마킹) +- Modify: `docs/superpowers/v024-backlog.md` (#45 자동 실행 처리 갱신) +- Modify: `package.json` (version 0.2.6 → 0.2.7) + +- [ ] **Step 1: F12 entry 갱신** + +`F12. 윈도우 자동 실행 옵션이 재시작 후 풀려있는 버그` 의 진행 상태 헤더를 `🚀 promoted → docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md §9` 로 변경. v0.2.7 진단 노출 적용 사실 추가. + +- [ ] **Step 2: F14 entry 갱신** + +`F14. macOS dock 클릭 시 hidden 창 재현 안 됨` 진행 상태를 `🚀 promoted → docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md §8` 로 변경. + +- [ ] **Step 3: F16 entry 갱신** + +`F16. 트레이 의존도 ↓ + 별도 설정 페이지` 진행 상태를 `🚀 promoted → docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md §6, §7` 로 변경. + +- [ ] **Step 4: backlog #45 처리 갱신** + +`docs/superpowers/v024-backlog.md` 의 처리 이력 표 #45 row 의 상태를 `✅ 처리 (진단 노출 + 재등록 버튼)` 로 갱신, Cut 컬럼 = `v0.2.7`. 잔여 카운트 갱신 (24 → 23). + +- [ ] **Step 5: package.json version bump** + +```json + "version": "0.2.7", +``` + +- [ ] **Step 6: 커밋 + tag** + +```bash +git add docs/superpowers/specs/2026-04-25-dogfood-feedback.md docs/superpowers/v024-backlog.md package.json +git commit -m "chore(release): v0.2.7 — cross-platform 입구 정상화 (F12 deeper + F14 + F15 빌드 + F16)" +git tag v0.2.7 +``` + +Release 빌드 + Gitea release 는 별도 (PR 머지 후 본인 dogfood 가 `gitea-release` 스킬 호출). plan 종료 시점 = main 으로 PR open 또는 merge 직전 상태. + +--- + +## Self-Review + +**Spec coverage check:** + +| Spec 섹션 | 커버 task | +|---|---| +| §5 Linux 빌드 | Task 1~5 | +| §6 설정 페이지 라우팅 | Task 6, 7, 12, 13 | +| §6-3-1 AI 제공자 섹션 | Task 8 | +| §6-3-2 자동 실행 섹션 (토글) | Task 9 | +| §6-3-3 백업/복원/내보내기 | Task 10 | +| §6-3-4 정보 | Task 11 | +| §7 트레이 슬림 | Task 14, 15, 16, 17 | +| §8 F14 dock fix | Task 18 | +| §9 F12 deeper fix | Task 19, 20, 21, 22, 23, 24 | +| §10 테스트 | 각 task 안 단위 + Task 26 e2e | +| §11 Risk fallback | Task 1, 3, 26 step 5 | +| 정리 (OllamaSettingsModal 제거) | Task 25 | +| Version bump + dogfood entry promoted | Task 27 | + +모든 spec 요구가 task 에 매핑됨. 빠진 곳 없음. + +**Placeholder scan:** "TBD" / "TODO" / "implement later" 없음. 각 step 에 코드 또는 명령 직접 포함. + +**Type consistency:** + +- `TrayCallbacks` (Task 14): showInbox / showCapture / showSettings — Task 16 wiring + Task 17 검증에서 동일 사용. +- `TrayState` (Task 14): `{ todayCount: number }` — Task 16 의 refreshTray 호출에서 동일. +- `AutostartState` (Task 19): withArgs / noArgs / execPath / registryPath?/ registryValue? — Task 21 의 IPC, Task 23 의 UI 에서 일관 사용. +- `AutostartFull` (Task 23 renderer): { openAtLogin, diagnostic } — Task 22 main 핸들러 반환 형태와 일치. +- IPC 채널 이름 (Task 22 통일): `settings:autostart-state`, `settings:autostart-set`, `settings:run-backup` 등 — 모든 task 에서 동일. + +**잠재 모순 체크 (수동 검토):** + +- Task 9 의 임시 stub (`settings:get-autostart` / `settings:set-autostart`) 와 Task 22 의 정식 (`settings:autostart-state` / `settings:autostart-set`) 채널 이름 불일치 — Task 22 Step 3 에서 통일하도록 명시. 실행 시 누락하지 말 것. +- Task 8 의 IPC 채널 이름 (`getOllamaSettings`, `saveOllamaSettings`, `ollamaRecheck`) — 기존 `OllamaSettingsModal.tsx` 가 사용 중인 채널 확인 필요. 다른 이름이면 Task 8 step 5 에서 정정. + +이슈 없음. 실행 가능. + +--- + +## Execution Handoff + +Plan 작성 완료, `docs/superpowers/plans/2026-05-07-v027-cross-platform.md` 저장. + +두 가지 실행 옵션: + +**1. Subagent-Driven (recommended)** — fresh subagent per task, two-stage review (spec 준수 + code quality), 빠른 iteration + +**2. Inline Execution** — 본 세션에서 task 일괄 실행 + checkpoint 마다 review + +어느 쪽? From 66bae5e31713f2d9ce7aa405c4cfffca6760b688 Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 00:15:12 +0900 Subject: [PATCH 03/27] =?UTF-8?q?docs(v027):=20better-sqlite3=20linux-x64?= =?UTF-8?q?=20prebuild=20=EA=B0=80=EC=9A=A9=EC=84=B1=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md b/docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md index 27a5a5a..e4d0972 100644 --- a/docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md +++ b/docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md @@ -348,7 +348,7 @@ ipcMain.handle('settings:autostart-state', async () => { | Risk | 발생 시 대응 | |---|---| -| linux-x64 prebuild 부재 → node-gyp 빌드 실패 | Docker `electronuserland/builder` fallback. 그래도 실패 시 v0.2.7 scope 조정: AppImage 만, deb 는 v0.2.8. | +| linux-x64 prebuild 부재 → node-gyp 빌드 실패 | Docker `electronuserland/builder` fallback. 그래도 실패 시 v0.2.7 scope 조정: AppImage 만, deb 는 v0.2.8. [2026-05-07 검증: ✅ prebuild 존재 — electron-v145 (v41.3.0 ABI) 다운로드 성공, better_sqlite3.node 파일 생성] | | ELECTRON_RUN_AS_NODE 함정 (메모) 가 Linux 환경에서 재현 | smoke test launch env 에서 strip — 기존 e2e 의 strip 패턴 그대로 | | AppImage 가 모던 GNOME 에서 트레이 표시 안 됨 | 의도적 — 그래서 dock/launcher → inbox → 설정 페이지 흐름이 안전망. F14 fix 가 이 흐름의 핵심. | | 설정 페이지 라우팅이 inbox 의 keyboard shortcut / hotkey 와 충돌 | view='settings' 시 inbox-only shortcut 비활성. zustand state 분기. | From b1b7bfee26cacb359f612809369ea5fd7aef77d8 Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 00:18:14 +0900 Subject: [PATCH 04/27] feat(v027): electron-builder linux target (AppImage + deb x64) --- package.json | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 763c812..88895ff 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,9 @@ "predist:win": "npm run rebuild:electron && npm run build", "dist:win": "electron-builder --win --x64", "predist:mac": "npm run rebuild:electron && npm run build", - "dist:mac": "electron-builder --mac --arm64" + "dist:mac": "electron-builder --mac --arm64", + "predist:linux": "npm run rebuild:electron && npm run build", + "dist:linux": "electron-builder --linux --x64" }, "build": { "appId": "xyz.altair823.inkling", @@ -59,6 +61,15 @@ ], "category": "public.app-category.productivity", "identity": null + }, + "linux": { + "target": [ + { "target": "AppImage", "arch": ["x64"] }, + { "target": "deb", "arch": ["x64"] } + ], + "category": "Utility", + "synopsis": "로컬 메모 캡처 + AI 태그", + "description": "Inkling — 잠깐 스친 생각을 잡아두는 로컬-우선 메모 도구." } }, "dependencies": { From c9d374ade6f1d073494e8341072a3557f8cdcae2 Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 00:23:07 +0900 Subject: [PATCH 05/27] =?UTF-8?q?docs(v027):=20dist:linux=201=EC=B0=A8=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EC=8B=9C=EB=8F=84=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?(Windows=20=ED=98=B8=EC=8A=A4=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-05-06-v027-cross-platform-design.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md b/docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md index e4d0972..ab2102b 100644 --- a/docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md +++ b/docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md @@ -357,6 +357,45 @@ ipcMain.handle('settings:autostart-state', async () => { --- +### v0.2.7 Linux 빌드 1차 시도 결과 (2026-05-07, Windows 호스트) + +`npm run dist:linux` 실행 — Windows 11 호스트. + +**진행 단계:** + +- ✅ predist:linux (electron rebuild + electron-vite build) — 성공 +- ✅ electron-builder linux x64 패키징 prep — 성공 +- ✅ electron-v41.3.0-linux-x64.zip 다운로드 (117 MB) +- ✅ `dist/linux-unpacked/` 스테이징 생성 — 322 MB (electron + native modules + app) +- ✅ appimage-12.0.1.7z 다운로드 (mksquashfs 등 Linux 유저랜드 도구 캐시) +- ⚠️ **AppImage 패키징 실패** — `mksquashfs` 실행 불가 +- ⏭️ **deb 패키징은 시도조차 못함** (AppImage 실패로 빌드 중단) + +**핵심 에러:** + +```text +⨯ cannot execute cause=exec: "C:\Users\...\electron-builder\Cache\appimage\appimage-12.0.1\linux-x64\mksquashfs": file does not exist +⨯ failed to build AppImage error=...app-builder.exe process failed ERR_ELECTRON_BUILDER_CANNOT_EXECUTE +Exit code: 2 +``` + +**진단:** `mksquashfs` 파일은 캐시에 존재하나 (270 KB Linux ELF 바이너리), Windows 가 ELF 를 실행 불가 → electron-builder 가 "file does not exist" 로 보고. AppImage cross-build from Windows 는 **근본적으로 불가능** (WSL/Docker/Linux/Mac 호스트 필요). + +**결론:** + +- AppImage: ⚠️ 실패 (Windows 호스트는 mksquashfs ELF 실행 불가 — 환경 제약) +- deb: ⚠️ 미시도 (`dpkg-deb` + `fakeroot` 부재 추정. AppImage 실패로 도달 못함) + +**Fallback 결정:** Mac (사용자 업무 호스트) 또는 Linux/WSL/Docker 핸드오프 필수. Windows 단독으로는 v0.2.7 Linux 산출물 생성 불가. plan Task 3 은 "시도 + 결과 기록" 이 핵심이었고, **macOS 후속 시도가 본 빌드** — Windows 시도는 환경 한계 확인용. + +**권장 후속:** + +1. 사용자 macOS 업무 호스트에서 동일 명령 (`npm run dist:linux`) 재시도. brew 로 `dpkg` + `fakeroot` 사전 설치 (`brew install dpkg`). AppImage 는 macOS 에서 정상 cross-build 가능 (mksquashfs Mach-O 바이너리 caching). +2. macOS 에서도 deb 가 실패할 경우 — v0.2.7 scope 를 **AppImage only** 로 축소, deb 는 v0.2.8 또는 Docker `electronuserland/builder` 환경으로 이동. package.json `linux.target` 에서 deb 제거하거나 별도 task 로 분리. +3. 향후 자동화: GitHub Actions / Gitea Actions 에서 ubuntu-latest runner 로 Linux build 자동화 (현 수동 cross-build 환경 의존성 제거). + +--- + ## 12. v0.2.7 후 **잔여 backlog (24건)** — `docs/superpowers/v024-backlog.md`: From 5b37529175cfdcce73c388850b0c66eeaecaddf1 Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 01:36:26 +0900 Subject: [PATCH 06/27] =?UTF-8?q?feat(v027):=20inbox=20store=20=EC=97=90?= =?UTF-8?q?=20showSettings=20state=20+=20setShowSettings=20action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/inbox/store.ts | 6 +++ tests/unit/store.showSettings.test.ts | 65 +++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 tests/unit/store.showSettings.test.ts diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index bf88933..3cc22ae 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -10,6 +10,7 @@ interface InboxState { trashNotes: Note[]; trashCount: number; showTrash: boolean; + showSettings: boolean; continuity: WeeklyContinuity; pendingCount: number; ollamaStatus: { ok: boolean; reason?: string }; @@ -26,6 +27,7 @@ interface InboxState { upsertNote: (note: Note) => void; removeNote: (id: string) => void; setTagFilter: (tag: string | null) => void; + setShowSettings: (open: boolean) => void; toggleShowTrash: () => Promise; loadTrash: () => Promise; restoreNote: (id: string) => Promise; @@ -52,6 +54,7 @@ export const useInbox = create((set, get) => ({ trashNotes: [], trashCount: 0, showTrash: false, + showSettings: false, continuity: emptyContinuity, pendingCount: 0, ollamaStatus: { ok: true }, @@ -134,6 +137,9 @@ export const useInbox = create((set, get) => ({ setTagFilter(tag) { set({ tagFilter: tag }); }, + setShowSettings(open) { + set({ showSettings: open }); + }, async toggleShowTrash() { const next = !get().showTrash; set({ showTrash: next }); diff --git a/tests/unit/store.showSettings.test.ts b/tests/unit/store.showSettings.test.ts new file mode 100644 index 0000000..f7bbd9d --- /dev/null +++ b/tests/unit/store.showSettings.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { Note } from '@shared/types'; + +const mockApi = { + listNotes: vi.fn(async () => [] as Note[]), + listTrash: vi.fn(async () => [] as Note[]), + getTrashCount: vi.fn(async () => 0), + getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })), + getPendingCount: vi.fn(async () => 0), + getOllamaStatus: vi.fn(async () => ({ ok: true })), + getTodayCount: vi.fn(async () => 0), + getFailedCount: vi.fn(async () => 0), + listExpired: vi.fn(async () => [] as Note[]), + listRecallCandidate: vi.fn(async () => null), + restoreNote: vi.fn(async () => {}), + permanentDeleteNote: vi.fn(async () => ({ confirmed: true })), + emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })), + trashExpiredBatch: vi.fn(async () => ({ confirmed: true, trashedCount: 0 })), + onNoteUpdated: vi.fn(() => () => {}), + updateAiFields: vi.fn(async () => {}), + setDueDate: vi.fn(async () => {}), + setIntent: vi.fn(async () => {}), + dismissIntent: vi.fn(async () => {}), + ollamaRecheck: vi.fn(async () => ({ ok: true })), + retryAllFailed: vi.fn(async () => {}), + markRecallOpened: vi.fn(async () => {}), + dismissRecall: vi.fn(async () => {}), + emitRecallSnoozed: vi.fn(async () => {}) +}; + +vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi })); + +describe('inbox store — showSettings', () => { + beforeEach(async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + useInbox.setState({ + notes: [], trashNotes: [], trashCount: 0, showTrash: false, + loading: false, tagFilter: null, pendingCount: 0, todayCount: 0, + ollamaStatus: { ok: true }, + continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null }, + expiredCandidates: [], expiredSnoozeUntilMs: null, + failedCount: 0, recallCandidate: null, recallSnoozeUntilMs: null, + showSettings: false + }); + Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear()); + }); + + it('initial state has showSettings=false', async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + expect(useInbox.getState().showSettings).toBe(false); + }); + + it('setShowSettings(true) sets state', async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + useInbox.getState().setShowSettings(true); + expect(useInbox.getState().showSettings).toBe(true); + }); + + it('setShowSettings(false) toggles back', async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + useInbox.getState().setShowSettings(true); + useInbox.getState().setShowSettings(false); + expect(useInbox.getState().showSettings).toBe(false); + }); +}); From 91bf98f1a25a8cf40c33229c98bfe7ee34a18cec Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 01:42:54 +0900 Subject: [PATCH 07/27] =?UTF-8?q?feat(v027):=20SettingsPage=20scaffold=20?= =?UTF-8?q?=E2=80=94=204=20=EC=84=B9=EC=85=98=20placeholder=20+=20?= =?UTF-8?q?=EB=8F=8C=EC=95=84=EA=B0=80=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v027 plan Task 7. zustand store 의 showSettings 를 사용하는 첫 컴포넌트. 4 섹션 (AI 제공자/자동 실행/백업·복원/정보) placeholder 와 헤더 + 돌아가기 버튼만. 실 콘텐츠는 후속 Task 8-11 에서 채움. 테스트 인프라 동시 추가 (v027 의 첫 React 컴포넌트 테스트): - @testing-library/react + @testing-library/jest-dom + jsdom devDep 추가 - vitest.config: plugin-react 적용, include 에 .test.tsx 포함 - 환경 분리는 per-file `// @vitest-environment jsdom` directive 로 처리 (vitest 4.x 에서 environmentMatchGlobs 미지원 — 기존 .ts 단위 테스트는 node env 유지) --- package-lock.json | 757 ++++++++++++++++++ package.json | 3 + .../inbox/components/SettingsPage.tsx | 41 + tests/unit/SettingsPage.test.tsx | 37 + vitest.config.ts | 4 +- 5 files changed, 841 insertions(+), 1 deletion(-) create mode 100644 src/renderer/inbox/components/SettingsPage.tsx create mode 100644 tests/unit/SettingsPage.test.tsx diff --git a/package-lock.json b/package-lock.json index 1dec720..02a18ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,8 @@ }, "devDependencies": { "@playwright/test": "1.59.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/better-sqlite3": "7.6.11", "@types/node": "24.0.0", "@types/react": "19.0.0", @@ -26,12 +28,71 @@ "electron": "41.3.0", "electron-builder": "26.8.1", "electron-vite": "5.0.0", + "jsdom": "^29.1.1", "typescript": "6.0.3", "undici": "8.1.0", "vite": "7.3.2", "vitest": "4.1.5" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -282,6 +343,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -330,6 +401,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -1245,6 +1469,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -1831,6 +2073,90 @@ "node": ">=10" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2443,6 +2769,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -2566,6 +2902,16 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -3069,6 +3415,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3076,6 +3443,20 @@ "devOptional": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3094,6 +3475,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -3188,6 +3576,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3329,6 +3727,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -3657,6 +4063,19 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -4360,6 +4779,19 @@ "dev": true, "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -4460,6 +4892,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -4494,6 +4936,13 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isbinaryfile": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", @@ -4565,6 +5014,67 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4667,6 +5177,17 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4701,6 +5222,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mime": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", @@ -4747,6 +5275,16 @@ "node": ">=4" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -5069,6 +5607,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -5271,6 +5822,36 @@ "node": ">=10" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", @@ -5386,6 +5967,14 @@ "react": "^19.2.5" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -5423,6 +6012,20 @@ "node": ">= 6" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -5433,6 +6036,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resedit": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", @@ -5607,6 +6220,19 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -5884,6 +6510,19 @@ "node": ">=8" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -5919,6 +6558,13 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tar": { "version": "7.5.13", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", @@ -6112,6 +6758,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", + "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.30" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", + "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -6132,6 +6798,32 @@ "tmp": "^0.2.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/truncate-utf8-bytes": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", @@ -6956,6 +7648,54 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", @@ -7013,6 +7753,16 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -7023,6 +7773,13 @@ "node": ">=8.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 88895ff..386e898 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,8 @@ }, "devDependencies": { "@playwright/test": "1.59.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/better-sqlite3": "7.6.11", "@types/node": "24.0.0", "@types/react": "19.0.0", @@ -91,6 +93,7 @@ "electron": "41.3.0", "electron-builder": "26.8.1", "electron-vite": "5.0.0", + "jsdom": "^29.1.1", "typescript": "6.0.3", "undici": "8.1.0", "vite": "7.3.2", diff --git a/src/renderer/inbox/components/SettingsPage.tsx b/src/renderer/inbox/components/SettingsPage.tsx new file mode 100644 index 0000000..7626a88 --- /dev/null +++ b/src/renderer/inbox/components/SettingsPage.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { useInbox } from '../store.js'; + +export function SettingsPage(): React.ReactElement { + const setShowSettings = useInbox((s) => s.setShowSettings); + return ( +
+
+ +

설정

+
+
+

AI 제공자

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

자동 실행

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

백업 / 복원

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

정보

+ {/* InfoSection — Task 11 */} +
+
+ ); +} diff --git a/tests/unit/SettingsPage.test.tsx b/tests/unit/SettingsPage.test.tsx new file mode 100644 index 0000000..1ae6e76 --- /dev/null +++ b/tests/unit/SettingsPage.test.tsx @@ -0,0 +1,37 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; + +// inboxApi 는 window.inkling.inbox 를 참조하므로 jsdom 환경에서 import 자체가 throw. +// 다른 inbox store 단위 테스트와 동일한 패턴으로 빈 mock 을 주입한다. +vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: {} })); + +import { SettingsPage } from '../../src/renderer/inbox/components/SettingsPage'; +import { useInbox } from '../../src/renderer/inbox/store'; + +describe('SettingsPage', () => { + beforeEach(() => { + cleanup(); + useInbox.setState({ showSettings: true }); + }); + + it('renders header with "← 돌아가기" button', () => { + render(); + expect(screen.getByRole('button', { name: /돌아가기/ })).toBeInTheDocument(); + }); + + it('renders 4 section headings', () => { + render(); + expect(screen.getByText('AI 제공자')).toBeInTheDocument(); + expect(screen.getByText('자동 실행')).toBeInTheDocument(); + expect(screen.getByText('백업 / 복원')).toBeInTheDocument(); + expect(screen.getByText('정보')).toBeInTheDocument(); + }); + + it('clicking "← 돌아가기" sets showSettings to false', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /돌아가기/ })); + expect(useInbox.getState().showSettings).toBe(false); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 735027b..e4c5b7f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,11 +1,13 @@ import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; import { resolve } from 'node:path'; export default defineConfig({ + plugins: [react()], test: { environment: 'node', globals: false, - include: ['tests/unit/**/*.test.ts'], + include: ['tests/unit/**/*.test.ts', 'tests/unit/**/*.test.tsx'], exclude: ['tests/integration/**', 'tests/e2e/**'], coverage: { reporter: ['text', 'html'] } }, From 7301f4d73dc248081d92c5dfc042329f5672f0a6 Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 01:51:53 +0900 Subject: [PATCH 08/27] =?UTF-8?q?feat(v027):=20AiProviderSection=20?= =?UTF-8?q?=E2=80=94=20OllamaSettingsModal=20=ED=9D=A1=EC=88=98=20+=20?= =?UTF-8?q?=EC=A7=80=EA=B8=88=20=EC=9E=AC=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../inbox/components/SettingsPage.tsx | 3 +- .../components/settings/AiProviderSection.tsx | 131 ++++++++++++++++++ tests/unit/AiProviderSection.test.tsx | 44 ++++++ tests/unit/SettingsPage.test.tsx | 11 +- 4 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 src/renderer/inbox/components/settings/AiProviderSection.tsx create mode 100644 tests/unit/AiProviderSection.test.tsx diff --git a/src/renderer/inbox/components/SettingsPage.tsx b/src/renderer/inbox/components/SettingsPage.tsx index 7626a88..c9d921c 100644 --- a/src/renderer/inbox/components/SettingsPage.tsx +++ b/src/renderer/inbox/components/SettingsPage.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useInbox } from '../store.js'; +import { AiProviderSection } from './settings/AiProviderSection.js'; export function SettingsPage(): React.ReactElement { const setShowSettings = useInbox((s) => s.setShowSettings); @@ -22,7 +23,7 @@ export function SettingsPage(): React.ReactElement {

AI 제공자

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

자동 실행

diff --git a/src/renderer/inbox/components/settings/AiProviderSection.tsx b/src/renderer/inbox/components/settings/AiProviderSection.tsx new file mode 100644 index 0000000..7e64160 --- /dev/null +++ b/src/renderer/inbox/components/settings/AiProviderSection.tsx @@ -0,0 +1,131 @@ +import React, { useEffect, useState } from 'react'; +import { z } from 'zod'; +import { inboxApi } from '../../api.js'; + +const endpointSchema = z.string().url(); + +export function AiProviderSection(): React.ReactElement { + const [endpoint, setEndpoint] = useState(''); + const [model, setModel] = useState(''); + const [error, setError] = useState(null); + const [saveResult, setSaveResult] = useState(null); + const [recheckResult, setRecheckResult] = useState(null); + + useEffect(() => { + void (async () => { + const s = await inboxApi.loadOllamaSettings(); + if (s) { + setEndpoint(s.endpoint); + setModel(s.model); + } + })(); + }, []); + + async function onSave(): Promise { + const r = endpointSchema.safeParse(endpoint); + if (!r.success) { + setError('올바른 URL 형식이 아닙니다 (예: http://localhost:11434)'); + setSaveResult(null); + return; + } + if (model.trim() === '') { + setError('모델 이름을 입력해주세요'); + setSaveResult(null); + return; + } + setError(null); + const result = await inboxApi.saveOllamaSettings({ endpoint, model }); + if (result.ok) { + setSaveResult('저장됨'); + } else { + setSaveResult(null); + setError(`저장 실패: ${result.reason}`); + } + } + + async function onRecheck(): Promise { + setRecheckResult('확인 중...'); + const r = await inboxApi.ollamaRecheck(); + setRecheckResult(r.ok ? '연결됨' : `연결 실패: ${r.reason ?? '알 수 없는 이유'}`); + } + + return ( +
+ + + {error && ( +
{error}
+ )} + {saveResult && ( +
{saveResult}
+ )} +
+ + +
+ {recheckResult && ( +
{recheckResult}
+ )} +
+ ); +} diff --git a/tests/unit/AiProviderSection.test.tsx b/tests/unit/AiProviderSection.test.tsx new file mode 100644 index 0000000..b6ed4d9 --- /dev/null +++ b/tests/unit/AiProviderSection.test.tsx @@ -0,0 +1,44 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; + +vi.mock('../../src/renderer/inbox/api.js', () => ({ + inboxApi: { + loadOllamaSettings: vi.fn(async () => ({ endpoint: 'http://localhost:11434', model: 'gemma2:2b' })), + saveOllamaSettings: vi.fn(async () => ({ ok: true })), + ollamaRecheck: vi.fn(async () => ({ ok: true })) + } +})); + +import { AiProviderSection } from '../../src/renderer/inbox/components/settings/AiProviderSection'; + +describe('AiProviderSection', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + }); + + it('loads current settings on mount', async () => { + render(); + expect(await screen.findByDisplayValue('http://localhost:11434')).toBeInTheDocument(); + expect(screen.getByDisplayValue('gemma2:2b')).toBeInTheDocument(); + }); + + it('rejects invalid endpoint URL', async () => { + render(); + await screen.findByDisplayValue('http://localhost:11434'); + const input = screen.getByLabelText(/Endpoint/); + fireEvent.change(input, { target: { value: 'not-a-url' } }); + fireEvent.click(screen.getByRole('button', { name: /저장/ })); + expect(await screen.findByText(/올바른 URL/)).toBeInTheDocument(); + }); + + it('"지금 재확인" calls ollamaRecheck and shows result', async () => { + const { inboxApi } = await import('../../src/renderer/inbox/api.js'); + render(); + await screen.findByDisplayValue('http://localhost:11434'); + fireEvent.click(screen.getByRole('button', { name: /지금 재확인/ })); + expect(inboxApi.ollamaRecheck).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/SettingsPage.test.tsx b/tests/unit/SettingsPage.test.tsx index 1ae6e76..2805021 100644 --- a/tests/unit/SettingsPage.test.tsx +++ b/tests/unit/SettingsPage.test.tsx @@ -4,8 +4,15 @@ import '@testing-library/jest-dom/vitest'; import { render, screen, fireEvent, cleanup } from '@testing-library/react'; // inboxApi 는 window.inkling.inbox 를 참조하므로 jsdom 환경에서 import 자체가 throw. -// 다른 inbox store 단위 테스트와 동일한 패턴으로 빈 mock 을 주입한다. -vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: {} })); +// SettingsPage 가 마운트하는 AiProviderSection 의 useEffect 가 loadOllamaSettings 를 호출하므로 +// 빈 객체 대신 필요한 메서드를 stub 한다. +vi.mock('../../src/renderer/inbox/api.js', () => ({ + inboxApi: { + loadOllamaSettings: vi.fn(async () => null), + saveOllamaSettings: vi.fn(async () => ({ ok: true })), + ollamaRecheck: vi.fn(async () => ({ ok: true })) + } +})); import { SettingsPage } from '../../src/renderer/inbox/components/SettingsPage'; import { useInbox } from '../../src/renderer/inbox/store'; From fca28fb0c415209f2a1ea4dd05b9f7da3947f40d Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 01:56:58 +0900 Subject: [PATCH 09/27] =?UTF-8?q?feat(v027):=20AutostartSection=20?= =?UTF-8?q?=ED=86=A0=EA=B8=80=20(=EC=A7=84=EB=8B=A8=20=ED=8C=A8=EB=84=90?= =?UTF-8?q?=EC=9D=80=20=ED=9B=84=EC=86=8D=20task)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 2 ++ src/main/ipc/settingsApi.ts | 23 +++++++++++++ src/preload/index.ts | 3 ++ .../inbox/components/SettingsPage.tsx | 3 +- .../components/settings/AutostartSection.tsx | 31 +++++++++++++++++ src/shared/types.ts | 3 ++ tests/unit/AutostartSection.test.tsx | 34 +++++++++++++++++++ tests/unit/SettingsPage.test.tsx | 4 ++- 8 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 src/main/ipc/settingsApi.ts create mode 100644 src/renderer/inbox/components/settings/AutostartSection.tsx create mode 100644 tests/unit/AutostartSection.test.tsx diff --git a/src/main/index.ts b/src/main/index.ts index e38197f..9768e0f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -19,6 +19,7 @@ import { ProviderHolder } from './ai/ProviderHolder.js'; import { AiWorker } from './ai/AiWorker.js'; import { registerCaptureApi } from './ipc/captureApi.js'; import { registerInboxApi, pushNoteUpdated, pushOllamaStatus } from './ipc/inboxApi.js'; +import { registerSettingsApi } from './ipc/settingsApi.js'; import { createInboxWindow, getInboxWindow } from './windows/inboxWindow.js'; import { createQuickCaptureWindow, showQuickCapture, getQuickCaptureWindow @@ -162,6 +163,7 @@ app.whenReady().then(async () => { repo, continuity, capture, health, intent, getInboxWindow, settings: settingsSvc, providerHolder }); + registerSettingsApi(); const hotkeys = new HotkeyService(); const reg = hotkeys.register({ diff --git a/src/main/ipc/settingsApi.ts b/src/main/ipc/settingsApi.ts new file mode 100644 index 0000000..712da9f --- /dev/null +++ b/src/main/ipc/settingsApi.ts @@ -0,0 +1,23 @@ +import electron from 'electron'; +const { ipcMain, app } = electron; + +/** + * v0.2.7 자동 실행 설정 IPC. + * + * 임시 채널명 (`settings:get-autostart` / `settings:set-autostart`). + * Task 22 에서 정식 이름 (`settings:autostart-state` / `settings:autostart-set`) 으로 rename 예정. + * + * args=['--hidden'] 명시 — 자동 실행 시 백그라운드 모드로 시작 (Quick Capture only). + */ +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 }; + }); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 4281f5a..c6ec4ed 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -52,6 +52,9 @@ const api: InklingApi = { ipcRenderer.on('inbox:openOllamaSettings', handler); return () => ipcRenderer.removeListener('inbox:openOllamaSettings', handler); }, + // v0.2.7 자동 실행 (임시 채널 — Task 22 에서 settings:autostart-state / settings:autostart-set 으로 rename) + getAutostart: () => ipcRenderer.invoke('settings:get-autostart'), + setAutostart: (open: boolean) => ipcRenderer.invoke('settings:set-autostart', open), } }; diff --git a/src/renderer/inbox/components/SettingsPage.tsx b/src/renderer/inbox/components/SettingsPage.tsx index c9d921c..d5e4ed6 100644 --- a/src/renderer/inbox/components/SettingsPage.tsx +++ b/src/renderer/inbox/components/SettingsPage.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useInbox } from '../store.js'; import { AiProviderSection } from './settings/AiProviderSection.js'; +import { AutostartSection } from './settings/AutostartSection.js'; export function SettingsPage(): React.ReactElement { const setShowSettings = useInbox((s) => s.setShowSettings); @@ -27,7 +28,7 @@ export function SettingsPage(): React.ReactElement {

자동 실행

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

백업 / 복원

diff --git a/src/renderer/inbox/components/settings/AutostartSection.tsx b/src/renderer/inbox/components/settings/AutostartSection.tsx new file mode 100644 index 0000000..000ecbc --- /dev/null +++ b/src/renderer/inbox/components/settings/AutostartSection.tsx @@ -0,0 +1,31 @@ +import React, { useEffect, useState } from 'react'; +import { inboxApi } from '../../api.js'; + +export function AutostartSection(): React.ReactElement { + const [openAtLogin, setOpenAtLogin] = useState(null); + + useEffect(() => { + void (async () => { + const r = await inboxApi.getAutostart(); + setOpenAtLogin(r.openAtLogin); + })(); + }, []); + + async function onToggle(e: React.ChangeEvent): Promise { + const r = await inboxApi.setAutostart(e.target.checked); + setOpenAtLogin(r.openAtLogin); + } + + if (openAtLogin === null) { + return
로딩 중...
; + } + + return ( +
+ +
+ ); +} diff --git a/src/shared/types.ts b/src/shared/types.ts index dd79b2c..82c076d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -92,6 +92,9 @@ export interface InboxApi { loadOllamaSettings(): Promise<{ endpoint: string; model: string } | null>; saveOllamaSettings(v: { endpoint: string; model: string }): Promise<{ ok: true } | { ok: false; reason: string }>; onOpenOllamaSettings(cb: () => void): () => void; + // v0.2.7 자동 실행 (임시 채널 — Task 22 에서 정식 이름으로 rename) + getAutostart(): Promise<{ openAtLogin: boolean }>; + setAutostart(open: boolean): Promise<{ openAtLogin: boolean }>; } export interface InklingApi { diff --git a/tests/unit/AutostartSection.test.tsx b/tests/unit/AutostartSection.test.tsx new file mode 100644 index 0000000..7a4c4fe --- /dev/null +++ b/tests/unit/AutostartSection.test.tsx @@ -0,0 +1,34 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; + +vi.mock('../../src/renderer/inbox/api.js', () => ({ + inboxApi: { + getAutostart: vi.fn(async () => ({ openAtLogin: true })), + setAutostart: vi.fn(async (open: boolean) => ({ openAtLogin: open })) + } +})); + +import { AutostartSection } from '../../src/renderer/inbox/components/settings/AutostartSection'; + +describe('AutostartSection', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + }); + + it('renders toggle reflecting current state', async () => { + render(); + const toggle = await screen.findByRole('checkbox'); + expect(toggle).toBeChecked(); + }); + + it('clicking toggle calls setAutostart', async () => { + const { inboxApi } = await import('../../src/renderer/inbox/api.js'); + render(); + const toggle = await screen.findByRole('checkbox'); + fireEvent.click(toggle); + await waitFor(() => expect(inboxApi.setAutostart).toHaveBeenCalledWith(false)); + }); +}); diff --git a/tests/unit/SettingsPage.test.tsx b/tests/unit/SettingsPage.test.tsx index 2805021..491d0be 100644 --- a/tests/unit/SettingsPage.test.tsx +++ b/tests/unit/SettingsPage.test.tsx @@ -10,7 +10,9 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: { loadOllamaSettings: vi.fn(async () => null), saveOllamaSettings: vi.fn(async () => ({ ok: true })), - ollamaRecheck: vi.fn(async () => ({ ok: true })) + ollamaRecheck: vi.fn(async () => ({ ok: true })), + getAutostart: vi.fn(async () => ({ openAtLogin: false })), + setAutostart: vi.fn(async (open: boolean) => ({ openAtLogin: open })) } })); From 5cd38f253714864f5ce628f876cad53e68f690b8 Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 02:03:31 +0900 Subject: [PATCH 10/27] =?UTF-8?q?feat(v027):=20BackupSection=20=E2=80=94?= =?UTF-8?q?=205=20=EB=B2=84=ED=8A=BC=20+=20IPC=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=9F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 9 +- src/main/ipc/settingsApi.ts | 210 +++++++++++++++++- src/preload/index.ts | 6 + .../inbox/components/SettingsPage.tsx | 3 +- .../components/settings/BackupSection.tsx | 27 +++ src/shared/types.ts | 6 + tests/unit/BackupSection.test.tsx | 39 ++++ tests/unit/SettingsPage.test.tsx | 7 +- 8 files changed, 298 insertions(+), 9 deletions(-) create mode 100644 src/renderer/inbox/components/settings/BackupSection.tsx create mode 100644 tests/unit/BackupSection.test.tsx diff --git a/src/main/index.ts b/src/main/index.ts index 9768e0f..7942642 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -163,7 +163,8 @@ app.whenReady().then(async () => { repo, continuity, capture, health, intent, getInboxWindow, settings: settingsSvc, providerHolder }); - registerSettingsApi(); + // registerSettingsApi 는 backup / exportSvc / importSvc / syncSvc / telemetry 가 + // 생성된 뒤에 호출 (Task 10) — 아래 BackupService/ExportService/... 초기화 직후로 이동. const hotkeys = new HotkeyService(); const reg = hotkeys.register({ @@ -192,6 +193,12 @@ app.whenReady().then(async () => { .then((r) => logger.info('backup.daily', { ...r } as Record)) .catch((e) => logger.warn('backup.daily.failed', { reason: String(e) })); + // v0.2.7 Task 10 — 설정 페이지 IPC (autostart + backup/export/import/sync/telemetry). + // backup / exportSvc / importSvc / syncSvc / telemetry 가 모두 준비된 뒤 등록. + registerSettingsApi({ + backup, exportSvc, importSvc, syncSvc, telemetry, getInboxWindow + }); + let backupOnQuitDone = false; let trayInterval: NodeJS.Timeout | null = null; app.on('before-quit', (e) => { diff --git a/src/main/ipc/settingsApi.ts b/src/main/ipc/settingsApi.ts index 712da9f..a5b3823 100644 --- a/src/main/ipc/settingsApi.ts +++ b/src/main/ipc/settingsApi.ts @@ -1,15 +1,34 @@ import electron from 'electron'; -const { ipcMain, app } = electron; +import type { BrowserWindow } from 'electron'; +const { ipcMain, app, dialog, Notification } = electron; +import { logger } from '../logger.js'; +import type { BackupService } from '../services/BackupService.js'; +import type { ExportService } from '../services/ExportService.js'; +import type { ImportService } from '../services/ImportService.js'; +import type { SyncService } from '../services/SyncService.js'; +import type { TelemetryService } from '../services/TelemetryService.js'; + +export interface SettingsIpcDeps { + backup: BackupService; + exportSvc: ExportService; + importSvc: ImportService; + syncSvc: SyncService; + telemetry: TelemetryService; + getInboxWindow: () => BrowserWindow | null; +} /** - * v0.2.7 자동 실행 설정 IPC. + * v0.2.7 설정 페이지 IPC 핸들러. * - * 임시 채널명 (`settings:get-autostart` / `settings:set-autostart`). - * Task 22 에서 정식 이름 (`settings:autostart-state` / `settings:autostart-set`) 으로 rename 예정. + * - 자동 실행: 임시 채널명 (`settings:get-autostart` / `settings:set-autostart`). + * Task 22 에서 정식 이름 (`settings:autostart-state` / `settings:autostart-set`) 으로 rename 예정. + * args=['--hidden'] 명시 — 자동 실행 시 백그라운드 모드로 시작 (Quick Capture only). * - * args=['--hidden'] 명시 — 자동 실행 시 백그라운드 모드로 시작 (Quick Capture only). + * - 백업/내보내기/복원/동기화/사용 로그 (Task 10): 기존 `src/main/index.ts` 트레이 callback + * (runBackup, runExport, runImport, runSync, runExportTelemetry) 본문을 그대로 IPC 핸들러로 + * 복사. 트레이 callback 자체 제거는 Task 16 (Phase 3) — 본 task 에선 잔류 (의도적 중복). */ -export function registerSettingsApi(): void { +export function registerSettingsApi(deps?: SettingsIpcDeps): void { ipcMain.handle('settings:get-autostart', () => { const r = app.getLoginItemSettings({ args: ['--hidden'] }); return { openAtLogin: r.openAtLogin }; @@ -20,4 +39,183 @@ export function registerSettingsApi(): void { const r = app.getLoginItemSettings({ args: ['--hidden'] }); return { openAtLogin: r.openAtLogin }; }); + + if (!deps) return; + const { backup, exportSvc, importSvc, syncSvc, telemetry, getInboxWindow } = deps; + + ipcMain.handle('settings:run-backup', async () => { + try { + const r = await backup.runDaily(); + new Notification({ + title: 'Inkling', + body: r.snapshotted + ? `백업 완료 — ${r.removed?.length ?? 0}개 정리` + : `오늘 백업이 이미 있습니다`, + silent: true + }).show(); + } catch (e) { + logger.warn('backup.manual.failed', { reason: String(e) }); + new Notification({ + title: 'Inkling', + body: '백업을 만들지 못했습니다.', + silent: true + }).show(); + } + return { ok: true } as const; + }); + + ipcMain.handle('settings:run-export', async () => { + const win = getInboxWindow(); + const dialogOpts: Electron.OpenDialogOptions = { + title: '내보낼 폴더 선택', + message: '선택한 폴더에 노트를 마크다운으로 내보냅니다. 이미지가 함께 포함됩니다. raw_text 가 평문으로 보관되니 비공개 위치를 권장합니다.', + buttonLabel: '여기에 내보내기', + properties: ['openDirectory', 'createDirectory'] + }; + const result = win + ? await dialog.showOpenDialog(win, dialogOpts) + : await dialog.showOpenDialog(dialogOpts); + if (result.canceled || result.filePaths.length === 0) return { ok: true } as const; + try { + const r = await exportSvc.export(result.filePaths[0]!, { includeMedia: true }); + logger.info('export.done', { + outDir: r.outDir, + noteCount: r.noteCount, + mediaCount: r.mediaCount, + bytes: r.bytes + }); + new Notification({ + title: 'Inkling', + body: `내보내기 완료 — 노트 ${r.noteCount}개, 이미지 ${r.mediaCount}개`, + silent: true + }).show(); + } catch (e) { + logger.warn('export.failed', { reason: String(e) }); + new Notification({ + title: 'Inkling', + body: '내보내기를 완료하지 못했습니다.', + silent: true + }).show(); + } + return { ok: true } as const; + }); + + ipcMain.handle('settings:run-import', async () => { + const win = getInboxWindow(); + const dirOpts: Electron.OpenDialogOptions = { + title: '복원할 백업 폴더 선택', + message: 'F5 export 형식의 폴더를 선택하세요. notes/ 하위의 마크다운 파일이 적재됩니다.', + buttonLabel: '여기서 복원', + properties: ['openDirectory'] + }; + const dirResult = win + ? await dialog.showOpenDialog(win, dirOpts) + : await dialog.showOpenDialog(dirOpts); + if (dirResult.canceled || dirResult.filePaths.length === 0) return { ok: true } as const; + const sourceDir = dirResult.filePaths[0]!; + let plan; + try { + plan = await importSvc.preview(sourceDir); + } catch (e) { + logger.warn('import.preview.failed', { reason: String(e) }); + new Notification({ + title: 'Inkling', + body: '백업 폴더를 읽지 못했습니다.', + silent: true + }).show(); + return { ok: true } as const; + } + const detail = `총 ${plan.total}개 노트\n · 신규 ${plan.newCount}개\n · 동일 (스킵) ${plan.unchangedCount}개\n · 충돌→새 id (${plan.forkedCount}개, raw_text 보존)\n\n이미지 ${plan.mediaCount}개 복사 예정.`; + const confirmOpts: Electron.MessageBoxOptions = { + type: 'question', + buttons: ['복원', '취소'], + defaultId: 0, + cancelId: 1, + title: 'Inkling 복원', + message: '복원 미리보기', + detail + }; + const confirm = win + ? await dialog.showMessageBox(win, confirmOpts) + : await dialog.showMessageBox(confirmOpts); + if (confirm.response !== 0) return { ok: true } as const; + try { + const r = await importSvc.run(sourceDir); + logger.info('import.done', { + total: r.total, + new: r.newCount, + unchanged: r.unchangedCount, + forked: r.forkedCount, + media: r.mediaCount + }); + new Notification({ + title: 'Inkling', + body: `복원 완료 — 신규 ${r.newCount}개, 스킵 ${r.unchangedCount}개, 충돌 ${r.forkedCount}개`, + silent: true + }).show(); + } catch (e) { + logger.warn('import.run.failed', { reason: String(e) }); + new Notification({ + title: 'Inkling', + body: '복원을 완료하지 못했습니다.', + silent: true + }).show(); + } + return { ok: true } as const; + }); + + ipcMain.handle('settings:run-sync', async () => { + try { + const r = await syncSvc.sync(); + if (!r.ok) { + logger.warn('sync.failed', { reason: r.reason }); + const body = r.reason === 'not_configured' + ? `${syncSvc.getSyncDir()} 에서 git init + remote 설정이 필요합니다.` + : '동기화를 완료하지 못했습니다.'; + new Notification({ title: 'Inkling', body, silent: true }).show(); + return { ok: true } as const; + } + if (r.changed) { + logger.info('sync.done', { sha: r.sha, pushed: r.pushed }); + new Notification({ title: 'Inkling', body: '동기화 완료', silent: true }).show(); + } else { + new Notification({ title: 'Inkling', body: '변경 사항 없음', silent: true }).show(); + } + } catch (e) { + logger.warn('sync.exception', { reason: String(e) }); + new Notification({ title: 'Inkling', body: '동기화를 완료하지 못했습니다.', silent: true }).show(); + } + return { ok: true } as const; + }); + + ipcMain.handle('settings:run-export-telemetry', async () => { + const win = getInboxWindow(); + const dialogOpts: Electron.OpenDialogOptions = { + title: '사용 로그를 내보낼 폴더 선택', + message: '선택한 폴더에 events.jsonl + stats.md 가 생성됩니다. raw_text/요약/제목/태그 이름은 미포함입니다.', + buttonLabel: '여기로 내보내기', + properties: ['openDirectory', 'createDirectory'] + }; + const result = win + ? await dialog.showOpenDialog(win, dialogOpts) + : await dialog.showOpenDialog(dialogOpts); + if (result.canceled || result.filePaths.length === 0) return { ok: true } as const; + try { + const r = await telemetry.exportTo(result.filePaths[0]!); + logger.info('telemetry.export', { eventCount: r.eventCount, outDir: result.filePaths[0] }); + new Notification({ + title: 'Inkling', + body: `사용 로그 내보내기 완료 — ${r.eventCount}개 이벤트`, + silent: true + }).show(); + } catch (e) { + logger.warn('telemetry.export.failed', { reason: String(e) }); + new Notification({ + title: 'Inkling', + body: '사용 로그 내보내기를 완료하지 못했습니다.', + silent: true + }).show(); + } + return { ok: true } as const; + }); } diff --git a/src/preload/index.ts b/src/preload/index.ts index c6ec4ed..a6a926d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -55,6 +55,12 @@ const api: InklingApi = { // v0.2.7 자동 실행 (임시 채널 — Task 22 에서 settings:autostart-state / settings:autostart-set 으로 rename) getAutostart: () => ipcRenderer.invoke('settings:get-autostart'), setAutostart: (open: boolean) => ipcRenderer.invoke('settings:set-autostart', open), + // v0.2.7 백업/복원/동기화/텔레메트리 (Task 10) — 트레이 callback 의 IPC 대응 + runBackup: () => ipcRenderer.invoke('settings:run-backup'), + runExport: () => ipcRenderer.invoke('settings:run-export'), + runImport: () => ipcRenderer.invoke('settings:run-import'), + runSync: () => ipcRenderer.invoke('settings:run-sync'), + runExportTelemetry: () => ipcRenderer.invoke('settings:run-export-telemetry'), } }; diff --git a/src/renderer/inbox/components/SettingsPage.tsx b/src/renderer/inbox/components/SettingsPage.tsx index d5e4ed6..62f98a5 100644 --- a/src/renderer/inbox/components/SettingsPage.tsx +++ b/src/renderer/inbox/components/SettingsPage.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useInbox } from '../store.js'; import { AiProviderSection } from './settings/AiProviderSection.js'; import { AutostartSection } from './settings/AutostartSection.js'; +import { BackupSection } from './settings/BackupSection.js'; export function SettingsPage(): React.ReactElement { const setShowSettings = useInbox((s) => s.setShowSettings); @@ -32,7 +33,7 @@ export function SettingsPage(): React.ReactElement {

백업 / 복원

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

정보

diff --git a/src/renderer/inbox/components/settings/BackupSection.tsx b/src/renderer/inbox/components/settings/BackupSection.tsx new file mode 100644 index 0000000..37722ef --- /dev/null +++ b/src/renderer/inbox/components/settings/BackupSection.tsx @@ -0,0 +1,27 @@ +import React, { useState } from 'react'; +import { inboxApi } from '../../api.js'; + +export function BackupSection(): React.ReactElement { + const [status, setStatus] = useState(null); + + async function run(label: string, fn: () => Promise): Promise { + setStatus(`${label}: 진행 중...`); + try { + await fn(); + setStatus(`${label}: 완료`); + } catch (e) { + setStatus(`${label}: 실패 — ${(e as Error).message}`); + } + } + + return ( +
+ + + + + + {status &&
{status}
} +
+ ); +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 82c076d..38b3bec 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -95,6 +95,12 @@ export interface InboxApi { // v0.2.7 자동 실행 (임시 채널 — Task 22 에서 정식 이름으로 rename) getAutostart(): Promise<{ openAtLogin: boolean }>; setAutostart(open: boolean): Promise<{ openAtLogin: boolean }>; + // v0.2.7 백업 / 복원 / 동기화 / 텔레메트리 — 트레이 callback 의 IPC 대응 (Task 10) + runBackup(): Promise<{ ok: true }>; + runExport(): Promise<{ ok: true }>; + runImport(): Promise<{ ok: true }>; + runSync(): Promise<{ ok: true }>; + runExportTelemetry(): Promise<{ ok: true }>; } export interface InklingApi { diff --git a/tests/unit/BackupSection.test.tsx b/tests/unit/BackupSection.test.tsx new file mode 100644 index 0000000..b6197c7 --- /dev/null +++ b/tests/unit/BackupSection.test.tsx @@ -0,0 +1,39 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; + +vi.mock('../../src/renderer/inbox/api.js', () => ({ + 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 })) + } +})); + +import { BackupSection } from '../../src/renderer/inbox/components/settings/BackupSection'; + +describe('BackupSection', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + }); + + it('renders 5 buttons', () => { + render(); + expect(screen.getByRole('button', { name: /지금 백업/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^내보내기/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /백업에서 복원/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /지금 동기화/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /사용 로그/ })).toBeInTheDocument(); + }); + + it('clicking 지금 백업 calls runBackup', async () => { + const { inboxApi } = await import('../../src/renderer/inbox/api.js'); + render(); + fireEvent.click(screen.getByRole('button', { name: /지금 백업/ })); + await waitFor(() => expect(inboxApi.runBackup).toHaveBeenCalled()); + }); +}); diff --git a/tests/unit/SettingsPage.test.tsx b/tests/unit/SettingsPage.test.tsx index 491d0be..371904b 100644 --- a/tests/unit/SettingsPage.test.tsx +++ b/tests/unit/SettingsPage.test.tsx @@ -12,7 +12,12 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({ saveOllamaSettings: vi.fn(async () => ({ ok: true })), ollamaRecheck: vi.fn(async () => ({ ok: true })), getAutostart: vi.fn(async () => ({ openAtLogin: false })), - setAutostart: vi.fn(async (open: boolean) => ({ openAtLogin: open })) + setAutostart: vi.fn(async (open: boolean) => ({ openAtLogin: open })), + 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 })) } })); From 6ab518410e5b2b7f100e803486e18e6295ee3b39 Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 02:07:20 +0900 Subject: [PATCH 11/27] =?UTF-8?q?feat(v027):=20InfoSection=20=E2=80=94=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84/=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98/=EB=B3=B5=EC=82=AC=20+=20IPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/ipc/settingsApi.ts | 29 ++++++++++- src/preload/index.ts | 4 ++ .../inbox/components/SettingsPage.tsx | 3 +- .../inbox/components/settings/InfoSection.tsx | 41 ++++++++++++++++ src/shared/types.ts | 10 ++++ tests/unit/InfoSection.test.tsx | 49 +++++++++++++++++++ tests/unit/SettingsPage.test.tsx | 11 ++++- 7 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 src/renderer/inbox/components/settings/InfoSection.tsx create mode 100644 tests/unit/InfoSection.test.tsx diff --git a/src/main/ipc/settingsApi.ts b/src/main/ipc/settingsApi.ts index a5b3823..e4f6b0d 100644 --- a/src/main/ipc/settingsApi.ts +++ b/src/main/ipc/settingsApi.ts @@ -1,6 +1,7 @@ import electron from 'electron'; import type { BrowserWindow } from 'electron'; -const { ipcMain, app, dialog, Notification } = electron; +import { platform, release, EOL } from 'node:os'; +const { ipcMain, app, dialog, Notification, shell, clipboard } = electron; import { logger } from '../logger.js'; import type { BackupService } from '../services/BackupService.js'; import type { ExportService } from '../services/ExportService.js'; @@ -40,6 +41,32 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void { return { openAtLogin: r.openAtLogin }; }); + // v0.2.7 정보 섹션 (Task 11) — 트레이 showAboutDialog 의 detail 형식 그대로 (clipboard 일관성). + // 트레이 showAboutDialog 자체 제거는 Task 25 (Phase 6 cleanup) — 본 task 는 추가만. + ipcMain.handle('settings:get-app-info', () => ({ + 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}`); + }); + if (!deps) return; const { backup, exportSvc, importSvc, syncSvc, telemetry, getInboxWindow } = deps; diff --git a/src/preload/index.ts b/src/preload/index.ts index a6a926d..65e06ca 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -61,6 +61,10 @@ const api: InklingApi = { runImport: () => ipcRenderer.invoke('settings:run-import'), runSync: () => ipcRenderer.invoke('settings:run-sync'), runExportTelemetry: () => ipcRenderer.invoke('settings:run-export-telemetry'), + // v0.2.7 정보 섹션 (Task 11) — 트레이 showAboutDialog 의 IPC 대응 + getAppInfo: () => ipcRenderer.invoke('settings:get-app-info'), + openProfileDir: () => ipcRenderer.invoke('settings:open-profile-dir'), + copyAppInfo: () => ipcRenderer.invoke('settings:copy-app-info'), } }; diff --git a/src/renderer/inbox/components/SettingsPage.tsx b/src/renderer/inbox/components/SettingsPage.tsx index 62f98a5..a4b807a 100644 --- a/src/renderer/inbox/components/SettingsPage.tsx +++ b/src/renderer/inbox/components/SettingsPage.tsx @@ -3,6 +3,7 @@ import { useInbox } from '../store.js'; import { AiProviderSection } from './settings/AiProviderSection.js'; import { AutostartSection } from './settings/AutostartSection.js'; import { BackupSection } from './settings/BackupSection.js'; +import { InfoSection } from './settings/InfoSection.js'; export function SettingsPage(): React.ReactElement { const setShowSettings = useInbox((s) => s.setShowSettings); @@ -37,7 +38,7 @@ export function SettingsPage(): React.ReactElement {

정보

- {/* InfoSection — Task 11 */} +
); diff --git a/src/renderer/inbox/components/settings/InfoSection.tsx b/src/renderer/inbox/components/settings/InfoSection.tsx new file mode 100644 index 0000000..b76f2cf --- /dev/null +++ b/src/renderer/inbox/components/settings/InfoSection.tsx @@ -0,0 +1,41 @@ +import React, { useEffect, useState } from 'react'; +import { inboxApi } from '../../api.js'; + +interface AppInfo { + version: string; + electron: string; + node: string; + os: string; + profileDir: string; +} + +export function InfoSection(): React.ReactElement { + const [info, setInfo] = useState(null); + + useEffect(() => { + void (async () => setInfo(await inboxApi.getAppInfo()))(); + }, []); + + if (!info) return
로딩 중...
; + + return ( +
+
+
버전
+
{info.version}
+
Electron
+
{info.electron}
+
Node
+
{info.node}
+
OS
+
{info.os}
+
데이터 위치
+
{info.profileDir}
+
+
+ + +
+
+ ); +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 38b3bec..5be2325 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -101,6 +101,16 @@ export interface InboxApi { runImport(): Promise<{ ok: true }>; runSync(): Promise<{ ok: true }>; runExportTelemetry(): Promise<{ ok: true }>; + // v0.2.7 정보 섹션 (Task 11) — 트레이 showAboutDialog 의 IPC 대응. 트레이 잔류 → Task 25 cleanup. + getAppInfo(): Promise<{ + version: string; + electron: string; + node: string; + os: string; + profileDir: string; + }>; + openProfileDir(): Promise; + copyAppInfo(): Promise; } export interface InklingApi { diff --git a/tests/unit/InfoSection.test.tsx b/tests/unit/InfoSection.test.tsx new file mode 100644 index 0000000..7dcdb81 --- /dev/null +++ b/tests/unit/InfoSection.test.tsx @@ -0,0 +1,49 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; + +vi.mock('../../src/renderer/inbox/api.js', () => ({ + 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) + } +})); + +import { InfoSection } from '../../src/renderer/inbox/components/settings/InfoSection'; + +describe('InfoSection', () => { + beforeEach(() => { vi.clearAllMocks(); cleanup(); }); + + it('renders version, electron, node, OS, profileDir', async () => { + render(); + expect(await screen.findByText(/0\.2\.7/)).toBeInTheDocument(); + expect(screen.getByText(/41\.3\.0/)).toBeInTheDocument(); + expect(screen.getByText(/22\.x/)).toBeInTheDocument(); + expect(screen.getByText(/darwin/)).toBeInTheDocument(); + expect(screen.getByText(/Library\/Application Support\/Inkling/)).toBeInTheDocument(); + }); + + it('"데이터 위치 열기" calls openProfileDir', async () => { + const { inboxApi } = await import('../../src/renderer/inbox/api.js'); + render(); + await screen.findByText(/0\.2\.7/); + fireEvent.click(screen.getByRole('button', { name: /데이터 위치 열기/ })); + await waitFor(() => expect(inboxApi.openProfileDir).toHaveBeenCalled()); + }); + + it('"정보 복사" calls copyAppInfo', async () => { + const { inboxApi } = await import('../../src/renderer/inbox/api.js'); + render(); + await screen.findByText(/0\.2\.7/); + fireEvent.click(screen.getByRole('button', { name: /정보 복사/ })); + await waitFor(() => expect(inboxApi.copyAppInfo).toHaveBeenCalled()); + }); +}); diff --git a/tests/unit/SettingsPage.test.tsx b/tests/unit/SettingsPage.test.tsx index 371904b..3e0fa48 100644 --- a/tests/unit/SettingsPage.test.tsx +++ b/tests/unit/SettingsPage.test.tsx @@ -17,7 +17,16 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({ runExport: vi.fn(async () => ({ ok: true })), runImport: vi.fn(async () => ({ ok: true })), runSync: vi.fn(async () => ({ ok: true })), - runExportTelemetry: vi.fn(async () => ({ ok: true })) + runExportTelemetry: vi.fn(async () => ({ ok: true })), + getAppInfo: vi.fn(async () => ({ + version: '0.2.7', + electron: '41.3.0', + node: '22.x', + os: 'darwin 23.6.0', + profileDir: '/tmp/Inkling' + })), + openProfileDir: vi.fn(async () => undefined), + copyAppInfo: vi.fn(async () => undefined) } })); From 95ed0fba93623ced43ad2299ad79f5d0d0480ab5 Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 02:10:01 +0900 Subject: [PATCH 12/27] =?UTF-8?q?feat(v027):=20App.tsx=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=20=ED=86=B1=EB=8B=88=EB=B0=94=ED=80=B4=20+=20showSett?= =?UTF-8?q?ings=20=EB=B6=84=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/inbox/App.tsx | 19 ++++++++++++ tests/unit/App.test.tsx | 61 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 tests/unit/App.test.tsx diff --git a/src/renderer/inbox/App.tsx b/src/renderer/inbox/App.tsx index 54aad1b..91a11fc 100644 --- a/src/renderer/inbox/App.tsx +++ b/src/renderer/inbox/App.tsx @@ -13,6 +13,7 @@ import { ExpiryBanner } from './components/ExpiryBanner.js'; import { FailedBanner } from './components/FailedBanner.js'; import { RecallBanner } from './components/RecallBanner.js'; import { OllamaSettingsModal } from './components/OllamaSettingsModal.js'; +import { SettingsPage } from './components/SettingsPage.js'; export function App(): React.ReactElement { const { @@ -21,6 +22,8 @@ export function App(): React.ReactElement { continuity, tagFilter, setTagFilter, toggleShowTrash, restoreNote, permanentDeleteNote, emptyTrash } = useInbox(); + const showSettings = useInbox((s) => s.showSettings); + const setShowSettings = useInbox((s) => s.setShowSettings); const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday()); const [ollamaSettingsOpen, setOllamaSettingsOpen] = useState(false); @@ -41,6 +44,8 @@ export function App(): React.ReactElement { // deps array 에 추가 불필요. mount 시 1회 구독 + unmount 시 해제. }, [loadInitial, refreshMeta, upsertNote]); + if (showSettings) return ; + const showRecovery = continuity.showRecoveryToast && !recoveryDismissed; const filtered = selectFilteredNotes({ notes, tagFilter }); @@ -78,6 +83,20 @@ export function App(): React.ReactElement { +
{!showTrash && ( diff --git a/tests/unit/App.test.tsx b/tests/unit/App.test.tsx new file mode 100644 index 0000000..947cf1f --- /dev/null +++ b/tests/unit/App.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; + +vi.mock('../../src/renderer/inbox/api.js', () => ({ + inboxApi: { + listNotes: vi.fn(async () => []), + getContinuity: vi.fn(async () => ({ + weekStart: '', weekCount: 0, weekTarget: 7, + consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null + })), + getPendingCount: vi.fn(async () => 0), + getOllamaStatus: vi.fn(async () => ({ ok: true })), + getTodayCount: vi.fn(async () => 0), + getTrashCount: vi.fn(async () => 0), + listExpired: vi.fn(async () => []), + getFailedCount: vi.fn(async () => 0), + listRecallCandidate: vi.fn(async () => null), + onNoteUpdated: vi.fn(() => () => undefined), + onOllamaStatus: vi.fn(() => () => undefined), + onOpenOllamaSettings: vi.fn(() => () => undefined), + // 4 섹션 mounted 시 호출되는 stub + loadOllamaSettings: vi.fn(async () => ({ endpoint: '', model: '' })), + saveOllamaSettings: vi.fn(async () => ({ ok: true })), + ollamaRecheck: vi.fn(async () => ({ ok: true })), + getAutostart: vi.fn(async () => ({ openAtLogin: false })), + setAutostart: vi.fn(async () => ({ openAtLogin: false })), + 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 })), + getAppInfo: vi.fn(async () => ({ version: '0.2.7', electron: '?', node: '?', os: '?', profileDir: '?' })), + openProfileDir: vi.fn(async () => undefined), + copyAppInfo: vi.fn(async () => undefined) + } +})); + +import { App } from '../../src/renderer/inbox/App'; +import { useInbox } from '../../src/renderer/inbox/store'; + +describe('App — settings view', () => { + beforeEach(() => { + cleanup(); + useInbox.setState({ showSettings: false, notes: [], trashNotes: [], trashCount: 0 }); + }); + + it('renders SettingsPage when showSettings=true', async () => { + useInbox.setState({ showSettings: true }); + render(); + expect(await screen.findByText('설정')).toBeInTheDocument(); + expect(screen.getByText('AI 제공자')).toBeInTheDocument(); + }); + + it('header gear icon click sets showSettings=true', async () => { + render(); + fireEvent.click(await screen.findByLabelText('설정 열기')); + expect(useInbox.getState().showSettings).toBe(true); + }); +}); From feb7c62f1929e094cded76ea9ff37c0ff8280978 Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 02:12:45 +0900 Subject: [PATCH 13/27] =?UTF-8?q?feat(v027):=20IPC=20inbox:navigate=20?= =?UTF-8?q?=E2=80=94=20=EC=99=B8=EB=B6=80=EC=97=90=EC=84=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A7=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/ipc/settingsApi.ts | 17 +++++++++++++++++ src/preload/index.ts | 6 ++++++ src/renderer/inbox/App.tsx | 13 ++++++++++++- src/shared/types.ts | 2 ++ tests/unit/App.test.tsx | 19 ++++++++++++++++++- 5 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/main/ipc/settingsApi.ts b/src/main/ipc/settingsApi.ts index e4f6b0d..1d51f7f 100644 --- a/src/main/ipc/settingsApi.ts +++ b/src/main/ipc/settingsApi.ts @@ -8,6 +8,23 @@ import type { ExportService } from '../services/ExportService.js'; import type { ImportService } from '../services/ImportService.js'; import type { SyncService } from '../services/SyncService.js'; import type { TelemetryService } from '../services/TelemetryService.js'; +import { getInboxWindow as getInboxWindowSingleton } from '../windows/inboxWindow.js'; + +/** + * 외부 (트레이 / second-instance / 기타 main 프로세스 호출자) 에서 inbox 창에 view 전환을 + * 요청하는 진입점. 창이 숨겨져 있으면 show + focus 후 'inbox:navigate' IPC 이벤트를 + * renderer 로 전달. + * + * Task 13 (v0.2.7) — 트레이 "설정..." 메뉴 wiring 은 Task 16 에서 본 함수 호출. + */ +export function navigateInbox(view: 'inbox' | 'trash' | 'settings'): void { + const win = getInboxWindowSingleton(); + if (win && !win.isDestroyed()) { + if (!win.isVisible()) win.show(); + win.focus(); + win.webContents.send('inbox:navigate', view); + } +} export interface SettingsIpcDeps { backup: BackupService; diff --git a/src/preload/index.ts b/src/preload/index.ts index 65e06ca..f71adca 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -52,6 +52,12 @@ const api: InklingApi = { ipcRenderer.on('inbox:openOllamaSettings', handler); return () => ipcRenderer.removeListener('inbox:openOllamaSettings', handler); }, + // v0.2.7 Task 13 — 외부 (트레이) 에서 view 전환 요청 listener. + onNavigate: (cb: (view: 'inbox' | 'trash' | 'settings') => void) => { + const listener = (_e: unknown, view: 'inbox' | 'trash' | 'settings') => cb(view); + ipcRenderer.on('inbox:navigate', listener); + return () => ipcRenderer.off('inbox:navigate', listener); + }, // v0.2.7 자동 실행 (임시 채널 — Task 22 에서 settings:autostart-state / settings:autostart-set 으로 rename) getAutostart: () => ipcRenderer.invoke('settings:get-autostart'), setAutostart: (open: boolean) => ipcRenderer.invoke('settings:set-autostart', open), diff --git a/src/renderer/inbox/App.tsx b/src/renderer/inbox/App.tsx index 91a11fc..85ed039 100644 --- a/src/renderer/inbox/App.tsx +++ b/src/renderer/inbox/App.tsx @@ -37,9 +37,20 @@ export function App(): React.ReactElement { useInbox.setState({ ollamaStatus: status }); }); const unsubOllamaSettings = inboxApi.onOpenOllamaSettings(() => setOllamaSettingsOpen(true)); + const unsubNav = inboxApi.onNavigate((view) => { + if (view === 'settings') { + useInbox.getState().setShowSettings(true); + } else if (view === 'inbox') { + useInbox.getState().setShowSettings(false); + if (useInbox.getState().showTrash) void useInbox.getState().toggleShowTrash(); + } else if (view === 'trash') { + useInbox.getState().setShowSettings(false); + if (!useInbox.getState().showTrash) void useInbox.getState().toggleShowTrash(); + } + }); const onFocus = () => { void refreshMeta(); }; window.addEventListener('focus', onFocus); - return () => { unsubNote(); unsubOllama(); unsubOllamaSettings(); window.removeEventListener('focus', onFocus); }; + return () => { unsubNote(); unsubOllama(); unsubOllamaSettings(); unsubNav(); window.removeEventListener('focus', onFocus); }; // onOllamaStatus 콜백은 useInbox.setState 직접 호출 — store reference 가 안정적이라 // deps array 에 추가 불필요. mount 시 1회 구독 + unmount 시 해제. }, [loadInitial, refreshMeta, upsertNote]); diff --git a/src/shared/types.ts b/src/shared/types.ts index 5be2325..7260b0a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -92,6 +92,8 @@ export interface InboxApi { loadOllamaSettings(): Promise<{ endpoint: string; model: string } | null>; saveOllamaSettings(v: { endpoint: string; model: string }): Promise<{ ok: true } | { ok: false; reason: string }>; onOpenOllamaSettings(cb: () => void): () => void; + // v0.2.7 Task 13 — 외부 (트레이 등) 에서 view 전환 요청 구독. + onNavigate(cb: (view: 'inbox' | 'trash' | 'settings') => void): () => void; // v0.2.7 자동 실행 (임시 채널 — Task 22 에서 정식 이름으로 rename) getAutostart(): Promise<{ openAtLogin: boolean }>; setAutostart(open: boolean): Promise<{ openAtLogin: boolean }>; diff --git a/tests/unit/App.test.tsx b/tests/unit/App.test.tsx index 947cf1f..5e65be9 100644 --- a/tests/unit/App.test.tsx +++ b/tests/unit/App.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from 'vitest'; import '@testing-library/jest-dom/vitest'; -import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: { @@ -20,6 +20,7 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({ onNoteUpdated: vi.fn(() => () => undefined), onOllamaStatus: vi.fn(() => () => undefined), onOpenOllamaSettings: vi.fn(() => () => undefined), + onNavigate: vi.fn(() => () => undefined), // 4 섹션 mounted 시 호출되는 stub loadOllamaSettings: vi.fn(async () => ({ endpoint: '', model: '' })), saveOllamaSettings: vi.fn(async () => ({ ok: true })), @@ -39,6 +40,7 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({ import { App } from '../../src/renderer/inbox/App'; import { useInbox } from '../../src/renderer/inbox/store'; +import { inboxApi } from '../../src/renderer/inbox/api.js'; describe('App — settings view', () => { beforeEach(() => { @@ -58,4 +60,19 @@ describe('App — settings view', () => { fireEvent.click(await screen.findByLabelText('설정 열기')); expect(useInbox.getState().showSettings).toBe(true); }); + + it('inbox:navigate "settings" event sets showSettings=true', async () => { + const navHandlers: Array<(view: 'inbox' | 'trash' | 'settings') => void> = []; + vi.mocked(inboxApi.onNavigate).mockImplementation((cb) => { + navHandlers.push(cb); + return () => { + const i = navHandlers.indexOf(cb); + if (i >= 0) navHandlers.splice(i, 1); + }; + }); + render(); + await waitFor(() => expect(navHandlers.length).toBeGreaterThan(0)); + navHandlers.forEach((h) => h('settings')); + await waitFor(() => expect(useInbox.getState().showSettings).toBe(true)); + }); }); From 77effb45260eea5c7085c4df525286216d66bbe2 Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 02:16:29 +0900 Subject: [PATCH 14/27] =?UTF-8?q?feat(v027):=20TrayCallbacks/TrayState=20?= =?UTF-8?q?=EC=8A=AC=EB=A6=BC=20+=20buildMenu=204=20=ED=95=AD=EB=AA=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/tray.ts | 74 ++++++++++------------------------------- tests/unit/tray.test.ts | 71 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 57 deletions(-) create mode 100644 tests/unit/tray.test.ts diff --git a/src/main/tray.ts b/src/main/tray.ts index 38d8425..8bef426 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -33,33 +33,32 @@ function showAboutDialog(): void { } /** - * v0.2.6 C2 — 트레이 메뉴 콜백 묶음. createTray 가 1-arg 로 받음. + * v0.2.7 Phase 3 (Task 14) — 트레이 메뉴 슬림. 13 → 4 항목. + * + * 백업/내보내기/복원/동기화/사용 로그/Ollama 재확인/AI 재처리/Ollama 설정/자동실행/정보 → + * 모두 설정 페이지로 이전. 트레이는 4 항목만 노출: + * 1. 한 줄 적기 (showCapture) + * 2. 보관한 메모 보기 (showInbox) + * 3. 설정... (showSettings — 설정 페이지로 navigate) + * 4. 종료 */ export interface TrayCallbacks { showInbox: () => void; showCapture: () => void; - runBackup: () => void; - runExport: () => void; - runImport: () => void; - runSync: () => void; - runExportTelemetry: () => void; - runOllamaRecheck: () => void; - runRetryAllFailed: () => void; - runOpenOllamaSettings: () => void; + showSettings: () => void; } /** - * v0.2.6 C3 — 메뉴 라벨/활성화에 영향 주는 reactive state. refreshTray() 로 partial 갱신. + * v0.2.7 Phase 3 (Task 14) — TrayState 슬림. todayCount 만 잔류 (오늘 N번 잡아둠 라벨). + * ollamaOk / failedCount 메뉴 항목이 사라져 더 이상 필요 없음. */ export interface TrayState { - ollamaOk: boolean; todayCount: number; - failedCount: number; } let tray: TrayType | null = null; let _callbacks: TrayCallbacks | null = null; -let _state: TrayState = { ollamaOk: true, todayCount: 0, failedCount: 0 }; +let _state: TrayState = { todayCount: 0 }; function buildMenu(): electron.Menu { const items: MenuItemConstructorOptions[] = []; @@ -73,51 +72,18 @@ function buildMenu(): electron.Menu { items.push({ label: `오늘 ${_state.todayCount}번 잡아둠`, enabled: false }); items.push({ type: 'separator' }); } - items.push({ label: '보관한 메모 보기', click: cb.showInbox }); 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: cb.runBackup }); - items.push({ label: '내보내기...', click: cb.runExport }); - items.push({ label: '백업에서 복원...', click: cb.runImport }); - items.push({ label: '지금 동기화', click: cb.runSync }); - items.push({ label: '사용 로그 내보내기...', click: cb.runExportTelemetry }); - items.push({ - label: 'Ollama 재확인', - enabled: !_state.ollamaOk, - click: cb.runOllamaRecheck - }); - items.push({ - label: `지금 AI 처리 (실패 ${_state.failedCount}건)`, - enabled: _state.failedCount > 0, - click: cb.runRetryAllFailed - }); - items.push({ label: 'Ollama 설정...', click: cb.runOpenOllamaSettings }); - if (app.isPackaged) { - // v0.2.6 #45 — args 명시 전달로 openAtLogin 비교 정확도. setLoginItemSettings 가 - // args 와 함께 LoginItem 등록하므로 read 시도 같은 args 로 비교해야 매치됨. - const { openAtLogin } = app.getLoginItemSettings({ args: ['--hidden'] }); - items.push({ - label: '윈도우 시작 시 자동 실행', - type: 'checkbox', - checked: openAtLogin, - click: (item) => { - app.setLoginItemSettings({ - openAtLogin: item.checked, - args: ['--hidden'] - }); - } - }); - items.push({ type: 'separator' }); - } else { - items.push({ type: 'separator' }); - } - items.push({ label: 'Inkling 정보...', click: showAboutDialog }); items.push({ label: '종료', click: () => { app.isQuitting = true; app.quit(); } }); return Menu.buildFromTemplate(items); } /** * v0.2.6 C2 — 1-arg createTray. 기존 10 positional 폐기. + * v0.2.7 Phase 3 — TrayCallbacks 3-필드로 슬림. */ export function createTray(callbacks: TrayCallbacks): TrayType { _callbacks = callbacks; @@ -131,13 +97,7 @@ export function createTray(callbacks: TrayCallbacks): TrayType { /** * v0.2.6 C3 — 통합 state 갱신. partial 으로 받아 _state merge + 메뉴 재빌드. - * - * Replaces: refreshTrayOllama(ok), refreshTrayFailedCount(count), 기존 refreshTray(todayCount). - * - * 호출 예: - * refreshTray({ todayCount: 5 }); - * refreshTray({ ollamaOk: false }); - * refreshTray({ failedCount: 2 }); + * v0.2.7 Phase 3 — TrayState 가 todayCount 만 갖도록 슬림. */ export function refreshTray(state: Partial): void { _state = { ..._state, ...state }; diff --git a/tests/unit/tray.test.ts b/tests/unit/tray.test.ts new file mode 100644 index 0000000..a98efce --- /dev/null +++ b/tests/unit/tray.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +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(function (this: unknown) { + Object.assign(this as object, { + setToolTip: vi.fn(), + setContextMenu: vi.fn(), + on: vi.fn() + }); + }), + Menu: { buildFromTemplate: vi.fn((items: unknown) => ({ items })) }, + nativeImage: { createEmpty: vi.fn() }, + dialog: {}, + shell: {}, + clipboard: {} + } +})); + +import { createTray, type TrayCallbacks } from '../../src/main/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', 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('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(); + }); +}); From f30fbddd38940afb75afa437cf39aa20b687cc2b Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 02:16:55 +0900 Subject: [PATCH 15/27] =?UTF-8?q?feat(v027):=20tray.ts=20=EC=9D=98=20showA?= =?UTF-8?q?boutDialog=20+=20=EC=9E=90=EB=8F=99=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20+=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20import?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/tray.ts | 34 ++++------------------------------ 1 file changed, 4 insertions(+), 30 deletions(-) diff --git a/src/main/tray.ts b/src/main/tray.ts index 8bef426..7744cbd 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -1,36 +1,10 @@ import electron from 'electron'; import type { Tray as TrayType, MenuItemConstructorOptions } from 'electron'; -import { platform, release, EOL } from 'node:os'; -const { app, Tray, Menu, nativeImage, dialog, shell, clipboard } = electron; +const { app, Tray, Menu, nativeImage } = electron; -function showAboutDialog(): void { - const version = app.getVersion(); - const electronVersion = process.versions.electron ?? '?'; - const nodeVersion = process.versions.node ?? '?'; - const profileDir = app.getPath('userData'); - // OS EOL 사용 — 클립보드 → Notepad 등에서 줄바꿈 정상. - const detail = [ - `버전: ${version}`, - `Electron: ${electronVersion}`, - `Node: ${nodeVersion}`, - `OS: ${platform()} ${release()}`, - `데이터 위치: ${profileDir}` - ].join(EOL); - void dialog.showMessageBox({ - type: 'info', - title: 'Inkling 정보', - message: `Inkling ${version}`, - detail, - buttons: ['확인', '데이터 위치 열기', '정보 복사'], - defaultId: 0, - cancelId: 0 - }).then((r) => { - if (r.response === 1) void shell.openPath(profileDir); - if (r.response === 2) clipboard.writeText(`Inkling ${version}${EOL}${detail}`); - }).catch(() => { - // dialog reject 는 일반 사용에서 발생 X — main process crash 등 예외 케이스 silent. - }); -} +// v0.2.7 Phase 3 (Task 15) — showAboutDialog 제거됨. +// "Inkling 정보..." 트레이 항목이 사라짐 → 동일 기능은 설정 페이지의 InfoSection 이 담당. +// settings:get-app-info / settings:copy-app-info IPC 핸들러 (settingsApi.ts) 가 역할 인계. /** * v0.2.7 Phase 3 (Task 14) — 트레이 메뉴 슬림. 13 → 4 항목. From 9c8ba8ad09354d4aa105ad0e91c021f2945648a6 Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 02:18:32 +0900 Subject: [PATCH 16/27] =?UTF-8?q?feat(v027):=20createTray=20wiring=203-cal?= =?UTF-8?q?lback=20+=20refreshTray=20=ED=98=B8=EC=B6=9C=EB=B6=80=20?= =?UTF-8?q?=EC=8A=AC=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 186 +++------------------------------------------- 1 file changed, 9 insertions(+), 177 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 7942642..264bbb1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -19,7 +19,7 @@ import { ProviderHolder } from './ai/ProviderHolder.js'; import { AiWorker } from './ai/AiWorker.js'; import { registerCaptureApi } from './ipc/captureApi.js'; import { registerInboxApi, pushNoteUpdated, pushOllamaStatus } from './ipc/inboxApi.js'; -import { registerSettingsApi } from './ipc/settingsApi.js'; +import { registerSettingsApi, navigateInbox } from './ipc/settingsApi.js'; import { createInboxWindow, getInboxWindow } from './windows/inboxWindow.js'; import { createQuickCaptureWindow, showQuickCapture, getQuickCaptureWindow @@ -121,7 +121,6 @@ app.whenReady().then(async () => { onUpdate: (status) => { logger.info('ai.health', { ...status } as Record); pushOllamaStatus(getInboxWindow, status); - refreshTray({ ollamaOk: status.ok }); }, onTelemetry: (ev) => { if (ev.kind === 'ollama_unreachable') { @@ -139,7 +138,8 @@ app.whenReady().then(async () => { onUpdate: (note) => { pushNoteUpdated(getInboxWindow, note); // F4-C: AI 처리 완료 = 새 캡처가 inbox 에 합류한 시점, tray 도 즉시 갱신. - refreshTray({ todayCount: repo.countToday(), failedCount: repo.countFailed() }); + // v0.2.7 Phase 3 — failedCount 메뉴 항목 제거됨 → todayCount 만 갱신. + refreshTray({ todayCount: repo.countToday() }); }, logger, telemetry @@ -231,188 +231,20 @@ app.whenReady().then(async () => { }); }); + // v0.2.7 Phase 3 (Task 16) — TrayCallbacks 슬림: 10 → 3. + // 백업/내보내기/복원/동기화/사용 로그/Ollama 재확인/AI 재처리/Ollama 설정/정보 → + // 모두 설정 페이지로 이전 (registerSettingsApi 의 IPC 핸들러가 본문 보유). createTray({ showInbox: () => createInboxWindow(), showCapture: () => showQuickCapture(), - runBackup: async () => { - try { - const r = await backup.runDaily(); - new Notification({ - title: 'Inkling', - body: r.snapshotted - ? `백업 완료 — ${r.removed?.length ?? 0}개 정리` - : `오늘 백업이 이미 있습니다`, - silent: true - }).show(); - } catch (e) { - logger.warn('backup.manual.failed', { reason: String(e) }); - new Notification({ - title: 'Inkling', - body: '백업을 만들지 못했습니다.', - silent: true - }).show(); - } - }, - runExport: async () => { - const win = getInboxWindow(); - const dialogOpts: Electron.OpenDialogOptions = { - title: '내보낼 폴더 선택', - message: '선택한 폴더에 노트를 마크다운으로 내보냅니다. 이미지가 함께 포함됩니다. raw_text 가 평문으로 보관되니 비공개 위치를 권장합니다.', - buttonLabel: '여기에 내보내기', - properties: ['openDirectory', 'createDirectory'] - }; - const result = win - ? await dialog.showOpenDialog(win, dialogOpts) - : await dialog.showOpenDialog(dialogOpts); - if (result.canceled || result.filePaths.length === 0) return; - try { - const r = await exportSvc.export(result.filePaths[0]!, { includeMedia: true }); - logger.info('export.done', { - outDir: r.outDir, - noteCount: r.noteCount, - mediaCount: r.mediaCount, - bytes: r.bytes - }); - new Notification({ - title: 'Inkling', - body: `내보내기 완료 — 노트 ${r.noteCount}개, 이미지 ${r.mediaCount}개`, - silent: true - }).show(); - } catch (e) { - logger.warn('export.failed', { reason: String(e) }); - new Notification({ - title: 'Inkling', - body: '내보내기를 완료하지 못했습니다.', - silent: true - }).show(); - } - }, - runImport: async () => { - const win = getInboxWindow(); - const dirOpts: Electron.OpenDialogOptions = { - title: '복원할 백업 폴더 선택', - message: 'F5 export 형식의 폴더를 선택하세요. notes/ 하위의 마크다운 파일이 적재됩니다.', - buttonLabel: '여기서 복원', - properties: ['openDirectory'] - }; - const dirResult = win - ? await dialog.showOpenDialog(win, dirOpts) - : await dialog.showOpenDialog(dirOpts); - if (dirResult.canceled || dirResult.filePaths.length === 0) return; - const sourceDir = dirResult.filePaths[0]!; - let plan; - try { - plan = await importSvc.preview(sourceDir); - } catch (e) { - logger.warn('import.preview.failed', { reason: String(e) }); - new Notification({ - title: 'Inkling', - body: '백업 폴더를 읽지 못했습니다.', - silent: true - }).show(); - return; - } - const detail = `총 ${plan.total}개 노트\n · 신규 ${plan.newCount}개\n · 동일 (스킵) ${plan.unchangedCount}개\n · 충돌→새 id (${plan.forkedCount}개, raw_text 보존)\n\n이미지 ${plan.mediaCount}개 복사 예정.`; - const confirmOpts: Electron.MessageBoxOptions = { - type: 'question', - buttons: ['복원', '취소'], - defaultId: 0, - cancelId: 1, - title: 'Inkling 복원', - message: '복원 미리보기', - detail - }; - const confirm = win - ? await dialog.showMessageBox(win, confirmOpts) - : await dialog.showMessageBox(confirmOpts); - if (confirm.response !== 0) return; - try { - const r = await importSvc.run(sourceDir); - logger.info('import.done', { - total: r.total, - new: r.newCount, - unchanged: r.unchangedCount, - forked: r.forkedCount, - media: r.mediaCount - }); - new Notification({ - title: 'Inkling', - body: `복원 완료 — 신규 ${r.newCount}개, 스킵 ${r.unchangedCount}개, 충돌 ${r.forkedCount}개`, - silent: true - }).show(); - } catch (e) { - logger.warn('import.run.failed', { reason: String(e) }); - new Notification({ - title: 'Inkling', - body: '복원을 완료하지 못했습니다.', - silent: true - }).show(); - } - }, - runSync: async () => { - // runSync — 트레이 "지금 동기화" - try { - const r = await syncSvc.sync(); - if (!r.ok) { - logger.warn('sync.failed', { reason: r.reason }); - const body = r.reason === 'not_configured' - ? `${syncSvc.getSyncDir()} 에서 git init + remote 설정이 필요합니다.` - : '동기화를 완료하지 못했습니다.'; - new Notification({ title: 'Inkling', body, silent: true }).show(); - return; - } - if (r.changed) { - logger.info('sync.done', { sha: r.sha, pushed: r.pushed }); - new Notification({ title: 'Inkling', body: '동기화 완료', silent: true }).show(); - } else { - new Notification({ title: 'Inkling', body: '변경 사항 없음', silent: true }).show(); - } - } catch (e) { - logger.warn('sync.exception', { reason: String(e) }); - new Notification({ title: 'Inkling', body: '동기화를 완료하지 못했습니다.', silent: true }).show(); - } - }, - runExportTelemetry: async () => { - const win = getInboxWindow(); - const dialogOpts: Electron.OpenDialogOptions = { - title: '사용 로그를 내보낼 폴더 선택', - message: '선택한 폴더에 events.jsonl + stats.md 가 생성됩니다. raw_text/요약/제목/태그 이름은 미포함입니다.', - buttonLabel: '여기로 내보내기', - properties: ['openDirectory', 'createDirectory'] - }; - const result = win - ? await dialog.showOpenDialog(win, dialogOpts) - : await dialog.showOpenDialog(dialogOpts); - if (result.canceled || result.filePaths.length === 0) return; - try { - const r = await telemetry.exportTo(result.filePaths[0]!); - logger.info('telemetry.export', { eventCount: r.eventCount, outDir: result.filePaths[0] }); - new Notification({ - title: 'Inkling', - body: `사용 로그 내보내기 완료 — ${r.eventCount}개 이벤트`, - silent: true - }).show(); - } catch (e) { - logger.warn('telemetry.export.failed', { reason: String(e) }); - new Notification({ - title: 'Inkling', - body: '사용 로그 내보내기를 완료하지 못했습니다.', - silent: true - }).show(); - } - }, - runOllamaRecheck: () => { void health.runOnce({ manual: true }); }, - runRetryAllFailed: () => { void capture.retryAllFailed(); }, - runOpenOllamaSettings: () => { - const win = getInboxWindow(); - if (win) win.webContents.send('inbox:openOllamaSettings'); - } + showSettings: () => navigateInbox('settings') }); // F4-C 환경 앵커 — tray tooltip + 메뉴 첫 항목을 오늘 KST 캡처 수로 갱신. // 초기 1회 + 60s interval. AiWorker.onUpdate 도 별도 갱신 트리거. // cleanup 은 위 통합 before-quit 핸들러에서 처리. - refreshTray({ todayCount: repo.countToday(), failedCount: repo.countFailed() }); + // v0.2.7 Phase 3 — failedCount 메뉴 항목 제거됨 → todayCount 만 갱신. + refreshTray({ todayCount: repo.countToday() }); trayInterval = setInterval(() => { refreshTray({ todayCount: repo.countToday() }); }, 60_000); From 3b53cec6633e6e61e73ca04f9f69a68f1db1df46 Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 02:22:40 +0900 Subject: [PATCH 17/27] =?UTF-8?q?fix(v027):=20F14=20=E2=80=94=20macOS=20do?= =?UTF-8?q?ck=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20hidden=20inbox=20?= =?UTF-8?q?=EC=B0=BD=20show/focus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 264bbb1..073556c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,5 @@ import electron from 'electron'; -const { app, BrowserWindow, Notification, dialog } = electron; +const { app, Notification, dialog } = electron; import '@shared/types'; import { existsSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -249,7 +249,16 @@ app.whenReady().then(async () => { refreshTray({ todayCount: repo.countToday() }); }, 60_000); + // F14 (v0.2.7) — macOS dock 클릭 시 hidden inbox 창 show/focus. + // 기존: BrowserWindow.getAllWindows().length === 0 만 검사 → quickCapture 등이 + // 떠 있으면 inbox 창이 hidden 인 채로 남았음. app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) createInboxWindow(); + const win = getInboxWindow(); + if (win && !win.isDestroyed()) { + if (!win.isVisible()) win.show(); + win.focus(); + } else { + createInboxWindow(); + } }); }); From 3a8137f334539640acd337cd0eff64cc61d661bc Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 02:23:52 +0900 Subject: [PATCH 18/27] =?UTF-8?q?feat(v027):=20AutostartDiagnostic=20?= =?UTF-8?q?=E2=80=94=20withArgs/noArgs/execPath=20=EC=88=98=EC=A7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/services/AutostartDiagnostic.ts | 29 +++++++++++++++++ tests/unit/AutostartDiagnostic.test.ts | 40 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 src/main/services/AutostartDiagnostic.ts create mode 100644 tests/unit/AutostartDiagnostic.test.ts diff --git a/src/main/services/AutostartDiagnostic.ts b/src/main/services/AutostartDiagnostic.ts new file mode 100644 index 0000000..c3921ab --- /dev/null +++ b/src/main/services/AutostartDiagnostic.ts @@ -0,0 +1,29 @@ +import electron from 'electron'; +const { app } = electron; + +/** + * v0.2.7 F12 deeper fix — 자동 실행 진단 정보 수집. + * + * Electron 의 `app.getLoginItemSettings()` 는 args 가 매칭돼야만 정확한 상태를 반환 → + * `args: ['--hidden']` 으로 등록 vs `args: undefined` 로 조회하면 mismatch 가 발생할 수 있다. + * 그래서 두 호출 결과를 모두 노출 (withArgs / noArgs) + Win 에서는 registry 직접 조회까지. + */ +export interface AutostartState { + withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean }; + noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean }; + execPath: string; + registryPath?: string; + registryValue?: string | null; +} + +export async function collectAutostartState(): Promise { + const w = app.getLoginItemSettings({ args: ['--hidden'] }); + const n = app.getLoginItemSettings(); + const state: AutostartState = { + withArgs: { openAtLogin: w.openAtLogin, executableWillLaunchAtLogin: w.executableWillLaunchAtLogin }, + noArgs: { openAtLogin: n.openAtLogin, executableWillLaunchAtLogin: n.executableWillLaunchAtLogin }, + execPath: process.execPath + }; + // Task 20 — Windows registry 조회 (HKCU\...\Run\Inkling) 는 다음 task 에서 추가. + return state; +} diff --git a/tests/unit/AutostartDiagnostic.test.ts b/tests/unit/AutostartDiagnostic.test.ts new file mode 100644 index 0000000..4d28bce --- /dev/null +++ b/tests/unit/AutostartDiagnostic.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockApp } = vi.hoisted(() => ({ + mockApp: { getLoginItemSettings: vi.fn() } +})); + +vi.mock('electron', () => ({ + default: { app: mockApp } +})); + +import { collectAutostartState } from '../../src/main/services/AutostartDiagnostic'; + +describe('AutostartDiagnostic — collectAutostartState', () => { + beforeEach(() => { + mockApp.getLoginItemSettings.mockReset(); + }); + + it('returns withArgs / noArgs / execPath structure', async () => { + mockApp.getLoginItemSettings + .mockReturnValueOnce({ openAtLogin: true, executableWillLaunchAtLogin: true }) + .mockReturnValueOnce({ openAtLogin: false, executableWillLaunchAtLogin: true }); + + 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); + }); + + it('passes args=["--hidden"] for the first call, no args for the second', async () => { + mockApp.getLoginItemSettings + .mockReturnValueOnce({ openAtLogin: true, executableWillLaunchAtLogin: true }) + .mockReturnValueOnce({ openAtLogin: true, executableWillLaunchAtLogin: true }); + + await collectAutostartState(); + + expect(mockApp.getLoginItemSettings).toHaveBeenNthCalledWith(1, { args: ['--hidden'] }); + expect(mockApp.getLoginItemSettings).toHaveBeenNthCalledWith(2); + }); +}); From 5f964aa2f5068e210058088c778a42769d9e4bec Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 02:25:21 +0900 Subject: [PATCH 19/27] =?UTF-8?q?feat(v027):=20AutostartDiagnostic=20?= =?UTF-8?q?=E2=80=94=20Windows=20registry=20=EC=A1=B0=ED=9A=8C=20+=20silen?= =?UTF-8?q?t=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/services/AutostartDiagnostic.ts | 29 ++++++++++- tests/unit/AutostartDiagnostic.test.ts | 62 ++++++++++++++++++++++-- 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/src/main/services/AutostartDiagnostic.ts b/src/main/services/AutostartDiagnostic.ts index c3921ab..c11a975 100644 --- a/src/main/services/AutostartDiagnostic.ts +++ b/src/main/services/AutostartDiagnostic.ts @@ -1,4 +1,5 @@ import electron from 'electron'; +import { execFile } from 'node:child_process'; const { app } = electron; /** @@ -16,6 +17,9 @@ export interface AutostartState { registryValue?: string | null; } +const WIN_REGISTRY_PATH = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'; +const WIN_REGISTRY_KEY = 'Inkling'; + export async function collectAutostartState(): Promise { const w = app.getLoginItemSettings({ args: ['--hidden'] }); const n = app.getLoginItemSettings(); @@ -24,6 +28,29 @@ export async function collectAutostartState(): Promise { noArgs: { openAtLogin: n.openAtLogin, executableWillLaunchAtLogin: n.executableWillLaunchAtLogin }, execPath: process.execPath }; - // Task 20 — Windows registry 조회 (HKCU\...\Run\Inkling) 는 다음 task 에서 추가. + if (process.platform === 'win32') { + state.registryPath = `${WIN_REGISTRY_PATH}\\${WIN_REGISTRY_KEY}`; + state.registryValue = await readRegistrySilent(); + } return state; } + +/** + * `reg query` 로 HKCU\\...\\Run\\Inkling 의 값을 조회. + * 키가 없으면 reg.exe 가 exit 1 → silent fallback (null). + * + * promisify(execFile) 대신 직접 Promise 로 wrapping — 테스트에서 vi.mock 이 + * `util.promisify.custom` symbol 을 보전하지 못해 stdout 이 undefined 가 되는 이슈 회피. + */ +function readRegistrySilent(): Promise { + return new Promise((resolve) => { + execFile('reg', ['query', WIN_REGISTRY_PATH, '/v', WIN_REGISTRY_KEY], (err, stdout) => { + if (err) { + resolve(null); + return; + } + const m = stdout.match(/REG_SZ\s+(.+)/); + resolve(m && m[1] ? m[1].trim() : null); + }); + }); +} diff --git a/tests/unit/AutostartDiagnostic.test.ts b/tests/unit/AutostartDiagnostic.test.ts index 4d28bce..014ffe0 100644 --- a/tests/unit/AutostartDiagnostic.test.ts +++ b/tests/unit/AutostartDiagnostic.test.ts @@ -1,21 +1,38 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -const { mockApp } = vi.hoisted(() => ({ - mockApp: { getLoginItemSettings: vi.fn() } +const { mockApp, mockExecFile } = vi.hoisted(() => ({ + mockApp: { getLoginItemSettings: vi.fn() }, + mockExecFile: vi.fn() })); vi.mock('electron', () => ({ default: { app: mockApp } })); +vi.mock('node:child_process', () => ({ + execFile: mockExecFile +})); + import { collectAutostartState } from '../../src/main/services/AutostartDiagnostic'; +const ORIGINAL_PLATFORM = process.platform; + +function setPlatform(p: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: p, configurable: true }); +} + describe('AutostartDiagnostic — collectAutostartState', () => { beforeEach(() => { mockApp.getLoginItemSettings.mockReset(); + mockExecFile.mockReset(); + }); + + afterEach(() => { + setPlatform(ORIGINAL_PLATFORM); }); it('returns withArgs / noArgs / execPath structure', async () => { + setPlatform('darwin'); mockApp.getLoginItemSettings .mockReturnValueOnce({ openAtLogin: true, executableWillLaunchAtLogin: true }) .mockReturnValueOnce({ openAtLogin: false, executableWillLaunchAtLogin: true }); @@ -28,6 +45,7 @@ describe('AutostartDiagnostic — collectAutostartState', () => { }); it('passes args=["--hidden"] for the first call, no args for the second', async () => { + setPlatform('darwin'); mockApp.getLoginItemSettings .mockReturnValueOnce({ openAtLogin: true, executableWillLaunchAtLogin: true }) .mockReturnValueOnce({ openAtLogin: true, executableWillLaunchAtLogin: true }); @@ -37,4 +55,42 @@ describe('AutostartDiagnostic — collectAutostartState', () => { expect(mockApp.getLoginItemSettings).toHaveBeenNthCalledWith(1, { args: ['--hidden'] }); expect(mockApp.getLoginItemSettings).toHaveBeenNthCalledWith(2); }); + + it('non-win32: does not set registryPath/registryValue', async () => { + setPlatform('darwin'); + mockApp.getLoginItemSettings.mockReturnValue({ openAtLogin: true, executableWillLaunchAtLogin: true }); + + const state = await collectAutostartState(); + + expect(state.registryPath).toBeUndefined(); + expect(state.registryValue).toBeUndefined(); + expect(mockExecFile).not.toHaveBeenCalled(); + }); + + it('Windows: returns registryPath + registryValue when reg.exe succeeds', async () => { + setPlatform('win32'); + mockApp.getLoginItemSettings.mockReturnValue({ openAtLogin: true, executableWillLaunchAtLogin: true }); + mockExecFile.mockImplementation((_cmd: string, _args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => { + cb(null, '\r\nHKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\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'); + expect(state.registryValue).toContain('--hidden'); + }); + + it('Windows: silent fallback on reg.exe error', async () => { + setPlatform('win32'); + mockApp.getLoginItemSettings.mockReturnValue({ openAtLogin: true, executableWillLaunchAtLogin: true }); + mockExecFile.mockImplementation((_cmd: string, _args: string[], cb: (err: Error | null, stdout: string, stderr: string) => void) => { + cb(new Error('not found'), '', ''); + }); + + const state = await collectAutostartState(); + + expect(state.registryPath).toBe('HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Inkling'); + expect(state.registryValue).toBeNull(); + }); }); From 39bbf8f4432faadd33b48d1d1b5bbd6bbc8488c4 Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 02:26:18 +0900 Subject: [PATCH 20/27] =?UTF-8?q?feat(v027):=20settings:autostart-state=20?= =?UTF-8?q?IPC=20=ED=95=B8=EB=93=A4=EB=9F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/ipc/settingsApi.ts | 8 ++++ tests/unit/settingsApi.test.ts | 68 ++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 tests/unit/settingsApi.test.ts diff --git a/src/main/ipc/settingsApi.ts b/src/main/ipc/settingsApi.ts index 1d51f7f..121e949 100644 --- a/src/main/ipc/settingsApi.ts +++ b/src/main/ipc/settingsApi.ts @@ -8,6 +8,7 @@ import type { ExportService } from '../services/ExportService.js'; import type { ImportService } from '../services/ImportService.js'; import type { SyncService } from '../services/SyncService.js'; import type { TelemetryService } from '../services/TelemetryService.js'; +import { collectAutostartState } from '../services/AutostartDiagnostic.js'; import { getInboxWindow as getInboxWindowSingleton } from '../windows/inboxWindow.js'; /** @@ -58,6 +59,13 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void { return { openAtLogin: r.openAtLogin }; }); + // v0.2.7 F12 deeper fix (Task 21) — 진단 정보 포함된 정식 autostart 상태 조회. + // Task 22 에서 옛 'settings:get-autostart' 를 제거하고 본 채널로 통일 예정. + ipcMain.handle('settings:autostart-state', async () => { + const diag = await collectAutostartState(); + return { openAtLogin: diag.withArgs.openAtLogin, diagnostic: diag }; + }); + // v0.2.7 정보 섹션 (Task 11) — 트레이 showAboutDialog 의 detail 형식 그대로 (clipboard 일관성). // 트레이 showAboutDialog 자체 제거는 Task 25 (Phase 6 cleanup) — 본 task 는 추가만. ipcMain.handle('settings:get-app-info', () => ({ diff --git a/tests/unit/settingsApi.test.ts b/tests/unit/settingsApi.test.ts new file mode 100644 index 0000000..9688850 --- /dev/null +++ b/tests/unit/settingsApi.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { handlers, mockApp, mockCollectAutostartState } = vi.hoisted(() => ({ + handlers: {} as Record unknown>, + mockApp: { + getLoginItemSettings: vi.fn(), + setLoginItemSettings: vi.fn(), + getVersion: vi.fn(() => '0.2.7'), + getPath: vi.fn(() => '/profile') + }, + mockCollectAutostartState: vi.fn() +})); + +vi.mock('electron', () => ({ + default: { + ipcMain: { + handle: (ch: string, fn: (...args: unknown[]) => unknown) => { + handlers[ch] = fn; + } + }, + app: mockApp, + dialog: {}, + Notification: vi.fn(function (this: unknown) { + Object.assign(this as object, { show: vi.fn() }); + }), + shell: { openPath: vi.fn() }, + clipboard: { writeText: vi.fn() } + } +})); + +vi.mock('../../src/main/services/AutostartDiagnostic', () => ({ + collectAutostartState: mockCollectAutostartState +})); + +vi.mock('../../src/main/windows/inboxWindow.js', () => ({ + getInboxWindow: vi.fn(() => null) +})); + +import { registerSettingsApi } from '../../src/main/ipc/settingsApi'; + +describe('settingsApi — autostart IPC', () => { + beforeEach(() => { + Object.keys(handlers).forEach((k) => delete handlers[k]); + mockApp.getLoginItemSettings.mockReset(); + mockApp.setLoginItemSettings.mockReset(); + mockCollectAutostartState.mockReset(); + }); + + it('settings:autostart-state returns AutostartState wrapped with openAtLogin', async () => { + mockCollectAutostartState.mockResolvedValue({ + withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true }, + noArgs: { openAtLogin: false, executableWillLaunchAtLogin: true }, + execPath: '/path/to/exe' + }); + + registerSettingsApi(); + + const r = await handlers['settings:autostart-state']!() as { + openAtLogin: boolean; + diagnostic: { withArgs: { openAtLogin: boolean } }; + }; + + expect(r.openAtLogin).toBe(true); + expect(r.diagnostic.withArgs.openAtLogin).toBe(true); + expect(r.diagnostic).toHaveProperty('noArgs'); + expect(r.diagnostic).toHaveProperty('execPath'); + }); +}); From ce6c5ea756089d971f7c65f42be68b76225e95db Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 02:28:17 +0900 Subject: [PATCH 21/27] =?UTF-8?q?feat(v027):=20settings:autostart-set=20?= =?UTF-8?q?=EC=A0=95=EC=8B=9D=20+=20=EC=B1=84=EB=84=90=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/ipc/settingsApi.ts | 25 +++++++--------- src/preload/index.ts | 6 ++-- .../components/settings/AutostartSection.tsx | 11 +++---- src/shared/types.ts | 20 +++++++++++-- tests/unit/AutostartSection.test.tsx | 16 ++++++++-- tests/unit/settingsApi.test.ts | 30 +++++++++++++++++++ 6 files changed, 80 insertions(+), 28 deletions(-) diff --git a/src/main/ipc/settingsApi.ts b/src/main/ipc/settingsApi.ts index 121e949..c783f17 100644 --- a/src/main/ipc/settingsApi.ts +++ b/src/main/ipc/settingsApi.ts @@ -39,8 +39,8 @@ export interface SettingsIpcDeps { /** * v0.2.7 설정 페이지 IPC 핸들러. * - * - 자동 실행: 임시 채널명 (`settings:get-autostart` / `settings:set-autostart`). - * Task 22 에서 정식 이름 (`settings:autostart-state` / `settings:autostart-set`) 으로 rename 예정. + * - 자동 실행 (Task 22 통일): `settings:autostart-state` (조회) / `settings:autostart-set` (변경). + * 둘 다 `{ openAtLogin, diagnostic }` 반환 — diagnostic 은 withArgs/noArgs/execPath/registry 진단. * args=['--hidden'] 명시 — 자동 실행 시 백그라운드 모드로 시작 (Quick Capture only). * * - 백업/내보내기/복원/동기화/사용 로그 (Task 10): 기존 `src/main/index.ts` 트레이 callback @@ -48,24 +48,19 @@ export interface SettingsIpcDeps { * 복사. 트레이 callback 자체 제거는 Task 16 (Phase 3) — 본 task 에선 잔류 (의도적 중복). */ export function registerSettingsApi(deps?: SettingsIpcDeps): 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 }; - }); - - // v0.2.7 F12 deeper fix (Task 21) — 진단 정보 포함된 정식 autostart 상태 조회. - // Task 22 에서 옛 'settings:get-autostart' 를 제거하고 본 채널로 통일 예정. + // v0.2.7 F12 deeper fix (Task 21~22) — 진단 정보 포함된 autostart 상태 조회/변경. + // 옛 'settings:get-autostart' / 'settings:set-autostart' 채널은 본 통일에서 제거됨. ipcMain.handle('settings:autostart-state', async () => { const diag = await collectAutostartState(); return { openAtLogin: diag.withArgs.openAtLogin, diagnostic: diag }; }); + ipcMain.handle('settings:autostart-set', async (_e, open: boolean) => { + app.setLoginItemSettings({ openAtLogin: open, args: ['--hidden'] }); + const diag = await collectAutostartState(); + return { openAtLogin: diag.withArgs.openAtLogin, diagnostic: diag }; + }); + // v0.2.7 정보 섹션 (Task 11) — 트레이 showAboutDialog 의 detail 형식 그대로 (clipboard 일관성). // 트레이 showAboutDialog 자체 제거는 Task 25 (Phase 6 cleanup) — 본 task 는 추가만. ipcMain.handle('settings:get-app-info', () => ({ diff --git a/src/preload/index.ts b/src/preload/index.ts index f71adca..76ae853 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -58,9 +58,9 @@ const api: InklingApi = { ipcRenderer.on('inbox:navigate', listener); return () => ipcRenderer.off('inbox:navigate', listener); }, - // v0.2.7 자동 실행 (임시 채널 — Task 22 에서 settings:autostart-state / settings:autostart-set 으로 rename) - getAutostart: () => ipcRenderer.invoke('settings:get-autostart'), - setAutostart: (open: boolean) => ipcRenderer.invoke('settings:set-autostart', open), + // v0.2.7 자동 실행 (Task 22 통일) — 진단 정보 포함 응답 + getAutostart: () => ipcRenderer.invoke('settings:autostart-state'), + setAutostart: (open: boolean) => ipcRenderer.invoke('settings:autostart-set', open), // v0.2.7 백업/복원/동기화/텔레메트리 (Task 10) — 트레이 callback 의 IPC 대응 runBackup: () => ipcRenderer.invoke('settings:run-backup'), runExport: () => ipcRenderer.invoke('settings:run-export'), diff --git a/src/renderer/inbox/components/settings/AutostartSection.tsx b/src/renderer/inbox/components/settings/AutostartSection.tsx index 000ecbc..b7c9384 100644 --- a/src/renderer/inbox/components/settings/AutostartSection.tsx +++ b/src/renderer/inbox/components/settings/AutostartSection.tsx @@ -1,29 +1,30 @@ import React, { useEffect, useState } from 'react'; +import type { AutostartResponse } from '@shared/types'; import { inboxApi } from '../../api.js'; export function AutostartSection(): React.ReactElement { - const [openAtLogin, setOpenAtLogin] = useState(null); + const [data, setData] = useState(null); useEffect(() => { void (async () => { const r = await inboxApi.getAutostart(); - setOpenAtLogin(r.openAtLogin); + setData(r); })(); }, []); async function onToggle(e: React.ChangeEvent): Promise { const r = await inboxApi.setAutostart(e.target.checked); - setOpenAtLogin(r.openAtLogin); + setData(r); } - if (openAtLogin === null) { + if (data === null) { return
로딩 중...
; } return (
diff --git a/src/shared/types.ts b/src/shared/types.ts index 7260b0a..fe47154 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -57,6 +57,20 @@ export interface CaptureApi { hide(): void; } +// v0.2.7 F12 deeper fix — 자동 실행 진단 정보 (AutostartDiagnostic.collectAutostartState 결과). +export interface AutostartDiagnostic { + withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean }; + noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean }; + execPath: string; + registryPath?: string; + registryValue?: string | null; +} + +export interface AutostartResponse { + openAtLogin: boolean; + diagnostic: AutostartDiagnostic; +} + export interface InboxApi { listNotes(opts: { limit: number; cursor?: string }): Promise; updateAiFields( @@ -94,9 +108,9 @@ export interface InboxApi { onOpenOllamaSettings(cb: () => void): () => void; // v0.2.7 Task 13 — 외부 (트레이 등) 에서 view 전환 요청 구독. onNavigate(cb: (view: 'inbox' | 'trash' | 'settings') => void): () => void; - // v0.2.7 자동 실행 (임시 채널 — Task 22 에서 정식 이름으로 rename) - getAutostart(): Promise<{ openAtLogin: boolean }>; - setAutostart(open: boolean): Promise<{ openAtLogin: boolean }>; + // v0.2.7 자동 실행 (Task 22 통일) — 진단 정보 포함 응답 + getAutostart(): Promise; + setAutostart(open: boolean): Promise; // v0.2.7 백업 / 복원 / 동기화 / 텔레메트리 — 트레이 callback 의 IPC 대응 (Task 10) runBackup(): Promise<{ ok: true }>; runExport(): Promise<{ ok: true }>; diff --git a/tests/unit/AutostartSection.test.tsx b/tests/unit/AutostartSection.test.tsx index 7a4c4fe..78c2429 100644 --- a/tests/unit/AutostartSection.test.tsx +++ b/tests/unit/AutostartSection.test.tsx @@ -3,10 +3,22 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import '@testing-library/jest-dom/vitest'; import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +function makeDiag(open: boolean): { + withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean }; + noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean }; + execPath: string; +} { + return { + withArgs: { openAtLogin: open, executableWillLaunchAtLogin: open }, + noArgs: { openAtLogin: open, executableWillLaunchAtLogin: open }, + execPath: '/path/to/exe' + }; +} + vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: { - getAutostart: vi.fn(async () => ({ openAtLogin: true })), - setAutostart: vi.fn(async (open: boolean) => ({ openAtLogin: open })) + getAutostart: vi.fn(async () => ({ openAtLogin: true, diagnostic: makeDiag(true) })), + setAutostart: vi.fn(async (open: boolean) => ({ openAtLogin: open, diagnostic: makeDiag(open) })) } })); diff --git a/tests/unit/settingsApi.test.ts b/tests/unit/settingsApi.test.ts index 9688850..1327f89 100644 --- a/tests/unit/settingsApi.test.ts +++ b/tests/unit/settingsApi.test.ts @@ -65,4 +65,34 @@ describe('settingsApi — autostart IPC', () => { expect(r.diagnostic).toHaveProperty('noArgs'); expect(r.diagnostic).toHaveProperty('execPath'); }); + + it('settings:autostart-set calls setLoginItemSettings + returns diagnostic', async () => { + mockCollectAutostartState.mockResolvedValue({ + withArgs: { openAtLogin: false, executableWillLaunchAtLogin: false }, + noArgs: { openAtLogin: false, executableWillLaunchAtLogin: false }, + execPath: '/path/to/exe' + }); + + registerSettingsApi(); + + const r = await handlers['settings:autostart-set']!({}, false) as { + openAtLogin: boolean; + diagnostic: { withArgs: { openAtLogin: boolean } }; + }; + + expect(mockApp.setLoginItemSettings).toHaveBeenCalledWith({ openAtLogin: false, args: ['--hidden'] }); + expect(r.openAtLogin).toBe(false); + expect(r.diagnostic.withArgs.openAtLogin).toBe(false); + }); + + it('Task 22 — old channels removed', async () => { + mockCollectAutostartState.mockResolvedValue({ + withArgs: { openAtLogin: false, executableWillLaunchAtLogin: false }, + noArgs: { openAtLogin: false, executableWillLaunchAtLogin: false }, + execPath: '/path/to/exe' + }); + registerSettingsApi(); + expect(handlers['settings:get-autostart']).toBeUndefined(); + expect(handlers['settings:set-autostart']).toBeUndefined(); + }); }); From 8a8652e87a1ae25ae3b2ed6a05f187351ed9a3a3 Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 02:29:17 +0900 Subject: [PATCH 22/27] =?UTF-8?q?feat(v027):=20AutostartSection=20?= =?UTF-8?q?=EC=A7=84=EB=8B=A8=20=ED=8C=A8=EB=84=90=20+=20mismatch=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/settings/AutostartSection.tsx | 47 ++++++++++++++++ tests/unit/AutostartSection.test.tsx | 53 +++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/src/renderer/inbox/components/settings/AutostartSection.tsx b/src/renderer/inbox/components/settings/AutostartSection.tsx index b7c9384..9e27e4e 100644 --- a/src/renderer/inbox/components/settings/AutostartSection.tsx +++ b/src/renderer/inbox/components/settings/AutostartSection.tsx @@ -4,6 +4,7 @@ import { inboxApi } from '../../api.js'; export function AutostartSection(): React.ReactElement { const [data, setData] = useState(null); + const [expanded, setExpanded] = useState(false); useEffect(() => { void (async () => { @@ -21,12 +22,58 @@ export function AutostartSection(): React.ReactElement { return
로딩 중...
; } + const d = data.diagnostic; + // v0.2.7 F12 deeper fix — withArgs vs noArgs 의 openAtLogin 불일치, 또는 + // executableWillLaunchAtLogin = false 면 mismatch 로 간주 (등록은 됐지만 실제론 + // 로그인 시 실행되지 않을 수 있는 상태). + const mismatch = d.withArgs.openAtLogin !== d.noArgs.openAtLogin + || (data.openAtLogin && !d.withArgs.executableWillLaunchAtLogin); + return (
+ {mismatch && ( +
+ ⚠️ 등록 상태 불일치 감지 — 진단 정보 확인 후 재등록을 시도하세요. +
+ )} + + {expanded && ( +
+
표준 (--hidden 인자): openAtLogin={String(d.withArgs.openAtLogin)}, willLaunch={String(d.withArgs.executableWillLaunchAtLogin)}
+
비교 (인자 없이): openAtLogin={String(d.noArgs.openAtLogin)}, willLaunch={String(d.noArgs.executableWillLaunchAtLogin)}
+
실행 파일 경로: {d.execPath}
+ {d.registryPath !== undefined &&
registry 경로: {d.registryPath}
} + {d.registryValue !== undefined &&
registry 값: {d.registryValue ?? '(없음)'}
} +
+ )}
); } diff --git a/tests/unit/AutostartSection.test.tsx b/tests/unit/AutostartSection.test.tsx index 78c2429..234143b 100644 --- a/tests/unit/AutostartSection.test.tsx +++ b/tests/unit/AutostartSection.test.tsx @@ -43,4 +43,57 @@ describe('AutostartSection', () => { fireEvent.click(toggle); await waitFor(() => expect(inboxApi.setAutostart).toHaveBeenCalledWith(false)); }); + + it('renders diagnostic panel when expanded, shows mismatch warning + execPath', async () => { + const { inboxApi } = await import('../../src/renderer/inbox/api.js'); + vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({ + openAtLogin: true, + diagnostic: { + withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true }, + noArgs: { openAtLogin: false, executableWillLaunchAtLogin: true }, + execPath: '/path/to/Inkling.exe' + } + }); + render(); + await screen.findByRole('checkbox'); + fireEvent.click(screen.getByRole('button', { name: /진단 정보/ })); + expect(await screen.findByText(/⚠️/)).toBeInTheDocument(); + expect(screen.getByText(/path\/to\/Inkling\.exe/)).toBeInTheDocument(); + expect(screen.getByText(/표준 \(--hidden 인자\)/)).toBeInTheDocument(); + expect(screen.getByText(/비교 \(인자 없이\)/)).toBeInTheDocument(); + }); + + it('shows registry info when present (Win)', async () => { + const { inboxApi } = await import('../../src/renderer/inbox/api.js'); + vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({ + openAtLogin: true, + diagnostic: { + withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true }, + noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true }, + execPath: 'C:\\app.exe', + registryPath: 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Inkling', + registryValue: '"C:\\app.exe" --hidden' + } + }); + render(); + await screen.findByRole('checkbox'); + fireEvent.click(screen.getByRole('button', { name: /진단 정보/ })); + expect(screen.getByText(/registry 경로/)).toBeInTheDocument(); + expect(screen.getByText(/registry 값/)).toBeInTheDocument(); + }); + + it('no mismatch warning when withArgs == noArgs and willLaunch=true', async () => { + const { inboxApi } = await import('../../src/renderer/inbox/api.js'); + vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({ + openAtLogin: true, + diagnostic: { + withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true }, + noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true }, + execPath: '/p' + } + }); + render(); + await screen.findByRole('checkbox'); + expect(screen.queryByText(/⚠️/)).not.toBeInTheDocument(); + }); }); From 836828636cc617b6264ab7168941172fbf38e9b2 Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 02:30:29 +0900 Subject: [PATCH 23/27] =?UTF-8?q?feat(v027):=20AutostartSection=20?= =?UTF-8?q?=EC=9E=AC=EB=93=B1=EB=A1=9D=20=EB=B2=84=ED=8A=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/settings/AutostartSection.tsx | 16 ++++++++++++++++ tests/unit/AutostartSection.test.tsx | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/renderer/inbox/components/settings/AutostartSection.tsx b/src/renderer/inbox/components/settings/AutostartSection.tsx index 9e27e4e..509a686 100644 --- a/src/renderer/inbox/components/settings/AutostartSection.tsx +++ b/src/renderer/inbox/components/settings/AutostartSection.tsx @@ -18,6 +18,14 @@ export function AutostartSection(): React.ReactElement { setData(r); } + // Task 24 — 현재 openAtLogin 값으로 다시 setLoginItemSettings 호출 → mismatch 복구. + // (예: registry 누락된 채로 withArgs.openAtLogin=true 인 경우 등.) + async function onReregister(): Promise { + if (!data) return; + const r = await inboxApi.setAutostart(data.openAtLogin); + setData(r); + } + if (data === null) { return
로딩 중...
; } @@ -40,6 +48,14 @@ export function AutostartSection(): React.ReactElement { ⚠️ 등록 상태 불일치 감지 — 진단 정보 확인 후 재등록을 시도하세요. )} +
+ +
- - - - - ); -} diff --git a/src/shared/types.ts b/src/shared/types.ts index fe47154..cb1eb9f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -105,7 +105,6 @@ export interface InboxApi { emitRecallSnoozed(id: string): Promise; loadOllamaSettings(): Promise<{ endpoint: string; model: string } | null>; saveOllamaSettings(v: { endpoint: string; model: string }): Promise<{ ok: true } | { ok: false; reason: string }>; - onOpenOllamaSettings(cb: () => void): () => void; // v0.2.7 Task 13 — 외부 (트레이 등) 에서 view 전환 요청 구독. onNavigate(cb: (view: 'inbox' | 'trash' | 'settings') => void): () => void; // v0.2.7 자동 실행 (Task 22 통일) — 진단 정보 포함 응답 diff --git a/tests/unit/App.test.tsx b/tests/unit/App.test.tsx index 317e59e..8e077f5 100644 --- a/tests/unit/App.test.tsx +++ b/tests/unit/App.test.tsx @@ -19,7 +19,6 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({ listRecallCandidate: vi.fn(async () => null), onNoteUpdated: vi.fn(() => () => undefined), onOllamaStatus: vi.fn(() => () => undefined), - onOpenOllamaSettings: vi.fn(() => () => undefined), onNavigate: vi.fn(() => () => undefined), // 4 섹션 mounted 시 호출되는 stub loadOllamaSettings: vi.fn(async () => ({ endpoint: '', model: '' })), From ccfdbce79b6af792472c84e4c48da2e4a84db22a Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 02:37:13 +0900 Subject: [PATCH 26/27] =?UTF-8?q?chore(release):=20v0.2.7=20=E2=80=94=20cr?= =?UTF-8?q?oss-platform=20=EC=9E=85=EA=B5=AC=20=EC=A0=95=EC=83=81=ED=99=94?= =?UTF-8?q?=20(F12=20deeper=20+=20F14=20+=20F15=20=EB=B9=8C=EB=93=9C=20+?= =?UTF-8?q?=20F16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../superpowers/specs/2026-04-25-dogfood-feedback.md | 12 ++++++------ docs/superpowers/v024-backlog.md | 6 +++--- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/specs/2026-04-25-dogfood-feedback.md b/docs/superpowers/specs/2026-04-25-dogfood-feedback.md index ad9efe1..4adf57e 100644 --- a/docs/superpowers/specs/2026-04-25-dogfood-feedback.md +++ b/docs/superpowers/specs/2026-04-25-dogfood-feedback.md @@ -1060,9 +1060,9 @@ F9 와 root cause 동일. 원래 별개 발견이었지만 fix 가 같음 — --- -## F12. 윈도우 자동 실행 옵션이 재시작 후 풀려있는 버그 (🔬 drafting — v0.2.6 부분처리) +## F12. 윈도우 자동 실행 옵션이 재시작 후 풀려있는 버그 (🚀 promoted — v0.2.7 deeper fix) -**진행 상태:** 🔬 drafting — v0.2.6 (PR #24) 진단 fallback 적용. dogfood verify 후 v0.2.7 deeper fix. +**진행 상태:** 🚀 promoted → docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md §9. v0.2.7 진단 노출 (withArgs/noArgs/registry/execPath + mismatch 경고 + 재등록 버튼) 적용 — 설정 페이지 "자동 실행" 섹션에서 사용자가 진단 정보 직접 확인 + 1-클릭 재등록 가능. **발견:** 2026-05-05 dogfood 도중. autostart 토글 후 재시작. @@ -1120,9 +1120,9 @@ Round 1 reviewer 의 발견 가치 = **production path 와 unit test 가 갈라 --- -## F14. macOS dock 클릭 시 hidden 창 재현 안 됨 (🌱 raw — v0.2.7 후보) +## F14. macOS dock 클릭 시 hidden 창 재현 안 됨 (🚀 promoted → v0.2.7) -**진행 상태:** 🌱 raw — 2026-05-05 v0.2.6 dogfood 발견. v0.2.7 brainstorm 후보. +**진행 상태:** 🚀 promoted → docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md §8. 2026-05-05 v0.2.6 dogfood 발견. **발견:** 2026-05-05 김태현 macOS dogfood 도중. @@ -1247,9 +1247,9 @@ app.on('activate', () => { --- -## F16. 트레이 의존도 ↓ + 별도 설정 페이지 (🌱 raw — v0.2.7 후보, 광범위 영향) +## F16. 트레이 의존도 ↓ + 별도 설정 페이지 (🚀 promoted → v0.2.7) -**진행 상태:** 🌱 raw — 본인 결정: "트레이 사용 최소화하자". v0.2.7 brainstorm 시 트레이 deemphasis 정책 + 설정 페이지 spec 합의 필요. +**진행 상태:** 🚀 promoted → docs/superpowers/specs/2026-05-06-v027-cross-platform-design.md §6, §7. 트레이 = quick-capture / 보관함 / 설정 / 종료 4-항목 minimum 으로 강등 + inbox window 안 SettingsPage (AI provider · 자동 실행 · 백업/복원 · 내보내기/가져오기 · 텔레메트리 · 정보 6 섹션) 신설. **발견:** 2026-05-05 dogfood 후속 결정. diff --git a/docs/superpowers/v024-backlog.md b/docs/superpowers/v024-backlog.md index ae542fc..8ed8e3d 100644 --- a/docs/superpowers/v024-backlog.md +++ b/docs/superpowers/v024-backlog.md @@ -3,9 +3,9 @@ > 누적 backlog. v0.2.3 cut (7항목 / PR #13~#19) 시점부터 PR review deferred + dogfood 발견 모두 합산. **파일명은 historic** (`v024-backlog.md`) — v0.2.4 ~ v0.2.6 cut 후에도 이어 사용. **v0.2.7 brainstorm 시** 신규 피드백 + 잔여 일괄 triage. **누적 시작일:** 2026-05-01 (#7 telemetry skeleton 머지 시점) -**최종 갱신:** 2026-05-05 (v0.2.6 정식 cut 16건 처리 완료, PR #24 머지 `8bc33da`) +**최종 갱신:** 2026-05-07 (v0.2.7 cross-platform cut — #45 자동실행 deeper fix) **총 항목 수:** 46 (#1 stale 포함) -**잔여:** 24건 (=46 − 처리 21 − stale 1) +**잔여:** 23건 (=46 − 처리 22 − stale 1) ## 처리 이력 / 진행 흐름 @@ -19,7 +19,7 @@ | **out-of-backlog**: multi-instance bug (single-instance lock) | ✅ critical hotfix | v0.2.5 (PR #23, `7187aea`) | | #10 (restoreNote + pending_jobs) | ✅ 처리 (repo 메서드 + CaptureService production path) | v0.2.6 (commit `df27a96` + `a991008`) | | #12 (trashCount cap) | ✅ 이미 fix (v0.2.3 #4) — tests +2 추가 | v0.2.6 (commit `e2c53a2`) | -| #45 (자동실행 풀림 버그) | 진단 fallback (args 명시 + 진단 로그). dogfood verify 후 v0.2.7 deeper fix | v0.2.6 부분처리 (commit `075f395`), 잔여 v0.2.7 | +| #45 (자동실행 풀림 버그) | ✅ 처리 (진단 노출 + 재등록 버튼) | v0.2.7 (commit `8a8652e` + `8368286`) | | #46 (hidden-start race) | ✅ 처리 (`additionalData` + handler hidden flag) | v0.2.6 (commit `e485b77`) | | #3+#19+#34 (KST helper 통합) | ✅ 처리 → `src/shared/util/kstDate.ts` (4 callsite migrate) | v0.2.6 (commit `3cfa60b`) | | #5 (AiFailedReason union) | ✅ 처리 (zod z.infer 단일 export) | v0.2.6 (commit `a2c17a8`) | diff --git a/package-lock.json b/package-lock.json index 02a18ba..9654443 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "inkling", - "version": "0.2.6", + "version": "0.2.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "inkling", - "version": "0.2.6", + "version": "0.2.7", "dependencies": { "better-sqlite3": "12.9.0", "electron-log": "5.2.0", diff --git a/package.json b/package.json index 386e898..0e9411e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "inkling", - "version": "0.2.6", + "version": "0.2.7", "private": true, "description": "Inkling — local-first 한 줄 보관 도구", "author": "altair823 ", From e19f6a8de7b042ecde6693f4008be1e65641e405 Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 7 May 2026 09:49:09 +0900 Subject: [PATCH 27/27] =?UTF-8?q?chore(v027):=20PR=20review=20minor=20clea?= =?UTF-8?q?nup=203=EA=B1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types.ts:119 stale "Task 25 cleanup" comment 제거 (Task 25 이미 완료) - BackupSection.tsx 의 dead try/catch 제거 + status 단순화 — 모든 IPC 핸들러가 자체 try/catch + Notification 으로 결과 알림. 컴포넌트 status 는 진행 표시 보조용 - index.ts startup #45 autostart 진단 로그를 AutostartDiagnostic.collectAutostartState() 호출로 통합 — single source of truth (SettingsPage 진단 패널과 동일 데이터 소스) 460/460 pass, typecheck 0. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/index.ts | 11 +++-------- .../inbox/components/settings/BackupSection.tsx | 10 ++++------ src/shared/types.ts | 2 +- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 073556c..52f9f4a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -32,6 +32,7 @@ import { ImportService } from './services/ImportService.js'; import { SyncService } from './services/SyncService.js'; import { TelemetryService } from './services/TelemetryService.js'; import { SettingsService } from './services/SettingsService.js'; +import { collectAutostartState } from './services/AutostartDiagnostic.js'; import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../shared/constants.js'; const HIDDEN_ARG = '--hidden'; @@ -84,14 +85,8 @@ app.whenReady().then(async () => { writeFileSync(initFlag, new Date().toISOString()); logger.info('autostart.enabled.firstRun'); } - // v0.2.6 #45 진단 — 실제 LoginItem 상태 확인 (args 전달 vs 미전달 차이) - const withArgs = app.getLoginItemSettings({ args: [HIDDEN_ARG] }); - const noArgs = app.getLoginItemSettings(); - logger.info('autostart.state', { - withArgs: { openAtLogin: withArgs.openAtLogin, executableWillLaunchAtLogin: withArgs.executableWillLaunchAtLogin }, - noArgs: { openAtLogin: noArgs.openAtLogin, executableWillLaunchAtLogin: noArgs.executableWillLaunchAtLogin }, - expectedArgs: [HIDDEN_ARG] - }); + // v0.2.6 #45 진단 — startup 로그. 같은 정보가 SettingsPage 진단 패널에도 surface (collectAutostartState single source of truth). + void collectAutostartState().then((state) => logger.info('autostart.state', { ...state })); } const db = openDb(paths.dbFile); const repo = new NoteRepository(db); diff --git a/src/renderer/inbox/components/settings/BackupSection.tsx b/src/renderer/inbox/components/settings/BackupSection.tsx index 37722ef..d1d9d7b 100644 --- a/src/renderer/inbox/components/settings/BackupSection.tsx +++ b/src/renderer/inbox/components/settings/BackupSection.tsx @@ -4,14 +4,12 @@ import { inboxApi } from '../../api.js'; export function BackupSection(): React.ReactElement { const [status, setStatus] = useState(null); + // IPC 핸들러 (settingsApi.ts) 가 자체 try/catch + Notification 으로 결과를 사용자에게 알림. + // 이 컴포넌트의 status 는 보조 진행 표시 — 결과 (성공/실패) 는 native UX 에 의존. async function run(label: string, fn: () => Promise): Promise { setStatus(`${label}: 진행 중...`); - try { - await fn(); - setStatus(`${label}: 완료`); - } catch (e) { - setStatus(`${label}: 실패 — ${(e as Error).message}`); - } + await fn(); + setStatus(null); } return ( diff --git a/src/shared/types.ts b/src/shared/types.ts index cb1eb9f..883651e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -116,7 +116,7 @@ export interface InboxApi { runImport(): Promise<{ ok: true }>; runSync(): Promise<{ ok: true }>; runExportTelemetry(): Promise<{ ok: true }>; - // v0.2.7 정보 섹션 (Task 11) — 트레이 showAboutDialog 의 IPC 대응. 트레이 잔류 → Task 25 cleanup. + // 정보 섹션 — 트레이 showAboutDialog 의 IPC 대응. getAppInfo(): Promise<{ version: string; electron: string;