36 Commits

Author SHA1 Message Date
b20473a593 Merge pull request 'v0.2.8 Cut A — 이미지 렌더링 + 앱 아이콘 (F22 + chore)' (#26) from worktree-v028-cut-a-image-icon into main
Reviewed-on: #26
2026-05-09 05:57:09 +00:00
altair823
6db449f86d chore(v028): final review minor 3건 cleanup
- inklingMedia.ts:39 no-op replace 제거 + 명료한 host+pathname 결합 코멘트
- inbox:open-media 빈 relPath 명시적 거절 (typeof + length 검사)
- NoteCard <img> alt="" decorative 의도 코멘트

472/472 + typecheck 0 유지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:27:42 +09:00
altair823
29259eef32 chore(release): v0.2.8 — Cut A (이미지 렌더링 + 앱 아이콘) 2026-05-09 14:23:51 +09:00
altair823
4d4dac5523 chore(v028): 앱 아이콘 (assets/icon.svg → ICO/ICNS/PNG) + electron-builder config
- electron-icon-builder + sharp devDep 추가
- assets/icon.svg → build/icon.{ico,icns,png} 산출 + git 추적
- electron-icon-builder 가 SVG 직접 input 안 받음 (Jimp MIME 에러) — sharp 로 SVG → PNG 1024 변환 후 input
- scripts/svg-to-png.mjs (sharp 사용 SVG→PNG) + scripts/finalize-icons.mjs (build/icons/ → build/ 정규 위치 정리)
- package.json build.{win,mac,linux}.icon 키 추가
- .gitignore: build/icons/ 와 build/icon-source.png (중간 산출물) 무시, build/icon.* 는 추적
- typecheck 0 errors + 472/472 단위 통과 유지 (회귀 없음)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:19:28 +09:00
altair823
9cdea1531c feat(v028): IPC inbox:open-media + path traversal + NoteCard cast 정리 2026-05-09 14:10:57 +09:00
altair823
f6bea623bf feat(v028): NoteCard 이미지 <img> 렌더링 + onClick (openMedia 시그니처는 Task 3)
- 회색 placeholder div → <img src=inkling-media://...> 로 교체
- onClick 으로 inboxApi.openMedia(relPath) 호출 (현재는 InboxApi 인터페이스에 부재 → unknown cast 사용; Task 3 에서 정식 시그니처 추가 후 cast 제거 예정)
- alt='' 로 decorative 처리 (role=presentation), title 에 relPath 유지
- flex-wrap 추가 — 다수 이미지 시 줄바꿈

Tests: tests/unit/NoteCard.test.tsx 신규 2건 (img src 검증, click → openMedia 호출)
회귀: 468 → 470 pass
2026-05-09 14:06:21 +09:00
altair823
470384bf80 feat(v028): inkling-media:// custom protocol + path traversal 검사
- registerSchemesAsPrivileged: inkling-media 스킴을 secure + supportFetchAPI + stream 으로 등록 (whenReady 이전 호출 필수).
- registerInklingMediaProtocol: profileDir/media 하위 파일을 raw URL traversal (.., %2e%2e) 검사 + normalize 후 mediaRoot 봉쇄로 이중 검증 후 readFile.
- inferMime: png/jpg/jpeg/gif/webp → image/*, 그 외 → application/octet-stream.
- src/main/index.ts: 모듈 import 직후 registerSchemesAsPrivileged(), whenReady 안 paths 결정 직후 registerInklingMediaProtocol(paths.profileDir).
- tests/unit/inklingMedia.test.ts: 8 unit (5 inferMime + 3 handler — valid/403/404). vitest 의 new Request() 가 url 을 normalize 하므로 raw url 보존을 위해 minimal mock req 사용.
2026-05-09 14:00:50 +09:00
e8cddc7889 Merge pull request 'v0.2.7 — cross-platform 입구 정상화 (F12 deeper + F14 + F15 빌드 + F16)' (#25) from worktree-v027-cross-platform into main
Reviewed-on: #25
2026-05-07 00:50:17 +00:00
altair823
e19f6a8de7 chore(v027): PR review minor cleanup 3건
- 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) <noreply@anthropic.com>
2026-05-07 09:49:09 +09:00
altair823
ccfdbce79b chore(release): v0.2.7 — cross-platform 입구 정상화 (F12 deeper + F14 + F15 빌드 + F16) 2026-05-07 02:37:13 +09:00
altair823
cffd1cec90 refactor(v027): OllamaSettingsModal 제거 + onOpenOllamaSettings 채널 cleanup 2026-05-07 02:35:43 +09:00
altair823
c5f2b8337a test(v027): App/SettingsPage 테스트 mock 을 새 AutostartResponse 형태로 갱신 2026-05-07 02:32:06 +09:00
altair823
836828636c feat(v027): AutostartSection 재등록 버튼 2026-05-07 02:30:29 +09:00
altair823
8a8652e87a feat(v027): AutostartSection 진단 패널 + mismatch 경고 2026-05-07 02:29:17 +09:00
altair823
ce6c5ea756 feat(v027): settings:autostart-set 정식 + 채널 이름 통일 2026-05-07 02:28:17 +09:00
altair823
39bbf8f443 feat(v027): settings:autostart-state IPC 핸들러 2026-05-07 02:26:18 +09:00
altair823
5f964aa2f5 feat(v027): AutostartDiagnostic — Windows registry 조회 + silent fallback 2026-05-07 02:25:21 +09:00
altair823
3a8137f334 feat(v027): AutostartDiagnostic — withArgs/noArgs/execPath 수집 2026-05-07 02:23:52 +09:00
altair823
3b53cec663 fix(v027): F14 — macOS dock 클릭 시 hidden inbox 창 show/focus 2026-05-07 02:22:40 +09:00
altair823
9c8ba8ad09 feat(v027): createTray wiring 3-callback + refreshTray 호출부 슬림 2026-05-07 02:18:32 +09:00
altair823
f30fbddd38 feat(v027): tray.ts 의 showAboutDialog + 자동실행 분기 + 미사용 import 제거 2026-05-07 02:16:55 +09:00
altair823
77effb4526 feat(v027): TrayCallbacks/TrayState 슬림 + buildMenu 4 항목 2026-05-07 02:16:29 +09:00
altair823
feb7c62f19 feat(v027): IPC inbox:navigate — 외부에서 설정 페이지 진입 2026-05-07 02:12:45 +09:00
altair823
95ed0fba93 feat(v027): App.tsx 헤더 톱니바퀴 + showSettings 분기 2026-05-07 02:10:01 +09:00
altair823
6ab518410e feat(v027): InfoSection — 버전/데이터 위치/복사 + IPC 2026-05-07 02:07:20 +09:00
altair823
5cd38f2537 feat(v027): BackupSection — 5 버튼 + IPC 핸들러 2026-05-07 02:03:31 +09:00
altair823
fca28fb0c4 feat(v027): AutostartSection 토글 (진단 패널은 후속 task) 2026-05-07 01:56:58 +09:00
altair823
7301f4d73d feat(v027): AiProviderSection — OllamaSettingsModal 흡수 + 지금 재확인 2026-05-07 01:51:53 +09:00
altair823
91bf98f1a2 feat(v027): SettingsPage scaffold — 4 섹션 placeholder + 돌아가기
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 유지)
2026-05-07 01:42:54 +09:00
altair823
5b37529175 feat(v027): inbox store 에 showSettings state + setShowSettings action 2026-05-07 01:36:26 +09:00
altair823
c9d374ade6 docs(v027): dist:linux 1차 빌드 시도 결과 (Windows 호스트) 2026-05-07 00:23:07 +09:00
altair823
b1b7bfee26 feat(v027): electron-builder linux target (AppImage + deb x64) 2026-05-07 00:18:14 +09:00
altair823
66bae5e317 docs(v027): better-sqlite3 linux-x64 prebuild 가용성 검증 2026-05-07 00:15:12 +09:00
altair823
5a605ef98f docs(v027): cross-platform 입구 정상화 implementation plan 작성
27 task / 6 phase. Phase 1 (Linux 빌드 risk-reduction first) → Phase 2
(설정 페이지 + IPC) → Phase 3 (트레이 슬림) → Phase 4 (F14 dock fix) →
Phase 5 (F12 deeper fix) → Phase 6 (cleanup + version bump).

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 00:10:25 +09:00
altair823
c2be135031 docs(v027): cross-platform 입구 정상화 design 작성
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) <noreply@anthropic.com>
2026-05-06 23:59:39 +09:00
altair823
9f47c13649 docs(dogfood): v0.2.6 release 후 dogfood 문서 갱신
dogfood-feedback.md (F1~F7 → F1~F13):
- Header: 진척 흐름 요약 표 추가 (v0.2.3 ~ v0.2.6 cuts + 신규 dogfood 발견)
- F8 Windows 11434 reserved → v0.2.3.1/v0.2.4 (PR #21/#22)
- F9 multi-instance spawn → v0.2.5 critical hotfix (PR #23)
- F10 버전 정보 부재 → v0.2.4 트레이 "Inkling 정보..." 추가
- F11 single-instance lock 부재 (F9 흡수)
- F12 autostart 풀림 → v0.2.6 진단 fallback (drafting, dogfood verify 후 v0.2.7)
- F13 PR review 발견 (restoreNote production path dead code) → v0.2.6 round 1 Critical fix

dogfood-strategy.md (Day 0 환경 step 갱신):
- v0.2.6 binary release 기준
- Ollama 설정: in-app UI (트레이 "Ollama 설정...") 가 1차, env var fallback 그 다음
- 11434 reserved 머신 우회 절차 (OLLAMA_HOST=127.0.0.1:11942)
- 데이터 위치 확인: 트레이 "Inkling 정보..." → "데이터 위치 열기"
- autostart 확인 절차 (F12 dogfood verify 영역)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:28:37 +09:00
45 changed files with 9422 additions and 459 deletions

4
.gitignore vendored
View File

@@ -7,3 +7,7 @@ dist/
coverage/
playwright-report/
test-results/
# build/ 산출물 — icon.{ico,icns,png} 만 커밋, 중간 산출물은 무시
build/icons/
build/icon-source.png

24
assets/icon.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="Inkling">
<!-- 배경 -->
<rect width="1024" height="1024" rx="192" fill="#1a6b6e"/>
<!-- 화살표 marker -->
<defs>
<marker id="head" markerWidth="14" markerHeight="14" refX="6" refY="7" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 12 7 L 0 14 Z" fill="#5fdbc8"/>
</marker>
</defs>
<!-- sync 호 1개 (270도, 시작점 + 끝 화살표) -->
<path d="M 512 132 A 380 380 0 1 1 132 512"
stroke="#5fdbc8" stroke-width="36" stroke-linecap="round" fill="none"
marker-end="url(#head)"/>
<circle cx="512" cy="132" r="28" fill="#5fdbc8"/>
<!-- 노트 1장 (단일 흰색 paper) -->
<rect x="332" y="332" width="360" height="360" rx="32" fill="#ffffff"/>
<!-- 텍스트 라인 2개 -->
<rect x="376" y="436" width="272" height="28" rx="14" fill="#1a6b6e"/>
<rect x="376" y="510" width="200" height="28" rx="14" fill="#1a6b6e"/>
</svg>

After

Width:  |  Height:  |  Size: 988 B

BIN
build/icon.icns Normal file

Binary file not shown.

BIN
build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

BIN
build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,31 @@
# Dogfood 피드백 수집
**작성일:** 2026-04-25 (open)
**작성일:** 2026-04-25 (open, ongoing — v0.4 slice → v0.2.x cuts 까지 누적)
**최종 갱신:** 2026-05-05 (v0.2.6 정식 cut 후, F8~F13 dogfood 발견 추가)
**저자:** 김태현 (dlsrks0734@gmail.com)
**문서 성격:** 슬라이스 v0.4 dogfood 중 발견된 본인 피드백 수집·정제하는 living document. 각 항목은 후속 spec 으로 승격될 후보다. 정식 spec 이 분기될 만큼 성숙하면 별도 파일로 추출하고 여기엔 링크만 남긴다.
**문서 성격:** v0.4 slice → v0.2.x cuts 동안 누적된 본인 dogfood 피드백 수집·정제 living document. 각 항목은 후속 spec/cut 으로 승격될 후보다.
**선행 문서:**
- `docs/superpowers/specs/2026-04-24-inkling-vertical-slice-design.md` v0.4 (슬라이스 본문)
- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` (v0.2.3 7항목 cut 로드맵)
- `docs/superpowers/strategy/strategy.md` (심리학 전략)
- `docs/superpowers/strategy/dogfood-strategy.md` (dogfood 운영안 — 본 문서와 의존 없음)
- `docs/superpowers/strategy/dogfood-strategy.md` (dogfood 운영안)
- `docs/superpowers/v024-backlog.md` (PR review deferred + dogfood 발견 누적 backlog, 잔여 24건)
---
## 진척 흐름 요약 (2026-05-05 기준)
본 문서가 작성된 v0.4 slice 시점 이후 흐름:
| 시점 | Cut | 주요 피드백 / 발견 |
|---|---|---|
| 2026-05-01 | v0.2.3 7항목 roadmap (PR #13~#19) | v0.2.2 dogfood 발견 6건 (`memory/project_v022_feedback.md`) → telemetry skeleton + 휴지통 + 만료 추천 + Ollama 회복 + AI retry + 태그 vocab + RecallBanner |
| 2026-05-04 | v0.2.3.1 (PR #21) → v0.2.4 (PR #22, semver 우회) | **F8 11434 reserved**, **F9 multi-instance**, **F10 버전 정보 부재** |
| 2026-05-05 | v0.2.5 critical hotfix (PR #23) | **F11 single-instance lock 부재** (critical, 해결됨) |
| 2026-05-05 | v0.2.6 정식 cut (PR #24) | **F12 autostart 풀림**, **F13 hidden-start race** (해결됨) |
**v0.2.7 brainstorm 트리거**: dogfood ≥1주 soak 후 telemetry export + 신규 피드백 + 잔여 backlog 24건 일괄 triage.
---
@@ -925,6 +943,441 @@ slice §1.3 종료 조건 ("크래시 0회") 와 별개로, **"데이터 손실
---
## F8. Windows 11434 포트 OS-level reserved (🚀 promoted → v0.2.3.1/v0.2.4)
**진행 상태:** 🚀 promoted → PR #21 (in-app Ollama 설정), v0.2.4 release.
**발견:** 2026-05-04 dogfood 시작 시도 직후, Windows 머신.
### 관찰
v0.2.3 binary 설치 후 첫 실행 시 OllamaBanner 가 "unreachable" 상태 지속. `curl http://localhost:11434/api/tags` 응답 없음. 하지만 `ollama app.exe` 프로세스는 떠있음. 진단 결과:
- Ollama server 가 11434 bind 무한 실패: `Error: listen tcp 127.0.0.1:11434: bind: An attempt was made to access a socket in a way forbidden by its access permissions.`
- `netsh int ipv4 show excludedportrange protocol=tcp` → 1124211941 범위 (8개 100-port 블록) reserved
- 원인: WSL2 (Ubuntu Running) + Hyper-V `hns` (Host Network Service) 가 부팅 시 동적 NAT 용 포트 블록 할당 — 11434 가 포함됨
### 제안 방향
**v0.2.3.1 → v0.2.4 cut**: env var 의존 제거, in-app endpoint/model 설정 UI 추가. 트레이 메뉴 + OllamaBanner "설정" 링크 → modal → JSON 영속화 (`<profileDir>/settings.json`).
추가: 사용자가 빈 포트 (11942 등) 로 Ollama 재시작 후 in-app 에서 endpoint 변경 — 시스템 설정 변경 (excludedportrange 등) 없이 dogfood 즉시 unblock.
### 결정 대기
- (해결됨) endpoint + model 둘 다 in-app 설정 가능해야 하는가 → Q1=B 결정 (v0.2.3.1 spec)
- (해결됨) JSON 영속화 vs SQLite 새 테이블 → Q3=B JSON (migration 회피)
- (해결됨) Settings precedence → settings > env > default
### 가설·측정
| # | 가설 | 측정 |
|---|------|------|
| H8.1 | dogfood 사용자 의 ≥30% 가 비기본 endpoint 사용 (LAN 또는 다른 포트) | telemetry `ollama_settings_changed` (count-only, v0.2.7) |
| H8.2 | 11434 reserved 가 다른 머신에도 발생 | LAN 머신 / 동료 dogfood |
### 범위
- **In:** SettingsService + ProviderHolder + OllamaSettingsModal + IPC + 트레이 메뉴 항목
- **Out:** Multi-provider abstraction (OpenAI 등), Settings dropdown UI (v0.2.4 freetext)
### 영향
- **Spec:** `docs/superpowers/specs/2026-05-04-v0231-ollama-settings-design.md`
- **Backlog 잔여:** #39 (`ollama_unreachable.reason` 의 endpoint URL PII 우회 노출 — telemetry 하드닝 v0.2.7)
---
## F9. 앱 다중 인스턴스 spawn — SQLite race 위험 (🚀 promoted → v0.2.5 critical hotfix)
**진행 상태:** 🚀 promoted → PR #23 critical hotfix, v0.2.5 release.
**발견:** 2026-05-05 dogfood 도중. v0.2.4 설치 후 단축키 / 트레이 / 시작메뉴 클릭 반복 시.
### 관찰
앱 아이콘 클릭 시마다 새 process spawn. 트레이 아이콘 여러 개 누적. 작업관리자 에서 `Inkling.exe` process 다중 확인. 잠재 영향:
- SQLite WAL 동시 write → DB 손상 가능
- AiWorker 중복 polling → 같은 pending_jobs 두 process race
- HealthChecker 중복 polling → Ollama 부하 + telemetry 이중 emit
- `settings.json` atomic write 의 temp/rename race
### 제안 방향
**Critical hotfix**`app.requestSingleInstanceLock()` 호출 + `second-instance` event handler 로 기존 inbox 창 restore + show + focus.
### 결정 대기
(해결됨) — Electron 표준 패턴 직접 적용.
### 범위
- **In:** `src/main/index.ts` 진입점 (whenReady 전 lock 획득). 단일 21줄 변경.
- **Out:** None — cross-platform 자동 동작.
### 영향
- v0.2.5 critical hotfix release (`Inkling Setup 0.2.5.exe`)
- **Backlog 잔여:** #46 hidden-start race (NSIS 직후 사용자 클릭 + autostart `--hidden` 동시 시도) — v0.2.6 (PR #24) 에서 `additionalData` 패턴으로 해결 완료.
### 비고
dogfood 발견 → 4시간 안에 hotfix release. 잠재 데이터 손실급 버그라 patch increment (v0.2.5) 사용. v0.2.4 사용자 즉시 업그레이드 권장.
---
## F10. 버전 / 빌드 정보 표시 surface 부재 (🚀 promoted → v0.2.4)
**진행 상태:** 🚀 promoted → PR #22, v0.2.4 release.
**발견:** 2026-05-04 dogfood 도중. 머신 간 버전 일치 검증 시도 시.
### 관찰
설치된 Inkling 의 버전을 UI 어디서도 확인 못함. 트레이 메뉴 / Inbox 푸터 / About 모달 모두 부재. 핸드오프 후 다른 머신 (Mac vs Windows) 에서 같은 버전인지 검증 path 없음. issue report 시 첨부할 디버그 정보 (Electron / Node / OS / profileDir) 도 노출 안 됨.
### 제안 방향
트레이 메뉴 마지막 항목 (종료 직전) "Inkling 정보..." → native `dialog.showMessageBox`. 표시:
- 버전 (`app.getVersion()`)
- Electron / Node 버전
- OS (`platform()` + `release()`)
- 데이터 위치 (`app.getPath('userData')`)
버튼: 확인 / 데이터 위치 열기 (`shell.openPath`) / 정보 복사 (`clipboard.writeText`).
### 영향
- v0.2.4 cut 동봉. 별도 spec 없음 (단순 추가).
- **Backlog 잔여:** Inbox footer 형태 검토 (v0.2.7 — 항상 보이는 작은 버전 라벨)
---
## F11. Single-instance lock 부재 (🚀 promoted)
**진행 상태:** 🚀 promoted → F9 와 동일 cut (v0.2.5 hotfix).
F9 와 root cause 동일. 원래 별개 발견이었지만 fix 가 같음 — 본 항목은 F9 의 부분으로 흡수.
---
## F12. 윈도우 자동 실행 옵션이 재시작 후 풀려있는 버그 (🚀 promoted — 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 토글 후 재시작.
### 관찰
트레이 메뉴 "윈도우 시작 시 자동 실행" 체크 → 종료 → 재실행 → 체크박스 풀려있음. `app.setLoginItemSettings({ openAtLogin, args: ['--hidden'] })` 호출 후 다음 부팅 시 `app.getLoginItemSettings()``openAtLogin=false` 반환.
### 제안 방향 (현 cut: 진단 fallback)
추정 원인:
- (a) Windows registry path mismatch (NSIS 설치 위치 변경 / 버전 업데이트 시 새 디렉터리)
- (b) Electron `setLoginItemSettings` Windows 구현 의 path canonicalization
- (c) `args: ['--hidden']` 와 actual launch args 비교 mismatch — `getLoginItemSettings()` 가 args 없이 호출되면 mismatch
**v0.2.6 적용**:
- `getLoginItemSettings({ args: ['--hidden'] })` 명시 → 트레이 checkbox checked 상태 가 실제 args 와 정합 비교
- `autostart.state` 진단 로그 (withArgs vs noArgs 비교 + executableWillLaunchAtLogin) — dogfood 에서 실제 동작 로그 수집
### 결정 대기
dogfood 후 `autostart.state` 로그 분석:
- args 명시 fix 만으로 충분 → close
- 여전히 mismatch → registry 직접 inspect (HKCU\Software\Microsoft\Windows\CurrentVersion\Run\inkling) → exe path 확인 → executable path canonicalization 검토
### 영향
- dogfood UX 핵심 마찰 — autostart 가 핸드오프 시 매번 수동 재설정 필요. 자동 실행 의도 자체가 dogfood "잊지 않고 매일 사용" 목적인데 깨짐.
- v0.2.6 진단 fallback 로 일단 시도, **v0.2.7 deeper fix 영역**.
---
## F13. PR review 발견: restoreNote production path dead code (🚀 promoted → v0.2.6 round 1 Critical fix)
**진행 상태:** 🚀 promoted → PR #24 round 1 Critical fix, v0.2.6 release.
**발견:** 2026-05-05 PR #24 round 1 reviewer (Claude code-reviewer subagent).
### 관찰
v0.2.6 cut 의 B1 (#10 restoreNote) 작업이 새 메서드 `NoteRepository.restoreNote(id)` 추가했지만 — **production path 가 옛 메서드 `repo.restore()` 호출 중**. CaptureService.restoreNote (line 93) 가 `this.repo.restore(noteId)` 호출 → `deleted_at = NULL` 만 set, `ai_status='failed'` 그대로 + `pending_jobs` 미재생성.
즉 새 메서드는 unit test 만 검증, 실제 사용자가 trash → AI fail → restore 흐름 시 영구 fail 상태 그대로.
### Fix
**Round 1 Critical fix (commit `a991008`)**:
- `CaptureService.restoreNote``repo.restoreNote` 호출 (production path 활성화)
- `before``ai_status``failed` 또는 `pending` 이면 `worker.enqueue(noteId)` 추가 호출 — in-memory AiWorker queue 갱신 (다음 app start 까지 대기 X)
테스트 +2 (CaptureService 의 enqueue 호출 검증).
### 비고
Round 1 reviewer 의 발견 가치 = **production path 와 unit test 가 갈라진 dead code 패턴**. 비슷한 패턴 다른 곳도 점검 가치. v0.2.7 brainstorm 시 grep 으로 "신 메서드 vs 옛 메서드 둘 다 존재 + 호출자 옛 사용" 패턴 검사.
---
## F14. macOS dock 클릭 시 hidden 창 재현 안 됨 (🚀 promoted → v0.2.7)
**진행 상태:** 🚀 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 도중.
### 관찰
- 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. 트레이 의존도 ↓ + 별도 설정 페이지 (🚀 promoted → v0.2.7)
**진행 상태:** 🚀 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 후속 결정.
### 관찰
- 현재 [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 설정 변경, 백업 등) 만 이동하고 자주 쓰는 캡처/보관함 은 트레이 잔류 → 체감 마찰 ↓ 예상.
---
## F22. NoteCard 이미지가 회색 placeholder 만 표시 (🚀 promoted → docs/superpowers/specs/2026-05-09-v028-cut-a-design.md)
**진행 상태:** 🚀 promoted → v0.2.8 Cut A. inkling-media:// custom protocol + NoteCard `<img>` + IPC inbox:open-media + OS viewer 클릭. (commit 470384b + f6bea62 + 9cdea15)
**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. 사용자 표현: "이미지 렌더링이 제대로 되지 않는 것 같아".
### 관찰
[src/renderer/inbox/components/NoteCard.tsx:334-340](src/renderer/inbox/components/NoteCard.tsx#L334-L340):
```tsx
{local.media.length > 0 && (
<div style={{ marginTop: 10, display: 'flex', gap: 6 }}>
{local.media.map((m) => (
<div key={m.id} style={{ width: 48, height: 48, background: '#eee', borderRadius: 4 }} title={m.relPath} />
))}
</div>
)}
```
**`<img>` 가 아니라 회색 `<div>`**. 즉 capture 시 첨부한 이미지가 보관함에서 회색 48x48 사각형만 표시 — title attribute (relPath) 만 hover tooltip 으로 보임. 실제 이미지 렌더링 자체 부재.
`MediaStore``<profileDir>/media/<noteId>/<filename>` 절대 경로로 파일 보존. relPath = `media/<noteId>/<filename>` 형태. Electron renderer 에서 직접 `file://` 또는 custom protocol 로 src 매핑 필요.
### 추정 원인 (placeholder 인 이유)
- 초기 v0.4 slice 단계에 thumbnail 렌더는 후순위로 미루고 placeholder 로 둔 채 그대로 잔류.
- Electron renderer 가 raw `file://` 경로 보안 정책상 직접 접근 어려움 — custom protocol (`inkling-media://`) 또는 IPC handle 로 base64 변환 필요.
### 제안 방향
**A. Custom protocol 등록** (권장):
- main process 에서 `protocol.registerFileProtocol('inkling-media', ...)` 등록 — `<profileDir>/media/` 하위 경로를 `inkling-media://<noteId>/<filename>` 으로 매핑
- NoteCard: `<img src={`inkling-media://${m.relPath.slice(6)}`} alt="" />`
- 보안: scheme 별 allowlist + protocol handler 가 path traversal 검사
**B. IPC 로 base64 변환** (작은 이미지에 한정):
- `inboxApi.getMediaDataUrl(relPath)` → main 이 file 읽고 `data:image/png;base64,...` 반환
- renderer 에 `<img src={dataUrl} />`
- 큰 이미지 (수 MB) 시 메모리 부담
**C. file:// 직접** (Electron 특수 설정 필요):
- `webPreferences.webSecurity: false` — 보안 약화 risk. **Reject**.
### 결정 대기
- thumbnail 표시 vs 클릭 시 full-size modal — UX 선택
- 다중 이미지 (현재 capture 가 N개 첨부 가능) 의 grid layout
- 이미지 alt text — capture 시 입력 또는 AI 자동 생성 (옵션)
### 가설·측정
- 본인 dogfood: capture 시 이미지 첨부 빈도 — 현재 추정치 < 일 1건. ≥ 일 1건이면 이미지 흐름 가치 큼.
- 옵션 A 도입 후 NoteCard 클릭 시 modal full-size 사용 빈도 — UX 선택 검증.
### 범위
- A (custom protocol + thumbnail): 1-2일.
- A + click → full-size modal: + 0.5일.
- alt text AI 생성: 별도 cut.
### 영향
- 명확한 bug 수정 — 사용자 마찰 명백.
- F19 (recall) 의 시각적 단서 — 이미지 보일 때 메모 회상 ↑.
- v0.2.8 narrow scope 에 포함 가치 (1-2일 작업).
---
## (다음 항목 자리)
새 피드백 추가 시 `## F8. 짧은 제목 (🌱 raw)` 헤더로 시작. 표준 슬롯 6개 채우거나 비워둔 채 시작 가능.
새 피드백 추가 시 `## F23. 짧은 제목 (🌱 raw)` 헤더로 시작. 표준 슬롯 6개 채우거나 비워둔 채 시작 가능.
v0.2.8 release 후 dogfood ≥1주 soak 동안 새 발견 항목들 여기 누적 → v0.2.9 brainstorm 트리거.

View File

@@ -0,0 +1,407 @@
# 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<TrayState> 패턴 그대로 활용 — 인터페이스 좁아질 뿐.
### 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. [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 분기. |
| 자동 실행 진단 패널이 Mac/Linux 에선 의미 없는 정보 노출 | 플랫폼 분기 — Mac/Linux 는 registry 행 숨김 + executableWillLaunchAtLogin 만 표시 |
| 트레이 callback 8개 제거 시 import 그래프에서 dead code 잔존 | 제거 후 typecheck + grep 으로 검증 |
---
### 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`:
- 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.

View File

@@ -1,9 +1,11 @@
# Inkling — Dogfooding 전략
**작성일:** 2026-04-25
**대상:** 김태현 (저자 본인) — 슬라이스 v0.4 dogfood 단계
**스펙 의존:** `2026-04-24-inkling-vertical-slice-design.md` v0.4 §1.3 (종료 조건)
**최종 갱신:** 2026-05-05 (v0.2.6 release 후 — environment step 갱신, 현재 단계 표기)
**대상:** 김태현 (저자 본인) — 슬라이스 v0.4 → v0.2.6 dogfood 진행 중
**스펙 의존:** `2026-04-24-inkling-vertical-slice-design.md` v0.4 §1.3 (종료 조건) + `2026-05-01-v023-feedback-roadmap-design.md` (v0.2.3 7항목 cut)
**전략 의존:** `strategy.md` §1·§2·§5 (행동 정의, Capture→Clarify→Capitalize, 회복 친화 스트릭)
**현재 binary:** v0.2.6 (`Inkling Setup 0.2.6.exe` — 2026-05-05 release)
---
@@ -27,14 +29,24 @@ dogfood 첫 날 시작 전, 환경을 한 번에 정렬한다.
### 1.1 환경
- [ ] `.nvmrc` 의 Node 버전 (24.15.0) 활성화
- [ ] `INKLING_OLLAMA_ENDPOINT` 가 LAN Ollama (`http://192.168.0.47:11434`) 를 가리킴
- [ ] LAN Ollama 에 `gemma4:e4b` 가 pull 된 상태 확인 (`curl http://192.168.0.47:11434/api/tags`)
- [ ] `npm run build``npm start` 로 정식 실행 (dev 모드 아님 — dogfood 는 프로덕션 빌드)
- [ ] 윈도우 트레이에 Inkling 아이콘 떠 있음
**v0.2.6 release 기준 (2026-05-05 갱신)**:
- [ ] **설치**: Gitea release 페이지 (`https://gitea.altair823.xyz/altair823-org/inkling/releases/tag/v0.2.6`) 에서 `Inkling-Setup-0.2.6.exe` 다운로드 + 설치
- 또는 source 빌드: `npm run dist:win` (Windows) / `npm run dist:mac` (Mac arm64)
- [ ] **Ollama 설정** (v0.2.3.1 PR #21 부터 in-app 가능):
- 트레이 메뉴 → "Ollama 설정..." → endpoint + model 직접 입력
- 또는 env var fallback: `INKLING_OLLAMA_ENDPOINT=http://192.168.0.47:11434` (LAN 서버)
- 또는 default: `http://localhost:11434` (로컬 ollama serve 시)
- **Windows 11434 reserved 머신 (Hyper-V/WSL2 사용 시)**: `OLLAMA_HOST=127.0.0.1:11942` setx + Inkling 설정 endpoint 도 11942 (자세한 내용 F8)
- [ ] LAN Ollama 에 `gemma4:e4b` 가 pull 된 상태 확인 (`curl <endpoint>/api/tags`)
- [ ] 윈도우 트레이에 Inkling 아이콘 떠 있음 (단일 instance — v0.2.5 PR #23 hotfix 로 multi-spawn 차단)
- [ ] `Ctrl+Shift+J` 가 다른 앱(Chrome, Edge DevTools 등)에 충돌 없이 잡힘
- [ ] OS 알림 권한 허용 — 첫 토스트 후 시스템 트레이에서 확인
- [ ] `%APPDATA%\Inkling\default\inkling.db` 가 새로 생성됨 (이전 dogfood 데이터 분리하려면 이 파일을 백업·삭제)
- [ ] **데이터 위치 확인** (v0.2.4 PR #22 트레이 "Inkling 정보..." → "데이터 위치 열기" 로 즉시 확인):
- Windows: `%APPDATA%\Inkling\Inkling\profiles\default\inkling.sqlite`
- macOS: `~/Library/Application Support/Inkling/Inkling/profiles/default/inkling.sqlite`
- 이전 dogfood 데이터 분리하려면 이 디렉터리 백업·삭제
- [ ] **autostart 확인** (v0.2.6 진단 fallback 적용 중, F12 dogfood verify 영역): 트레이 메뉴 "윈도우 시작 시 자동 실행" 체크 → 종료 → 재실행 → 체크박스 유지 여부 확인 (`autostart.state` 로그 같이 확인 = `<userData>/Inkling/logs/main-YYYY-MM-DD.log`)
### 1.2 dogfood 로그 파일 준비

View File

@@ -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`) |

4185
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "inkling",
"version": "0.2.6",
"version": "0.2.8",
"private": true,
"description": "Inkling — local-first 한 줄 보관 도구",
"author": "altair823 <dlsrks0734@gmail.com>",
@@ -28,7 +28,11 @@
"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:icons:png": "node scripts/svg-to-png.mjs assets/icon.svg build/icon-source.png 1024",
"build:icons": "npm run build:icons:png && electron-icon-builder --input=build/icon-source.png --output=build --flatten && node scripts/finalize-icons.mjs"
},
"build": {
"appId": "xyz.altair823.inkling",
@@ -42,8 +46,14 @@
"**/*.node"
],
"win": {
"icon": "build/icon.ico",
"target": [
{ "target": "nsis", "arch": ["x64"] }
{
"target": "nsis",
"arch": [
"x64"
]
}
]
},
"nsis": {
@@ -54,11 +64,37 @@
"shortcutName": "Inkling"
},
"mac": {
"icon": "build/icon.icns",
"target": [
{ "target": "dmg", "arch": ["arm64"] }
{
"target": "dmg",
"arch": [
"arm64"
]
}
],
"category": "public.app-category.productivity",
"identity": null
},
"linux": {
"icon": "build/icon.png",
"target": [
{
"target": "AppImage",
"arch": [
"x64"
]
},
{
"target": "deb",
"arch": [
"x64"
]
}
],
"category": "Utility",
"synopsis": "로컬 메모 캡처 + AI 태그",
"description": "Inkling — 잠깐 스친 생각을 잡아두는 로컬-우선 메모 도구."
}
},
"dependencies": {
@@ -72,6 +108,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",
@@ -79,7 +117,10 @@
"@vitejs/plugin-react": "5.1.4",
"electron": "41.3.0",
"electron-builder": "26.8.1",
"electron-icon-builder": "^2.0.1",
"electron-vite": "5.0.0",
"jsdom": "^29.1.1",
"sharp": "^0.34.5",
"typescript": "6.0.3",
"undici": "8.1.0",
"vite": "7.3.2",

View File

@@ -0,0 +1,35 @@
import { copyFileSync, renameSync, existsSync } from 'node:fs';
import { join } from 'node:path';
// electron-icon-builder --flatten 은 build/icons/ 안에 icon.ico, icon.icns, <size>x<size>.png
// 들을 만든다. electron-builder 는 build/icon.ico, build/icon.icns, build/icon.png 를
// 기대 — 정규 위치로 옮긴다.
const buildDir = 'build';
const iconsDir = join(buildDir, 'icons');
const moves = [
['icon.ico', 'icon.ico'],
['icon.icns', 'icon.icns'],
];
for (const [src, dest] of moves) {
const from = join(iconsDir, src);
const to = join(buildDir, dest);
if (existsSync(from)) {
renameSync(from, to);
console.log(`Moved: ${from} -> ${to}`);
} else {
console.error(`MISSING: ${from}`);
process.exit(1);
}
}
const png1024 = join(iconsDir, '1024x1024.png');
const pngOut = join(buildDir, 'icon.png');
if (existsSync(png1024)) {
copyFileSync(png1024, pngOut);
console.log(`Copied: ${png1024} -> ${pngOut}`);
} else {
console.error(`MISSING: ${png1024}`);
process.exit(1);
}

14
scripts/svg-to-png.mjs Normal file
View File

@@ -0,0 +1,14 @@
import sharp from 'sharp';
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
const [, , input, output, size = '1024'] = process.argv;
if (!input || !output) {
console.error('Usage: svg-to-png.mjs <input.svg> <output.png> [size]');
process.exit(1);
}
mkdirSync(dirname(output), { recursive: true });
const svg = readFileSync(input);
const png = await sharp(svg).resize(Number(size), Number(size)).png().toBuffer();
writeFileSync(output, png);
console.log(`OK: ${output} (${size}x${size})`);

View File

@@ -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';
@@ -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, navigateInbox } from './ipc/settingsApi.js';
import { createInboxWindow, getInboxWindow } from './windows/inboxWindow.js';
import {
createQuickCaptureWindow, showQuickCapture, getQuickCaptureWindow
@@ -31,11 +32,18 @@ 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 { registerSchemesAsPrivileged, registerInklingMediaProtocol } from './protocol/inklingMedia.js';
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../shared/constants.js';
const HIDDEN_ARG = '--hidden';
const startedHidden = process.argv.includes(HIDDEN_ARG);
// v0.2.8 Cut A — `inkling-media://` custom protocol 스킴은 app.whenReady() 전에
// privileged 등록 필수 (Electron 표준). 이미지 asset 을 main process 가 직접
// 서빙해 file:// hack 없이 작동.
registerSchemesAsPrivileged();
// CRITICAL — single-instance lock + hidden-flag 전달 (v0.2.6 #46).
// 두 번째 .exe 가 hidden 으로 spawn 됐다면 (autostart) 첫 instance 의 inbox 창
// 띄우지 않음 — 사용자가 명시적으로 클릭한 게 아니므로.
@@ -71,6 +79,9 @@ app.whenReady().then(async () => {
const paths = resolveProfilePaths('default');
// v0.2.8 Cut A — `inkling-media://` request handler 등록 (profileDir 결정 후).
registerInklingMediaProtocol(paths.profileDir);
const telemetry = new TelemetryService(join(paths.profileDir, 'telemetry'), () => new Date(), 14, { silent: true });
void telemetry.cleanupOldFiles()
.then((r) => logger.info('telemetry.cleanup', { removed: r.removed.length }))
@@ -83,14 +94,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);
@@ -120,7 +125,6 @@ app.whenReady().then(async () => {
onUpdate: (status) => {
logger.info('ai.health', { ...status } as Record<string, unknown>);
pushOllamaStatus(getInboxWindow, status);
refreshTray({ ollamaOk: status.ok });
},
onTelemetry: (ev) => {
if (ev.kind === 'ollama_unreachable') {
@@ -138,7 +142,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
@@ -160,8 +165,11 @@ app.whenReady().then(async () => {
registerCaptureApi(capture, getQuickCaptureWindow);
registerInboxApi({
repo, continuity, capture, health, intent,
getInboxWindow, settings: settingsSvc, providerHolder
getInboxWindow, settings: settingsSvc, providerHolder,
paths: { profileDir: paths.profileDir }
});
// registerSettingsApi 는 backup / exportSvc / importSvc / syncSvc / telemetry 가
// 생성된 뒤에 호출 (Task 10) — 아래 BackupService/ExportService/... 초기화 직후로 이동.
const hotkeys = new HotkeyService();
const reg = hotkeys.register({
@@ -190,6 +198,12 @@ app.whenReady().then(async () => {
.then((r) => logger.info('backup.daily', { ...r } as Record<string, unknown>))
.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) => {
@@ -222,193 +236,34 @@ 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);
// 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();
}
});
});

View File

@@ -1,6 +1,7 @@
import electron from 'electron';
import type { BrowserWindow } from 'electron';
const { ipcMain, dialog } = electron;
const { ipcMain, dialog, shell } = electron;
import { join, normalize, sep } from 'node:path';
import type { NoteRepository } from '../repository/NoteRepository.js';
import type { ContinuityService } from '../services/ContinuityService.js';
import type { CaptureService } from '../services/CaptureService.js';
@@ -21,6 +22,8 @@ export interface InboxIpcDeps {
getInboxWindow: () => BrowserWindow | null;
settings: SettingsService;
providerHolder: ProviderHolder;
// v0.2.8 Cut A — `inbox:open-media` 의 path traversal 검사 baseline.
paths: { profileDir: string };
}
export function registerInboxApi(deps: InboxIpcDeps): void {
@@ -153,6 +156,22 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
return s.ollama ?? null;
});
// v0.2.8 Cut A — 첨부 이미지 클릭 시 OS 기본 뷰어로 열기 (Task 3).
// path traversal 검사는 inkling-media:// protocol handler 와 동일한 패턴 (Task 1).
ipcMain.handle('inbox:open-media', async (_e, relPath: string) => {
if (typeof relPath !== 'string' || relPath.length === 0) {
return { ok: false as const, reason: 'invalid path' as const };
}
const profileDir = deps.paths.profileDir;
const mediaRoot = join(profileDir, 'media');
const target = normalize(join(profileDir, relPath));
if (!target.startsWith(mediaRoot + sep) && target !== mediaRoot) {
return { ok: false as const, reason: 'invalid path' as const };
}
await shell.openPath(target);
return { ok: true as const };
});
ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => {
// 검증: 새 인스턴스로 healthCheck
const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model });

268
src/main/ipc/settingsApi.ts Normal file
View File

@@ -0,0 +1,268 @@
import electron from 'electron';
import type { BrowserWindow } from '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';
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';
/**
* 외부 (트레이 / 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;
exportSvc: ExportService;
importSvc: ImportService;
syncSvc: SyncService;
telemetry: TelemetryService;
getInboxWindow: () => BrowserWindow | null;
}
/**
* v0.2.7 설정 페이지 IPC 핸들러.
*
* - 자동 실행 (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
* (runBackup, runExport, runImport, runSync, runExportTelemetry) 본문을 그대로 IPC 핸들러로
* 복사. 트레이 callback 자체 제거는 Task 16 (Phase 3) — 본 task 에선 잔류 (의도적 중복).
*/
export function registerSettingsApi(deps?: SettingsIpcDeps): void {
// 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', () => ({
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;
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;
});
}

View File

@@ -0,0 +1,54 @@
import electron from 'electron';
import { readFile } from 'node:fs/promises';
import { join, normalize, sep, extname } from 'node:path';
const { protocol } = electron;
export function registerSchemesAsPrivileged(): void {
protocol.registerSchemesAsPrivileged([
{ scheme: 'inkling-media', privileges: { secure: true, supportFetchAPI: true, stream: true } }
]);
}
export function inferMime(filename: string): string {
const ext = extname(filename).toLowerCase();
switch (ext) {
case '.png': return 'image/png';
case '.jpg':
case '.jpeg': return 'image/jpeg';
case '.gif': return 'image/gif';
case '.webp': return 'image/webp';
default: return 'application/octet-stream';
}
}
export function registerInklingMediaProtocol(profileDir: string): void {
const mediaRoot = join(profileDir, 'media');
protocol.handle('inkling-media', async (req) => {
// Raw URL 에서 `..` 세그먼트 (URL-encoded 포함) 검출 — `new URL()` 이 normalize 하기 전에 차단.
const rawLower = req.url.toLowerCase();
if (
rawLower.includes('/../') ||
rawLower.endsWith('/..') ||
rawLower.includes('/%2e%2e/') ||
rawLower.endsWith('/%2e%2e')
) {
return new Response(null, { status: 403 });
}
const url = new URL(req.url);
// inkling-media://media/<noteId>/<file> → host='media', pathname='/<noteId>/<file>'
const relPath = decodeURIComponent(`${url.host}${url.pathname}`);
const target = normalize(join(profileDir, relPath));
if (!target.startsWith(mediaRoot + sep) && target !== mediaRoot) {
return new Response(null, { status: 403 });
}
try {
const data = await readFile(target);
return new Response(new Uint8Array(data), {
headers: { 'content-type': inferMime(target) }
});
} catch {
return new Response(null, { status: 404 });
}
});
}

View File

@@ -0,0 +1,56 @@
import electron from 'electron';
import { execFile } from 'node:child_process';
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;
}
const WIN_REGISTRY_PATH = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
const WIN_REGISTRY_KEY = 'Inkling';
export async function collectAutostartState(): Promise<AutostartState> {
const w = app.getLoginItemSettings({ args: ['--hidden'] });
const n = app.getLoginItemSettings();
const state: AutostartState = {
withArgs: { openAtLogin: w.openAtLogin, executableWillLaunchAtLogin: w.executableWillLaunchAtLogin },
noArgs: { openAtLogin: n.openAtLogin, executableWillLaunchAtLogin: n.executableWillLaunchAtLogin },
execPath: process.execPath
};
if (process.platform === 'win32') {
state.registryPath = `${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<string | null> {
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);
});
});
}

View File

@@ -1,65 +1,38 @@
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.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 +46,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 +71,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<TrayState>): void {
_state = { ..._state, ...state };

View File

@@ -47,11 +47,27 @@ const api: InklingApi = {
emitRecallSnoozed: (id: string) => ipcRenderer.invoke('inbox:emitRecallSnoozed', id),
loadOllamaSettings: () => ipcRenderer.invoke('inbox:loadOllamaSettings'),
saveOllamaSettings: (v: { endpoint: string; model: string }) => ipcRenderer.invoke('inbox:saveOllamaSettings', v),
onOpenOllamaSettings: (cb: () => void) => {
const handler = () => cb();
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 통일) — 진단 정보 포함 응답
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'),
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'),
// v0.2.8 Cut A — 첨부 이미지를 OS 기본 뷰어로 열기 (Task 3).
openMedia: (relPath: string) => ipcRenderer.invoke('inbox:open-media', relPath),
}
};

View File

@@ -12,7 +12,7 @@ import { TagUndoToast } from './components/TagUndoToast.js';
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,8 +21,9 @@ 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);
useEffect(() => {
void loadInitial();
@@ -33,14 +34,26 @@ export function App(): React.ReactElement {
const unsubOllama = inboxApi.onOllamaStatus((status) => {
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(); unsubNav(); window.removeEventListener('focus', onFocus); };
// onOllamaStatus 콜백은 useInbox.setState 직접 호출 — store reference 가 안정적이라
// deps array 에 추가 불필요. mount 시 1회 구독 + unmount 시 해제.
}, [loadInitial, refreshMeta, upsertNote]);
if (showSettings) return <SettingsPage />;
const showRecovery = continuity.showRecoveryToast && !recoveryDismissed;
const filtered = selectFilteredNotes({ notes, tagFilter });
@@ -78,11 +91,25 @@ export function App(): React.ReactElement {
<ContinuityBadge />
<IdentityCounter />
</div>
<button
aria-label="설정 열기"
onClick={() => setShowSettings(true)}
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: 4,
fontSize: 16,
marginLeft: 8
}}
>
</button>
</div>
<main className="main">
{!showTrash && (
<>
<OllamaBanner onOpenSettings={() => setOllamaSettingsOpen(true)} />
<OllamaBanner onOpenSettings={() => setShowSettings(true)} />
<RecoveryToast
show={showRecovery}
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
@@ -157,10 +184,6 @@ export function App(): React.ReactElement {
)}
</main>
<TagUndoToast />
<OllamaSettingsModal
open={ollamaSettingsOpen}
onClose={() => setOllamaSettingsOpen(false)}
/>
</>
);
}

View File

@@ -332,9 +332,24 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
)}
{local.media.length > 0 && (
<div style={{ marginTop: 10, display: 'flex', gap: 6 }}>
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{local.media.map((m) => (
<div key={m.id} style={{ width: 48, height: 48, background: '#eee', borderRadius: 4 }} title={m.relPath} />
// alt="" — decorative (relPath 는 사용자 의미 X). title 이 hover tooltip.
<img
key={m.id}
src={`inkling-media://${m.relPath}`}
alt=""
title={m.relPath}
onClick={() => { void inboxApi.openMedia(m.relPath); }}
style={{
width: 48,
height: 48,
objectFit: 'cover',
borderRadius: 4,
cursor: 'pointer',
border: '1px solid #e0e0e0'
}}
/>
))}
</div>
)}

View File

@@ -1,140 +0,0 @@
import React, { useEffect, useState } from 'react';
import { z } from 'zod';
import { inboxApi } from '../api.js';
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../../../shared/constants.js';
const EndpointSchema = z.string().url();
interface Props {
open: boolean;
onClose: () => void;
}
export function OllamaSettingsModal({ open, onClose }: Props): React.ReactElement | null {
const [endpoint, setEndpoint] = useState(DEFAULT_OLLAMA_ENDPOINT);
const [model, setModel] = useState(DEFAULT_OLLAMA_MODEL);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// 마운트/open 시 현재 설정 fetch
useEffect(() => {
if (!open) return;
void inboxApi.loadOllamaSettings().then((s) => {
if (s) {
setEndpoint(s.endpoint);
setModel(s.model);
}
setError(null);
});
}, [open]);
if (!open) return null;
async function handleSave() {
if (saving) return; // m4 fix: synchronous double-click 가드
setSaving(true);
setError(null);
try {
// v0.2.6 #42 — client-side URL validation, server-side healthCheck 전에 명확한 메시지
const parseResult = EndpointSchema.safeParse(endpoint);
if (!parseResult.success) {
setError('유효한 URL 형식이 아닙니다 (예: http://localhost:11434)');
return;
}
if (model.trim().length === 0) {
setError('모델명을 입력하세요');
return;
}
const r = await inboxApi.saveOllamaSettings({ endpoint, model });
if (r.ok) {
onClose();
} else {
setError(r.reason);
}
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
}
return (
<div
onKeyDown={(e) => {
if (e.key === 'Escape' && !saving) onClose();
if (e.key === 'Enter' && !saving) void handleSave();
}}
tabIndex={-1}
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000
}}
>
<div style={{
background: '#fff', borderRadius: 8, padding: 20, minWidth: 400, maxWidth: 500,
boxShadow: '0 4px 16px rgba(0,0,0,0.2)'
}}>
<h2 style={{ margin: '0 0 12px 0', fontSize: 16 }}>Ollama </h2>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>
Endpoint URL
</label>
<input
type="text"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
placeholder="http://localhost:11434"
autoFocus
style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4 }}
disabled={saving}
/>
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>
Model
</label>
<input
type="text"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="gemma4:e4b"
style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4 }}
disabled={saving}
/>
</div>
{error && (
<div style={{
background: '#fce4e4', color: '#a33', padding: '6px 10px', borderRadius: 4,
fontSize: 12, marginBottom: 12
}}>
: {error}
</div>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={onClose}
disabled={saving}
style={{
background: 'transparent', color: '#666',
border: '1px solid #ccc', borderRadius: 4,
padding: '6px 14px', fontSize: 12, cursor: saving ? 'not-allowed' : 'pointer'
}}
>
</button>
<button
onClick={() => void handleSave()}
disabled={saving}
style={{
background: saving ? '#999' : '#0a4b80', color: '#fff',
border: 'none', borderRadius: 4,
padding: '6px 14px', fontSize: 12, cursor: saving ? 'not-allowed' : 'pointer'
}}
>
{saving ? '검증 중...' : '저장'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
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';
import { InfoSection } from './settings/InfoSection.js';
export function SettingsPage(): React.ReactElement {
const setShowSettings = useInbox((s) => s.setShowSettings);
return (
<div style={{ padding: 16, maxWidth: 720, margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<button
onClick={() => setShowSettings(false)}
style={{
background: 'transparent',
border: 'none',
fontSize: 14,
cursor: 'pointer',
color: '#0a4b80'
}}
>
</button>
<h1 style={{ fontSize: 18, margin: 0 }}></h1>
</div>
<section style={{ marginBottom: 24 }}>
<h2 style={{ fontSize: 14, marginBottom: 8 }}>AI </h2>
<AiProviderSection />
</section>
<section style={{ marginBottom: 24 }}>
<h2 style={{ fontSize: 14, marginBottom: 8 }}> </h2>
<AutostartSection />
</section>
<section style={{ marginBottom: 24 }}>
<h2 style={{ fontSize: 14, marginBottom: 8 }}> / </h2>
<BackupSection />
</section>
<section style={{ marginBottom: 24 }}>
<h2 style={{ fontSize: 14, marginBottom: 8 }}></h2>
<InfoSection />
</section>
</div>
);
}

View File

@@ -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<string | null>(null);
const [saveResult, setSaveResult] = useState<string | null>(null);
const [recheckResult, setRecheckResult] = useState<string | null>(null);
useEffect(() => {
void (async () => {
const s = await inboxApi.loadOllamaSettings();
if (s) {
setEndpoint(s.endpoint);
setModel(s.model);
}
})();
}, []);
async function onSave(): Promise<void> {
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<void> {
setRecheckResult('확인 중...');
const r = await inboxApi.ollamaRecheck();
setRecheckResult(r.ok ? '연결됨' : `연결 실패: ${r.reason ?? '알 수 없는 이유'}`);
}
return (
<div>
<label style={{ display: 'block', marginBottom: 8, fontSize: 12, color: '#666' }}>
Endpoint
<input
type="text"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
placeholder="http://localhost:11434"
style={{
display: 'block',
width: '100%',
padding: '6px 8px',
marginTop: 4,
fontSize: 13,
border: '1px solid #ccc',
borderRadius: 4
}}
/>
</label>
<label style={{ display: 'block', marginBottom: 8, fontSize: 12, color: '#666' }}>
Model
<input
type="text"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="gemma2:2b"
style={{
display: 'block',
width: '100%',
padding: '6px 8px',
marginTop: 4,
fontSize: 13,
border: '1px solid #ccc',
borderRadius: 4
}}
/>
</label>
{error && (
<div style={{ color: '#c33', fontSize: 12, marginBottom: 8 }}>{error}</div>
)}
{saveResult && (
<div style={{ fontSize: 12, marginBottom: 8, color: '#0a4b80' }}>{saveResult}</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => void onSave()}
style={{
background: '#0a4b80',
color: '#fff',
border: 'none',
borderRadius: 4,
padding: '6px 14px',
fontSize: 12,
cursor: 'pointer'
}}
>
</button>
<button
onClick={() => void onRecheck()}
style={{
background: 'transparent',
color: '#0a4b80',
border: '1px solid #0a4b80',
borderRadius: 4,
padding: '6px 14px',
fontSize: 12,
cursor: 'pointer'
}}
>
</button>
</div>
{recheckResult && (
<div style={{ fontSize: 12, marginTop: 8 }}>{recheckResult}</div>
)}
</div>
);
}

View File

@@ -0,0 +1,95 @@
import React, { useEffect, useState } from 'react';
import type { AutostartResponse } from '@shared/types';
import { inboxApi } from '../../api.js';
export function AutostartSection(): React.ReactElement {
const [data, setData] = useState<AutostartResponse | null>(null);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
void (async () => {
const r = await inboxApi.getAutostart();
setData(r);
})();
}, []);
async function onToggle(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
const r = await inboxApi.setAutostart(e.target.checked);
setData(r);
}
// Task 24 — 현재 openAtLogin 값으로 다시 setLoginItemSettings 호출 → mismatch 복구.
// (예: registry 누락된 채로 withArgs.openAtLogin=true 인 경우 등.)
async function onReregister(): Promise<void> {
if (!data) return;
const r = await inboxApi.setAutostart(data.openAtLogin);
setData(r);
}
if (data === null) {
return <div style={{ fontSize: 12, color: '#666' }}> ...</div>;
}
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 (
<div>
<label style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 13 }}>
<input type="checkbox" checked={data.openAtLogin} onChange={onToggle} />
</label>
{mismatch && (
<div style={{ color: '#c33', fontSize: 12, marginTop: 4 }}>
.
</div>
)}
<div style={{ marginTop: 6 }}>
<button
onClick={() => { void onReregister(); }}
style={{ fontSize: 12, padding: '4px 10px' }}
>
</button>
</div>
<button
onClick={() => setExpanded(!expanded)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 12,
color: '#0a4b80',
marginTop: 4,
padding: 0
}}
>
{expanded ? '▾' : '▸'}
</button>
{expanded && (
<div
style={{
fontSize: 11,
lineHeight: 1.6,
marginTop: 4,
fontFamily: 'monospace',
background: '#f5f5f5',
padding: 8,
borderRadius: 4,
wordBreak: 'break-all'
}}
>
<div> (--hidden ): openAtLogin={String(d.withArgs.openAtLogin)}, willLaunch={String(d.withArgs.executableWillLaunchAtLogin)}</div>
<div> ( ): openAtLogin={String(d.noArgs.openAtLogin)}, willLaunch={String(d.noArgs.executableWillLaunchAtLogin)}</div>
<div> : {d.execPath}</div>
{d.registryPath !== undefined && <div>registry : {d.registryPath}</div>}
{d.registryValue !== undefined && <div>registry : {d.registryValue ?? '(없음)'}</div>}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,25 @@
import React, { useState } from 'react';
import { inboxApi } from '../../api.js';
export function BackupSection(): React.ReactElement {
const [status, setStatus] = useState<string | null>(null);
// IPC 핸들러 (settingsApi.ts) 가 자체 try/catch + Notification 으로 결과를 사용자에게 알림.
// 이 컴포넌트의 status 는 보조 진행 표시 — 결과 (성공/실패) 는 native UX 에 의존.
async function run(label: string, fn: () => Promise<unknown>): Promise<void> {
setStatus(`${label}: 진행 중...`);
await fn();
setStatus(null);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<button onClick={() => run('지금 백업', () => inboxApi.runBackup())}> </button>
<button onClick={() => run('내보내기', () => inboxApi.runExport())}>...</button>
<button onClick={() => run('백업에서 복원', () => inboxApi.runImport())}> ...</button>
<button onClick={() => run('지금 동기화', () => inboxApi.runSync())}> </button>
<button onClick={() => run('사용 로그 내보내기', () => inboxApi.runExportTelemetry())}> ...</button>
{status && <div style={{ fontSize: 12 }}>{status}</div>}
</div>
);
}

View File

@@ -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<AppInfo | null>(null);
useEffect(() => {
void (async () => setInfo(await inboxApi.getAppInfo()))();
}, []);
if (!info) return <div style={{ fontSize: 12 }}> ...</div>;
return (
<div>
<dl style={{ fontSize: 12, lineHeight: 1.6 }}>
<dt style={{ fontWeight: 600 }}></dt>
<dd>{info.version}</dd>
<dt style={{ fontWeight: 600 }}>Electron</dt>
<dd>{info.electron}</dd>
<dt style={{ fontWeight: 600 }}>Node</dt>
<dd>{info.node}</dd>
<dt style={{ fontWeight: 600 }}>OS</dt>
<dd>{info.os}</dd>
<dt style={{ fontWeight: 600 }}> </dt>
<dd style={{ wordBreak: 'break-all' }}>{info.profileDir}</dd>
</dl>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button onClick={() => void inboxApi.openProfileDir()}> </button>
<button onClick={() => void inboxApi.copyAppInfo()}> </button>
</div>
</div>
);
}

View File

@@ -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<void>;
loadTrash: () => Promise<void>;
restoreNote: (id: string) => Promise<void>;
@@ -52,6 +54,7 @@ export const useInbox = create<InboxState>((set, get) => ({
trashNotes: [],
trashCount: 0,
showTrash: false,
showSettings: false,
continuity: emptyContinuity,
pendingCount: 0,
ollamaStatus: { ok: true },
@@ -134,6 +137,9 @@ export const useInbox = create<InboxState>((set, get) => ({
setTagFilter(tag) {
set({ tagFilter: tag });
},
setShowSettings(open) {
set({ showSettings: open });
},
async toggleShowTrash() {
const next = !get().showTrash;
set({ showTrash: next });

View File

@@ -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<Note[]>;
updateAiFields(
@@ -91,7 +105,29 @@ export interface InboxApi {
emitRecallSnoozed(id: string): Promise<void>;
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 통일) — 진단 정보 포함 응답
getAutostart(): Promise<AutostartResponse>;
setAutostart(open: boolean): Promise<AutostartResponse>;
// 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 }>;
// 정보 섹션 — 트레이 showAboutDialog 의 IPC 대응.
getAppInfo(): Promise<{
version: string;
electron: string;
node: string;
os: string;
profileDir: string;
}>;
openProfileDir(): Promise<void>;
copyAppInfo(): Promise<void>;
// v0.2.8 Cut A — 첨부 이미지를 OS 기본 뷰어로 열기 (Task 3).
openMedia(relPath: string): Promise<{ ok: true } | { ok: false; reason: string }>;
}
export interface InklingApi {

View File

@@ -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(<AiProviderSection />);
expect(await screen.findByDisplayValue('http://localhost:11434')).toBeInTheDocument();
expect(screen.getByDisplayValue('gemma2:2b')).toBeInTheDocument();
});
it('rejects invalid endpoint URL', async () => {
render(<AiProviderSection />);
await screen.findByDisplayValue('http://localhost:11434');
const input = screen.getByLabelText(/Endpoint/);
fireEvent.change(input, { target: { value: 'not-a-url' } });
fireEvent.click(screen.getByRole('button', { name: /저장/ }));
expect(await screen.findByText(/올바른 URL/)).toBeInTheDocument();
});
it('"지금 재확인" calls ollamaRecheck and shows result', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
render(<AiProviderSection />);
await screen.findByDisplayValue('http://localhost:11434');
fireEvent.click(screen.getByRole('button', { name: /지금 재확인/ }));
expect(inboxApi.ollamaRecheck).toHaveBeenCalled();
});
});

91
tests/unit/App.test.tsx Normal file
View File

@@ -0,0 +1,91 @@
// @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: {
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),
onNavigate: 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,
diagnostic: {
withArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
noArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
execPath: '/p'
}
})),
setAutostart: vi.fn(async () => ({
openAtLogin: false,
diagnostic: {
withArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
noArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
execPath: '/p'
}
})),
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';
import { inboxApi } from '../../src/renderer/inbox/api.js';
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(<App />);
expect(await screen.findByText('설정')).toBeInTheDocument();
expect(screen.getByText('AI 제공자')).toBeInTheDocument();
});
it('header gear icon click sets showSettings=true', async () => {
render(<App />);
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(<App />);
await waitFor(() => expect(navHandlers.length).toBeGreaterThan(0));
navHandlers.forEach((h) => h('settings'));
await waitFor(() => expect(useInbox.getState().showSettings).toBe(true));
});
});

View File

@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
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 });
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 () => {
setPlatform('darwin');
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);
});
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();
});
});

View File

@@ -0,0 +1,115 @@
// @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';
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, diagnostic: makeDiag(true) })),
setAutostart: vi.fn(async (open: boolean) => ({ openAtLogin: open, diagnostic: makeDiag(open) }))
}
}));
import { AutostartSection } from '../../src/renderer/inbox/components/settings/AutostartSection';
describe('AutostartSection', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
it('renders toggle reflecting current state', async () => {
render(<AutostartSection />);
const toggle = await screen.findByRole('checkbox');
expect(toggle).toBeChecked();
});
it('clicking toggle calls setAutostart', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
render(<AutostartSection />);
const toggle = await screen.findByRole('checkbox');
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(<AutostartSection />);
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(<AutostartSection />);
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(<AutostartSection />);
await screen.findByRole('checkbox');
expect(screen.queryByText(/⚠️/)).not.toBeInTheDocument();
});
it('"재등록" button calls setAutostart with current openAtLogin value', 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(<AutostartSection />);
await screen.findByRole('checkbox');
fireEvent.click(screen.getByRole('button', { name: /재등록/ }));
await waitFor(() => expect(inboxApi.setAutostart).toHaveBeenCalledWith(true));
});
});

View File

@@ -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(<BackupSection />);
expect(screen.getByRole('button', { name: /지금 백업/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^내보내기/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /백업에서 복원/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /지금 동기화/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /사용 로그/ })).toBeInTheDocument();
});
it('clicking 지금 백업 calls runBackup', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
render(<BackupSection />);
fireEvent.click(screen.getByRole('button', { name: /지금 백업/ }));
await waitFor(() => expect(inboxApi.runBackup).toHaveBeenCalled());
});
});

View File

@@ -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(<InfoSection />);
expect(await screen.findByText(/0\.2\.7/)).toBeInTheDocument();
expect(screen.getByText(/41\.3\.0/)).toBeInTheDocument();
expect(screen.getByText(/22\.x/)).toBeInTheDocument();
expect(screen.getByText(/darwin/)).toBeInTheDocument();
expect(screen.getByText(/Library\/Application Support\/Inkling/)).toBeInTheDocument();
});
it('"데이터 위치 열기" calls openProfileDir', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
render(<InfoSection />);
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(<InfoSection />);
await screen.findByText(/0\.2\.7/);
fireEvent.click(screen.getByRole('button', { name: /정보 복사/ }));
await waitFor(() => expect(inboxApi.copyAppInfo).toHaveBeenCalled());
});
});

View File

@@ -0,0 +1,81 @@
// @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 type { Note } from '@shared/types';
const { mockOpenMedia } = vi.hoisted(() => ({
mockOpenMedia: vi.fn(async () => ({ ok: true }))
}));
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
openMedia: mockOpenMedia,
deleteNote: vi.fn(),
restoreNote: vi.fn(),
permanentDeleteNote: vi.fn(),
updateAiFields: vi.fn(),
setDueDate: vi.fn(),
setIntent: vi.fn(),
dismissIntent: vi.fn()
}
}));
vi.mock('../../src/renderer/inbox/store.js', () => ({
useInbox: Object.assign(
() => ({}),
{ getState: () => ({ setTagFilter: vi.fn() }) }
)
}));
import { NoteCard } from '../../src/renderer/inbox/components/NoteCard';
const baseNote: Note = {
id: 'n1',
rawText: 'test',
aiTitle: 'T',
aiSummary: 'S',
aiStatus: 'done',
aiError: null,
aiProvider: null,
aiGeneratedAt: '2026-05-09T00:00:00Z',
titleEditedByUser: false,
summaryEditedByUser: false,
userIntent: null,
intentPromptedAt: '2026-05-09T00:00:00Z',
dueDate: null,
dueDateEditedByUser: false,
deletedAt: null,
lastRecalledAt: null,
recallDismissedAt: null,
createdAt: '2026-05-09T00:00:00Z',
updatedAt: '2026-05-09T00:00:00Z',
tags: [],
media: [
{ id: 'm1', kind: 'image', relPath: 'media/n1/img1.png', mime: 'image/png', bytes: 100 },
{ id: 'm2', kind: 'image', relPath: 'media/n1/img2.jpg', mime: 'image/jpeg', bytes: 200 }
]
};
describe('NoteCard — image rendering', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
it('renders <img> for each media item', () => {
render(<NoteCard note={baseNote} onUpdated={() => {}} mode="inbox" />);
const imgs = screen.getAllByRole('presentation');
expect(imgs).toHaveLength(2);
expect(imgs[0]?.getAttribute('src')).toBe('inkling-media://media/n1/img1.png');
expect(imgs[1]?.getAttribute('src')).toBe('inkling-media://media/n1/img2.jpg');
});
it('clicking <img> calls inboxApi.openMedia', () => {
render(<NoteCard note={baseNote} onUpdated={() => {}} mode="inbox" />);
const first = screen.getAllByRole('presentation')[0];
if (first === undefined) throw new Error('expected at least one img');
fireEvent.click(first);
expect(mockOpenMedia).toHaveBeenCalledWith('media/n1/img1.png');
});
});

View File

@@ -0,0 +1,74 @@
// @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.
// 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 })),
getAutostart: vi.fn(async () => ({
openAtLogin: false,
diagnostic: {
withArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
noArgs: { openAtLogin: false, executableWillLaunchAtLogin: false },
execPath: '/p'
}
})),
setAutostart: vi.fn(async (open: boolean) => ({
openAtLogin: open,
diagnostic: {
withArgs: { openAtLogin: open, executableWillLaunchAtLogin: open },
noArgs: { openAtLogin: open, executableWillLaunchAtLogin: open },
execPath: '/p'
}
})),
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: '41.3.0',
node: '22.x',
os: 'darwin 23.6.0',
profileDir: '/tmp/Inkling'
})),
openProfileDir: vi.fn(async () => undefined),
copyAppInfo: vi.fn(async () => undefined)
}
}));
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(<SettingsPage />);
expect(screen.getByRole('button', { name: /돌아가기/ })).toBeInTheDocument();
});
it('renders 4 section headings', () => {
render(<SettingsPage />);
expect(screen.getByText('AI 제공자')).toBeInTheDocument();
expect(screen.getByText('자동 실행')).toBeInTheDocument();
expect(screen.getByText('백업 / 복원')).toBeInTheDocument();
expect(screen.getByText('정보')).toBeInTheDocument();
});
it('clicking "← 돌아가기" sets showSettings to false', () => {
render(<SettingsPage />);
fireEvent.click(screen.getByRole('button', { name: /돌아가기/ }));
expect(useInbox.getState().showSettings).toBe(false);
});
});

View File

@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { join } from 'node:path';
const { handlers, mockOpenPath } = vi.hoisted(() => ({
handlers: {} as Record<string, (...args: unknown[]) => unknown>,
mockOpenPath: vi.fn(async () => '')
}));
vi.mock('electron', () => ({
default: {
ipcMain: {
handle: (ch: string, fn: (...args: unknown[]) => unknown) => {
handlers[ch] = fn;
}
},
dialog: {},
shell: { openPath: mockOpenPath }
}
}));
import { registerInboxApi } from '../../src/main/ipc/inboxApi';
function makeDeps(profileDir: string): Parameters<typeof registerInboxApi>[0] {
// Minimal stub — `inbox:open-media` 핸들러는 deps.paths.profileDir 만 참조.
return {
repo: {} as never,
continuity: {} as never,
capture: {} as never,
health: {} as never,
intent: {} as never,
getInboxWindow: () => null,
settings: {} as never,
providerHolder: {} as never,
paths: { profileDir }
};
}
describe('inbox:open-media IPC', () => {
beforeEach(() => {
Object.keys(handlers).forEach((k) => delete handlers[k]);
mockOpenPath.mockClear();
});
it('opens valid relPath with shell.openPath', async () => {
registerInboxApi(makeDeps('/profile'));
const handler = handlers['inbox:open-media'];
if (handler === undefined) throw new Error('handler not registered');
const r = await handler(null, 'media/note1/img.png');
expect(r).toEqual({ ok: true });
expect(mockOpenPath).toHaveBeenCalledWith(join('/profile', 'media', 'note1', 'img.png'));
});
it('rejects path traversal with reason "invalid path"', async () => {
registerInboxApi(makeDeps('/profile'));
const handler = handlers['inbox:open-media'];
if (handler === undefined) throw new Error('handler not registered');
const r = await handler(null, '../etc/passwd') as { ok: boolean; reason?: string };
expect(r.ok).toBe(false);
expect(r.reason).toBe('invalid path');
expect(mockOpenPath).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { join, sep } from 'node:path';
const { mockReadFile, mockHandle, mockRegisterSchemes } = vi.hoisted(() => ({
mockReadFile: vi.fn(),
mockHandle: vi.fn(),
mockRegisterSchemes: vi.fn()
}));
vi.mock('node:fs/promises', () => ({
readFile: mockReadFile
}));
vi.mock('electron', () => ({
default: {
protocol: {
registerSchemesAsPrivileged: mockRegisterSchemes,
handle: mockHandle
}
}
}));
import { registerInklingMediaProtocol, inferMime } from '../../src/main/protocol/inklingMedia';
describe('inferMime', () => {
it('returns image/png for .png', () => { expect(inferMime('foo.png')).toBe('image/png'); });
it('returns image/jpeg for .jpg and .jpeg', () => {
expect(inferMime('foo.jpg')).toBe('image/jpeg');
expect(inferMime('foo.jpeg')).toBe('image/jpeg');
});
it('returns image/gif for .gif', () => { expect(inferMime('foo.gif')).toBe('image/gif'); });
it('returns image/webp for .webp', () => { expect(inferMime('foo.webp')).toBe('image/webp'); });
it('returns application/octet-stream for unknown', () => { expect(inferMime('foo.xyz')).toBe('application/octet-stream'); });
});
describe('inkling-media protocol handler', () => {
beforeEach(() => { vi.clearAllMocks(); });
function getHandler(profileDir: string): (req: { url: string }) => Promise<Response> {
registerInklingMediaProtocol(profileDir);
const call = mockHandle.mock.calls[0];
if (!call) throw new Error('protocol.handle not called');
return call[1] as (req: { url: string }) => Promise<Response>;
}
// 실 운영 (Electron protocol.handle) 에서는 req.url 이 raw 문자열로 전달되지만,
// vitest 의 `new Request()` constructor 는 url 을 즉시 normalize (`/../` 제거) 함.
// 따라서 traversal 검사 로직이 raw URL 단계에서 작동하는지 검증하려면
// raw url 을 보존한 minimal mock 을 직접 전달.
function rawReq(url: string): { url: string } { return { url }; }
it('serves valid file with correct mime', async () => {
mockReadFile.mockResolvedValueOnce(Buffer.from([1, 2, 3]));
const handler = getHandler('/profile');
const res = await handler(rawReq('inkling-media://media/note1/img.png'));
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('image/png');
expect(mockReadFile).toHaveBeenCalledWith(join('/profile', 'media', 'note1', 'img.png'));
});
it('returns 403 on path traversal attempt', async () => {
const handler = getHandler('/profile');
const res = await handler(rawReq('inkling-media://media/../etc/passwd'));
expect(res.status).toBe(403);
expect(mockReadFile).not.toHaveBeenCalled();
});
it('returns 404 when file missing', async () => {
mockReadFile.mockRejectedValueOnce(new Error('ENOENT'));
const handler = getHandler('/profile');
const res = await handler(rawReq('inkling-media://media/note1/missing.png'));
expect(res.status).toBe(404);
});
});

View File

@@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { handlers, mockApp, mockCollectAutostartState } = vi.hoisted(() => ({
handlers: {} as Record<string, (...args: unknown[]) => 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');
});
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();
});
});

View File

@@ -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);
});
});

71
tests/unit/tray.test.ts Normal file
View File

@@ -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();
});
});

View File

@@ -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'] }
},