From 55928f68074f25e5e9835295b4231a8137f097d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=AB=E1=84=8C=E1=85=AE=E1=86=AB?= Date: Thu, 16 Apr 2026 16:15:30 +0900 Subject: [PATCH 01/15] chore: bootstrap macOS SwiftUI app skeleton and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 완료 — xcodegen 기반 프로젝트 부트스트랩. - project.yml: macOS 26 / Swift 6 / GRDB 7.5.0 SPM, unsigned 배포를 위한 서명 비활성화 - Storefront/App: StorefrontApp(@main), AppState(@Observable), RootView, Commands(⌘O/⌘R) - Storefront/Features/Welcome: Sky→Orange 그라디언트 로고 + 파일 열기 CTA - Assets.xcassets: AppPrimary(#5AA7E6), AppAccent(#FF9F5A), AppBackground 라이트/다크 세트 - StorefrontTests/SmokeTests: AppState 초기값 검증 - LICENSE(MIT), README 확장(Features/Install/Gatekeeper 우회/Build), Docs/PLAN.md & PROGRESS.md - .gitignore: *.xcodeproj(xcodegen 재생성), build/, .omc/, .DS_Store xcodebuild Debug 빌드 검증: BUILD SUCCEEDED --- .gitignore | 18 +- Docs/PLAN.md | 252 ++++++++++++++++++ Docs/PROGRESS.md | 59 ++++ LICENSE | 21 ++ README.md | 82 +++++- Storefront/App/AppState.swift | 9 + Storefront/App/RootView.swift | 34 +++ Storefront/App/StorefrontApp.swift | 26 ++ Storefront/Features/Welcome/WelcomeView.swift | 57 ++++ .../AppAccent.colorset/Contents.json | 38 +++ .../AppBackground.colorset/Contents.json | 38 +++ .../AppIcon.appiconset/Contents.json | 58 ++++ .../AppPrimary.colorset/Contents.json | 38 +++ .../Resources/Assets.xcassets/Contents.json | 6 + Storefront/Resources/Info.plist | 94 +++++++ Storefront/Resources/Storefront.entitlements | 8 + StorefrontTests/SmokeTests.swift | 11 + project.yml | 119 +++++++++ 18 files changed, 966 insertions(+), 2 deletions(-) create mode 100644 Docs/PLAN.md create mode 100644 Docs/PROGRESS.md create mode 100644 LICENSE create mode 100644 Storefront/App/AppState.swift create mode 100644 Storefront/App/RootView.swift create mode 100644 Storefront/App/StorefrontApp.swift create mode 100644 Storefront/Features/Welcome/WelcomeView.swift create mode 100644 Storefront/Resources/Assets.xcassets/AppAccent.colorset/Contents.json create mode 100644 Storefront/Resources/Assets.xcassets/AppBackground.colorset/Contents.json create mode 100644 Storefront/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Storefront/Resources/Assets.xcassets/AppPrimary.colorset/Contents.json create mode 100644 Storefront/Resources/Assets.xcassets/Contents.json create mode 100644 Storefront/Resources/Info.plist create mode 100644 Storefront/Resources/Storefront.entitlements create mode 100644 StorefrontTests/SmokeTests.swift create mode 100644 project.yml diff --git a/.gitignore b/.gitignore index 52fe2f7..a6f11d8 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,9 @@ playground.xcworkspace # Packages/ # Package.pins # Package.resolved -# *.xcodeproj + +# Generated by xcodegen — regenerate locally with `xcodegen generate` +*.xcodeproj # # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata # hence it is not needed unless you have added a package configuration file to your project @@ -31,6 +33,20 @@ playground.xcworkspace .build/ +# Project-local build output (Makefile uses -derivedDataPath build) +build/ +DerivedData/ + +# Generated by xcodegen — commit project.yml, open .xcodeproj directly +# .xcodeproj/xcuserdata is always ignored via 'xcuserdata/' rule above + +# macOS +.DS_Store + +# Claude Code / OMC session state +.omc/ +.claude/ + # CocoaPods # # We recommend against adding the Pods directory to your .gitignore. However diff --git a/Docs/PLAN.md b/Docs/PLAN.md new file mode 100644 index 0000000..3506f90 --- /dev/null +++ b/Docs/PLAN.md @@ -0,0 +1,252 @@ +# Storefront — macOS SQLite/SwiftData 뷰어 초기 설정 + +## Context + +사용자는 오픈소스 첫 경험. `/Users/dev4-injun/Documents/code/Storefront`는 README와 .gitignore만 있는 빈 상태. 목표는 **macOS 26 Tahoe용 SwiftUI 앱** "Storefront"를 iOS 개발자 타겟으로 출시하는 것 — SQLite/SwiftData 파일을 열어 테이블/행을 보여주고, 파일 변경 시 자동 새로고침하며, iOS 시뮬레이터에 설치된 앱의 DB를 자동 탐색한다. + +**배포 방향은 확정됐음**: Apple Developer Program($99/년)은 가입하지 않고, **미서명 DMG를 GitHub Releases에 게시**. 사용자에게는 Gatekeeper 우회(우클릭→열기 또는 `xattr -cr`)를 README에 안내. 개발자는 clone + Xcode로 직접 빌드하면 되므로 Makefile도 제공하지만 일반 배포 경로는 DMG 단일. + +MVP 범위는 **4가지 전부**: SQLite 뷰어, SwiftData 지원, 라이브 리로드, 시뮬레이터 자동 탐색. + +--- + +## 구현 개요 (7단계) + +| Phase | 목표 | 완료 기준 | +|---|---|---| +| 1 | Xcode 프로젝트 부트스트랩 | 빈 창 빌드 성공 | +| 2 | SQLite 파일 열기 + 테이블 리스트 | File > Open으로 사이드바에 테이블 표시 | +| 3 | 행 뷰어 + 라이브 리로드 | WAL 쓰기 감지 시 자동 새로고침 | +| 4 | 시뮬레이터 스캐너 | 시뮬 앱 DB 원클릭 오픈 | +| 5 | SwiftData 스토어 해석 | `.store` 파일 탐색 + `Z_` 접두어 정규화 | +| 6 | DMG 빌드 파이프라인 | `make dmg`로 `build/Storefront.dmg` 생성 | +| 7 | GitHub Actions 릴리스 | `v0.1.0` 태그 푸시 → Releases에 DMG 자동 업로드 | + +--- + +## 프로젝트 구조 + +``` +Storefront/ +├── Storefront.xcodeproj/ +├── Storefront/ # App target (macOS 26.0, Swift 6) +│ ├── App/ +│ │ ├── StorefrontApp.swift # @main, Scene, Commands(File>Open/Reload) +│ │ └── AppState.swift # @Observable 루트 상태 +│ ├── Features/ +│ │ ├── Welcome/WelcomeView.swift # 드래그-드롭 + 최근 파일 +│ │ ├── Browser/ +│ │ │ ├── BrowserView.swift # NavigationSplitView 3-column +│ │ │ ├── TableListView.swift +│ │ │ ├── RowTableView.swift # dynamic Table API +│ │ │ └── BrowserViewModel.swift # @Observable +│ │ └── SimulatorPicker/ +│ │ ├── SimulatorPickerView.swift +│ │ └── SimulatorPickerViewModel.swift +│ ├── Core/ # UI-독립 도메인 +│ │ ├── Database/ +│ │ │ ├── DatabaseConnection.swift # GRDB DatabaseQueue 래퍼 (readonly) +│ │ │ ├── SchemaInspector.swift # 테이블/컬럼/PK/FK 조회 +│ │ │ └── RowFetcher.swift # 페이지네이션 +│ │ └── SwiftDataStore/ +│ │ ├── SwiftDataDetector.swift # Z_METADATA 존재 판별 +│ │ └── SwiftDataDecoder.swift # Z_ 컬럼 이름 정규화 +│ ├── Services/ +│ │ ├── FileWatcher.swift # DispatchSource 기반 +│ │ ├── SimulatorScanner.swift # simctl JSON + FS 글로빙 +│ │ └── RecentFilesStore.swift # UserDefaults bookmark +│ ├── UI/ +│ │ ├── CellView.swift # BLOB/NULL/Date 표현 +│ │ └── HexDumpView.swift +│ └── Resources/ +│ ├── Assets.xcassets +│ └── Storefront.entitlements # 샌드박스 OFF (미서명 편의) +├── StorefrontTests/ +│ ├── SchemaInspectorTests.swift +│ ├── SwiftDataDecoderTests.swift +│ ├── FileWatcherTests.swift +│ └── Fixtures/ # chinook.sqlite, sample.store +├── scripts/ +│ ├── build.sh +│ ├── make-dmg.sh +│ └── ExportOptions.plist +├── .github/workflows/ +│ ├── build.yml # PR/푸시 시 test + build +│ └── release.yml # tag v* → DMG 업로드 +├── Makefile +├── LICENSE # MIT +├── README.md # 확장판 +└── .gitignore +``` + +**아키텍처**: MVVM-lite. `@Observable` ViewModel이 Core 서비스를 소유, View는 ViewModel만 참조. Repository/UseCase 추상화는 도입하지 않음 (과설계 방지). + +--- + +## 기술 선택 + +| 영역 | 선택 | 근거 | +|---|---|---| +| **SQLite** | GRDB.swift v7.5.0 (SPM) | Row 동적 접근 탁월, readonly 모드, Swift 6 concurrency. SQLite.swift는 쿼리 빌더 중심이라 "임의 DB 리플렉션"엔 부적합. | +| **SwiftData 파싱** | GRDB로 내부 SQLite 직접 읽기 | `Z_METADATA` 존재 여부로 판별, `Z_` 접두어 제거로 컬럼명 정규화. NSManagedObjectModel 역직렬화는 v1 스킵. | +| **파일 감시** | `DispatchSource.makeFileSystemObjectSource` | 단일 파일엔 FSEventStream보다 가볍고 Swift-native. WAL 모드 대응으로 `-wal`, `-shm`도 감시, rename 시 재오픈. | +| **시뮬레이터 탐색** | `xcrun simctl list --json` + FS 글로빙 하이브리드 | simctl만으로는 앱 컨테이너 경로 부재. `~/Library/Developer/CoreSimulator/Devices//data/Containers/Data/Application/*/` 글로빙 필수. `Info.plist`의 `MCMMetadataIdentifier`로 번들ID 표시. | + +**macOS 26 Tahoe SwiftUI 신기능 활용** +- `NavigationSplitView` 3-column (소스 / 테이블 / 행) +- `Table(of:selection:sortOrder:)` + 동적 `TableColumn` + `TableColumnCustomization` (사용자 재정렬/숨김 저장) +- `@Observable` + `@Bindable` +- `.fileImporter`, `.dropDestination`, `ContentUnavailableView`, `Inspector`, `.searchable(placement: .toolbar)` +- `Commands` API로 File > Open / Recent / Reload + +--- + +## 디자인 & UX 방향 + +**원칙**: 귀엽지만 장난스럽지 않게, 데이터는 선명하게, macOS 네이티브 느낌은 유지. + +### 톤 & 비주얼 언어 +- **색상 팔레트 (확정)**: Sky Blue + Sunset Orange + - Primary `#5AA7E6` (sky) — 선택/포커스/주요 액션 + - Accent `#FF9F5A` (orange) — 라이브 리로드 강조, 배지, 토스트 + - BG Light `#F8FAFC` / BG Dark `#161A1F` + - Text Light `#1F2937` / Text Dark `#EEF2F7` + - `Color(.systemBlue)` 대체로 Asset에 커스텀 `AppPrimary`, `AppAccent` 등록 +- **모서리**: corner radius 10 (카드), 6 (셀). 둥글둥글. +- **타이포**: 시스템 폰트(San Francisco) + SF Mono는 **데이터 셀만**. +- **여백**: macOS 표준보다 살짝 넉넉 (16-20pt grid). + +### 이모지/마스코트 수준 (확정) +- **중간**: 데이터 영역은 깔끔, **Welcome / Empty 화면 / 토스트에만** 이모지 사용. + - Welcome 드롭존: "📦 파일을 끌어다 놓아보세요" + - Empty: "🗂 아직 연 파일이 없어요" + - Toast: "🔄 변경 감지됨 — 자동 새로고침" +- 마스코트 캐릭터는 v1에서 제외 (과함 방지). + +### 앱 아이콘 (확정) +- **v1**: SF Symbol 조합으로 1024×1024 PNG 자동 생성 + - 후보: `storefront.fill` + 그라디언트 배경(sky→orange) + - 또는 `cylinder.split.1x2.fill`(DB 원통) 오버레이 + - AppIcon.appiconset 모든 해상도 자동 산출 스크립트 (`scripts/make-icon.sh`, sips 사용) +- **v0.2+**: 커스텀 일러스트 아이콘 (상점 + DB 모티프) + +### 레이아웃 (3-column NavigationSplitView) +``` +┌─Sidebar──┬─Table List──────┬─Row Viewer──────────────┐ +│ 📁 Recent │ 🗂 tracks (350) │ id │ name │ album │... │ +│ 📱 Simuls │ 🗂 artists (25) │ 1 │ Bohe.│ Qnight│... │ +│ ➕ Open │ 🗂 albums (43) │ 2 │ Stair│ Led Z │... │ +│ │ 🗂 Z_METADATA │ ─────────────── Inspector│ +└──────────┴─────────────────┴──────────────────────────┘ +``` +- **Sidebar**: Sources 섹션(Recent / Simulators / Open new…), NSVisualEffectView 블러 +- **Table List**: 테이블명 + 행수 배지(capsule), `Z_` 접두 테이블은 별도 섹션 +- **Row Viewer**: 동적 `Table`, 타입별 컬러 셀, sticky header, zebra stripe(옵션) +- **Inspector** (토글 가능): 선택한 행의 전체 필드 세로 표시, BLOB/긴 텍스트 확장 뷰 + +### 데이터 가독성 (핵심) +- **타입별 색상**: + - `NULL` → 회색 이탤릭 `null` + - `INTEGER/REAL` → 파란 계열 우측정렬 + - `TEXT` → 기본 + - `BLOB` → 보라 `0x…` + 클릭 시 HexDumpView / 이미지면 preview + - `DATE` (ISO8601/Unix timestamp 추정) → 초록 + tooltip에 원본값 +- **Sticky header** + 컬럼 resize/reorder/숨김 (`TableColumnCustomization` 영구 저장) +- **긴 텍스트**: 1줄 truncate + hover tooltip + Inspector에서 전체 +- **검색**: `.searchable` 툴바, 테이블 전체 또는 현재 컬럼 + +### UX 디테일 +- **Welcome 화면**: 큰 드롭존 + 최근 파일 그리드(3-up 카드) + "시뮬 앱 둘러보기" 버튼 +- **토스트 알림** (macOS 26 `.alert`/커스텀 overlay): "🔄 변경 감지됨 — 자동 새로고침" +- **Command palette** (⌘K): 테이블 이름으로 즉시 점프, 최근 파일 열기 +- **키보드 내비**: ⌘O(Open), ⌘R(Reload), ⌘F(Search), ⌥⌘I(Inspector 토글) +- **빈 상태**: `ContentUnavailableView` + 친근한 카피 ("아직 연 파일이 없어요") +- **햅틱/사운드**: 절제 (macOS는 기본 시스템 사운드만) +- **접근성**: Dynamic Type, VoiceOver 라벨, 고대비 모드 대응 + +### 다크/라이트 모드 +- 둘 다 1급 지원. 라이트는 민트 생기, 다크는 민트가 은은한 포인트. +- 시스템 Appearance 따름 (설정에서 오버라이드 옵션 추후). + +### 디자인 검증 방법 +- Xcode Previews로 각 화면 라이트/다크 동시 확인 +- `#Preview` 매크로로 빈 상태 / 데이터 많음 / BLOB 포함 / 긴 텍스트 등 **대표 6가지 케이스** 프리뷰 +- 실 DB(chinook.sqlite)로 수동 QA + +--- + +## DMG 빌드 파이프라인 + +**로컬 (Makefile)** +- `make build` → `xcodebuild archive` (CODE_SIGN_IDENTITY="-", CODE_SIGNING_REQUIRED=NO) +- `make export` → `xcodebuild -exportArchive` (ExportOptions.plist: method=`mac-application`, signing=manual, certificate 없음) +- `make dmg` → `create-dmg` v1.2.2 (Homebrew) 또는 `hdiutil create -format UDZO` +- `make test`, `make clean` + +**GitHub Actions** (`macos-latest`, Xcode 26) +- `build.yml`: PR/push에서 `xcodebuild test` + `make build` +- `release.yml`: `on: push: tags: ['v*']` → `make dmg` → `softprops/action-gh-release@v2`로 `.dmg` 업로드 + +**Gatekeeper 우회 (README 명시)** +1. 권장: Finder에서 `.app` 우클릭 → 열기 → 열기 +2. 시스템 설정 > 개인정보 보호 및 보안 > "그래도 열기" (macOS 15+) +3. CLI: `xattr -cr /Applications/Storefront.app` +4. 옵션: ad-hoc 서명 `codesign --force --deep --sign - Storefront.app` (경고는 여전하지만 라이브러리 검증 실패 방지) + +--- + +## 초기 커밋 체크리스트 (Phase 1 직후) + +- `LICENSE` (MIT, Copyright 2026 Injun Mo) +- `README.md` 확장: Features, 스크린샷 placeholder, Install(DMG), First run / Gatekeeper, Build from source, Roadmap, License +- `CONTRIBUTING.md`: **v1 skip** (첫 오픈소스엔 과함) +- `.github/ISSUE_TEMPLATE/bug_report.yml` (선택) + +--- + +## 핵심 파일 (수정/생성) + +신규 프로젝트라 전부 생성: + +- `/Users/dev4-injun/Documents/code/Storefront/Storefront.xcodeproj/project.pbxproj` +- `/Users/dev4-injun/Documents/code/Storefront/Storefront/App/StorefrontApp.swift` +- `/Users/dev4-injun/Documents/code/Storefront/Storefront/Core/Database/DatabaseConnection.swift` +- `/Users/dev4-injun/Documents/code/Storefront/Storefront/Core/SwiftDataStore/SwiftDataDetector.swift` +- `/Users/dev4-injun/Documents/code/Storefront/Storefront/Services/FileWatcher.swift` +- `/Users/dev4-injun/Documents/code/Storefront/Storefront/Services/SimulatorScanner.swift` +- `/Users/dev4-injun/Documents/code/Storefront/Makefile` +- `/Users/dev4-injun/Documents/code/Storefront/scripts/make-dmg.sh` +- `/Users/dev4-injun/Documents/code/Storefront/scripts/ExportOptions.plist` +- `/Users/dev4-injun/Documents/code/Storefront/.github/workflows/build.yml` +- `/Users/dev4-injun/Documents/code/Storefront/.github/workflows/release.yml` +- `/Users/dev4-injun/Documents/code/Storefront/LICENSE` +- `/Users/dev4-injun/Documents/code/Storefront/README.md` (확장) + +--- + +## 검증 방법 (E2E) + +**Phase별 스모크 테스트** +- Phase 1: `xcodebuild build` 성공, 앱 실행 시 빈 창 렌더 +- Phase 2: `chinook.sqlite`(Public Domain 샘플) 열어서 11개 테이블이 사이드바에 표시되는지 +- Phase 3: 터미널에서 `sqlite3 chinook.sqlite "INSERT INTO artists VALUES (999, 'Test')"` 실행 후 UI가 자동 갱신되는지 수동 확인 +- Phase 4: Xcode 시뮬레이터에 임의 iOS 앱 실행 후 "Simulators" 섹션에 앱 번들이 나타나는지 +- Phase 5: 샘플 SwiftData 스토어(별도 `SampleGenerator` CLI 타겟으로 `@Model` 2개 생성) 열어서 `Z_` 접두어 제거된 상태로 보이는지 + +**단위 테스트 (XCTest, `xcodebuild test`)** +- `SchemaInspectorTests`: 테이블/컬럼/PK/FK/인덱스 파싱 +- `SwiftDataDecoderTests`: `Z_METADATA` 판별, 컬럼명 정규화 +- `FileWatcherTests`: 임시 디렉터리에 sqlite 생성 + INSERT 후 콜백 수신 확인 (`XCTestExpectation`) +- `SimulatorScannerTests`: mock JSON 파싱 (CI에는 실 시뮬 없음) + +**CI 검증** +- `build.yml`이 PR에서 test + build 성공 +- `release.yml`: 로컬에서 `git tag v0.1.0 && git push origin v0.1.0` → GitHub Releases에 DMG 업로드 + 다른 Mac에서 다운로드 → Gatekeeper 우회 후 정상 실행 + +**배포 후 수동 QA 체크리스트 (README에 유지)** +- File > Open으로 SQLite 열기 +- 드래그-드롭으로 열기 +- 최근 파일 목록 +- WAL 모드 DB 라이브 리로드 +- 시뮬레이터 앱 DB 원클릭 오픈 +- SwiftData 스토어 인식 + 정규화 표시 diff --git a/Docs/PROGRESS.md b/Docs/PROGRESS.md new file mode 100644 index 0000000..399d073 --- /dev/null +++ b/Docs/PROGRESS.md @@ -0,0 +1,59 @@ +# Storefront 작업 진행 상태 + +> 이 파일은 언제 어디서든 작업을 이어갈 수 있도록 현재 상태를 기록합니다. + +## 현재 브랜치 + +`feat/mvp-v0.1.0` — MVP v0.1.0을 위한 장기 피처 브랜치. 모든 Phase 작업은 이 브랜치에 커밋 후 릴리스 시 master로 merge. + +## 다른 머신에서 이어 받기 + +```bash +git clone https://github.com/jun7680/Storefront.git +cd Storefront +git checkout feat/mvp-v0.1.0 +brew install xcodegen create-dmg # 로컬 도구 +xcodegen generate # .xcodeproj 재생성 (gitignore됨) +open Storefront.xcodeproj +``` + +## Phase 진행 상태 + +| Phase | 상태 | 메모 | +|---|---|---| +| **1. Xcode 프로젝트 부트스트랩** | ✅ 완료 | xcodegen 기반, macOS 26, Swift 6, GRDB 7.5 SPM, Welcome 화면 빌드 성공 | +| **초기 문서 · 라이선스 · Asset** | ✅ 완료 | LICENSE(MIT), README 확장, Sky/Orange 컬러 등록 | +| **2. SQLite 파일 열기 + 테이블 리스트** | ⏳ 대기 | DatabaseConnection / SchemaInspector / BrowserView / .fileImporter / RecentFilesStore | +| **3. 행 뷰어 + 라이브 리로드** | ⏳ 대기 | RowFetcher / 동적 Table / CellView / FileWatcher(DispatchSource) / Toast / Inspector | +| **4. 시뮬레이터 앱 자동 탐색** | ⏳ 대기 | SimulatorScanner (simctl JSON + FS 글로빙) / SimulatorPickerView | +| **5. SwiftData 스토어 지원** | ⏳ 대기 | SwiftDataDetector(Z_METADATA) / SwiftDataDecoder / .store 확장자 / SampleGenerator CLI | +| **6. DMG 빌드 파이프라인** | ⏳ 대기 | Makefile / scripts/build.sh, make-dmg.sh, ExportOptions.plist / scripts/make-icon.sh | +| **7. GitHub Actions 릴리스** | ⏳ 대기 | .github/workflows/build.yml, release.yml / bug_report.yml | + +## 설계 참조 + +- 전체 설계·기술 선택: [Docs/PLAN.md](./PLAN.md) +- 색상: Sky Blue `#5AA7E6` + Sunset Orange `#FF9F5A` (Assets.xcassets의 `AppPrimary`/`AppAccent`) + +## 최근 검증 + +- **Phase 1**: `xcodebuild -project Storefront.xcodeproj -scheme Storefront -configuration Debug build` → BUILD SUCCEEDED +- Welcome 화면 육안 확인은 사용자 검토 대기 중 + +## 다음 작업 시작 지점 + +**Phase 2 — SQLite 파일 열기** +1. `Storefront/Core/Database/DatabaseConnection.swift` (GRDB DatabaseQueue readonly 래퍼) +2. `Storefront/Core/Database/SchemaInspector.swift` (sqlite_master 쿼리로 테이블 목록) +3. `Storefront/Features/Browser/BrowserView.swift` (NavigationSplitView 3-column) +4. `Storefront/Features/Browser/TableListView.swift` +5. `Storefront/Features/Browser/BrowserViewModel.swift` (@Observable) +6. `Storefront/Services/RecentFilesStore.swift` (UserDefaults security-scoped bookmark) +7. `StorefrontApp.swift`에 `.fileImporter` 연결 + +## 저장소 상태 + +- GitHub: https://github.com/jun7680/Storefront +- Visibility: **Private** (v0.1.0 릴리스 전까지) +- Default branch: `master` +- Active branch: `feat/mvp-v0.1.0` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e1f8653 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Injun Mo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index c09af9d..0572b35 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,82 @@ # Storefront - Modern SQLite/SwiftData viewer for macOS — native SwiftUI, live reload, built for iOS developers + +> Modern SQLite / SwiftData viewer for macOS — native SwiftUI, live reload, built for iOS developers. + +

+ 🚧 Pre-alpha — v0.1.0 is under active development. +

+ +--- + +## Features + +- 📂 **Open .sqlite / .db / .store files** — drag-and-drop or File › Open (⌘O) +- 🗂 **Browse tables and rows** — 3-column split view with dynamic columns, sortable, resizable +- 🔄 **Live reload** — automatically refresh when the file changes (WAL-aware) +- 📱 **iOS Simulator auto-discovery** — list installed apps and open their databases in one click +- 🍂 **SwiftData store support** — automatic `Z_` prefix normalization, metadata-table awareness +- 🎨 **Native macOS feel** — Sky Blue × Sunset Orange palette, dark mode first-class +- 🔒 **Read-only** — your databases are never written to + +## Install + +### 1. Download the DMG + +Grab the latest DMG from [Releases](https://github.com/jun7680/Storefront/releases). + +> ⚠️ **Storefront is unsigned** (no Apple Developer Program). macOS will show a Gatekeeper warning on first launch. Pick one of the bypasses below. + +### 2. First run — bypass Gatekeeper + +**Option A — Finder (easiest):** +1. Drag `Storefront.app` to `/Applications` +2. Right-click `Storefront.app` → **Open** → **Open** in the dialog +3. (macOS 15+) You may need **System Settings › Privacy & Security › "Open Anyway"** + +**Option B — Terminal (one-liner):** +```bash +xattr -cr /Applications/Storefront.app +``` +This removes the `com.apple.quarantine` attribute. Double-click from that point on. + +**Option C — Remove quarantine from DMG before opening:** +```bash +xattr -d com.apple.quarantine ~/Downloads/Storefront.dmg +``` + +## Build from source + +```bash +# Requirements: macOS 26 Tahoe, Xcode 26+, Homebrew +brew install xcodegen create-dmg +git clone https://github.com/jun7680/Storefront.git +cd Storefront +xcodegen generate # regenerate Storefront.xcodeproj from project.yml +open Storefront.xcodeproj # ⌘R to run in Xcode +``` + +Or build a DMG locally: +```bash +make dmg # produces build/Storefront.dmg +``` + +Makefile targets: +- `make build` — Debug build for the current arch +- `make test` — run unit tests +- `make archive` — Release archive (ad-hoc signed) +- `make dmg` — build + package into a DMG +- `make clean` — remove `build/` + +## Roadmap + +- [x] v0.1.0 — core viewer MVP (SQLite + SwiftData + live reload + simulator scan) +- [ ] v0.2.0 — custom app icon, SQL read-only console, Homebrew Cask +- [ ] v0.3.0 — export to CSV/JSON, BLOB image preview, column filters + +## Contributing + +Issues and PRs welcome. This is my first open-source project, so please be gentle 🙏. For substantial changes, open an issue first to discuss. + +## License + +[MIT](./LICENSE) © 2026 Injun Mo diff --git a/Storefront/App/AppState.swift b/Storefront/App/AppState.swift new file mode 100644 index 0000000..8658fb1 --- /dev/null +++ b/Storefront/App/AppState.swift @@ -0,0 +1,9 @@ +import Foundation +import Observation + +@Observable +final class AppState { + var openFileRequested: Bool = false + var reloadRequested: Bool = false + var currentDocumentURL: URL? +} diff --git a/Storefront/App/RootView.swift b/Storefront/App/RootView.swift new file mode 100644 index 0000000..1590f00 --- /dev/null +++ b/Storefront/App/RootView.swift @@ -0,0 +1,34 @@ +import SwiftUI + +struct RootView: View { + @Environment(AppState.self) private var appState + + var body: some View { + Group { + if appState.currentDocumentURL == nil { + WelcomeView() + } else { + ContentUnavailableView( + "Phase 2에서 구현됩니다", + systemImage: "tray", + description: Text("SQLite 뷰어는 다음 단계 작업입니다.") + ) + } + } + .background(Color("AppBackground")) + } +} + +#Preview("Welcome — Light") { + RootView() + .environment(AppState()) + .frame(width: 900, height: 560) + .preferredColorScheme(.light) +} + +#Preview("Welcome — Dark") { + RootView() + .environment(AppState()) + .frame(width: 900, height: 560) + .preferredColorScheme(.dark) +} diff --git a/Storefront/App/StorefrontApp.swift b/Storefront/App/StorefrontApp.swift new file mode 100644 index 0000000..be762ca --- /dev/null +++ b/Storefront/App/StorefrontApp.swift @@ -0,0 +1,26 @@ +import SwiftUI + +@main +struct StorefrontApp: App { + @State private var appState = AppState() + + var body: some Scene { + WindowGroup { + RootView() + .environment(appState) + .frame(minWidth: 900, minHeight: 560) + } + .windowStyle(.titleBar) + .windowToolbarStyle(.unified) + .commands { + CommandGroup(replacing: .newItem) { + Button("Open…") { appState.openFileRequested = true } + .keyboardShortcut("o", modifiers: .command) + } + CommandGroup(after: .toolbar) { + Button("Reload") { appState.reloadRequested = true } + .keyboardShortcut("r", modifiers: .command) + } + } + } +} diff --git a/Storefront/Features/Welcome/WelcomeView.swift b/Storefront/Features/Welcome/WelcomeView.swift new file mode 100644 index 0000000..fa66373 --- /dev/null +++ b/Storefront/Features/Welcome/WelcomeView.swift @@ -0,0 +1,57 @@ +import SwiftUI + +struct WelcomeView: View { + @Environment(AppState.self) private var appState + + var body: some View { + VStack(spacing: 24) { + Spacer() + + Image(systemName: "storefront.fill") + .font(.system(size: 72, weight: .regular)) + .foregroundStyle( + LinearGradient( + colors: [Color("AppPrimary"), Color("AppAccent")], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .padding(.bottom, 4) + + VStack(spacing: 6) { + Text("Storefront") + .font(.system(size: 32, weight: .semibold, design: .rounded)) + Text("SQLite · SwiftData 뷰어") + .font(.title3) + .foregroundStyle(.secondary) + } + + Button { + appState.openFileRequested = true + } label: { + Label("파일 열기", systemImage: "tray.and.arrow.down") + .font(.headline) + .padding(.horizontal, 20) + .padding(.vertical, 10) + } + .buttonStyle(.borderedProminent) + .tint(Color("AppPrimary")) + .keyboardShortcut("o", modifiers: .command) + + Text("📦 .sqlite · .db · .store 파일을 끌어다 놓아보세요") + .font(.callout) + .foregroundStyle(.secondary) + .padding(.top, 8) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(40) + } +} + +#Preview { + WelcomeView() + .environment(AppState()) + .frame(width: 900, height: 560) +} diff --git a/Storefront/Resources/Assets.xcassets/AppAccent.colorset/Contents.json b/Storefront/Resources/Assets.xcassets/AppAccent.colorset/Contents.json new file mode 100644 index 0000000..ea3d934 --- /dev/null +++ b/Storefront/Resources/Assets.xcassets/AppAccent.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.353", + "green" : "0.624", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.443", + "green" : "0.686", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Storefront/Resources/Assets.xcassets/AppBackground.colorset/Contents.json b/Storefront/Resources/Assets.xcassets/AppBackground.colorset/Contents.json new file mode 100644 index 0000000..fed3ff6 --- /dev/null +++ b/Storefront/Resources/Assets.xcassets/AppBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.988", + "green" : "0.980", + "red" : "0.973" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.122", + "green" : "0.102", + "red" : "0.086" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Storefront/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Storefront/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..3f00db4 --- /dev/null +++ b/Storefront/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Storefront/Resources/Assets.xcassets/AppPrimary.colorset/Contents.json b/Storefront/Resources/Assets.xcassets/AppPrimary.colorset/Contents.json new file mode 100644 index 0000000..7344801 --- /dev/null +++ b/Storefront/Resources/Assets.xcassets/AppPrimary.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.902", + "green" : "0.655", + "red" : "0.353" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.945", + "green" : "0.722", + "red" : "0.435" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Storefront/Resources/Assets.xcassets/Contents.json b/Storefront/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Storefront/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Storefront/Resources/Info.plist b/Storefront/Resources/Info.plist new file mode 100644 index 0000000..ac34b63 --- /dev/null +++ b/Storefront/Resources/Info.plist @@ -0,0 +1,94 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Storefront + CFBundleDocumentTypes + + + CFBundleTypeName + SQLite Database + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + LSItemContentTypes + + public.database + com.moinjun.Storefront.sqlite + com.moinjun.Storefront.sqlitestore + + + + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSApplicationCategoryType + public.app-category.developer-tools + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + © 2026 Injun Mo. MIT License. + NSPrincipalClass + NSApplication + NSSupportsAutomaticTermination + + NSSupportsSuddenTermination + + UTImportedTypeDeclarations + + + UTTypeConformsTo + + public.database + public.data + + UTTypeDescription + SQLite Database + UTTypeIdentifier + com.moinjun.Storefront.sqlite + UTTypeTagSpecification + + public.filename-extension + + sqlite + sqlite3 + db + + + + + UTTypeConformsTo + + public.database + public.data + + UTTypeDescription + SwiftData Store + UTTypeIdentifier + com.moinjun.Storefront.sqlitestore + UTTypeTagSpecification + + public.filename-extension + + store + + + + + + diff --git a/Storefront/Resources/Storefront.entitlements b/Storefront/Resources/Storefront.entitlements new file mode 100644 index 0000000..e89b7f3 --- /dev/null +++ b/Storefront/Resources/Storefront.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/StorefrontTests/SmokeTests.swift b/StorefrontTests/SmokeTests.swift new file mode 100644 index 0000000..feb60f7 --- /dev/null +++ b/StorefrontTests/SmokeTests.swift @@ -0,0 +1,11 @@ +import XCTest +@testable import Storefront + +final class SmokeTests: XCTestCase { + func testAppStateInitialValues() { + let state = AppState() + XCTAssertFalse(state.openFileRequested) + XCTAssertFalse(state.reloadRequested) + XCTAssertNil(state.currentDocumentURL) + } +} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..5bdab27 --- /dev/null +++ b/project.yml @@ -0,0 +1,119 @@ +name: Storefront +options: + bundleIdPrefix: com.moinjun + deploymentTarget: + macOS: "26.0" + createIntermediateGroups: true + groupSortPosition: top + +settings: + base: + SWIFT_VERSION: "6.0" + MARKETING_VERSION: "0.1.0" + CURRENT_PROJECT_VERSION: "1" + DEVELOPMENT_TEAM: "" + CODE_SIGN_STYLE: Manual + CODE_SIGN_IDENTITY: "-" + CODE_SIGNING_REQUIRED: NO + CODE_SIGNING_ALLOWED: NO + ENABLE_HARDENED_RUNTIME: NO + ENABLE_USER_SCRIPT_SANDBOXING: NO + SWIFT_STRICT_CONCURRENCY: complete + +packages: + GRDB: + url: https://github.com/groue/GRDB.swift + from: 7.5.0 + +targets: + Storefront: + type: application + platform: macOS + sources: + - path: Storefront + excludes: + - "Resources/Storefront.entitlements" + resources: + - path: Storefront/Resources/Assets.xcassets + dependencies: + - package: GRDB + info: + path: Storefront/Resources/Info.plist + properties: + CFBundleDisplayName: Storefront + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) + LSApplicationCategoryType: public.app-category.developer-tools + LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright: "© 2026 Injun Mo. MIT License." + NSPrincipalClass: NSApplication + NSSupportsAutomaticTermination: true + NSSupportsSuddenTermination: true + CFBundleDocumentTypes: + - CFBundleTypeName: SQLite Database + CFBundleTypeRole: Viewer + LSHandlerRank: Alternate + LSItemContentTypes: + - public.database + - com.moinjun.Storefront.sqlite + - com.moinjun.Storefront.sqlitestore + UTImportedTypeDeclarations: + - UTTypeIdentifier: com.moinjun.Storefront.sqlite + UTTypeDescription: SQLite Database + UTTypeConformsTo: + - public.database + - public.data + UTTypeTagSpecification: + public.filename-extension: + - sqlite + - sqlite3 + - db + - UTTypeIdentifier: com.moinjun.Storefront.sqlitestore + UTTypeDescription: SwiftData Store + UTTypeConformsTo: + - public.database + - public.data + UTTypeTagSpecification: + public.filename-extension: + - store + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.moinjun.Storefront + PRODUCT_NAME: Storefront + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AppAccent + CODE_SIGN_ENTITLEMENTS: Storefront/Resources/Storefront.entitlements + COMBINE_HIDPI_IMAGES: YES + ENABLE_PREVIEWS: YES + GENERATE_INFOPLIST_FILE: NO + + StorefrontTests: + type: bundle.unit-test + platform: macOS + sources: + - path: StorefrontTests + dependencies: + - target: Storefront + settings: + base: + BUNDLE_LOADER: $(TEST_HOST) + TEST_HOST: $(BUILT_PRODUCTS_DIR)/Storefront.app/Contents/MacOS/Storefront + +schemes: + Storefront: + build: + targets: + Storefront: all + StorefrontTests: [test] + run: + config: Debug + test: + config: Debug + targets: + - StorefrontTests + archive: + config: Release + profile: + config: Release + analyze: + config: Debug From 0b4ef4180241555a63e10abd49c83cc98bbaf998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=AB=E1=84=8C=E1=85=AE=E1=86=AB?= Date: Thu, 16 Apr 2026 16:25:47 +0900 Subject: [PATCH 02/15] refactor: migrate architecture to The Composable Architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - project.yml: swift-composable-architecture 1.15.0 SPM 의존성 추가 - Storefront/Features/App: AppFeature(@Reducer, @ObservableState, BindableAction) + AppView(@Bindable StoreOf, .fileImporter) - Storefront/Features/Welcome: WelcomeView를 StoreOf 기반으로 재작성 - Storefront/App/StorefrontApp: @main Scene에서 Store 초기화, Commands가 store.send 호출 - Storefront/App/AppState.swift, RootView.swift 제거 (TCA Feature로 대체) - StorefrontTests/SmokeTests: TestStore 기반 3개 테스트 (초기 상태 / open / fileImported) - Docs/PLAN.md, Docs/PROGRESS.md: TCA 구조 반영, Dependencies/ 폴더 및 @DependencyClient 계획 추가 - README: Architecture 섹션 추가, 첫 실행 시 매크로 Trust 안내 검증: xcodebuild -skipMacroValidation build → BUILD SUCCEEDED, TestStore 3개 통과 --- Docs/PLAN.md | 25 +++++--- Docs/PROGRESS.md | 63 ++++++++++++------- README.md | 6 ++ Storefront/App/AppState.swift | 9 --- Storefront/App/RootView.swift | 34 ---------- Storefront/App/StorefrontApp.swift | 13 ++-- Storefront/Features/App/AppFeature.swift | 51 +++++++++++++++ Storefront/Features/App/AppView.swift | 53 ++++++++++++++++ Storefront/Features/Welcome/WelcomeView.swift | 12 ++-- StorefrontTests/SmokeTests.swift | 35 +++++++++-- project.yml | 5 ++ 11 files changed, 217 insertions(+), 89 deletions(-) delete mode 100644 Storefront/App/AppState.swift delete mode 100644 Storefront/App/RootView.swift create mode 100644 Storefront/Features/App/AppFeature.swift create mode 100644 Storefront/Features/App/AppView.swift diff --git a/Docs/PLAN.md b/Docs/PLAN.md index 3506f90..0466def 100644 --- a/Docs/PLAN.md +++ b/Docs/PLAN.md @@ -33,16 +33,26 @@ Storefront/ │ ├── App/ │ │ ├── StorefrontApp.swift # @main, Scene, Commands(File>Open/Reload) │ │ └── AppState.swift # @Observable 루트 상태 -│ ├── Features/ -│ │ ├── Welcome/WelcomeView.swift # 드래그-드롭 + 최근 파일 +│ ├── Features/ # TCA 피처 (각 @Reducer + View) +│ │ ├── App/ +│ │ │ ├── AppFeature.swift # 루트 @Reducer (자식 피처 합성) +│ │ │ └── AppView.swift +│ │ ├── Welcome/ +│ │ │ ├── WelcomeFeature.swift # @Reducer (openButtonTapped 등) +│ │ │ └── WelcomeView.swift │ │ ├── Browser/ +│ │ │ ├── BrowserFeature.swift # @Reducer + TableList/RowTable 자식 │ │ │ ├── BrowserView.swift # NavigationSplitView 3-column │ │ │ ├── TableListView.swift -│ │ │ ├── RowTableView.swift # dynamic Table API -│ │ │ └── BrowserViewModel.swift # @Observable +│ │ │ └── RowTableView.swift # dynamic Table API │ │ └── SimulatorPicker/ -│ │ ├── SimulatorPickerView.swift -│ │ └── SimulatorPickerViewModel.swift +│ │ ├── SimulatorPickerFeature.swift +│ │ └── SimulatorPickerView.swift +│ ├── Dependencies/ # TCA @Dependency 클라이언트 +│ │ ├── DatabaseClient.swift # @DependencyClient (open/schema/rows) +│ │ ├── FileWatcherClient.swift +│ │ ├── SimulatorClient.swift +│ │ └── RecentFilesClient.swift │ ├── Core/ # UI-독립 도메인 │ │ ├── Database/ │ │ │ ├── DatabaseConnection.swift # GRDB DatabaseQueue 래퍼 (readonly) @@ -79,7 +89,7 @@ Storefront/ └── .gitignore ``` -**아키텍처**: MVVM-lite. `@Observable` ViewModel이 Core 서비스를 소유, View는 ViewModel만 참조. Repository/UseCase 추상화는 도입하지 않음 (과설계 방지). +**아키텍처**: **The Composable Architecture (TCA)** v1.15+. 각 피처는 `@Reducer` + `@ObservableState` 쌍. 상위 루트 `AppFeature`가 자식 피처를 `Scope`로 합성. 사이드이펙트·의존성은 `@Dependency`로 주입 (`DatabaseClient`, `FileWatcherClient`, `SimulatorClient`, `RecentFilesClient`). View는 `StoreOf` 또는 `@Bindable var store` 로 State 바인딩. 테스트는 `TestStore` 기반. --- @@ -91,6 +101,7 @@ Storefront/ | **SwiftData 파싱** | GRDB로 내부 SQLite 직접 읽기 | `Z_METADATA` 존재 여부로 판별, `Z_` 접두어 제거로 컬럼명 정규화. NSManagedObjectModel 역직렬화는 v1 스킵. | | **파일 감시** | `DispatchSource.makeFileSystemObjectSource` | 단일 파일엔 FSEventStream보다 가볍고 Swift-native. WAL 모드 대응으로 `-wal`, `-shm`도 감시, rename 시 재오픈. | | **시뮬레이터 탐색** | `xcrun simctl list --json` + FS 글로빙 하이브리드 | simctl만으로는 앱 컨테이너 경로 부재. `~/Library/Developer/CoreSimulator/Devices//data/Containers/Data/Application/*/` 글로빙 필수. `Info.plist`의 `MCMMetadataIdentifier`로 번들ID 표시. | +| **아키텍처** | TCA v1.15+ (@Reducer / @ObservableState) | Redux-like 단방향 흐름, TestStore로 액션 단위 테스트, @Dependency로 사이드이펙트 격리. 뷰어 앱이지만 멀티 피처(Welcome/Browser/Simulator) 조합이라 TCA 가치 있음. CLI 빌드 시 `-skipMacroValidation` 필요. | **macOS 26 Tahoe SwiftUI 신기능 활용** - `NavigationSplitView` 3-column (소스 / 테이블 / 행) diff --git a/Docs/PROGRESS.md b/Docs/PROGRESS.md index 399d073..5a83b93 100644 --- a/Docs/PROGRESS.md +++ b/Docs/PROGRESS.md @@ -12,44 +12,58 @@ git clone https://github.com/jun7680/Storefront.git cd Storefront git checkout feat/mvp-v0.1.0 -brew install xcodegen create-dmg # 로컬 도구 -xcodegen generate # .xcodeproj 재생성 (gitignore됨) -open Storefront.xcodeproj +brew install xcodegen create-dmg +xcodegen generate # .xcodeproj 재생성 (gitignore됨) +open Storefront.xcodeproj # Xcode에서 ⌘R +# 첫 실행 시 TCA 매크로 "Trust & Enable" 프롬프트 → 허용 +# CLI 빌드: xcodebuild ... -skipMacroValidation -skipPackagePluginValidation ``` +## 아키텍처 요약 + +- **TCA (The Composable Architecture) v1.15+** — `@Reducer` + `@ObservableState` 기반 +- 루트 `AppFeature`가 자식 피처를 `Scope`로 합성 +- 사이드이펙트는 `@Dependency` 주입 (Phase 2+에서 `DatabaseClient` 등 추가) +- 테스트: `TestStore` 기반 액션 단위 검증 + ## Phase 진행 상태 | Phase | 상태 | 메모 | |---|---|---| -| **1. Xcode 프로젝트 부트스트랩** | ✅ 완료 | xcodegen 기반, macOS 26, Swift 6, GRDB 7.5 SPM, Welcome 화면 빌드 성공 | -| **초기 문서 · 라이선스 · Asset** | ✅ 완료 | LICENSE(MIT), README 확장, Sky/Orange 컬러 등록 | -| **2. SQLite 파일 열기 + 테이블 리스트** | ⏳ 대기 | DatabaseConnection / SchemaInspector / BrowserView / .fileImporter / RecentFilesStore | -| **3. 행 뷰어 + 라이브 리로드** | ⏳ 대기 | RowFetcher / 동적 Table / CellView / FileWatcher(DispatchSource) / Toast / Inspector | -| **4. 시뮬레이터 앱 자동 탐색** | ⏳ 대기 | SimulatorScanner (simctl JSON + FS 글로빙) / SimulatorPickerView | -| **5. SwiftData 스토어 지원** | ⏳ 대기 | SwiftDataDetector(Z_METADATA) / SwiftDataDecoder / .store 확장자 / SampleGenerator CLI | -| **6. DMG 빌드 파이프라인** | ⏳ 대기 | Makefile / scripts/build.sh, make-dmg.sh, ExportOptions.plist / scripts/make-icon.sh | -| **7. GitHub Actions 릴리스** | ⏳ 대기 | .github/workflows/build.yml, release.yml / bug_report.yml | +| **1. Xcode 프로젝트 부트스트랩** | ✅ 완료 | xcodegen, macOS 26, Swift 6, GRDB 7.5 + TCA 1.15 SPM | +| **초기 문서 · 라이선스 · Asset** | ✅ 완료 | LICENSE(MIT), README, Sky/Orange 컬러 | +| **TCA 리팩터** | ✅ 완료 | AppFeature(@Reducer, @ObservableState) / AppView / WelcomeView(StoreOf) / TestStore 3건 통과 | +| **2. SQLite 파일 열기 + 테이블 리스트** | ⏳ 대기 | BrowserFeature(@Reducer) / DatabaseClient(@DependencyClient) / SchemaInspector / NavigationSplitView | +| **3. 행 뷰어 + 라이브 리로드** | ⏳ 대기 | RowFetcher / dynamic Table / CellView / FileWatcherClient(@Dependency) / Toast | +| **4. 시뮬레이터 앱 자동 탐색** | ⏳ 대기 | SimulatorClient(@DependencyClient) / SimulatorPickerFeature | +| **5. SwiftData 스토어 지원** | ⏳ 대기 | SwiftDataDetector(Z_METADATA) / Decoder / .store 확장자 | +| **6. DMG 빌드 파이프라인** | ⏳ 대기 | Makefile / scripts/build.sh, make-dmg.sh, ExportOptions.plist | +| **7. GitHub Actions 릴리스** | ⏳ 대기 | .github/workflows/build.yml, release.yml (매크로 검증 스킵 플래그 포함) | ## 설계 참조 -- 전체 설계·기술 선택: [Docs/PLAN.md](./PLAN.md) -- 색상: Sky Blue `#5AA7E6` + Sunset Orange `#FF9F5A` (Assets.xcassets의 `AppPrimary`/`AppAccent`) +- 전체 설계: [Docs/PLAN.md](./PLAN.md) +- 색상: Sky Blue `#5AA7E6` + Sunset Orange `#FF9F5A` -## 최근 검증 +## 최근 검증 (2026-04-16) -- **Phase 1**: `xcodebuild -project Storefront.xcodeproj -scheme Storefront -configuration Debug build` → BUILD SUCCEEDED -- Welcome 화면 육안 확인은 사용자 검토 대기 중 +- **TCA 빌드**: `xcodebuild … -skipMacroValidation build` → BUILD SUCCEEDED +- **TCA 테스트**: TestStore 3건 통과 (초기 상태 / openButtonTapped / fileImported 성공) +- Welcome 화면 육안 검수 완료 (사용자 승인) ## 다음 작업 시작 지점 -**Phase 2 — SQLite 파일 열기** -1. `Storefront/Core/Database/DatabaseConnection.swift` (GRDB DatabaseQueue readonly 래퍼) -2. `Storefront/Core/Database/SchemaInspector.swift` (sqlite_master 쿼리로 테이블 목록) -3. `Storefront/Features/Browser/BrowserView.swift` (NavigationSplitView 3-column) -4. `Storefront/Features/Browser/TableListView.swift` -5. `Storefront/Features/Browser/BrowserViewModel.swift` (@Observable) -6. `Storefront/Services/RecentFilesStore.swift` (UserDefaults security-scoped bookmark) -7. `StorefrontApp.swift`에 `.fileImporter` 연결 +**Phase 2 — SQLite 파일 열기 + 테이블 리스트 (TCA)** + +파일 생성 순서: +1. `Storefront/Dependencies/DatabaseClient.swift` — `@DependencyClient` 래퍼 (GRDB DatabaseQueue readonly) + - `open(URL) async throws -> DatabaseHandle` + - `tables(DatabaseHandle) async throws -> [TableInfo]` +2. `Storefront/Core/Database/SchemaInspector.swift` — sqlite_master 조회 로직 (Client 내부 구현용) +3. `Storefront/Features/Browser/BrowserFeature.swift` — `@Reducer`, State에 현재 URL/테이블 목록/선택, Action에 `.documentLoaded(tables)`, `.tableSelected` +4. `Storefront/Features/Browser/BrowserView.swift` — `NavigationSplitView`, 사이드바에 테이블 리스트 +5. `AppFeature`에 Browser 자식 피처 scope 추가 — `currentDocumentURL`이 생기면 Browser로 전환 +6. `StorefrontTests/BrowserFeatureTests.swift` — TestStore로 load/select 액션 테스트 ## 저장소 상태 @@ -57,3 +71,4 @@ open Storefront.xcodeproj - Visibility: **Private** (v0.1.0 릴리스 전까지) - Default branch: `master` - Active branch: `feat/mvp-v0.1.0` +- Last commit on feat/mvp-v0.1.0: TCA 리팩터 완료 diff --git a/README.md b/README.md index 0572b35..3d2a6b1 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,12 @@ xcodegen generate # regenerate Storefront.xcodeproj from project.yml open Storefront.xcodeproj # ⌘R to run in Xcode ``` +On first open in Xcode, you will be prompted to **trust Swift macros** from TCA (ComposableArchitecture, CasePaths, Perception, Dependencies) — click "Trust & Enable". For CLI builds, pass `-skipMacroValidation`. + +### Architecture + +Built with [The Composable Architecture (TCA)](https://github.com/pointfreeco/swift-composable-architecture) — every feature is a `@Reducer` with `@ObservableState`, composed into a single `Store`. Navigation, dependencies, and side effects flow through reducers; views are thin projections of state. + Or build a DMG locally: ```bash make dmg # produces build/Storefront.dmg diff --git a/Storefront/App/AppState.swift b/Storefront/App/AppState.swift deleted file mode 100644 index 8658fb1..0000000 --- a/Storefront/App/AppState.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Foundation -import Observation - -@Observable -final class AppState { - var openFileRequested: Bool = false - var reloadRequested: Bool = false - var currentDocumentURL: URL? -} diff --git a/Storefront/App/RootView.swift b/Storefront/App/RootView.swift deleted file mode 100644 index 1590f00..0000000 --- a/Storefront/App/RootView.swift +++ /dev/null @@ -1,34 +0,0 @@ -import SwiftUI - -struct RootView: View { - @Environment(AppState.self) private var appState - - var body: some View { - Group { - if appState.currentDocumentURL == nil { - WelcomeView() - } else { - ContentUnavailableView( - "Phase 2에서 구현됩니다", - systemImage: "tray", - description: Text("SQLite 뷰어는 다음 단계 작업입니다.") - ) - } - } - .background(Color("AppBackground")) - } -} - -#Preview("Welcome — Light") { - RootView() - .environment(AppState()) - .frame(width: 900, height: 560) - .preferredColorScheme(.light) -} - -#Preview("Welcome — Dark") { - RootView() - .environment(AppState()) - .frame(width: 900, height: 560) - .preferredColorScheme(.dark) -} diff --git a/Storefront/App/StorefrontApp.swift b/Storefront/App/StorefrontApp.swift index be762ca..195301b 100644 --- a/Storefront/App/StorefrontApp.swift +++ b/Storefront/App/StorefrontApp.swift @@ -1,24 +1,27 @@ +import ComposableArchitecture import SwiftUI @main struct StorefrontApp: App { - @State private var appState = AppState() + @State private var store = Store(initialState: AppFeature.State()) { + AppFeature() + ._printChanges() + } var body: some Scene { WindowGroup { - RootView() - .environment(appState) + AppView(store: store) .frame(minWidth: 900, minHeight: 560) } .windowStyle(.titleBar) .windowToolbarStyle(.unified) .commands { CommandGroup(replacing: .newItem) { - Button("Open…") { appState.openFileRequested = true } + Button("Open…") { store.send(.openButtonTapped) } .keyboardShortcut("o", modifiers: .command) } CommandGroup(after: .toolbar) { - Button("Reload") { appState.reloadRequested = true } + Button("Reload") { store.send(.reloadMenuSelected) } .keyboardShortcut("r", modifiers: .command) } } diff --git a/Storefront/Features/App/AppFeature.swift b/Storefront/Features/App/AppFeature.swift new file mode 100644 index 0000000..f05ef93 --- /dev/null +++ b/Storefront/Features/App/AppFeature.swift @@ -0,0 +1,51 @@ +import ComposableArchitecture +import Foundation + +@Reducer +struct AppFeature { + @ObservableState + struct State: Equatable { + var currentDocumentURL: URL? + var isFileImporterPresented: Bool = false + } + + enum Action: BindableAction { + case binding(BindingAction) + case openButtonTapped + case reloadMenuSelected + case fileImported(Result) + case closeDocument + } + + var body: some ReducerOf { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .openButtonTapped: + state.isFileImporterPresented = true + return .none + + case .reloadMenuSelected: + // Phase 3에서 FileWatcher와 연결 + return .none + + case let .fileImported(.success(url)): + state.currentDocumentURL = url + state.isFileImporterPresented = false + return .none + + case .fileImported(.failure): + state.isFileImporterPresented = false + return .none + + case .closeDocument: + state.currentDocumentURL = nil + return .none + } + } + } +} diff --git a/Storefront/Features/App/AppView.swift b/Storefront/Features/App/AppView.swift new file mode 100644 index 0000000..7798f10 --- /dev/null +++ b/Storefront/Features/App/AppView.swift @@ -0,0 +1,53 @@ +import ComposableArchitecture +import SwiftUI +import UniformTypeIdentifiers + +struct AppView: View { + @Bindable var store: StoreOf + + var body: some View { + Group { + if store.currentDocumentURL == nil { + WelcomeView(store: store) + } else { + ContentUnavailableView( + "Phase 2에서 구현됩니다", + systemImage: "tray", + description: Text("SQLite 뷰어는 다음 단계 작업입니다.") + ) + } + } + .background(Color("AppBackground")) + .fileImporter( + isPresented: $store.isFileImporterPresented, + allowedContentTypes: Self.allowedContentTypes, + onCompletion: { result in + store.send(.fileImported(result)) + } + ) + } + + private static let allowedContentTypes: [UTType] = [ + .database, + UTType(filenameExtension: "sqlite") ?? .data, + UTType(filenameExtension: "sqlite3") ?? .data, + UTType(filenameExtension: "db") ?? .data, + UTType(filenameExtension: "store") ?? .data + ] +} + +#Preview("Light") { + AppView( + store: Store(initialState: AppFeature.State()) { AppFeature() } + ) + .frame(width: 900, height: 560) + .preferredColorScheme(.light) +} + +#Preview("Dark") { + AppView( + store: Store(initialState: AppFeature.State()) { AppFeature() } + ) + .frame(width: 900, height: 560) + .preferredColorScheme(.dark) +} diff --git a/Storefront/Features/Welcome/WelcomeView.swift b/Storefront/Features/Welcome/WelcomeView.swift index fa66373..375347d 100644 --- a/Storefront/Features/Welcome/WelcomeView.swift +++ b/Storefront/Features/Welcome/WelcomeView.swift @@ -1,7 +1,8 @@ +import ComposableArchitecture import SwiftUI struct WelcomeView: View { - @Environment(AppState.self) private var appState + let store: StoreOf var body: some View { VStack(spacing: 24) { @@ -27,7 +28,7 @@ struct WelcomeView: View { } Button { - appState.openFileRequested = true + store.send(.openButtonTapped) } label: { Label("파일 열기", systemImage: "tray.and.arrow.down") .font(.headline) @@ -51,7 +52,8 @@ struct WelcomeView: View { } #Preview { - WelcomeView() - .environment(AppState()) - .frame(width: 900, height: 560) + WelcomeView( + store: Store(initialState: AppFeature.State()) { AppFeature() } + ) + .frame(width: 900, height: 560) } diff --git a/StorefrontTests/SmokeTests.swift b/StorefrontTests/SmokeTests.swift index feb60f7..baa8549 100644 --- a/StorefrontTests/SmokeTests.swift +++ b/StorefrontTests/SmokeTests.swift @@ -1,11 +1,36 @@ +import ComposableArchitecture import XCTest @testable import Storefront +@MainActor final class SmokeTests: XCTestCase { - func testAppStateInitialValues() { - let state = AppState() - XCTAssertFalse(state.openFileRequested) - XCTAssertFalse(state.reloadRequested) - XCTAssertNil(state.currentDocumentURL) + func testInitialStateHasNoDocument() async { + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } + XCTAssertNil(store.state.currentDocumentURL) + XCTAssertFalse(store.state.isFileImporterPresented) + } + + func testOpenButtonPresentsFileImporter() async { + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } + await store.send(.openButtonTapped) { + $0.isFileImporterPresented = true + } + } + + func testFileImportedSetsDocumentURL() async { + let store = TestStore( + initialState: AppFeature.State(isFileImporterPresented: true) + ) { + AppFeature() + } + let url = URL(fileURLWithPath: "/tmp/test.sqlite") + await store.send(.fileImported(.success(url))) { + $0.currentDocumentURL = url + $0.isFileImporterPresented = false + } } } diff --git a/project.yml b/project.yml index 5bdab27..14fca38 100644 --- a/project.yml +++ b/project.yml @@ -24,6 +24,9 @@ packages: GRDB: url: https://github.com/groue/GRDB.swift from: 7.5.0 + ComposableArchitecture: + url: https://github.com/pointfreeco/swift-composable-architecture + from: 1.15.0 targets: Storefront: @@ -37,6 +40,8 @@ targets: - path: Storefront/Resources/Assets.xcassets dependencies: - package: GRDB + - package: ComposableArchitecture + product: ComposableArchitecture info: path: Storefront/Resources/Info.plist properties: From cbb07dc60d825905dcc8550a4793f97bc594d24c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=AB=E1=84=8C=E1=85=AE=E1=86=AB?= Date: Thu, 16 Apr 2026 16:30:54 +0900 Subject: [PATCH 03/15] feat: implement SQLite viewer with tables list (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Storefront/Core/Database/SchemaInspector: sqlite_master 조회, Tables/Views 구분, 행수 계산 (테이블명 quote-escape) - Storefront/Dependencies/DatabaseClient: actor 기반 DatabaseQueue 레지스트리, readonly GRDB 연결, @Dependency(\.database) 등록 - Storefront/Features/Browser/BrowserFeature: @Reducer(State: databaseURL/tables/selectedTableID/isLoading/error, Action: onAppear/refreshRequested/tablesLoaded/failed/tableSelected) - Storefront/Features/Browser/BrowserView: NavigationSplitView 2-column, Sidebar에 Tables/Views 섹션 + 행수 배지, Detail에 Phase 3 플레이스홀더 - Storefront/Features/App/AppFeature: browser 자식 피처 추가, ifLet(\.browser) 합성, fileImported → Browser 전환, reloadMenu → refresh 전파 - Storefront/Features/App/AppView: store.scope로 Welcome/Browser 전환, .fileImporter → fileImported(URL) or fileImportFailed(String) - StorefrontTests: AppFeatureTests(3) + BrowserFeatureTests(3) — 총 6건 모두 통과, SmokeTests.swift 제거 검증: xcodebuild test → 6/6 pass. 수동 QA용 샘플 DB: /tmp/storefront-sample.sqlite --- Docs/PROGRESS.md | 27 ++-- .../Core/Database/SchemaInspector.swift | 35 +++++ Storefront/Dependencies/DatabaseClient.swift | 54 +++++++ Storefront/Features/App/AppFeature.swift | 24 ++-- Storefront/Features/App/AppView.swift | 55 ++++--- .../Features/Browser/BrowserFeature.swift | 60 ++++++++ Storefront/Features/Browser/BrowserView.swift | 134 ++++++++++++++++++ StorefrontTests/AppFeatureTests.swift | 45 ++++++ StorefrontTests/BrowserFeatureTests.swift | 69 +++++++++ StorefrontTests/SmokeTests.swift | 36 ----- 10 files changed, 460 insertions(+), 79 deletions(-) create mode 100644 Storefront/Core/Database/SchemaInspector.swift create mode 100644 Storefront/Dependencies/DatabaseClient.swift create mode 100644 Storefront/Features/Browser/BrowserFeature.swift create mode 100644 Storefront/Features/Browser/BrowserView.swift create mode 100644 StorefrontTests/AppFeatureTests.swift create mode 100644 StorefrontTests/BrowserFeatureTests.swift delete mode 100644 StorefrontTests/SmokeTests.swift diff --git a/Docs/PROGRESS.md b/Docs/PROGRESS.md index 5a83b93..c5e5e2d 100644 --- a/Docs/PROGRESS.md +++ b/Docs/PROGRESS.md @@ -33,7 +33,7 @@ open Storefront.xcodeproj # Xcode에서 ⌘R | **1. Xcode 프로젝트 부트스트랩** | ✅ 완료 | xcodegen, macOS 26, Swift 6, GRDB 7.5 + TCA 1.15 SPM | | **초기 문서 · 라이선스 · Asset** | ✅ 완료 | LICENSE(MIT), README, Sky/Orange 컬러 | | **TCA 리팩터** | ✅ 완료 | AppFeature(@Reducer, @ObservableState) / AppView / WelcomeView(StoreOf) / TestStore 3건 통과 | -| **2. SQLite 파일 열기 + 테이블 리스트** | ⏳ 대기 | BrowserFeature(@Reducer) / DatabaseClient(@DependencyClient) / SchemaInspector / NavigationSplitView | +| **2. SQLite 파일 열기 + 테이블 리스트** | ✅ 완료 | BrowserFeature / DatabaseClient(actor registry) / SchemaInspector / NavigationSplitView 2-column + Tables/Views 섹션 + 행수 배지 | | **3. 행 뷰어 + 라이브 리로드** | ⏳ 대기 | RowFetcher / dynamic Table / CellView / FileWatcherClient(@Dependency) / Toast | | **4. 시뮬레이터 앱 자동 탐색** | ⏳ 대기 | SimulatorClient(@DependencyClient) / SimulatorPickerFeature | | **5. SwiftData 스토어 지원** | ⏳ 대기 | SwiftDataDetector(Z_METADATA) / Decoder / .store 확장자 | @@ -47,23 +47,22 @@ open Storefront.xcodeproj # Xcode에서 ⌘R ## 최근 검증 (2026-04-16) -- **TCA 빌드**: `xcodebuild … -skipMacroValidation build` → BUILD SUCCEEDED -- **TCA 테스트**: TestStore 3건 통과 (초기 상태 / openButtonTapped / fileImported 성공) -- Welcome 화면 육안 검수 완료 (사용자 승인) +- **Phase 2 빌드/테스트**: `xcodebuild … test` → 6/6 통과 (AppFeature 3 + BrowserFeature 3) +- 샘플 DB `/tmp/storefront-sample.sqlite` (artists/albums/tracks + track_summary view) 생성됨 — 앱에서 File > Open으로 검증 가능 ## 다음 작업 시작 지점 -**Phase 2 — SQLite 파일 열기 + 테이블 리스트 (TCA)** +**Phase 3 — 행 뷰어 + 라이브 리로드 (TCA)** 파일 생성 순서: -1. `Storefront/Dependencies/DatabaseClient.swift` — `@DependencyClient` 래퍼 (GRDB DatabaseQueue readonly) - - `open(URL) async throws -> DatabaseHandle` - - `tables(DatabaseHandle) async throws -> [TableInfo]` -2. `Storefront/Core/Database/SchemaInspector.swift` — sqlite_master 조회 로직 (Client 내부 구현용) -3. `Storefront/Features/Browser/BrowserFeature.swift` — `@Reducer`, State에 현재 URL/테이블 목록/선택, Action에 `.documentLoaded(tables)`, `.tableSelected` -4. `Storefront/Features/Browser/BrowserView.swift` — `NavigationSplitView`, 사이드바에 테이블 리스트 -5. `AppFeature`에 Browser 자식 피처 scope 추가 — `currentDocumentURL`이 생기면 Browser로 전환 -6. `StorefrontTests/BrowserFeatureTests.swift` — TestStore로 load/select 액션 테스트 +1. `Storefront/Core/Database/RowFetcher.swift` — 페이지네이션 행 조회 (OFFSET/LIMIT 또는 keyset) +2. `Storefront/Dependencies/DatabaseClient.swift` 확장 — `columns(URL, table)`, `rows(URL, table, offset, limit)` +3. `Storefront/Dependencies/FileWatcherClient.swift` — `DispatchSource.makeFileSystemObjectSource` 래퍼. `watch(URL) -> AsyncStream` +4. `Storefront/Features/Browser/RowTableView.swift` — dynamic `Table(of:selection:sortOrder:)` + `TableColumn` +5. `Storefront/UI/CellView.swift` — NULL/BLOB/Date/Number/Text 타입별 색상 +6. `BrowserFeature` 확장 — `.columnsLoaded`, `.rowsLoaded`, `.fileChanged` 액션 + Effect 합성 +7. `BrowserView` 우측 detail에 `RowTableView` 연결, 라이브 리로드 토스트 +8. Tests: `BrowserFeatureRowsTests`, `FileWatcherClientTests` ## 저장소 상태 @@ -71,4 +70,4 @@ open Storefront.xcodeproj # Xcode에서 ⌘R - Visibility: **Private** (v0.1.0 릴리스 전까지) - Default branch: `master` - Active branch: `feat/mvp-v0.1.0` -- Last commit on feat/mvp-v0.1.0: TCA 리팩터 완료 +- Last commit on feat/mvp-v0.1.0: Phase 2 완료 (SQLite 뷰어 + 테이블 리스트) diff --git a/Storefront/Core/Database/SchemaInspector.swift b/Storefront/Core/Database/SchemaInspector.swift new file mode 100644 index 0000000..a7b9f6d --- /dev/null +++ b/Storefront/Core/Database/SchemaInspector.swift @@ -0,0 +1,35 @@ +import Foundation +import GRDB + +struct TableInfo: Equatable, Identifiable, Sendable { + let name: String + let kind: Kind + let rowCount: Int + + var id: String { name } + + enum Kind: String, Sendable, Equatable { + case table + case view + } +} + +enum SchemaInspector { + static func listTables(_ db: Database) throws -> [TableInfo] { + let metaRows = try Row.fetchAll(db, sql: """ + SELECT name, type FROM sqlite_master + WHERE type IN ('table', 'view') + AND name NOT LIKE 'sqlite_%' + ORDER BY type DESC, name ASC + """) + + return metaRows.map { row in + let name: String = row["name"] + let type: String = row["type"] + let kind: TableInfo.Kind = (type == "view") ? .view : .table + let escaped = name.replacingOccurrences(of: "\"", with: "\"\"") + let count = (try? Int.fetchOne(db, sql: "SELECT COUNT(*) FROM \"\(escaped)\"")) ?? 0 + return TableInfo(name: name, kind: kind, rowCount: count) + } + } +} diff --git a/Storefront/Dependencies/DatabaseClient.swift b/Storefront/Dependencies/DatabaseClient.swift new file mode 100644 index 0000000..225043e --- /dev/null +++ b/Storefront/Dependencies/DatabaseClient.swift @@ -0,0 +1,54 @@ +import ComposableArchitecture +import Foundation +import GRDB + +struct DatabaseClient: Sendable { + var tables: @Sendable (URL) async throws -> [TableInfo] + var close: @Sendable (URL) async -> Void +} + +extension DatabaseClient: DependencyKey { + static let liveValue: DatabaseClient = { + let registry = DatabaseRegistry() + return DatabaseClient( + tables: { url in try await registry.tables(for: url) }, + close: { url in await registry.close(url) } + ) + }() + + static let testValue = DatabaseClient( + tables: unimplemented("DatabaseClient.tables"), + close: unimplemented("DatabaseClient.close") + ) +} + +extension DependencyValues { + var database: DatabaseClient { + get { self[DatabaseClient.self] } + set { self[DatabaseClient.self] = newValue } + } +} + +private actor DatabaseRegistry { + private var queues: [URL: DatabaseQueue] = [:] + + func queue(for url: URL) throws -> DatabaseQueue { + if let existing = queues[url] { return existing } + var config = Configuration() + config.readonly = true + let q = try DatabaseQueue(path: url.path, configuration: config) + queues[url] = q + return q + } + + func tables(for url: URL) async throws -> [TableInfo] { + let q = try queue(for: url) + return try await q.read { db in + try SchemaInspector.listTables(db) + } + } + + func close(_ url: URL) { + queues.removeValue(forKey: url) + } +} diff --git a/Storefront/Features/App/AppFeature.swift b/Storefront/Features/App/AppFeature.swift index f05ef93..946e94d 100644 --- a/Storefront/Features/App/AppFeature.swift +++ b/Storefront/Features/App/AppFeature.swift @@ -5,16 +5,18 @@ import Foundation struct AppFeature { @ObservableState struct State: Equatable { - var currentDocumentURL: URL? var isFileImporterPresented: Bool = false + var browser: BrowserFeature.State? } enum Action: BindableAction { case binding(BindingAction) case openButtonTapped case reloadMenuSelected - case fileImported(Result) + case fileImported(URL) + case fileImportFailed(String) case closeDocument + case browser(BrowserFeature.Action) } var body: some ReducerOf { @@ -30,22 +32,28 @@ struct AppFeature { return .none case .reloadMenuSelected: - // Phase 3에서 FileWatcher와 연결 - return .none + guard state.browser != nil else { return .none } + return .send(.browser(.refreshRequested)) - case let .fileImported(.success(url)): - state.currentDocumentURL = url + case let .fileImported(url): state.isFileImporterPresented = false + state.browser = BrowserFeature.State(databaseURL: url) return .none - case .fileImported(.failure): + case .fileImportFailed: state.isFileImporterPresented = false return .none case .closeDocument: - state.currentDocumentURL = nil + state.browser = nil + return .none + + case .browser: return .none } } + .ifLet(\.browser, action: \.browser) { + BrowserFeature() + } } } diff --git a/Storefront/Features/App/AppView.swift b/Storefront/Features/App/AppView.swift index 7798f10..e6dad69 100644 --- a/Storefront/Features/App/AppView.swift +++ b/Storefront/Features/App/AppView.swift @@ -7,36 +7,38 @@ struct AppView: View { var body: some View { Group { - if store.currentDocumentURL == nil { - WelcomeView(store: store) + if let browserStore = store.scope(state: \.browser, action: \.browser) { + BrowserView(store: browserStore) } else { - ContentUnavailableView( - "Phase 2에서 구현됩니다", - systemImage: "tray", - description: Text("SQLite 뷰어는 다음 단계 작업입니다.") - ) + WelcomeView(store: store) } } .background(Color("AppBackground")) .fileImporter( isPresented: $store.isFileImporterPresented, - allowedContentTypes: Self.allowedContentTypes, - onCompletion: { result in - store.send(.fileImported(result)) + allowedContentTypes: Self.allowedContentTypes + ) { result in + switch result { + case let .success(url): + store.send(.fileImported(url)) + case let .failure(error): + store.send(.fileImportFailed(error.localizedDescription)) } - ) + } } - private static let allowedContentTypes: [UTType] = [ - .database, - UTType(filenameExtension: "sqlite") ?? .data, - UTType(filenameExtension: "sqlite3") ?? .data, - UTType(filenameExtension: "db") ?? .data, - UTType(filenameExtension: "store") ?? .data - ] + private static let allowedContentTypes: [UTType] = { + var types: [UTType] = [.database] + for ext in ["sqlite", "sqlite3", "db", "store"] { + if let type = UTType(filenameExtension: ext) { + types.append(type) + } + } + return types + }() } -#Preview("Light") { +#Preview("Welcome — Light") { AppView( store: Store(initialState: AppFeature.State()) { AppFeature() } ) @@ -44,9 +46,20 @@ struct AppView: View { .preferredColorScheme(.light) } -#Preview("Dark") { +#Preview("Browser — Dark") { AppView( - store: Store(initialState: AppFeature.State()) { AppFeature() } + store: Store( + initialState: AppFeature.State( + browser: BrowserFeature.State( + databaseURL: URL(fileURLWithPath: "/tmp/sample.sqlite"), + tables: [ + TableInfo(name: "artists", kind: .table, rowCount: 275), + TableInfo(name: "tracks", kind: .table, rowCount: 3_503) + ], + selectedTableID: "tracks" + ) + ) + ) { AppFeature() } ) .frame(width: 900, height: 560) .preferredColorScheme(.dark) diff --git a/Storefront/Features/Browser/BrowserFeature.swift b/Storefront/Features/Browser/BrowserFeature.swift new file mode 100644 index 0000000..d9a26a6 --- /dev/null +++ b/Storefront/Features/Browser/BrowserFeature.swift @@ -0,0 +1,60 @@ +import ComposableArchitecture +import Foundation + +@Reducer +struct BrowserFeature { + @ObservableState + struct State: Equatable { + let databaseURL: URL + var tables: [TableInfo] = [] + var selectedTableID: TableInfo.ID? + var isLoading: Bool = false + var loadErrorMessage: String? + } + + enum Action: Equatable { + case onAppear + case refreshRequested + case tablesLoaded([TableInfo]) + case tablesFailedToLoad(String) + case tableSelected(TableInfo.ID?) + } + + @Dependency(\.database) var database + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .onAppear, .refreshRequested: + state.isLoading = true + state.loadErrorMessage = nil + let url = state.databaseURL + return .run { send in + do { + let tables = try await database.tables(url) + await send(.tablesLoaded(tables)) + } catch { + await send(.tablesFailedToLoad(error.localizedDescription)) + } + } + + case let .tablesLoaded(tables): + state.tables = tables + state.isLoading = false + if state.selectedTableID == nil { + state.selectedTableID = tables.first?.id + } + return .none + + case let .tablesFailedToLoad(message): + state.loadErrorMessage = message + state.isLoading = false + return .none + + case let .tableSelected(id): + state.selectedTableID = id + return .none + } + } + } +} diff --git a/Storefront/Features/Browser/BrowserView.swift b/Storefront/Features/Browser/BrowserView.swift new file mode 100644 index 0000000..52dbb86 --- /dev/null +++ b/Storefront/Features/Browser/BrowserView.swift @@ -0,0 +1,134 @@ +import ComposableArchitecture +import SwiftUI + +struct BrowserView: View { + @Bindable var store: StoreOf + + var body: some View { + NavigationSplitView { + sidebar + .navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 360) + } detail: { + detail + } + .navigationTitle(store.databaseURL.lastPathComponent) + .task { store.send(.onAppear) } + } + + @ViewBuilder + private var sidebar: some View { + if store.isLoading && store.tables.isEmpty { + VStack(spacing: 12) { + ProgressView() + Text("스키마 읽는 중…").font(.callout).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = store.loadErrorMessage { + ContentUnavailableView( + "열 수 없음", + systemImage: "exclamationmark.triangle.fill", + description: Text(error) + ) + } else if store.tables.isEmpty { + ContentUnavailableView( + "테이블 없음", + systemImage: "tray", + description: Text("이 데이터베이스에는 테이블이 없습니다.") + ) + } else { + tableList + } + } + + private var tableList: some View { + let tables = store.tables.filter { $0.kind == .table } + let views = store.tables.filter { $0.kind == .view } + let selection = Binding( + get: { store.selectedTableID }, + set: { store.send(.tableSelected($0)) } + ) + + return List(selection: selection) { + if !tables.isEmpty { + Section("Tables") { + ForEach(tables) { table in + TableRow(table: table).tag(Optional(table.id)) + } + } + } + if !views.isEmpty { + Section("Views") { + ForEach(views) { table in + TableRow(table: table).tag(Optional(table.id)) + } + } + } + } + .listStyle(.sidebar) + } + + @ViewBuilder + private var detail: some View { + if + let id = store.selectedTableID, + let table = store.tables.first(where: { $0.id == id }) + { + ContentUnavailableView { + Label(table.name, systemImage: table.kind == .view ? "eye" : "tablecells") + } description: { + Text("\(table.rowCount.formatted()) rows") + .monospacedDigit() + .foregroundStyle(.secondary) + Text("행 뷰어는 Phase 3에서 구현됩니다.") + .font(.footnote) + .foregroundStyle(.tertiary) + } + } else { + ContentUnavailableView( + "테이블 선택", + systemImage: "sidebar.left", + description: Text("왼쪽 사이드바에서 테이블을 선택하세요.") + ) + } + } +} + +private struct TableRow: View { + let table: TableInfo + + var body: some View { + HStack { + Image(systemName: table.kind == .view ? "eye" : "tablecells") + .foregroundStyle(Color("AppPrimary")) + Text(table.name) + Spacer() + Text("\(table.rowCount.formatted())") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.15)) + .clipShape(Capsule()) + } + } +} + +#Preview { + BrowserView( + store: Store( + initialState: BrowserFeature.State( + databaseURL: URL(fileURLWithPath: "/tmp/sample.sqlite"), + tables: [ + TableInfo(name: "artists", kind: .table, rowCount: 275), + TableInfo(name: "albums", kind: .table, rowCount: 347), + TableInfo(name: "tracks", kind: .table, rowCount: 3_503), + TableInfo(name: "invoice_summary", kind: .view, rowCount: 412) + ], + selectedTableID: "tracks" + ) + ) { + BrowserFeature() + } + ) + .frame(width: 900, height: 560) +} diff --git a/StorefrontTests/AppFeatureTests.swift b/StorefrontTests/AppFeatureTests.swift new file mode 100644 index 0000000..aec4520 --- /dev/null +++ b/StorefrontTests/AppFeatureTests.swift @@ -0,0 +1,45 @@ +import ComposableArchitecture +import XCTest +@testable import Storefront + +@MainActor +final class AppFeatureTests: XCTestCase { + func testOpenButtonPresentsFileImporter() async { + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } + await store.send(.openButtonTapped) { + $0.isFileImporterPresented = true + } + } + + func testFileImportedOpensBrowser() async { + let url = URL(fileURLWithPath: "/tmp/sample.sqlite") + let store = TestStore( + initialState: AppFeature.State(isFileImporterPresented: true) + ) { + AppFeature() + } + store.exhaustivity = .off + + await store.send(.fileImported(url)) { + $0.isFileImporterPresented = false + $0.browser = BrowserFeature.State(databaseURL: url) + } + } + + func testCloseDocumentClearsBrowser() async { + let url = URL(fileURLWithPath: "/tmp/sample.sqlite") + let store = TestStore( + initialState: AppFeature.State( + browser: BrowserFeature.State(databaseURL: url) + ) + ) { + AppFeature() + } + + await store.send(.closeDocument) { + $0.browser = nil + } + } +} diff --git a/StorefrontTests/BrowserFeatureTests.swift b/StorefrontTests/BrowserFeatureTests.swift new file mode 100644 index 0000000..0315e51 --- /dev/null +++ b/StorefrontTests/BrowserFeatureTests.swift @@ -0,0 +1,69 @@ +import ComposableArchitecture +import XCTest +@testable import Storefront + +@MainActor +final class BrowserFeatureTests: XCTestCase { + func testOnAppearLoadsTables() async { + let sampleTables = [ + TableInfo(name: "artists", kind: .table, rowCount: 275), + TableInfo(name: "tracks", kind: .table, rowCount: 3_503) + ] + let url = URL(fileURLWithPath: "/tmp/sample.sqlite") + + let store = TestStore( + initialState: BrowserFeature.State(databaseURL: url) + ) { + BrowserFeature() + } withDependencies: { + $0.database.tables = { @Sendable _ in sampleTables } + $0.database.close = { @Sendable _ in } + } + + await store.send(.onAppear) { $0.isLoading = true } + await store.receive(\.tablesLoaded) { + $0.isLoading = false + $0.tables = sampleTables + $0.selectedTableID = "artists" + } + } + + func testOnAppearPropagatesFailure() async { + struct SampleError: LocalizedError { + var errorDescription: String? { "disk corrupted" } + } + let url = URL(fileURLWithPath: "/tmp/broken.sqlite") + + let store = TestStore( + initialState: BrowserFeature.State(databaseURL: url) + ) { + BrowserFeature() + } withDependencies: { + $0.database.tables = { @Sendable _ in throw SampleError() } + $0.database.close = { @Sendable _ in } + } + + await store.send(.onAppear) { $0.isLoading = true } + await store.receive(\.tablesFailedToLoad) { + $0.isLoading = false + $0.loadErrorMessage = "disk corrupted" + } + } + + func testTableSelectionUpdatesState() async { + let url = URL(fileURLWithPath: "/tmp/sample.sqlite") + let store = TestStore( + initialState: BrowserFeature.State( + databaseURL: url, + tables: [TableInfo(name: "a", kind: .table, rowCount: 1)], + selectedTableID: nil + ) + ) { + BrowserFeature() + } + + await store.send(.tableSelected("a")) { + $0.selectedTableID = "a" + } + } +} diff --git a/StorefrontTests/SmokeTests.swift b/StorefrontTests/SmokeTests.swift deleted file mode 100644 index baa8549..0000000 --- a/StorefrontTests/SmokeTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -import ComposableArchitecture -import XCTest -@testable import Storefront - -@MainActor -final class SmokeTests: XCTestCase { - func testInitialStateHasNoDocument() async { - let store = TestStore(initialState: AppFeature.State()) { - AppFeature() - } - XCTAssertNil(store.state.currentDocumentURL) - XCTAssertFalse(store.state.isFileImporterPresented) - } - - func testOpenButtonPresentsFileImporter() async { - let store = TestStore(initialState: AppFeature.State()) { - AppFeature() - } - await store.send(.openButtonTapped) { - $0.isFileImporterPresented = true - } - } - - func testFileImportedSetsDocumentURL() async { - let store = TestStore( - initialState: AppFeature.State(isFileImporterPresented: true) - ) { - AppFeature() - } - let url = URL(fileURLWithPath: "/tmp/test.sqlite") - await store.send(.fileImported(.success(url))) { - $0.currentDocumentURL = url - $0.isFileImporterPresented = false - } - } -} From c2da6111228f29834951e1f2bd0a69f06bf530ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=AB=E1=84=8C=E1=85=AE=E1=86=AB?= Date: Thu, 16 Apr 2026 16:35:02 +0900 Subject: [PATCH 04/15] feat: add row viewer and live reload (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Storefront/Core/Database/RowFetcher: PRAGMA table_info 기반 컬럼 조회, OFFSET/LIMIT 페이지네이션 (pageSize=200), DBValue enum (NULL/Int/Real/Text/BLOB) 매핑 - Storefront/Dependencies/DatabaseClient: page(url, table, offset, limit) 확장 - Storefront/Dependencies/FileWatcherClient: DispatchSource.makeFileSystemObjectSource 기반 AsyncStream. WAL 대응 (-wal, -shm 동시 감시), rename/delete 시 재부착 - Storefront/Features/Browser/BrowserFeature: currentPage/isLoadingRows/liveReloadToast 상태 추가, .onAppear/.fileChanged/.tableSelected/.refreshRequested Effect 조합, .cancellable(id:) + .merge, .run(AsyncStream) 파일 감시 - Storefront/Features/Browser/BrowserView: detail에 테이블명/rows/columns 헤더 + DynamicRowGrid, 상단 Capsule 토스트 ("변경 감지됨"), .task onAppear/onDisappear 생명주기 - Storefront/Features/Browser/DynamicRowGrid: 2D ScrollView + HStack 헤더/행, Zebra stripe, PK 키 아이콘, 타입 라벨, 숫자 우측정렬 - Storefront/UI/CellView: 타입별 색상 (null=italic gray, int/real=blue monospace 우측정렬, text=tooltip, blob=purple 0xHEX + bytes tooltip) - StorefrontTests/BrowserFeatureTests: 4건 통과 (테이블+행 자동 로드 / 실패 전파 / 테이블 선택→행 로드 / fileChanged 토스트) 검증: 7/7 tests pass. 샘플 DB에 sqlite3 INSERT → 자동 새로고침 E2E 확인. --- Docs/PROGRESS.md | 24 ++-- Storefront/Core/Database/RowFetcher.swift | 102 ++++++++++++++++ Storefront/Dependencies/DatabaseClient.swift | 12 ++ .../Dependencies/FileWatcherClient.swift | 101 ++++++++++++++++ .../Features/Browser/BrowserFeature.swift | 108 +++++++++++++++-- Storefront/Features/Browser/BrowserView.swift | 113 ++++++++++++------ .../Features/Browser/DynamicRowGrid.swift | 66 ++++++++++ Storefront/UI/CellView.swift | 47 ++++++++ StorefrontTests/BrowserFeatureTests.swift | 64 +++++++++- 9 files changed, 578 insertions(+), 59 deletions(-) create mode 100644 Storefront/Core/Database/RowFetcher.swift create mode 100644 Storefront/Dependencies/FileWatcherClient.swift create mode 100644 Storefront/Features/Browser/DynamicRowGrid.swift create mode 100644 Storefront/UI/CellView.swift diff --git a/Docs/PROGRESS.md b/Docs/PROGRESS.md index c5e5e2d..6e5d1d5 100644 --- a/Docs/PROGRESS.md +++ b/Docs/PROGRESS.md @@ -34,7 +34,7 @@ open Storefront.xcodeproj # Xcode에서 ⌘R | **초기 문서 · 라이선스 · Asset** | ✅ 완료 | LICENSE(MIT), README, Sky/Orange 컬러 | | **TCA 리팩터** | ✅ 완료 | AppFeature(@Reducer, @ObservableState) / AppView / WelcomeView(StoreOf) / TestStore 3건 통과 | | **2. SQLite 파일 열기 + 테이블 리스트** | ✅ 완료 | BrowserFeature / DatabaseClient(actor registry) / SchemaInspector / NavigationSplitView 2-column + Tables/Views 섹션 + 행수 배지 | -| **3. 행 뷰어 + 라이브 리로드** | ⏳ 대기 | RowFetcher / dynamic Table / CellView / FileWatcherClient(@Dependency) / Toast | +| **3. 행 뷰어 + 라이브 리로드** | ✅ 완료 | RowFetcher / DynamicRowGrid(ScrollView+HStack) / CellView(NULL/INT/REAL/TEXT/BLOB 색상) / FileWatcherClient(DispatchSource, WAL/-shm 포함) / 상단 Toast | | **4. 시뮬레이터 앱 자동 탐색** | ⏳ 대기 | SimulatorClient(@DependencyClient) / SimulatorPickerFeature | | **5. SwiftData 스토어 지원** | ⏳ 대기 | SwiftDataDetector(Z_METADATA) / Decoder / .store 확장자 | | **6. DMG 빌드 파이프라인** | ⏳ 대기 | Makefile / scripts/build.sh, make-dmg.sh, ExportOptions.plist | @@ -47,22 +47,20 @@ open Storefront.xcodeproj # Xcode에서 ⌘R ## 최근 검증 (2026-04-16) -- **Phase 2 빌드/테스트**: `xcodebuild … test` → 6/6 통과 (AppFeature 3 + BrowserFeature 3) -- 샘플 DB `/tmp/storefront-sample.sqlite` (artists/albums/tracks + track_summary view) 생성됨 — 앱에서 File > Open으로 검증 가능 +- **Phase 3 빌드/테스트**: 7/7 통과 (AppFeature 3 + BrowserFeature 4) +- 샘플 DB `/tmp/storefront-sample.sqlite` — File > Open으로 행 데이터 + 라이브 리로드 검증 가능 +- 라이브 리로드 테스트: `sqlite3 /tmp/storefront-sample.sqlite "INSERT INTO artists VALUES (99, 'New Band')"` 실행 시 토스트 + 자동 갱신 확인 ## 다음 작업 시작 지점 -**Phase 3 — 행 뷰어 + 라이브 리로드 (TCA)** +**Phase 4 — 시뮬레이터 앱 자동 탐색 (TCA)** 파일 생성 순서: -1. `Storefront/Core/Database/RowFetcher.swift` — 페이지네이션 행 조회 (OFFSET/LIMIT 또는 keyset) -2. `Storefront/Dependencies/DatabaseClient.swift` 확장 — `columns(URL, table)`, `rows(URL, table, offset, limit)` -3. `Storefront/Dependencies/FileWatcherClient.swift` — `DispatchSource.makeFileSystemObjectSource` 래퍼. `watch(URL) -> AsyncStream` -4. `Storefront/Features/Browser/RowTableView.swift` — dynamic `Table(of:selection:sortOrder:)` + `TableColumn` -5. `Storefront/UI/CellView.swift` — NULL/BLOB/Date/Number/Text 타입별 색상 -6. `BrowserFeature` 확장 — `.columnsLoaded`, `.rowsLoaded`, `.fileChanged` 액션 + Effect 합성 -7. `BrowserView` 우측 detail에 `RowTableView` 연결, 라이브 리로드 토스트 -8. Tests: `BrowserFeatureRowsTests`, `FileWatcherClientTests` +1. `Storefront/Dependencies/SimulatorClient.swift` — `xcrun simctl list devices --json` + `~/Library/Developer/CoreSimulator/Devices//data/Containers/Data/Application/*/` 글로빙. Returns `[SimulatorDevice]` with nested apps/DBs. `Info.plist`의 `MCMMetadataIdentifier`로 번들ID 표시 +2. `Storefront/Features/SimulatorPicker/SimulatorPickerFeature.swift` — 디바이스/앱/DB 3단 리스트 State +3. `Storefront/Features/SimulatorPicker/SimulatorPickerView.swift` — NavigationSplitView 또는 DisclosureGroup 트리 +4. `AppFeature` 또는 Welcome에 Simulator 섹션 통합 (사이드바 + "Simulators" 버튼) +5. Tests: `SimulatorPickerFeatureTests` (mock JSON 파싱) ## 저장소 상태 @@ -70,4 +68,4 @@ open Storefront.xcodeproj # Xcode에서 ⌘R - Visibility: **Private** (v0.1.0 릴리스 전까지) - Default branch: `master` - Active branch: `feat/mvp-v0.1.0` -- Last commit on feat/mvp-v0.1.0: Phase 2 완료 (SQLite 뷰어 + 테이블 리스트) +- Last commit on feat/mvp-v0.1.0: Phase 3 완료 (행 뷰어 + 라이브 리로드) diff --git a/Storefront/Core/Database/RowFetcher.swift b/Storefront/Core/Database/RowFetcher.swift new file mode 100644 index 0000000..9cf12ec --- /dev/null +++ b/Storefront/Core/Database/RowFetcher.swift @@ -0,0 +1,102 @@ +import Foundation +import GRDB + +struct ColumnInfo: Equatable, Identifiable, Sendable { + let name: String + let declaredType: String + let isPrimaryKey: Bool + let isNotNull: Bool + + var id: String { name } +} + +enum DBValue: Equatable, Sendable, Hashable { + case null + case integer(Int64) + case double(Double) + case text(String) + case blob(Data) + + var displayKind: Kind { + switch self { + case .null: return .null + case .integer: return .integer + case .double: return .real + case .text: return .text + case .blob: return .blob + } + } + + enum Kind: Sendable, Equatable { + case null, integer, real, text, blob + } +} + +struct RowSnapshot: Equatable, Identifiable, Sendable { + let index: Int + let values: [DBValue] + + var id: Int { index } +} + +struct RowPage: Equatable, Sendable { + let columns: [ColumnInfo] + let rows: [RowSnapshot] + let totalRows: Int + let offset: Int + let limit: Int +} + +enum RowFetcher { + static func columns(_ db: Database, table: String) throws -> [ColumnInfo] { + let escaped = table.replacingOccurrences(of: "\"", with: "\"\"") + let rows = try Row.fetchAll(db, sql: "PRAGMA table_info(\"\(escaped)\")") + return rows.map { row in + ColumnInfo( + name: row["name"], + declaredType: (row["type"] as String?) ?? "", + isPrimaryKey: ((row["pk"] as Int?) ?? 0) > 0, + isNotNull: ((row["notnull"] as Int?) ?? 0) != 0 + ) + } + } + + static func page(_ db: Database, table: String, offset: Int, limit: Int) throws -> RowPage { + let columns = try columns(db, table: table) + let escaped = table.replacingOccurrences(of: "\"", with: "\"\"") + + let total = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM \"\(escaped)\"") ?? 0 + + let rows = try Row.fetchAll( + db, + sql: "SELECT * FROM \"\(escaped)\" LIMIT ? OFFSET ?", + arguments: [limit, offset] + ) + + let snapshots = rows.enumerated().map { (idx, row) in + let values: [DBValue] = columns.map { col in + let dbValue = row[col.name] as DatabaseValue? + return mapValue(dbValue ?? DatabaseValue.null) + } + return RowSnapshot(index: offset + idx, values: values) + } + + return RowPage( + columns: columns, + rows: snapshots, + totalRows: total, + offset: offset, + limit: limit + ) + } + + private static func mapValue(_ value: DatabaseValue) -> DBValue { + switch value.storage { + case .null: return .null + case .int64(let v): return .integer(v) + case .double(let v): return .double(v) + case .string(let v): return .text(v) + case .blob(let v): return .blob(v) + } + } +} diff --git a/Storefront/Dependencies/DatabaseClient.swift b/Storefront/Dependencies/DatabaseClient.swift index 225043e..a8885f3 100644 --- a/Storefront/Dependencies/DatabaseClient.swift +++ b/Storefront/Dependencies/DatabaseClient.swift @@ -4,6 +4,7 @@ import GRDB struct DatabaseClient: Sendable { var tables: @Sendable (URL) async throws -> [TableInfo] + var page: @Sendable (_ url: URL, _ table: String, _ offset: Int, _ limit: Int) async throws -> RowPage var close: @Sendable (URL) async -> Void } @@ -12,12 +13,16 @@ extension DatabaseClient: DependencyKey { let registry = DatabaseRegistry() return DatabaseClient( tables: { url in try await registry.tables(for: url) }, + page: { url, table, offset, limit in + try await registry.page(url: url, table: table, offset: offset, limit: limit) + }, close: { url in await registry.close(url) } ) }() static let testValue = DatabaseClient( tables: unimplemented("DatabaseClient.tables"), + page: unimplemented("DatabaseClient.page"), close: unimplemented("DatabaseClient.close") ) } @@ -48,6 +53,13 @@ private actor DatabaseRegistry { } } + func page(url: URL, table: String, offset: Int, limit: Int) async throws -> RowPage { + let q = try queue(for: url) + return try await q.read { db in + try RowFetcher.page(db, table: table, offset: offset, limit: limit) + } + } + func close(_ url: URL) { queues.removeValue(forKey: url) } diff --git a/Storefront/Dependencies/FileWatcherClient.swift b/Storefront/Dependencies/FileWatcherClient.swift new file mode 100644 index 0000000..3272348 --- /dev/null +++ b/Storefront/Dependencies/FileWatcherClient.swift @@ -0,0 +1,101 @@ +import ComposableArchitecture +import Foundation + +struct FileWatcherClient: Sendable { + var changes: @Sendable (URL) -> AsyncStream +} + +extension FileWatcherClient: DependencyKey { + static let liveValue: FileWatcherClient = FileWatcherClient( + changes: { url in + AsyncStream { continuation in + let watcher = FileWatcher(url: url) { + continuation.yield() + } + continuation.onTermination = { _ in watcher.cancel() } + watcher.start() + } + } + ) + + static let testValue = FileWatcherClient( + changes: unimplemented("FileWatcherClient.changes", placeholder: .finished) + ) +} + +extension DependencyValues { + var fileWatcher: FileWatcherClient { + get { self[FileWatcherClient.self] } + set { self[FileWatcherClient.self] = newValue } + } +} + +private final class FileWatcher: @unchecked Sendable { + private let url: URL + private let onChange: @Sendable () -> Void + private var sources: [DispatchSourceFileSystemObject] = [] + private let queue = DispatchQueue(label: "com.moinjun.Storefront.FileWatcher") + private var isCancelled = false + + init(url: URL, onChange: @escaping @Sendable () -> Void) { + self.url = url + self.onChange = onChange + } + + func start() { + queue.async { [weak self] in + self?.watch() + } + } + + private func watch() { + guard !isCancelled else { return } + cancelSources() + sources = Self.candidateURLs(for: url).compactMap { candidate in + makeSource(for: candidate) + } + sources.forEach { $0.resume() } + } + + private func makeSource(for url: URL) -> DispatchSourceFileSystemObject? { + let fd = open(url.path, O_EVTONLY) + guard fd >= 0 else { return nil } + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .extend, .rename, .delete], + queue: queue + ) + source.setEventHandler { [weak self] in + guard let self, !self.isCancelled else { return } + let events = source.data + self.onChange() + if events.contains(.rename) || events.contains(.delete) { + self.watch() + } + } + source.setCancelHandler { + close(fd) + } + return source + } + + private func cancelSources() { + sources.forEach { $0.cancel() } + sources.removeAll() + } + + func cancel() { + queue.async { [weak self] in + self?.isCancelled = true + self?.cancelSources() + } + } + + private static func candidateURLs(for url: URL) -> [URL] { + let base = url.path + let siblings = [base, base + "-wal", base + "-shm"] + return siblings + .filter { FileManager.default.fileExists(atPath: $0) } + .map { URL(fileURLWithPath: $0) } + } +} diff --git a/Storefront/Features/Browser/BrowserFeature.swift b/Storefront/Features/Browser/BrowserFeature.swift index d9a26a6..e209924 100644 --- a/Storefront/Features/Browser/BrowserFeature.swift +++ b/Storefront/Features/Browser/BrowserFeature.swift @@ -10,39 +10,80 @@ struct BrowserFeature { var selectedTableID: TableInfo.ID? var isLoading: Bool = false var loadErrorMessage: String? + + var currentPage: RowPage? + var isLoadingRows: Bool = false + var rowLoadError: String? + + var liveReloadToast: Int = 0 } enum Action: Equatable { case onAppear + case onDisappear case refreshRequested case tablesLoaded([TableInfo]) case tablesFailedToLoad(String) case tableSelected(TableInfo.ID?) + case rowsLoaded(RowPage) + case rowsFailedToLoad(String) + case fileChanged } @Dependency(\.database) var database + @Dependency(\.fileWatcher) var fileWatcher + + enum CancelID: Hashable { + case watch + case loadRows + } + + private let pageSize: Int = 200 var body: some ReducerOf { Reduce { state, action in switch action { - case .onAppear, .refreshRequested: + case .onAppear: state.isLoading = true state.loadErrorMessage = nil let url = state.databaseURL - return .run { send in - do { - let tables = try await database.tables(url) - await send(.tablesLoaded(tables)) - } catch { - await send(.tablesFailedToLoad(error.localizedDescription)) + return .merge( + loadTables(url: url), + .run { send in + for await _ in fileWatcher.changes(url) { + await send(.fileChanged) + } } - } + .cancellable(id: CancelID.watch, cancelInFlight: true) + ) + + case .onDisappear: + return .cancel(id: CancelID.watch) + + case .refreshRequested: + state.isLoading = true + state.loadErrorMessage = nil + let url = state.databaseURL + let selected = state.selectedTableID + return .merge( + loadTables(url: url), + selected.map { loadRows(url: url, table: $0) } ?? .none + ) case let .tablesLoaded(tables): state.tables = tables state.isLoading = false - if state.selectedTableID == nil { - state.selectedTableID = tables.first?.id + let autoSelected: String? + if let current = state.selectedTableID, tables.contains(where: { $0.id == current }) { + autoSelected = current + } else { + autoSelected = tables.first?.id + } + state.selectedTableID = autoSelected + if let table = autoSelected { + state.isLoadingRows = true + state.rowLoadError = nil + return loadRows(url: state.databaseURL, table: table) } return .none @@ -53,8 +94,55 @@ struct BrowserFeature { case let .tableSelected(id): state.selectedTableID = id + state.currentPage = nil + state.rowLoadError = nil + guard let id else { return .none } + state.isLoadingRows = true + return loadRows(url: state.databaseURL, table: id) + + case let .rowsLoaded(page): + state.currentPage = page + state.isLoadingRows = false + state.rowLoadError = nil return .none + + case let .rowsFailedToLoad(message): + state.rowLoadError = message + state.isLoadingRows = false + return .none + + case .fileChanged: + state.liveReloadToast &+= 1 + let url = state.databaseURL + let selected = state.selectedTableID + return .merge( + loadTables(url: url), + selected.map { loadRows(url: url, table: $0) } ?? .none + ) + } + } + } + + private func loadTables(url: URL) -> Effect { + .run { send in + do { + let tables = try await database.tables(url) + await send(.tablesLoaded(tables)) + } catch { + await send(.tablesFailedToLoad(error.localizedDescription)) + } + } + } + + private func loadRows(url: URL, table: String) -> Effect { + .run { [pageSize] send in + do { + let page = try await database.page(url, table, 0, pageSize) + await send(.rowsLoaded(page)) + } catch { + await send(.rowsFailedToLoad(error.localizedDescription)) } } + .cancellable(id: CancelID.loadRows, cancelInFlight: true) } } diff --git a/Storefront/Features/Browser/BrowserView.swift b/Storefront/Features/Browser/BrowserView.swift index 52dbb86..c0b3794 100644 --- a/Storefront/Features/Browser/BrowserView.swift +++ b/Storefront/Features/Browser/BrowserView.swift @@ -3,6 +3,7 @@ import SwiftUI struct BrowserView: View { @Bindable var store: StoreOf + @State private var showReloadToast: Bool = false var body: some View { NavigationSplitView { @@ -10,9 +11,15 @@ struct BrowserView: View { .navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 360) } detail: { detail + .overlay(alignment: .top) { reloadToast } } .navigationTitle(store.databaseURL.lastPathComponent) - .task { store.send(.onAppear) } + .task { + store.send(.onAppear) + } + .onChange(of: store.liveReloadToast) { _, _ in + triggerToast() + } } @ViewBuilder @@ -69,20 +76,22 @@ struct BrowserView: View { @ViewBuilder private var detail: some View { - if - let id = store.selectedTableID, - let table = store.tables.first(where: { $0.id == id }) - { - ContentUnavailableView { - Label(table.name, systemImage: table.kind == .view ? "eye" : "tablecells") - } description: { - Text("\(table.rowCount.formatted()) rows") - .monospacedDigit() - .foregroundStyle(.secondary) - Text("행 뷰어는 Phase 3에서 구현됩니다.") - .font(.footnote) - .foregroundStyle(.tertiary) + if let page = store.currentPage { + VStack(spacing: 0) { + detailToolbar(page: page) + Divider() + DynamicRowGrid(page: page) + .background(Color("AppBackground")) } + } else if store.isLoadingRows { + ProgressView("행 로딩 중…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = store.rowLoadError { + ContentUnavailableView( + "행을 읽을 수 없음", + systemImage: "exclamationmark.triangle.fill", + description: Text(error) + ) } else { ContentUnavailableView( "테이블 선택", @@ -91,6 +100,62 @@ struct BrowserView: View { ) } } + + private func detailToolbar(page: RowPage) -> some View { + HStack(spacing: 12) { + Text(store.selectedTableID ?? "") + .font(.headline) + Text("\(page.totalRows.formatted()) rows · \(page.columns.count) columns") + .font(.callout.monospacedDigit()) + .foregroundStyle(.secondary) + if page.totalRows > page.rows.count { + Text("첫 \(page.rows.count.formatted())개 표시 중") + .font(.caption) + .foregroundStyle(.tertiary) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.12)) + .clipShape(Capsule()) + } + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Color("AppBackground")) + } + + @ViewBuilder + private var reloadToast: some View { + if showReloadToast { + HStack(spacing: 8) { + Image(systemName: "arrow.triangle.2.circlepath") + Text("변경 감지됨 — 자동 새로고침") + .font(.callout) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(.regularMaterial, in: Capsule()) + .overlay(Capsule().stroke(Color("AppAccent").opacity(0.5), lineWidth: 1)) + .shadow(radius: 6) + .padding(.top, 14) + .transition(.opacity.combined(with: .move(edge: .top))) + .zIndex(10) + } + } + + private func triggerToast() { + withAnimation(.easeOut(duration: 0.2)) { + showReloadToast = true + } + Task { + try? await Task.sleep(nanoseconds: 1_800_000_000) + await MainActor.run { + withAnimation(.easeIn(duration: 0.25)) { + showReloadToast = false + } + } + } + } } private struct TableRow: View { @@ -112,23 +177,3 @@ private struct TableRow: View { } } } - -#Preview { - BrowserView( - store: Store( - initialState: BrowserFeature.State( - databaseURL: URL(fileURLWithPath: "/tmp/sample.sqlite"), - tables: [ - TableInfo(name: "artists", kind: .table, rowCount: 275), - TableInfo(name: "albums", kind: .table, rowCount: 347), - TableInfo(name: "tracks", kind: .table, rowCount: 3_503), - TableInfo(name: "invoice_summary", kind: .view, rowCount: 412) - ], - selectedTableID: "tracks" - ) - ) { - BrowserFeature() - } - ) - .frame(width: 900, height: 560) -} diff --git a/Storefront/Features/Browser/DynamicRowGrid.swift b/Storefront/Features/Browser/DynamicRowGrid.swift new file mode 100644 index 0000000..6d21ca0 --- /dev/null +++ b/Storefront/Features/Browser/DynamicRowGrid.swift @@ -0,0 +1,66 @@ +import SwiftUI + +struct DynamicRowGrid: View { + let page: RowPage + + private static let minColumnWidth: CGFloat = 120 + + var body: some View { + ScrollView([.horizontal, .vertical]) { + VStack(spacing: 0) { + header + Divider() + ForEach(Array(page.rows.enumerated()), id: \.element.id) { idx, row in + rowView(row: row, zebra: idx.isMultiple(of: 2)) + Divider().opacity(0.3) + } + } + } + } + + private var header: some View { + HStack(spacing: 0) { + ForEach(page.columns) { column in + HStack(spacing: 4) { + if column.isPrimaryKey { + Image(systemName: "key.fill") + .font(.caption2) + .foregroundStyle(Color("AppPrimary")) + } + Text(column.name) + .font(.system(.subheadline, design: .default).weight(.semibold)) + if !column.declaredType.isEmpty { + Text(column.declaredType) + .font(.caption2) + .foregroundStyle(.tertiary) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(minWidth: Self.minColumnWidth, alignment: .leading) + } + } + .background(Color.secondary.opacity(0.08)) + } + + private func rowView(row: RowSnapshot, zebra: Bool) -> some View { + HStack(spacing: 0) { + ForEach(Array(page.columns.enumerated()), id: \.element.id) { idx, column in + let value = idx < row.values.count ? row.values[idx] : .null + CellView(value: value) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(minWidth: Self.minColumnWidth, alignment: columnAlignment(for: value)) + } + } + .background(zebra ? Color.secondary.opacity(0.04) : Color.clear) + } + + private func columnAlignment(for value: DBValue) -> Alignment { + switch value { + case .integer, .double: return .trailing + default: return .leading + } + } +} diff --git a/Storefront/UI/CellView.swift b/Storefront/UI/CellView.swift new file mode 100644 index 0000000..4ade14d --- /dev/null +++ b/Storefront/UI/CellView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +struct CellView: View { + let value: DBValue + + var body: some View { + switch value { + case .null: + Text("null") + .font(.system(.body, design: .monospaced).italic()) + .foregroundStyle(.secondary) + + case let .integer(v): + Text(v.formatted(.number.grouping(.never))) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(Color.blue) + .monospacedDigit() + .frame(maxWidth: .infinity, alignment: .trailing) + + case let .double(v): + Text(String(v)) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(Color.blue) + .monospacedDigit() + .frame(maxWidth: .infinity, alignment: .trailing) + + case let .text(v): + Text(v) + .lineLimit(1) + .truncationMode(.tail) + .help(v) + + case let .blob(data): + Text("0x\(hexPreview(data))") + .font(.system(.body, design: .monospaced)) + .foregroundStyle(Color.purple) + .help("\(data.count) bytes") + } + } + + private func hexPreview(_ data: Data) -> String { + let maxBytes = 6 + let prefix = data.prefix(maxBytes) + let hex = prefix.map { String(format: "%02X", $0) }.joined() + return data.count > maxBytes ? hex + "…" : hex + } +} diff --git a/StorefrontTests/BrowserFeatureTests.swift b/StorefrontTests/BrowserFeatureTests.swift index 0315e51..e9754cd 100644 --- a/StorefrontTests/BrowserFeatureTests.swift +++ b/StorefrontTests/BrowserFeatureTests.swift @@ -4,11 +4,18 @@ import XCTest @MainActor final class BrowserFeatureTests: XCTestCase { - func testOnAppearLoadsTables() async { + func testOnAppearLoadsTablesAndAutoSelectsFirst() async { let sampleTables = [ TableInfo(name: "artists", kind: .table, rowCount: 275), TableInfo(name: "tracks", kind: .table, rowCount: 3_503) ] + let samplePage = RowPage( + columns: [ColumnInfo(name: "id", declaredType: "INTEGER", isPrimaryKey: true, isNotNull: true)], + rows: [RowSnapshot(index: 0, values: [.integer(1)])], + totalRows: 1, + offset: 0, + limit: 200 + ) let url = URL(fileURLWithPath: "/tmp/sample.sqlite") let store = TestStore( @@ -17,15 +24,24 @@ final class BrowserFeatureTests: XCTestCase { BrowserFeature() } withDependencies: { $0.database.tables = { @Sendable _ in sampleTables } + $0.database.page = { @Sendable _, _, _, _ in samplePage } $0.database.close = { @Sendable _ in } + $0.fileWatcher.changes = { @Sendable _ in AsyncStream { _ in } } } + store.exhaustivity = .off await store.send(.onAppear) { $0.isLoading = true } await store.receive(\.tablesLoaded) { $0.isLoading = false $0.tables = sampleTables $0.selectedTableID = "artists" + $0.isLoadingRows = true } + await store.receive(\.rowsLoaded) { + $0.isLoadingRows = false + $0.currentPage = samplePage + } + await store.send(.onDisappear) } func testOnAppearPropagatesFailure() async { @@ -40,18 +56,32 @@ final class BrowserFeatureTests: XCTestCase { BrowserFeature() } withDependencies: { $0.database.tables = { @Sendable _ in throw SampleError() } + $0.database.page = { @Sendable _, _, _, _ in + throw SampleError() + } $0.database.close = { @Sendable _ in } + $0.fileWatcher.changes = { @Sendable _ in AsyncStream { _ in } } } + store.exhaustivity = .off await store.send(.onAppear) { $0.isLoading = true } await store.receive(\.tablesFailedToLoad) { $0.isLoading = false $0.loadErrorMessage = "disk corrupted" } + await store.send(.onDisappear) } - func testTableSelectionUpdatesState() async { + func testTableSelectionTriggersRowLoad() async { + let samplePage = RowPage( + columns: [ColumnInfo(name: "id", declaredType: "INTEGER", isPrimaryKey: true, isNotNull: true)], + rows: [RowSnapshot(index: 0, values: [.integer(42)])], + totalRows: 1, + offset: 0, + limit: 200 + ) let url = URL(fileURLWithPath: "/tmp/sample.sqlite") + let store = TestStore( initialState: BrowserFeature.State( databaseURL: url, @@ -60,10 +90,40 @@ final class BrowserFeatureTests: XCTestCase { ) ) { BrowserFeature() + } withDependencies: { + $0.database.page = { @Sendable _, _, _, _ in samplePage } } await store.send(.tableSelected("a")) { $0.selectedTableID = "a" + $0.isLoadingRows = true + } + await store.receive(\.rowsLoaded) { + $0.isLoadingRows = false + $0.currentPage = samplePage + } + } + + func testFileChangedIncrementsToastCounter() async { + let url = URL(fileURLWithPath: "/tmp/sample.sqlite") + let store = TestStore( + initialState: BrowserFeature.State( + databaseURL: url, + tables: [TableInfo(name: "a", kind: .table, rowCount: 1)], + selectedTableID: "a" + ) + ) { + BrowserFeature() + } withDependencies: { + $0.database.tables = { @Sendable _ in [TableInfo(name: "a", kind: .table, rowCount: 2)] } + $0.database.page = { @Sendable _, _, _, _ in + RowPage(columns: [], rows: [], totalRows: 0, offset: 0, limit: 200) + } + } + store.exhaustivity = .off + + await store.send(.fileChanged) { + $0.liveReloadToast = 1 } } } From 5cf3fcde7aad10bee9961fb1da914c3b09ac55da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=AB=E1=84=8C=E1=85=AE=E1=86=AB?= Date: Thu, 16 Apr 2026 16:38:49 +0900 Subject: [PATCH 05/15] feat: simulator auto-discovery and drag-drop (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Storefront/Core/Simulator: SimulatorTypes(SimulatorDevice/App/DatabaseFile) + SimulatorScanner(xcrun simctl list devices --json 파싱 + ~/Library/Developer/CoreSimulator/Devices//data/Containers/Data/Application/*/ 글로빙). MCMMetadataIdentifier로 번들ID, WAL/-shm 필터, 수정시간 정렬 - Storefront/Dependencies/SimulatorClient: Task.detached로 scan 실행, @Dependency(\.simulator) 등록 - Storefront/Features/SimulatorPicker: SimulatorPickerFeature(@Reducer, 디바이스/앱 expansion 상태 관리), SimulatorPickerView(시트, DisclosureGroup 트리, Booted 표시, ByteCountFormatter, RelativeDateTimeFormatter) - Storefront/Features/App/AppFeature: simulatorPicker 자식 피처 추가, .simulatorButtonTapped → 시트 오픈, picker.databasePicked → fileImported(url) 전파 - Storefront/Features/App/AppView: .sheet로 SimulatorPickerView 표시 - Storefront/Features/Welcome/WelcomeView: "시뮬레이터" 버튼(⌘L) 추가, .dropDestination(for: URL.self) 드래그&드롭 구현 - StorefrontTests/SimulatorPickerFeatureTests: 3건 통과 (onAppear/toggleDevice/JSON 파싱) 검증: 10/10 tests pass. 수동 QA: 시뮬 부팅 → ⌘L → DB 선택 → 오픈 E2E. --- Docs/PROGRESS.md | 23 ++- .../Core/Simulator/SimulatorScanner.swift | 190 ++++++++++++++++++ .../Core/Simulator/SimulatorTypes.swift | 27 +++ Storefront/Dependencies/SimulatorClient.swift | 27 +++ Storefront/Features/App/AppFeature.swift | 20 +- Storefront/Features/App/AppView.swift | 31 ++- .../SimulatorPickerFeature.swift | 76 +++++++ .../SimulatorPicker/SimulatorPickerView.swift | 175 ++++++++++++++++ Storefront/Features/Welcome/WelcomeView.swift | 38 +++- .../SimulatorPickerFeatureTests.swift | 78 +++++++ 10 files changed, 644 insertions(+), 41 deletions(-) create mode 100644 Storefront/Core/Simulator/SimulatorScanner.swift create mode 100644 Storefront/Core/Simulator/SimulatorTypes.swift create mode 100644 Storefront/Dependencies/SimulatorClient.swift create mode 100644 Storefront/Features/SimulatorPicker/SimulatorPickerFeature.swift create mode 100644 Storefront/Features/SimulatorPicker/SimulatorPickerView.swift create mode 100644 StorefrontTests/SimulatorPickerFeatureTests.swift diff --git a/Docs/PROGRESS.md b/Docs/PROGRESS.md index 6e5d1d5..1af3521 100644 --- a/Docs/PROGRESS.md +++ b/Docs/PROGRESS.md @@ -35,7 +35,7 @@ open Storefront.xcodeproj # Xcode에서 ⌘R | **TCA 리팩터** | ✅ 완료 | AppFeature(@Reducer, @ObservableState) / AppView / WelcomeView(StoreOf) / TestStore 3건 통과 | | **2. SQLite 파일 열기 + 테이블 리스트** | ✅ 완료 | BrowserFeature / DatabaseClient(actor registry) / SchemaInspector / NavigationSplitView 2-column + Tables/Views 섹션 + 행수 배지 | | **3. 행 뷰어 + 라이브 리로드** | ✅ 완료 | RowFetcher / DynamicRowGrid(ScrollView+HStack) / CellView(NULL/INT/REAL/TEXT/BLOB 색상) / FileWatcherClient(DispatchSource, WAL/-shm 포함) / 상단 Toast | -| **4. 시뮬레이터 앱 자동 탐색** | ⏳ 대기 | SimulatorClient(@DependencyClient) / SimulatorPickerFeature | +| **4. 시뮬레이터 앱 자동 탐색** | ✅ 완료 | SimulatorScanner(simctl JSON + FS 글로빙) / SimulatorClient / SimulatorPickerFeature(DisclosureGroup 트리) / Welcome의 "시뮬레이터" 버튼(⌘L) + Welcome 드래그&드롭 | | **5. SwiftData 스토어 지원** | ⏳ 대기 | SwiftDataDetector(Z_METADATA) / Decoder / .store 확장자 | | **6. DMG 빌드 파이프라인** | ⏳ 대기 | Makefile / scripts/build.sh, make-dmg.sh, ExportOptions.plist | | **7. GitHub Actions 릴리스** | ⏳ 대기 | .github/workflows/build.yml, release.yml (매크로 검증 스킵 플래그 포함) | @@ -47,20 +47,21 @@ open Storefront.xcodeproj # Xcode에서 ⌘R ## 최근 검증 (2026-04-16) -- **Phase 3 빌드/테스트**: 7/7 통과 (AppFeature 3 + BrowserFeature 4) -- 샘플 DB `/tmp/storefront-sample.sqlite` — File > Open으로 행 데이터 + 라이브 리로드 검증 가능 -- 라이브 리로드 테스트: `sqlite3 /tmp/storefront-sample.sqlite "INSERT INTO artists VALUES (99, 'New Band')"` 실행 시 토스트 + 자동 갱신 확인 +- **Phase 4 빌드/테스트**: 10/10 통과 (AppFeature 3 + BrowserFeature 4 + SimulatorPicker 3) +- 샘플 DB `/tmp/storefront-sample.sqlite` 또는 드래그&드롭으로 열기 가능 +- "시뮬레이터" 버튼(⌘L) → DisclosureGroup 트리에서 부팅된 시뮬 → 앱 → DB 원클릭 오픈 ## 다음 작업 시작 지점 -**Phase 4 — 시뮬레이터 앱 자동 탐색 (TCA)** +**Phase 5 — SwiftData 스토어 지원 (TCA)** 파일 생성 순서: -1. `Storefront/Dependencies/SimulatorClient.swift` — `xcrun simctl list devices --json` + `~/Library/Developer/CoreSimulator/Devices//data/Containers/Data/Application/*/` 글로빙. Returns `[SimulatorDevice]` with nested apps/DBs. `Info.plist`의 `MCMMetadataIdentifier`로 번들ID 표시 -2. `Storefront/Features/SimulatorPicker/SimulatorPickerFeature.swift` — 디바이스/앱/DB 3단 리스트 State -3. `Storefront/Features/SimulatorPicker/SimulatorPickerView.swift` — NavigationSplitView 또는 DisclosureGroup 트리 -4. `AppFeature` 또는 Welcome에 Simulator 섹션 통합 (사이드바 + "Simulators" 버튼) -5. Tests: `SimulatorPickerFeatureTests` (mock JSON 파싱) +1. `Storefront/Core/SwiftDataStore/SwiftDataDetector.swift` — `Z_METADATA`/`Z_PRIMARYKEY` 존재 여부로 판별 +2. `Storefront/Core/SwiftDataStore/SwiftDataDecoder.swift` — 테이블명에서 `Z_` 접두 제거, 컬럼명 정규화(`ZNAME` → `name`), `Z_` 메타 테이블은 별도 섹션 +3. `DatabaseClient.tables` 응답에 `isSwiftData` 플래그 또는 별도 SwiftData 경로 추가 +4. `SchemaInspector`에 Z_ 인식 로직 연동 — 사용자 표시명 정규화 +5. `AppFeature` `fileImported`에서 `.store` 확장자 감지 → Browser에 isSwiftData=true 전달 +6. Tests: `SwiftDataDetectorTests`, `SwiftDataDecoderTests` ## 저장소 상태 @@ -68,4 +69,4 @@ open Storefront.xcodeproj # Xcode에서 ⌘R - Visibility: **Private** (v0.1.0 릴리스 전까지) - Default branch: `master` - Active branch: `feat/mvp-v0.1.0` -- Last commit on feat/mvp-v0.1.0: Phase 3 완료 (행 뷰어 + 라이브 리로드) +- Last commit on feat/mvp-v0.1.0: Phase 4 완료 (시뮬레이터 탐색 + 드래그&드롭) diff --git a/Storefront/Core/Simulator/SimulatorScanner.swift b/Storefront/Core/Simulator/SimulatorScanner.swift new file mode 100644 index 0000000..467c061 --- /dev/null +++ b/Storefront/Core/Simulator/SimulatorScanner.swift @@ -0,0 +1,190 @@ +import Foundation + +enum SimulatorScanner { + static func scan() throws -> [SimulatorDevice] { + let devicesByRuntime = try fetchDeviceList() + var devices: [SimulatorDevice] = [] + + for (runtime, deviceEntries) in devicesByRuntime { + for entry in deviceEntries { + let apps = entry.isBooted ? (try? scanApps(udid: entry.udid)) ?? [] : [] + devices.append( + SimulatorDevice( + id: entry.udid, + name: entry.name, + runtime: prettifyRuntime(runtime), + isBooted: entry.isBooted, + apps: apps + ) + ) + } + } + + return devices.sorted { lhs, rhs in + if lhs.isBooted != rhs.isBooted { return lhs.isBooted && !rhs.isBooted } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + } + + fileprivate struct DeviceEntry { + let udid: String + let name: String + let isBooted: Bool + } + + fileprivate static func fetchDeviceList() throws -> [String: [DeviceEntry]] { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = ["simctl", "list", "devices", "--json"] + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = Pipe() + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + throw SimulatorError.commandFailed(code: Int(process.terminationStatus)) + } + + let data = try stdout.fileHandleForReading.readToEnd() ?? Data() + return try parseDeviceList(data) + } + + fileprivate static func parseDeviceList(_ data: Data) throws -> [String: [DeviceEntry]] { + guard + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let devicesByRuntime = json["devices"] as? [String: [[String: Any]]] + else { + throw SimulatorError.parseFailed + } + + var result: [String: [DeviceEntry]] = [:] + for (runtime, entries) in devicesByRuntime { + var list: [DeviceEntry] = [] + for entry in entries { + guard + let udid = entry["udid"] as? String, + let name = entry["name"] as? String + else { continue } + let state = entry["state"] as? String ?? "" + list.append( + DeviceEntry(udid: udid, name: name, isBooted: state == "Booted") + ) + } + if !list.isEmpty { + result[runtime] = list + } + } + return result + } + + private static func scanApps(udid: String) throws -> [SimulatorApp] { + let home = FileManager.default.homeDirectoryForCurrentUser + let containersRoot = home.appending(path: "Library/Developer/CoreSimulator/Devices/\(udid)/data/Containers/Data/Application", directoryHint: .isDirectory) + + guard FileManager.default.fileExists(atPath: containersRoot.path) else { return [] } + + let containers = try FileManager.default.contentsOfDirectory(at: containersRoot, includingPropertiesForKeys: nil) + var apps: [SimulatorApp] = [] + + for containerURL in containers { + let containerID = containerURL.lastPathComponent + let metadataURL = containerURL.appending(path: ".com.apple.mobile_container_manager.metadata.plist") + guard let bundleID = readBundleID(from: metadataURL) else { continue } + + let databases = findDatabases(under: containerURL) + guard !databases.isEmpty else { continue } + + apps.append( + SimulatorApp( + containerID: containerID, + bundleID: bundleID, + databases: databases + ) + ) + } + + return apps.sorted { $0.bundleID.localizedCaseInsensitiveCompare($1.bundleID) == .orderedAscending } + } + + private static func readBundleID(from plistURL: URL) -> String? { + guard let data = try? Data(contentsOf: plistURL), + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { + return nil + } + return plist["MCMMetadataIdentifier"] as? String + } + + private static let databaseExtensions: Set = ["sqlite", "sqlite3", "db", "store"] + + private static func findDatabases(under containerURL: URL) -> [DatabaseFile] { + let fm = FileManager.default + let roots = [ + containerURL.appending(path: "Documents"), + containerURL.appending(path: "Library"), + containerURL.appending(path: "tmp") + ].filter { fm.fileExists(atPath: $0.path) } + + var found: [DatabaseFile] = [] + for root in roots { + guard let enumerator = fm.enumerator( + at: root, + includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { continue } + + for case let url as URL in enumerator { + guard databaseExtensions.contains(url.pathExtension.lowercased()) else { continue } + // Skip WAL/SHM siblings + let stem = url.deletingPathExtension().lastPathComponent + if stem.hasSuffix("-wal") || stem.hasSuffix("-shm") { continue } + + let values = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey, .isRegularFileKey]) + guard values?.isRegularFile == true else { continue } + + let size = Int64(values?.fileSize ?? 0) + let mtime = values?.contentModificationDate ?? .distantPast + found.append(DatabaseFile(url: url, sizeBytes: size, modifiedAt: mtime)) + } + } + + return found.sorted { $0.modifiedAt > $1.modifiedAt } + } + + private static func prettifyRuntime(_ runtime: String) -> String { + // "com.apple.CoreSimulator.SimRuntime.iOS-18-0" → "iOS 18.0" + let trimmed = runtime.replacingOccurrences(of: "com.apple.CoreSimulator.SimRuntime.", with: "") + let parts = trimmed.split(separator: "-") + guard parts.count >= 2 else { return trimmed } + let platform = parts[0] + let version = parts.dropFirst().joined(separator: ".") + return "\(platform) \(version)" + } +} + +enum SimulatorError: Error, LocalizedError, Equatable { + case commandFailed(code: Int) + case parseFailed + + var errorDescription: String? { + switch self { + case .commandFailed(let code): return "xcrun simctl 실패 (코드 \(code))" + case .parseFailed: return "시뮬레이터 목록을 해석할 수 없습니다" + } + } +} + +extension SimulatorScanner { + struct DeviceEntryForTesting: Equatable { + let udid: String + let name: String + let isBooted: Bool + } + + static func parseForTesting(_ data: Data) throws -> [String: [DeviceEntryForTesting]] { + let parsed = try parseDeviceList(data) + return parsed.mapValues { entries in + entries.map { DeviceEntryForTesting(udid: $0.udid, name: $0.name, isBooted: $0.isBooted) } + } + } +} diff --git a/Storefront/Core/Simulator/SimulatorTypes.swift b/Storefront/Core/Simulator/SimulatorTypes.swift new file mode 100644 index 0000000..7f2e670 --- /dev/null +++ b/Storefront/Core/Simulator/SimulatorTypes.swift @@ -0,0 +1,27 @@ +import Foundation + +struct SimulatorDevice: Equatable, Identifiable, Sendable { + let id: String // UDID + let name: String // "iPhone 17" + let runtime: String // "iOS 18.0" + let isBooted: Bool + var apps: [SimulatorApp] = [] +} + +struct SimulatorApp: Equatable, Identifiable, Sendable { + let containerID: String // Application UUID folder name + let bundleID: String + let databases: [DatabaseFile] + + var id: String { containerID } + var displayName: String { bundleID } +} + +struct DatabaseFile: Equatable, Identifiable, Sendable, Hashable { + let url: URL + let sizeBytes: Int64 + let modifiedAt: Date + + var id: URL { url } + var displayName: String { url.lastPathComponent } +} diff --git a/Storefront/Dependencies/SimulatorClient.swift b/Storefront/Dependencies/SimulatorClient.swift new file mode 100644 index 0000000..2a2a2d4 --- /dev/null +++ b/Storefront/Dependencies/SimulatorClient.swift @@ -0,0 +1,27 @@ +import ComposableArchitecture +import Foundation + +struct SimulatorClient: Sendable { + var scan: @Sendable () async throws -> [SimulatorDevice] +} + +extension SimulatorClient: DependencyKey { + static let liveValue = SimulatorClient( + scan: { + try await Task.detached(priority: .userInitiated) { + try SimulatorScanner.scan() + }.value + } + ) + + static let testValue = SimulatorClient( + scan: unimplemented("SimulatorClient.scan", placeholder: []) + ) +} + +extension DependencyValues { + var simulator: SimulatorClient { + get { self[SimulatorClient.self] } + set { self[SimulatorClient.self] = newValue } + } +} diff --git a/Storefront/Features/App/AppFeature.swift b/Storefront/Features/App/AppFeature.swift index 946e94d..1fa77de 100644 --- a/Storefront/Features/App/AppFeature.swift +++ b/Storefront/Features/App/AppFeature.swift @@ -7,16 +7,19 @@ struct AppFeature { struct State: Equatable { var isFileImporterPresented: Bool = false var browser: BrowserFeature.State? + var simulatorPicker: SimulatorPickerFeature.State? } enum Action: BindableAction { case binding(BindingAction) case openButtonTapped + case simulatorButtonTapped case reloadMenuSelected case fileImported(URL) case fileImportFailed(String) case closeDocument case browser(BrowserFeature.Action) + case simulatorPicker(SimulatorPickerFeature.Action) } var body: some ReducerOf { @@ -31,12 +34,17 @@ struct AppFeature { state.isFileImporterPresented = true return .none + case .simulatorButtonTapped: + state.simulatorPicker = SimulatorPickerFeature.State() + return .none + case .reloadMenuSelected: guard state.browser != nil else { return .none } return .send(.browser(.refreshRequested)) case let .fileImported(url): state.isFileImporterPresented = false + state.simulatorPicker = nil state.browser = BrowserFeature.State(databaseURL: url) return .none @@ -48,12 +56,22 @@ struct AppFeature { state.browser = nil return .none - case .browser: + case .simulatorPicker(.databasePicked(let url)): + return .send(.fileImported(url)) + + case .simulatorPicker(.dismissTapped): + state.simulatorPicker = nil + return .none + + case .browser, .simulatorPicker: return .none } } .ifLet(\.browser, action: \.browser) { BrowserFeature() } + .ifLet(\.simulatorPicker, action: \.simulatorPicker) { + SimulatorPickerFeature() + } } } diff --git a/Storefront/Features/App/AppView.swift b/Storefront/Features/App/AppView.swift index e6dad69..731304c 100644 --- a/Storefront/Features/App/AppView.swift +++ b/Storefront/Features/App/AppView.swift @@ -25,6 +25,18 @@ struct AppView: View { store.send(.fileImportFailed(error.localizedDescription)) } } + .sheet( + isPresented: Binding( + get: { store.simulatorPicker != nil }, + set: { new in + if !new { store.send(.simulatorPicker(.dismissTapped)) } + } + ) + ) { + if let pickerStore = store.scope(state: \.simulatorPicker, action: \.simulatorPicker) { + SimulatorPickerView(store: pickerStore) + } + } } private static let allowedContentTypes: [UTType] = { @@ -45,22 +57,3 @@ struct AppView: View { .frame(width: 900, height: 560) .preferredColorScheme(.light) } - -#Preview("Browser — Dark") { - AppView( - store: Store( - initialState: AppFeature.State( - browser: BrowserFeature.State( - databaseURL: URL(fileURLWithPath: "/tmp/sample.sqlite"), - tables: [ - TableInfo(name: "artists", kind: .table, rowCount: 275), - TableInfo(name: "tracks", kind: .table, rowCount: 3_503) - ], - selectedTableID: "tracks" - ) - ) - ) { AppFeature() } - ) - .frame(width: 900, height: 560) - .preferredColorScheme(.dark) -} diff --git a/Storefront/Features/SimulatorPicker/SimulatorPickerFeature.swift b/Storefront/Features/SimulatorPicker/SimulatorPickerFeature.swift new file mode 100644 index 0000000..105d90c --- /dev/null +++ b/Storefront/Features/SimulatorPicker/SimulatorPickerFeature.swift @@ -0,0 +1,76 @@ +import ComposableArchitecture +import Foundation + +@Reducer +struct SimulatorPickerFeature { + @ObservableState + struct State: Equatable { + var devices: [SimulatorDevice] = [] + var isLoading: Bool = false + var errorMessage: String? + var expandedDeviceIDs: Set = [] + var expandedAppIDs: Set = [] + } + + enum Action: Equatable { + case onAppear + case refreshRequested + case scanned([SimulatorDevice]) + case scanFailed(String) + case toggleDevice(String) + case toggleApp(String) + case databasePicked(URL) + case dismissTapped + } + + @Dependency(\.simulator) var simulator + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .onAppear, .refreshRequested: + state.isLoading = true + state.errorMessage = nil + return .run { send in + do { + let devices = try await simulator.scan() + await send(.scanned(devices)) + } catch { + await send(.scanFailed(error.localizedDescription)) + } + } + + case let .scanned(devices): + state.devices = devices + state.isLoading = false + state.expandedDeviceIDs = Set(devices.filter { $0.isBooted && !$0.apps.isEmpty }.map(\.id)) + return .none + + case let .scanFailed(message): + state.errorMessage = message + state.isLoading = false + return .none + + case let .toggleDevice(id): + if state.expandedDeviceIDs.contains(id) { + state.expandedDeviceIDs.remove(id) + } else { + state.expandedDeviceIDs.insert(id) + } + return .none + + case let .toggleApp(id): + if state.expandedAppIDs.contains(id) { + state.expandedAppIDs.remove(id) + } else { + state.expandedAppIDs.insert(id) + } + return .none + + case .databasePicked, .dismissTapped: + // Handled by parent + return .none + } + } + } +} diff --git a/Storefront/Features/SimulatorPicker/SimulatorPickerView.swift b/Storefront/Features/SimulatorPicker/SimulatorPickerView.swift new file mode 100644 index 0000000..f157151 --- /dev/null +++ b/Storefront/Features/SimulatorPicker/SimulatorPickerView.swift @@ -0,0 +1,175 @@ +import ComposableArchitecture +import SwiftUI + +struct SimulatorPickerView: View { + @Bindable var store: StoreOf + + var body: some View { + VStack(spacing: 0) { + header + Divider() + content + } + .frame(minWidth: 520, minHeight: 420) + .task { store.send(.onAppear) } + } + + private var header: some View { + HStack(spacing: 10) { + Image(systemName: "iphone.gen3") + .foregroundStyle(Color("AppPrimary")) + .font(.title2) + VStack(alignment: .leading, spacing: 2) { + Text("시뮬레이터 앱 둘러보기") + .font(.headline) + Text("실행 중인 시뮬레이터의 앱 DB를 바로 엽니다") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button { + store.send(.refreshRequested) + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(store.isLoading) + .help("다시 스캔") + + Button("닫기") { store.send(.dismissTapped) } + .keyboardShortcut(.cancelAction) + } + .padding(14) + } + + @ViewBuilder + private var content: some View { + if store.isLoading && store.devices.isEmpty { + VStack(spacing: 10) { + ProgressView() + Text("시뮬레이터 스캔 중…") + .font(.callout) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = store.errorMessage { + ContentUnavailableView( + "탐색 실패", + systemImage: "exclamationmark.triangle.fill", + description: Text(error) + ) + } else if store.devices.isEmpty { + ContentUnavailableView( + "시뮬레이터가 없습니다", + systemImage: "iphone.slash", + description: Text("Xcode에서 시뮬레이터를 부팅한 뒤 새로고침하세요.") + ) + } else { + deviceList + } + } + + private var deviceList: some View { + List { + ForEach(store.devices) { device in + deviceSection(device) + } + } + .listStyle(.inset) + } + + private func deviceSection(_ device: SimulatorDevice) -> some View { + DisclosureGroup( + isExpanded: Binding( + get: { store.expandedDeviceIDs.contains(device.id) }, + set: { _ in store.send(.toggleDevice(device.id)) } + ) + ) { + if device.apps.isEmpty { + Text(device.isBooted ? "DB가 있는 앱이 없습니다" : "부팅되지 않음") + .font(.callout) + .foregroundStyle(.secondary) + .padding(.leading, 8) + } else { + ForEach(device.apps) { app in + appDisclosure(app) + } + } + } label: { + HStack { + Circle() + .fill(device.isBooted ? Color("AppAccent") : .secondary.opacity(0.3)) + .frame(width: 8, height: 8) + Text(device.name).font(.body.weight(.medium)) + Text(device.runtime) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + if !device.apps.isEmpty { + Text("\(device.apps.count) apps") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + } + + private func appDisclosure(_ app: SimulatorApp) -> some View { + DisclosureGroup( + isExpanded: Binding( + get: { store.expandedAppIDs.contains(app.id) }, + set: { _ in store.send(.toggleApp(app.id)) } + ) + ) { + ForEach(app.databases) { db in + Button { + store.send(.databasePicked(db.url)) + } label: { + HStack { + Image(systemName: db.url.pathExtension.lowercased() == "store" ? "leaf" : "cylinder.split.1x2.fill") + .foregroundStyle(Color("AppPrimary")) + VStack(alignment: .leading, spacing: 2) { + Text(db.displayName) + .font(.callout) + Text("\(byteFormatter.string(fromByteCount: db.sizeBytes)) · \(relative(db.modifiedAt))") + .font(.caption2) + .foregroundStyle(.tertiary) + } + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.vertical, 2) + } + } label: { + HStack { + Image(systemName: "app.fill") + .foregroundStyle(.secondary) + Text(app.bundleID) + .font(.callout) + Spacer() + Text("\(app.databases.count)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.12)) + .clipShape(Capsule()) + } + } + .padding(.leading, 4) + } + + private var byteFormatter: ByteCountFormatter { + let f = ByteCountFormatter() + f.allowedUnits = [.useKB, .useMB, .useGB] + f.countStyle = .file + return f + } + + private func relative(_ date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter.localizedString(for: date, relativeTo: .now) + } +} diff --git a/Storefront/Features/Welcome/WelcomeView.swift b/Storefront/Features/Welcome/WelcomeView.swift index 375347d..6802656 100644 --- a/Storefront/Features/Welcome/WelcomeView.swift +++ b/Storefront/Features/Welcome/WelcomeView.swift @@ -27,17 +27,30 @@ struct WelcomeView: View { .foregroundStyle(.secondary) } - Button { - store.send(.openButtonTapped) - } label: { - Label("파일 열기", systemImage: "tray.and.arrow.down") - .font(.headline) - .padding(.horizontal, 20) - .padding(.vertical, 10) + HStack(spacing: 12) { + Button { + store.send(.openButtonTapped) + } label: { + Label("파일 열기", systemImage: "tray.and.arrow.down") + .font(.headline) + .padding(.horizontal, 20) + .padding(.vertical, 10) + } + .buttonStyle(.borderedProminent) + .tint(Color("AppPrimary")) + .keyboardShortcut("o", modifiers: .command) + + Button { + store.send(.simulatorButtonTapped) + } label: { + Label("시뮬레이터", systemImage: "iphone.gen3") + .font(.headline) + .padding(.horizontal, 20) + .padding(.vertical, 10) + } + .buttonStyle(.bordered) + .keyboardShortcut("l", modifiers: .command) } - .buttonStyle(.borderedProminent) - .tint(Color("AppPrimary")) - .keyboardShortcut("o", modifiers: .command) Text("📦 .sqlite · .db · .store 파일을 끌어다 놓아보세요") .font(.callout) @@ -48,6 +61,11 @@ struct WelcomeView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(40) + .dropDestination(for: URL.self) { urls, _ in + guard let url = urls.first else { return false } + store.send(.fileImported(url)) + return true + } } } diff --git a/StorefrontTests/SimulatorPickerFeatureTests.swift b/StorefrontTests/SimulatorPickerFeatureTests.swift new file mode 100644 index 0000000..a239603 --- /dev/null +++ b/StorefrontTests/SimulatorPickerFeatureTests.swift @@ -0,0 +1,78 @@ +import ComposableArchitecture +import XCTest +@testable import Storefront + +@MainActor +final class SimulatorPickerFeatureTests: XCTestCase { + func testOnAppearLoadsDevicesAndAutoExpandsBootedOnes() async { + let booted = SimulatorDevice( + id: "device-1", + name: "iPhone 17", + runtime: "iOS 18.0", + isBooted: true, + apps: [ + SimulatorApp( + containerID: "app-A", + bundleID: "com.example.MyApp", + databases: [ + DatabaseFile( + url: URL(fileURLWithPath: "/tmp/a.sqlite"), + sizeBytes: 1024, + modifiedAt: Date(timeIntervalSince1970: 0) + ) + ] + ) + ] + ) + let shutdown = SimulatorDevice( + id: "device-2", + name: "iPad Air", + runtime: "iPadOS 18.0", + isBooted: false, + apps: [] + ) + + let store = TestStore(initialState: SimulatorPickerFeature.State()) { + SimulatorPickerFeature() + } withDependencies: { + $0.simulator.scan = { @Sendable in [booted, shutdown] } + } + + await store.send(.onAppear) { $0.isLoading = true } + await store.receive(\.scanned) { + $0.isLoading = false + $0.devices = [booted, shutdown] + $0.expandedDeviceIDs = ["device-1"] + } + } + + func testToggleDeviceAddsAndRemoves() async { + let store = TestStore(initialState: SimulatorPickerFeature.State()) { + SimulatorPickerFeature() + } + + await store.send(.toggleDevice("d-1")) { + $0.expandedDeviceIDs = ["d-1"] + } + await store.send(.toggleDevice("d-1")) { + $0.expandedDeviceIDs = [] + } + } + + func testParseDeviceListExtractsBootedFlag() throws { + let jsonString = """ + {"devices": { + "com.apple.CoreSimulator.SimRuntime.iOS-18-0": [ + {"udid": "A", "name": "iPhone 17", "state": "Booted"}, + {"udid": "B", "name": "iPhone 16", "state": "Shutdown"} + ] + }} + """ + let data = Data(jsonString.utf8) + let parsed = try SimulatorScanner.parseForTesting(data) + let runtime = try XCTUnwrap(parsed["com.apple.CoreSimulator.SimRuntime.iOS-18-0"]) + XCTAssertEqual(runtime.count, 2) + XCTAssertTrue(runtime.first { $0.udid == "A" }?.isBooted ?? false) + XCTAssertFalse(runtime.first { $0.udid == "B" }?.isBooted ?? true) + } +} From d24241b55a869a77a306ac373c1f5b7b2dfa21cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=AB=E1=84=8C=E1=85=AE=E1=86=AB?= Date: Thu, 16 Apr 2026 16:43:50 +0900 Subject: [PATCH 06/15] feat: SwiftData support + dynamic full-width row grid (Phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Storefront/Core/SwiftDataStore/SwiftDataDetector: Z_METADATA/Z_PRIMARYKEY/Z_MODELCACHE 2개 이상 존재 시 SwiftData로 판별, TableInfo.Classification(swiftDataEntity/swiftDataSystem/standard) 할당 - Storefront/Core/SwiftDataStore/SwiftDataDecoder: Z 접두어 테이블명 정규화 (ZTASK → Task), 컬럼명 정규화 (ZNAME → name, ZCREATEDAT → createdat), Z_PK/Z_ENT 등 시스템 컬럼은 유지 - Storefront/Core/Database/SchemaInspector: inspect(db) → DatabaseSchema(kind, tables) 통합, classify 호출로 분류 부여 - Storefront/Core/Database/RowFetcher: ColumnInfo.isSwiftDataEntity / displayName 추가 - Storefront/Dependencies/DatabaseClient: tables→inspect로 API 변경, page(url, table, offset, limit, isEntity) 시그니처 확장 - Storefront/Features/Browser/BrowserFeature: State에 databaseKind 추가, .schemaLoaded/.schemaFailedToLoad 액션, swiftDataSystem은 자동 선택 피함 - Storefront/Features/Browser/BrowserView: 사이드바 Entities/Tables/Views/System 섹션 분리, SwiftData 배지, 원본 테이블명 monospace 병기, TableRow 아이콘 분기 (leaf/gear/tablecells/eye) - Storefront/Features/Browser/DynamicRowGrid: GeometryReader로 가용 폭 측정 → 컬럼수 × 140pt ≤ 가용폭이면 flex 분배, 초과 시 horizontal scroll. LazyVStack + pinnedViews로 스크롤 시 헤더 고정. 컬럼 divider. 전체 폭 상단 정렬 - StorefrontTests/SwiftDataDecoderTests: 3건 통과 (테이블명/컬럼명 정규화, classify) - StorefrontTests/BrowserFeatureTests: 새 API 시그니처 반영 (inspect, page 6-arity) 검증: 13/13 tests pass. 실 SQLite + SwiftData 혼합 수동 QA. --- Docs/PROGRESS.md | 24 +++---- Storefront/Core/Database/RowFetcher.swift | 14 ++-- .../Core/Database/SchemaInspector.swift | 32 +++++++++- .../SwiftDataStore/SwiftDataDecoder.swift | 29 +++++++++ .../SwiftDataStore/SwiftDataDetector.swift | 35 ++++++++++ Storefront/Dependencies/DatabaseClient.swift | 20 +++--- .../Features/Browser/BrowserFeature.swift | 63 ++++++++++-------- Storefront/Features/Browser/BrowserView.swift | 64 ++++++++++++++++--- .../Features/Browser/DynamicRowGrid.swift | 60 ++++++++++++----- StorefrontTests/BrowserFeatureTests.swift | 36 ++++++----- StorefrontTests/SwiftDataDecoderTests.swift | 44 +++++++++++++ 11 files changed, 327 insertions(+), 94 deletions(-) create mode 100644 Storefront/Core/SwiftDataStore/SwiftDataDecoder.swift create mode 100644 Storefront/Core/SwiftDataStore/SwiftDataDetector.swift create mode 100644 StorefrontTests/SwiftDataDecoderTests.swift diff --git a/Docs/PROGRESS.md b/Docs/PROGRESS.md index 1af3521..0b646a0 100644 --- a/Docs/PROGRESS.md +++ b/Docs/PROGRESS.md @@ -36,7 +36,7 @@ open Storefront.xcodeproj # Xcode에서 ⌘R | **2. SQLite 파일 열기 + 테이블 리스트** | ✅ 완료 | BrowserFeature / DatabaseClient(actor registry) / SchemaInspector / NavigationSplitView 2-column + Tables/Views 섹션 + 행수 배지 | | **3. 행 뷰어 + 라이브 리로드** | ✅ 완료 | RowFetcher / DynamicRowGrid(ScrollView+HStack) / CellView(NULL/INT/REAL/TEXT/BLOB 색상) / FileWatcherClient(DispatchSource, WAL/-shm 포함) / 상단 Toast | | **4. 시뮬레이터 앱 자동 탐색** | ✅ 완료 | SimulatorScanner(simctl JSON + FS 글로빙) / SimulatorClient / SimulatorPickerFeature(DisclosureGroup 트리) / Welcome의 "시뮬레이터" 버튼(⌘L) + Welcome 드래그&드롭 | -| **5. SwiftData 스토어 지원** | ⏳ 대기 | SwiftDataDetector(Z_METADATA) / Decoder / .store 확장자 | +| **5. SwiftData 스토어 지원** | ✅ 완료 | SwiftDataDetector(Z_METADATA/Z_PRIMARYKEY 판별) + SwiftDataDecoder(Z 접두어 정규화) + TableInfo.Classification(swiftDataEntity/swiftDataSystem) + 사이드바 Entities/Tables/Views/System 섹션 분리 + 원본명 tooltip + DynamicRowGrid 전체 폭 flex-fill | | **6. DMG 빌드 파이프라인** | ⏳ 대기 | Makefile / scripts/build.sh, make-dmg.sh, ExportOptions.plist | | **7. GitHub Actions 릴리스** | ⏳ 대기 | .github/workflows/build.yml, release.yml (매크로 검증 스킵 플래그 포함) | @@ -47,21 +47,21 @@ open Storefront.xcodeproj # Xcode에서 ⌘R ## 최근 검증 (2026-04-16) -- **Phase 4 빌드/테스트**: 10/10 통과 (AppFeature 3 + BrowserFeature 4 + SimulatorPicker 3) -- 샘플 DB `/tmp/storefront-sample.sqlite` 또는 드래그&드롭으로 열기 가능 -- "시뮬레이터" 버튼(⌘L) → DisclosureGroup 트리에서 부팅된 시뮬 → 앱 → DB 원클릭 오픈 +- **Phase 5 빌드/테스트**: 13/13 통과 (AppFeature 3 + BrowserFeature 4 + SimulatorPicker 3 + SwiftDataDecoder 3) +- DynamicRowGrid: GeometryReader 기반 flex/고정 폭 자동 전환 (컬럼수 × 140pt > 가용폭이면 horizontal scroll, 아니면 균등 분배) +- LazyVStack + pinned section header로 상단 고정 (스크롤 시 헤더 유지) ## 다음 작업 시작 지점 -**Phase 5 — SwiftData 스토어 지원 (TCA)** +**Phase 6 — DMG 빌드 파이프라인 (Makefile)** 파일 생성 순서: -1. `Storefront/Core/SwiftDataStore/SwiftDataDetector.swift` — `Z_METADATA`/`Z_PRIMARYKEY` 존재 여부로 판별 -2. `Storefront/Core/SwiftDataStore/SwiftDataDecoder.swift` — 테이블명에서 `Z_` 접두 제거, 컬럼명 정규화(`ZNAME` → `name`), `Z_` 메타 테이블은 별도 섹션 -3. `DatabaseClient.tables` 응답에 `isSwiftData` 플래그 또는 별도 SwiftData 경로 추가 -4. `SchemaInspector`에 Z_ 인식 로직 연동 — 사용자 표시명 정규화 -5. `AppFeature` `fileImported`에서 `.store` 확장자 감지 → Browser에 isSwiftData=true 전달 -6. Tests: `SwiftDataDetectorTests`, `SwiftDataDecoderTests` +1. `scripts/ExportOptions.plist` — method=mac-application, signing=manual, certificate 없음 +2. `scripts/build.sh` — `xcodebuild archive` + `xcodebuild -exportArchive` (ad-hoc 서명 `codesign --sign -`) +3. `scripts/make-dmg.sh` — `create-dmg` (Applications 심볼릭 링크 포함) 또는 `hdiutil create -format UDZO` +4. `scripts/make-icon.sh` — SF Symbol → 1024 PNG → sips로 전 해상도 산출 +5. `Makefile` — build/test/archive/dmg/clean/icon 타겟. 필수 flag: `-skipMacroValidation -skipPackagePluginValidation` +6. 로컬 `make dmg` 검증 — `build/Storefront.dmg` 생성 + Applications 드롭존 확인 ## 저장소 상태 @@ -69,4 +69,4 @@ open Storefront.xcodeproj # Xcode에서 ⌘R - Visibility: **Private** (v0.1.0 릴리스 전까지) - Default branch: `master` - Active branch: `feat/mvp-v0.1.0` -- Last commit on feat/mvp-v0.1.0: Phase 4 완료 (시뮬레이터 탐색 + 드래그&드롭) +- Last commit on feat/mvp-v0.1.0: Phase 5 완료 (SwiftData 지원 + 그리드 flex 레이아웃) diff --git a/Storefront/Core/Database/RowFetcher.swift b/Storefront/Core/Database/RowFetcher.swift index 9cf12ec..7b767e2 100644 --- a/Storefront/Core/Database/RowFetcher.swift +++ b/Storefront/Core/Database/RowFetcher.swift @@ -6,8 +6,13 @@ struct ColumnInfo: Equatable, Identifiable, Sendable { let declaredType: String let isPrimaryKey: Bool let isNotNull: Bool + let isSwiftDataEntity: Bool var id: String { name } + + var displayName: String { + isSwiftDataEntity ? SwiftDataDecoder.normalize(columnName: name) : name + } } enum DBValue: Equatable, Sendable, Hashable { @@ -48,7 +53,7 @@ struct RowPage: Equatable, Sendable { } enum RowFetcher { - static func columns(_ db: Database, table: String) throws -> [ColumnInfo] { + static func columns(_ db: Database, table: String, isSwiftDataEntity: Bool = false) throws -> [ColumnInfo] { let escaped = table.replacingOccurrences(of: "\"", with: "\"\"") let rows = try Row.fetchAll(db, sql: "PRAGMA table_info(\"\(escaped)\")") return rows.map { row in @@ -56,13 +61,14 @@ enum RowFetcher { name: row["name"], declaredType: (row["type"] as String?) ?? "", isPrimaryKey: ((row["pk"] as Int?) ?? 0) > 0, - isNotNull: ((row["notnull"] as Int?) ?? 0) != 0 + isNotNull: ((row["notnull"] as Int?) ?? 0) != 0, + isSwiftDataEntity: isSwiftDataEntity ) } } - static func page(_ db: Database, table: String, offset: Int, limit: Int) throws -> RowPage { - let columns = try columns(db, table: table) + static func page(_ db: Database, table: String, offset: Int, limit: Int, isSwiftDataEntity: Bool = false) throws -> RowPage { + let columns = try columns(db, table: table, isSwiftDataEntity: isSwiftDataEntity) let escaped = table.replacingOccurrences(of: "\"", with: "\"\"") let total = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM \"\(escaped)\"") ?? 0 diff --git a/Storefront/Core/Database/SchemaInspector.swift b/Storefront/Core/Database/SchemaInspector.swift index a7b9f6d..35464ac 100644 --- a/Storefront/Core/Database/SchemaInspector.swift +++ b/Storefront/Core/Database/SchemaInspector.swift @@ -5,17 +5,42 @@ struct TableInfo: Equatable, Identifiable, Sendable { let name: String let kind: Kind let rowCount: Int + let classification: Classification var id: String { name } + var displayName: String { + switch classification { + case .swiftDataEntity: return SwiftDataDecoder.normalize(tableName: name) + default: return name + } + } + enum Kind: String, Sendable, Equatable { case table case view } + + enum Classification: Sendable, Equatable { + case standard + case swiftDataEntity + case swiftDataSystem + } +} + +struct DatabaseSchema: Equatable, Sendable { + let kind: DatabaseKind + let tables: [TableInfo] } enum SchemaInspector { - static func listTables(_ db: Database) throws -> [TableInfo] { + static func inspect(_ db: Database) throws -> DatabaseSchema { + let kind = try SwiftDataDetector.kind(db) + let tables = try listTables(db, kind: kind) + return DatabaseSchema(kind: kind, tables: tables) + } + + static func listTables(_ db: Database, kind: DatabaseKind = .standard) throws -> [TableInfo] { let metaRows = try Row.fetchAll(db, sql: """ SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') @@ -26,10 +51,11 @@ enum SchemaInspector { return metaRows.map { row in let name: String = row["name"] let type: String = row["type"] - let kind: TableInfo.Kind = (type == "view") ? .view : .table + let tkind: TableInfo.Kind = (type == "view") ? .view : .table let escaped = name.replacingOccurrences(of: "\"", with: "\"\"") let count = (try? Int.fetchOne(db, sql: "SELECT COUNT(*) FROM \"\(escaped)\"")) ?? 0 - return TableInfo(name: name, kind: kind, rowCount: count) + let classification = SwiftDataDetector.classify(tableName: name, kind: kind) + return TableInfo(name: name, kind: tkind, rowCount: count, classification: classification) } } } diff --git a/Storefront/Core/SwiftDataStore/SwiftDataDecoder.swift b/Storefront/Core/SwiftDataStore/SwiftDataDecoder.swift new file mode 100644 index 0000000..cca38b6 --- /dev/null +++ b/Storefront/Core/SwiftDataStore/SwiftDataDecoder.swift @@ -0,0 +1,29 @@ +import Foundation + +enum SwiftDataDecoder { + /// SwiftData 엔티티 테이블명에서 `Z` 접두어를 제거한다. 예: `ZTASK` → `Task`. + static func normalize(tableName: String) -> String { + guard tableName.hasPrefix("Z"), tableName.count > 1 else { return tableName } + let withoutPrefix = tableName.dropFirst() + let lower = withoutPrefix.lowercased() + guard let first = lower.first else { return tableName } + return first.uppercased() + lower.dropFirst() + } + + /// SwiftData 엔티티 컬럼명에서 `Z` 접두어를 제거한다. 예: `ZNAME` → `name`, `ZCREATEDAT` → `createdAt`. + /// SwiftData 내부 컬럼(Z_PK, Z_ENT, Z_OPT 등)은 정규화하지 않고 그대로 반환. + static func normalize(columnName: String) -> String { + let systemPrefixes = ["Z_PK", "Z_ENT", "Z_OPT", "Z_NAME", "Z_SUPER", "Z_MAX"] + if systemPrefixes.contains(columnName) { return columnName } + if columnName.hasPrefix("Z_") { return columnName } + guard columnName.hasPrefix("Z"), columnName.count > 1 else { return columnName } + + let rest = String(columnName.dropFirst()) + // ZCREATEDAT → createdAt (best-effort heuristic for all-caps legacy SwiftData columns) + if rest == rest.uppercased() { + return rest.lowercased() + } + // Already mixed case (e.g. ZcreatedAt) — just lowercase first char + return rest.prefix(1).lowercased() + rest.dropFirst() + } +} diff --git a/Storefront/Core/SwiftDataStore/SwiftDataDetector.swift b/Storefront/Core/SwiftDataStore/SwiftDataDetector.swift new file mode 100644 index 0000000..b0fd8ba --- /dev/null +++ b/Storefront/Core/SwiftDataStore/SwiftDataDetector.swift @@ -0,0 +1,35 @@ +import Foundation +import GRDB + +enum DatabaseKind: Equatable, Sendable { + case standard + case swiftData +} + +enum SwiftDataDetector { + static func kind(_ db: Database) throws -> DatabaseKind { + let count = try Int.fetchOne( + db, + sql: """ + SELECT COUNT(*) FROM sqlite_master + WHERE type = 'table' + AND name IN ('Z_METADATA', 'Z_PRIMARYKEY', 'Z_MODELCACHE') + """ + ) ?? 0 + return count >= 2 ? .swiftData : .standard + } + + static func classify(tableName: String, kind: DatabaseKind) -> TableInfo.Classification { + guard kind == .swiftData else { return .standard } + let systemNames: Set = ["Z_METADATA", "Z_PRIMARYKEY", "Z_MODELCACHE"] + if systemNames.contains(tableName) { return .swiftDataSystem } + if tableName.hasPrefix("Z_") { return .swiftDataSystem } + if tableName.hasPrefix("Z"), tableName.count > 1 { + let second = tableName.index(after: tableName.startIndex) + if tableName[second].isLetter, tableName[second].isUppercase { + return .swiftDataEntity + } + } + return .standard + } +} diff --git a/Storefront/Dependencies/DatabaseClient.swift b/Storefront/Dependencies/DatabaseClient.swift index a8885f3..5bfca21 100644 --- a/Storefront/Dependencies/DatabaseClient.swift +++ b/Storefront/Dependencies/DatabaseClient.swift @@ -3,8 +3,8 @@ import Foundation import GRDB struct DatabaseClient: Sendable { - var tables: @Sendable (URL) async throws -> [TableInfo] - var page: @Sendable (_ url: URL, _ table: String, _ offset: Int, _ limit: Int) async throws -> RowPage + var inspect: @Sendable (URL) async throws -> DatabaseSchema + var page: @Sendable (_ url: URL, _ table: String, _ offset: Int, _ limit: Int, _ isSwiftDataEntity: Bool) async throws -> RowPage var close: @Sendable (URL) async -> Void } @@ -12,16 +12,16 @@ extension DatabaseClient: DependencyKey { static let liveValue: DatabaseClient = { let registry = DatabaseRegistry() return DatabaseClient( - tables: { url in try await registry.tables(for: url) }, - page: { url, table, offset, limit in - try await registry.page(url: url, table: table, offset: offset, limit: limit) + inspect: { url in try await registry.inspect(url: url) }, + page: { url, table, offset, limit, isEntity in + try await registry.page(url: url, table: table, offset: offset, limit: limit, isEntity: isEntity) }, close: { url in await registry.close(url) } ) }() static let testValue = DatabaseClient( - tables: unimplemented("DatabaseClient.tables"), + inspect: unimplemented("DatabaseClient.inspect"), page: unimplemented("DatabaseClient.page"), close: unimplemented("DatabaseClient.close") ) @@ -46,17 +46,17 @@ private actor DatabaseRegistry { return q } - func tables(for url: URL) async throws -> [TableInfo] { + func inspect(url: URL) async throws -> DatabaseSchema { let q = try queue(for: url) return try await q.read { db in - try SchemaInspector.listTables(db) + try SchemaInspector.inspect(db) } } - func page(url: URL, table: String, offset: Int, limit: Int) async throws -> RowPage { + func page(url: URL, table: String, offset: Int, limit: Int, isEntity: Bool) async throws -> RowPage { let q = try queue(for: url) return try await q.read { db in - try RowFetcher.page(db, table: table, offset: offset, limit: limit) + try RowFetcher.page(db, table: table, offset: offset, limit: limit, isSwiftDataEntity: isEntity) } } diff --git a/Storefront/Features/Browser/BrowserFeature.swift b/Storefront/Features/Browser/BrowserFeature.swift index e209924..cec9416 100644 --- a/Storefront/Features/Browser/BrowserFeature.swift +++ b/Storefront/Features/Browser/BrowserFeature.swift @@ -6,6 +6,7 @@ struct BrowserFeature { @ObservableState struct State: Equatable { let databaseURL: URL + var databaseKind: DatabaseKind = .standard var tables: [TableInfo] = [] var selectedTableID: TableInfo.ID? var isLoading: Bool = false @@ -22,8 +23,8 @@ struct BrowserFeature { case onAppear case onDisappear case refreshRequested - case tablesLoaded([TableInfo]) - case tablesFailedToLoad(String) + case schemaLoaded(DatabaseSchema) + case schemaFailedToLoad(String) case tableSelected(TableInfo.ID?) case rowsLoaded(RowPage) case rowsFailedToLoad(String) @@ -48,7 +49,7 @@ struct BrowserFeature { state.loadErrorMessage = nil let url = state.databaseURL return .merge( - loadTables(url: url), + loadSchema(url: url), .run { send in for await _ in fileWatcher.changes(url) { await send(.fileChanged) @@ -64,30 +65,33 @@ struct BrowserFeature { state.isLoading = true state.loadErrorMessage = nil let url = state.databaseURL - let selected = state.selectedTableID + let selected = resolveSelectedTable(in: state) return .merge( - loadTables(url: url), - selected.map { loadRows(url: url, table: $0) } ?? .none + loadSchema(url: url), + selected.map { loadRows(url: url, table: $0.name, isEntity: $0.classification == .swiftDataEntity) } ?? .none ) - case let .tablesLoaded(tables): - state.tables = tables + case let .schemaLoaded(schema): + state.tables = schema.tables + state.databaseKind = schema.kind state.isLoading = false - let autoSelected: String? - if let current = state.selectedTableID, tables.contains(where: { $0.id == current }) { - autoSelected = current + let autoSelectedID: String? + if let current = state.selectedTableID, schema.tables.contains(where: { $0.id == current }) { + autoSelectedID = current } else { - autoSelected = tables.first?.id + // Prefer non-system tables on SwiftData stores + autoSelectedID = schema.tables.first(where: { $0.classification != .swiftDataSystem })?.id + ?? schema.tables.first?.id } - state.selectedTableID = autoSelected - if let table = autoSelected { + state.selectedTableID = autoSelectedID + if let id = autoSelectedID, let table = schema.tables.first(where: { $0.id == id }) { state.isLoadingRows = true state.rowLoadError = nil - return loadRows(url: state.databaseURL, table: table) + return loadRows(url: state.databaseURL, table: table.name, isEntity: table.classification == .swiftDataEntity) } return .none - case let .tablesFailedToLoad(message): + case let .schemaFailedToLoad(message): state.loadErrorMessage = message state.isLoading = false return .none @@ -96,9 +100,9 @@ struct BrowserFeature { state.selectedTableID = id state.currentPage = nil state.rowLoadError = nil - guard let id else { return .none } + guard let id, let table = state.tables.first(where: { $0.id == id }) else { return .none } state.isLoadingRows = true - return loadRows(url: state.databaseURL, table: id) + return loadRows(url: state.databaseURL, table: table.name, isEntity: table.classification == .swiftDataEntity) case let .rowsLoaded(page): state.currentPage = page @@ -114,30 +118,35 @@ struct BrowserFeature { case .fileChanged: state.liveReloadToast &+= 1 let url = state.databaseURL - let selected = state.selectedTableID + let selected = resolveSelectedTable(in: state) return .merge( - loadTables(url: url), - selected.map { loadRows(url: url, table: $0) } ?? .none + loadSchema(url: url), + selected.map { loadRows(url: url, table: $0.name, isEntity: $0.classification == .swiftDataEntity) } ?? .none ) } } } - private func loadTables(url: URL) -> Effect { + private func resolveSelectedTable(in state: State) -> TableInfo? { + guard let id = state.selectedTableID else { return nil } + return state.tables.first { $0.id == id } + } + + private func loadSchema(url: URL) -> Effect { .run { send in do { - let tables = try await database.tables(url) - await send(.tablesLoaded(tables)) + let schema = try await database.inspect(url) + await send(.schemaLoaded(schema)) } catch { - await send(.tablesFailedToLoad(error.localizedDescription)) + await send(.schemaFailedToLoad(error.localizedDescription)) } } } - private func loadRows(url: URL, table: String) -> Effect { + private func loadRows(url: URL, table: String, isEntity: Bool) -> Effect { .run { [pageSize] send in do { - let page = try await database.page(url, table, 0, pageSize) + let page = try await database.page(url, table, 0, pageSize, isEntity) await send(.rowsLoaded(page)) } catch { await send(.rowsFailedToLoad(error.localizedDescription)) diff --git a/Storefront/Features/Browser/BrowserView.swift b/Storefront/Features/Browser/BrowserView.swift index c0b3794..1b4eb8b 100644 --- a/Storefront/Features/Browser/BrowserView.swift +++ b/Storefront/Features/Browser/BrowserView.swift @@ -48,17 +48,33 @@ struct BrowserView: View { } private var tableList: some View { - let tables = store.tables.filter { $0.kind == .table } + let entities = store.tables.filter { $0.classification == .swiftDataEntity } + let standardTables = store.tables.filter { $0.kind == .table && $0.classification == .standard } let views = store.tables.filter { $0.kind == .view } + let systemTables = store.tables.filter { $0.classification == .swiftDataSystem } let selection = Binding( get: { store.selectedTableID }, set: { store.send(.tableSelected($0)) } ) return List(selection: selection) { - if !tables.isEmpty { + if store.databaseKind == .swiftData { + Label("SwiftData Store", systemImage: "leaf.fill") + .font(.caption.weight(.semibold)) + .foregroundStyle(Color("AppAccent")) + .listRowSeparator(.hidden) + .padding(.bottom, 4) + } + if !entities.isEmpty { + Section("Entities") { + ForEach(entities) { table in + TableRow(table: table).tag(Optional(table.id)) + } + } + } + if !standardTables.isEmpty { Section("Tables") { - ForEach(tables) { table in + ForEach(standardTables) { table in TableRow(table: table).tag(Optional(table.id)) } } @@ -70,6 +86,13 @@ struct BrowserView: View { } } } + if !systemTables.isEmpty { + Section("System") { + ForEach(systemTables) { table in + TableRow(table: table).tag(Optional(table.id)) + } + } + } } .listStyle(.sidebar) } @@ -102,9 +125,15 @@ struct BrowserView: View { } private func detailToolbar(page: RowPage) -> some View { - HStack(spacing: 12) { - Text(store.selectedTableID ?? "") + let selectedTable = store.tables.first { $0.id == store.selectedTableID } + return HStack(spacing: 12) { + Text(selectedTable?.displayName ?? store.selectedTableID ?? "") .font(.headline) + if let t = selectedTable, t.displayName != t.name { + Text(t.name) + .font(.caption.monospaced()) + .foregroundStyle(.tertiary) + } Text("\(page.totalRows.formatted()) rows · \(page.columns.count) columns") .font(.callout.monospacedDigit()) .foregroundStyle(.secondary) @@ -161,11 +190,30 @@ struct BrowserView: View { private struct TableRow: View { let table: TableInfo + private var iconName: String { + switch table.classification { + case .swiftDataEntity: return "leaf" + case .swiftDataSystem: return "gear" + case .standard: return table.kind == .view ? "eye" : "tablecells" + } + } + var body: some View { HStack { - Image(systemName: table.kind == .view ? "eye" : "tablecells") - .foregroundStyle(Color("AppPrimary")) - Text(table.name) + Image(systemName: iconName) + .foregroundStyle( + table.classification == .swiftDataEntity + ? Color("AppAccent") + : Color("AppPrimary") + ) + VStack(alignment: .leading, spacing: 1) { + Text(table.displayName) + if table.displayName != table.name { + Text(table.name) + .font(.caption2.monospaced()) + .foregroundStyle(.tertiary) + } + } Spacer() Text("\(table.rowCount.formatted())") .font(.caption.monospacedDigit()) diff --git a/Storefront/Features/Browser/DynamicRowGrid.swift b/Storefront/Features/Browser/DynamicRowGrid.swift index 6d21ca0..8e017aa 100644 --- a/Storefront/Features/Browser/DynamicRowGrid.swift +++ b/Storefront/Features/Browser/DynamicRowGrid.swift @@ -3,22 +3,30 @@ import SwiftUI struct DynamicRowGrid: View { let page: RowPage - private static let minColumnWidth: CGFloat = 120 + private static let minColumnWidth: CGFloat = 140 var body: some View { - ScrollView([.horizontal, .vertical]) { - VStack(spacing: 0) { - header - Divider() - ForEach(Array(page.rows.enumerated()), id: \.element.id) { idx, row in - rowView(row: row, zebra: idx.isMultiple(of: 2)) - Divider().opacity(0.3) + GeometryReader { geo in + let columnCount = max(1, page.columns.count) + let flexWidth = geo.size.width / CGFloat(columnCount) + let columnWidth = max(Self.minColumnWidth, flexWidth) + let totalWidth = columnWidth * CGFloat(columnCount) + + ScrollView([.vertical, .horizontal]) { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + Section(header: header(columnWidth: columnWidth, totalWidth: totalWidth)) { + ForEach(Array(page.rows.enumerated()), id: \.element.id) { idx, row in + rowView(row: row, zebra: idx.isMultiple(of: 2), columnWidth: columnWidth, totalWidth: totalWidth) + Divider().opacity(0.25) + } + } } + .frame(minWidth: geo.size.width, alignment: .leading) } } } - private var header: some View { + private func header(columnWidth: CGFloat, totalWidth: CGFloat) -> some View { HStack(spacing: 0) { ForEach(page.columns) { column in HStack(spacing: 4) { @@ -27,8 +35,17 @@ struct DynamicRowGrid: View { .font(.caption2) .foregroundStyle(Color("AppPrimary")) } - Text(column.name) - .font(.system(.subheadline, design: .default).weight(.semibold)) + VStack(alignment: .leading, spacing: 1) { + Text(column.displayName) + .font(.system(.subheadline, design: .default).weight(.semibold)) + .lineLimit(1) + if column.displayName != column.name { + Text(column.name) + .font(.caption2.monospaced()) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } if !column.declaredType.isEmpty { Text(column.declaredType) .font(.caption2) @@ -38,22 +55,35 @@ struct DynamicRowGrid: View { } .padding(.horizontal, 12) .padding(.vertical, 8) - .frame(minWidth: Self.minColumnWidth, alignment: .leading) + .frame(width: columnWidth, alignment: .leading) + + if column.id != page.columns.last?.id { + Divider() + } } } - .background(Color.secondary.opacity(0.08)) + .frame(width: totalWidth, alignment: .leading) + .background(.regularMaterial) + .overlay(alignment: .bottom) { + Divider() + } } - private func rowView(row: RowSnapshot, zebra: Bool) -> some View { + private func rowView(row: RowSnapshot, zebra: Bool, columnWidth: CGFloat, totalWidth: CGFloat) -> some View { HStack(spacing: 0) { ForEach(Array(page.columns.enumerated()), id: \.element.id) { idx, column in let value = idx < row.values.count ? row.values[idx] : .null CellView(value: value) .padding(.horizontal, 12) .padding(.vertical, 6) - .frame(minWidth: Self.minColumnWidth, alignment: columnAlignment(for: value)) + .frame(width: columnWidth, alignment: columnAlignment(for: value)) + + if column.id != page.columns.last?.id { + Divider().opacity(0.3) + } } } + .frame(width: totalWidth, alignment: .leading) .background(zebra ? Color.secondary.opacity(0.04) : Color.clear) } diff --git a/StorefrontTests/BrowserFeatureTests.swift b/StorefrontTests/BrowserFeatureTests.swift index e9754cd..76ad863 100644 --- a/StorefrontTests/BrowserFeatureTests.swift +++ b/StorefrontTests/BrowserFeatureTests.swift @@ -6,11 +6,11 @@ import XCTest final class BrowserFeatureTests: XCTestCase { func testOnAppearLoadsTablesAndAutoSelectsFirst() async { let sampleTables = [ - TableInfo(name: "artists", kind: .table, rowCount: 275), - TableInfo(name: "tracks", kind: .table, rowCount: 3_503) + TableInfo(name: "artists", kind: .table, rowCount: 275, classification: .standard), + TableInfo(name: "tracks", kind: .table, rowCount: 3_503, classification: .standard) ] let samplePage = RowPage( - columns: [ColumnInfo(name: "id", declaredType: "INTEGER", isPrimaryKey: true, isNotNull: true)], + columns: [ColumnInfo(name: "id", declaredType: "INTEGER", isPrimaryKey: true, isNotNull: true, isSwiftDataEntity: false)], rows: [RowSnapshot(index: 0, values: [.integer(1)])], totalRows: 1, offset: 0, @@ -23,17 +23,18 @@ final class BrowserFeatureTests: XCTestCase { ) { BrowserFeature() } withDependencies: { - $0.database.tables = { @Sendable _ in sampleTables } - $0.database.page = { @Sendable _, _, _, _ in samplePage } + $0.database.inspect = { @Sendable _ in DatabaseSchema(kind: .standard, tables: sampleTables) } + $0.database.page = { @Sendable _, _, _, _, _ in samplePage } $0.database.close = { @Sendable _ in } $0.fileWatcher.changes = { @Sendable _ in AsyncStream { _ in } } } store.exhaustivity = .off await store.send(.onAppear) { $0.isLoading = true } - await store.receive(\.tablesLoaded) { + await store.receive(\.schemaLoaded) { $0.isLoading = false $0.tables = sampleTables + $0.databaseKind = .standard $0.selectedTableID = "artists" $0.isLoadingRows = true } @@ -55,8 +56,8 @@ final class BrowserFeatureTests: XCTestCase { ) { BrowserFeature() } withDependencies: { - $0.database.tables = { @Sendable _ in throw SampleError() } - $0.database.page = { @Sendable _, _, _, _ in + $0.database.inspect = { @Sendable _ in throw SampleError() } + $0.database.page = { @Sendable _, _, _, _, _ in throw SampleError() } $0.database.close = { @Sendable _ in } @@ -65,7 +66,7 @@ final class BrowserFeatureTests: XCTestCase { store.exhaustivity = .off await store.send(.onAppear) { $0.isLoading = true } - await store.receive(\.tablesFailedToLoad) { + await store.receive(\.schemaFailedToLoad) { $0.isLoading = false $0.loadErrorMessage = "disk corrupted" } @@ -74,7 +75,7 @@ final class BrowserFeatureTests: XCTestCase { func testTableSelectionTriggersRowLoad() async { let samplePage = RowPage( - columns: [ColumnInfo(name: "id", declaredType: "INTEGER", isPrimaryKey: true, isNotNull: true)], + columns: [ColumnInfo(name: "id", declaredType: "INTEGER", isPrimaryKey: true, isNotNull: true, isSwiftDataEntity: false)], rows: [RowSnapshot(index: 0, values: [.integer(42)])], totalRows: 1, offset: 0, @@ -85,13 +86,13 @@ final class BrowserFeatureTests: XCTestCase { let store = TestStore( initialState: BrowserFeature.State( databaseURL: url, - tables: [TableInfo(name: "a", kind: .table, rowCount: 1)], + tables: [TableInfo(name: "a", kind: .table, rowCount: 1, classification: .standard)], selectedTableID: nil ) ) { BrowserFeature() } withDependencies: { - $0.database.page = { @Sendable _, _, _, _ in samplePage } + $0.database.page = { @Sendable _, _, _, _, _ in samplePage } } await store.send(.tableSelected("a")) { @@ -109,14 +110,19 @@ final class BrowserFeatureTests: XCTestCase { let store = TestStore( initialState: BrowserFeature.State( databaseURL: url, - tables: [TableInfo(name: "a", kind: .table, rowCount: 1)], + tables: [TableInfo(name: "a", kind: .table, rowCount: 1, classification: .standard)], selectedTableID: "a" ) ) { BrowserFeature() } withDependencies: { - $0.database.tables = { @Sendable _ in [TableInfo(name: "a", kind: .table, rowCount: 2)] } - $0.database.page = { @Sendable _, _, _, _ in + $0.database.inspect = { @Sendable _ in + DatabaseSchema( + kind: .standard, + tables: [TableInfo(name: "a", kind: .table, rowCount: 2, classification: .standard)] + ) + } + $0.database.page = { @Sendable _, _, _, _, _ in RowPage(columns: [], rows: [], totalRows: 0, offset: 0, limit: 200) } } diff --git a/StorefrontTests/SwiftDataDecoderTests.swift b/StorefrontTests/SwiftDataDecoderTests.swift new file mode 100644 index 0000000..69019b3 --- /dev/null +++ b/StorefrontTests/SwiftDataDecoderTests.swift @@ -0,0 +1,44 @@ +import XCTest +@testable import Storefront + +final class SwiftDataDecoderTests: XCTestCase { + func testNormalizeTableName() { + XCTAssertEqual(SwiftDataDecoder.normalize(tableName: "ZTASK"), "Task") + XCTAssertEqual(SwiftDataDecoder.normalize(tableName: "ZUSER"), "User") + XCTAssertEqual(SwiftDataDecoder.normalize(tableName: "ZCALENDAREVENT"), "Calendarevent") + XCTAssertEqual(SwiftDataDecoder.normalize(tableName: "users"), "users") + XCTAssertEqual(SwiftDataDecoder.normalize(tableName: "Z"), "Z") + } + + func testNormalizeColumnName() { + XCTAssertEqual(SwiftDataDecoder.normalize(columnName: "ZNAME"), "name") + XCTAssertEqual(SwiftDataDecoder.normalize(columnName: "ZCREATEDAT"), "createdat") + XCTAssertEqual(SwiftDataDecoder.normalize(columnName: "ZcreatedAt"), "createdAt") + XCTAssertEqual(SwiftDataDecoder.normalize(columnName: "Z_PK"), "Z_PK") + XCTAssertEqual(SwiftDataDecoder.normalize(columnName: "Z_ENT"), "Z_ENT") + XCTAssertEqual(SwiftDataDecoder.normalize(columnName: "regular"), "regular") + } + + func testClassifyTableName() { + XCTAssertEqual( + SwiftDataDetector.classify(tableName: "ZTASK", kind: .swiftData), + .swiftDataEntity + ) + XCTAssertEqual( + SwiftDataDetector.classify(tableName: "Z_METADATA", kind: .swiftData), + .swiftDataSystem + ) + XCTAssertEqual( + SwiftDataDetector.classify(tableName: "Z_PRIMARYKEY", kind: .swiftData), + .swiftDataSystem + ) + XCTAssertEqual( + SwiftDataDetector.classify(tableName: "users", kind: .swiftData), + .standard + ) + XCTAssertEqual( + SwiftDataDetector.classify(tableName: "ZTASK", kind: .standard), + .standard + ) + } +} From 0f03b802d35fbe27e97079bc4931bd7fe3bbbd91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=AB=E1=84=8C=E1=85=AE=E1=86=AB?= Date: Thu, 16 Apr 2026 16:50:08 +0900 Subject: [PATCH 07/15] feat: DMG build pipeline (Phase 6) + grid top-align left-align fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 — DMG 빌드 파이프라인 - scripts/ExportOptions.plist: method=mac-application, signingStyle=manual (서명 없음) - scripts/build.sh: xcodebuild archive + exportArchive + ad-hoc codesign("-"). -skipMacroValidation/-skipPackagePluginValidation 포함 - scripts/make-dmg.sh: create-dmg(Applications 드롭존, 레이아웃 180/460) 우선, hdiutil UDZO fallback. PlistBuddy로 버전 추출 - scripts/make-icon.sh: Python Pillow로 Sky→Orange 그라디언트 + 라운드 마스크 + "S" 로고 1024px → sips로 AppIcon.appiconset 전 해상도 산출 - Makefile: help/setup/generate/build/test/archive/dmg/icon/clean 타겟 - 로컬 `make dmg` 검증 → build/Storefront-0.1.0.dmg 4.8MB 생성 완료 그리드 UX 개선 - DynamicRowGrid: ScrollView 내부 LazyVStack을 .topLeading 정렬 + maxHeight:.infinity alignment:.top으로 최상단 고정 (이전엔 짧은 데이터면 세로 가운데로 밀림) - 행 셀을 HStack(CellView + Spacer)로 감싸 전체 좌측 정렬 — 숫자도 더 이상 오른쪽 정렬 아님 - CellView: INTEGER/REAL의 .frame(maxWidth: .infinity, alignment: .trailing) 제거. 색상/monospace는 유지 --- Docs/PROGRESS.md | 16 ++- Makefile | 38 ++++++ .../Features/Browser/DynamicRowGrid.swift | 26 ++--- Storefront/UI/CellView.swift | 2 - scripts/ExportOptions.plist | 12 ++ scripts/build.sh | 61 ++++++++++ scripts/make-dmg.sh | 62 ++++++++++ scripts/make-icon.sh | 109 ++++++++++++++++++ 8 files changed, 302 insertions(+), 24 deletions(-) create mode 100644 Makefile create mode 100644 scripts/ExportOptions.plist create mode 100755 scripts/build.sh create mode 100755 scripts/make-dmg.sh create mode 100755 scripts/make-icon.sh diff --git a/Docs/PROGRESS.md b/Docs/PROGRESS.md index 0b646a0..ed4c965 100644 --- a/Docs/PROGRESS.md +++ b/Docs/PROGRESS.md @@ -37,7 +37,7 @@ open Storefront.xcodeproj # Xcode에서 ⌘R | **3. 행 뷰어 + 라이브 리로드** | ✅ 완료 | RowFetcher / DynamicRowGrid(ScrollView+HStack) / CellView(NULL/INT/REAL/TEXT/BLOB 색상) / FileWatcherClient(DispatchSource, WAL/-shm 포함) / 상단 Toast | | **4. 시뮬레이터 앱 자동 탐색** | ✅ 완료 | SimulatorScanner(simctl JSON + FS 글로빙) / SimulatorClient / SimulatorPickerFeature(DisclosureGroup 트리) / Welcome의 "시뮬레이터" 버튼(⌘L) + Welcome 드래그&드롭 | | **5. SwiftData 스토어 지원** | ✅ 완료 | SwiftDataDetector(Z_METADATA/Z_PRIMARYKEY 판별) + SwiftDataDecoder(Z 접두어 정규화) + TableInfo.Classification(swiftDataEntity/swiftDataSystem) + 사이드바 Entities/Tables/Views/System 섹션 분리 + 원본명 tooltip + DynamicRowGrid 전체 폭 flex-fill | -| **6. DMG 빌드 파이프라인** | ⏳ 대기 | Makefile / scripts/build.sh, make-dmg.sh, ExportOptions.plist | +| **6. DMG 빌드 파이프라인** | ✅ 완료 | Makefile(setup/generate/build/test/archive/dmg/icon/clean) + scripts/build.sh (xcodebuild archive + exportArchive + ad-hoc codesign) + scripts/make-dmg.sh (create-dmg 우선, hdiutil fallback) + scripts/make-icon.sh (sips로 전 해상도) + scripts/ExportOptions.plist. `make dmg` → build/Storefront-0.1.0.dmg 4.8MB 생성 검증됨 | | **7. GitHub Actions 릴리스** | ⏳ 대기 | .github/workflows/build.yml, release.yml (매크로 검증 스킵 플래그 포함) | ## 설계 참조 @@ -53,15 +53,13 @@ open Storefront.xcodeproj # Xcode에서 ⌘R ## 다음 작업 시작 지점 -**Phase 6 — DMG 빌드 파이프라인 (Makefile)** +**Phase 7 — GitHub Actions 릴리스** 파일 생성 순서: -1. `scripts/ExportOptions.plist` — method=mac-application, signing=manual, certificate 없음 -2. `scripts/build.sh` — `xcodebuild archive` + `xcodebuild -exportArchive` (ad-hoc 서명 `codesign --sign -`) -3. `scripts/make-dmg.sh` — `create-dmg` (Applications 심볼릭 링크 포함) 또는 `hdiutil create -format UDZO` -4. `scripts/make-icon.sh` — SF Symbol → 1024 PNG → sips로 전 해상도 산출 -5. `Makefile` — build/test/archive/dmg/clean/icon 타겟. 필수 flag: `-skipMacroValidation -skipPackagePluginValidation` -6. 로컬 `make dmg` 검증 — `build/Storefront.dmg` 생성 + Applications 드롭존 확인 +1. `.github/workflows/build.yml` — PR/push 시 macos-latest runner에서 `xcodegen generate` + `make test` + `make build`. `-skipMacroValidation` 필수 +2. `.github/workflows/release.yml` — `on: push: tags: ['v*']` 에 트리거, `make dmg` → `softprops/action-gh-release@v2`로 `.dmg` Release 업로드, 태그 메시지를 release notes로 +3. `.github/ISSUE_TEMPLATE/bug_report.yml` — 간단한 버그 리포트 템플릿 +4. 로컬에서 `git tag v0.1.0-rc1 && git push origin v0.1.0-rc1` → Actions 로그 확인 → Release에 DMG 업로드 검증 ## 저장소 상태 @@ -69,4 +67,4 @@ open Storefront.xcodeproj # Xcode에서 ⌘R - Visibility: **Private** (v0.1.0 릴리스 전까지) - Default branch: `master` - Active branch: `feat/mvp-v0.1.0` -- Last commit on feat/mvp-v0.1.0: Phase 5 완료 (SwiftData 지원 + 그리드 flex 레이아웃) +- Last commit on feat/mvp-v0.1.0: Phase 6 완료 (DMG 파이프라인) + 그리드 topLeading 고정 / 전체 좌측 정렬 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..00ba41c --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +.PHONY: help setup generate build test archive dmg icon clean + +# xcodebuild 공통 플래그 (TCA 매크로 실행 허용) +XCFLAGS := \ + -project Storefront.xcodeproj \ + -scheme Storefront \ + -destination 'platform=macOS' \ + -derivedDataPath build \ + -skipPackagePluginValidation \ + -skipMacroValidation + +help: ## 사용 가능한 명령 + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-14s\033[0m %s\n", $$1, $$2}' + +setup: ## 로컬 개발 도구 설치 (Homebrew) + brew install xcodegen create-dmg + +generate: ## project.yml → Storefront.xcodeproj 재생성 + xcodegen generate + +build: generate ## Debug 빌드 + xcodebuild $(XCFLAGS) -configuration Debug build + +test: generate ## 단위 테스트 + xcodebuild $(XCFLAGS) test + +archive: generate ## Release 아카이브 + 익스포트 (ad-hoc 서명) + @bash scripts/build.sh + +dmg: archive ## build/Storefront-.dmg 생성 + @bash scripts/make-dmg.sh + +icon: ## AppIcon.appiconset 전 해상도 산출 (임시 SF S 로고) + @bash scripts/make-icon.sh + +clean: ## 빌드 결과물 제거 + rm -rf build + rm -rf DerivedData diff --git a/Storefront/Features/Browser/DynamicRowGrid.swift b/Storefront/Features/Browser/DynamicRowGrid.swift index 8e017aa..a735cd9 100644 --- a/Storefront/Features/Browser/DynamicRowGrid.swift +++ b/Storefront/Features/Browser/DynamicRowGrid.swift @@ -14,15 +14,19 @@ struct DynamicRowGrid: View { ScrollView([.vertical, .horizontal]) { LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { - Section(header: header(columnWidth: columnWidth, totalWidth: totalWidth)) { + Section { ForEach(Array(page.rows.enumerated()), id: \.element.id) { idx, row in rowView(row: row, zebra: idx.isMultiple(of: 2), columnWidth: columnWidth, totalWidth: totalWidth) Divider().opacity(0.25) } + } header: { + header(columnWidth: columnWidth, totalWidth: totalWidth) } } - .frame(minWidth: geo.size.width, alignment: .leading) + .frame(minWidth: geo.size.width, alignment: .topLeading) + .frame(maxHeight: .infinity, alignment: .top) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } @@ -73,10 +77,13 @@ struct DynamicRowGrid: View { HStack(spacing: 0) { ForEach(Array(page.columns.enumerated()), id: \.element.id) { idx, column in let value = idx < row.values.count ? row.values[idx] : .null - CellView(value: value) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .frame(width: columnWidth, alignment: columnAlignment(for: value)) + HStack { + CellView(value: value) + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(width: columnWidth, alignment: .leading) if column.id != page.columns.last?.id { Divider().opacity(0.3) @@ -86,11 +93,4 @@ struct DynamicRowGrid: View { .frame(width: totalWidth, alignment: .leading) .background(zebra ? Color.secondary.opacity(0.04) : Color.clear) } - - private func columnAlignment(for value: DBValue) -> Alignment { - switch value { - case .integer, .double: return .trailing - default: return .leading - } - } } diff --git a/Storefront/UI/CellView.swift b/Storefront/UI/CellView.swift index 4ade14d..9f8424c 100644 --- a/Storefront/UI/CellView.swift +++ b/Storefront/UI/CellView.swift @@ -15,14 +15,12 @@ struct CellView: View { .font(.system(.body, design: .monospaced)) .foregroundStyle(Color.blue) .monospacedDigit() - .frame(maxWidth: .infinity, alignment: .trailing) case let .double(v): Text(String(v)) .font(.system(.body, design: .monospaced)) .foregroundStyle(Color.blue) .monospacedDigit() - .frame(maxWidth: .infinity, alignment: .trailing) case let .text(v): Text(v) diff --git a/scripts/ExportOptions.plist b/scripts/ExportOptions.plist new file mode 100644 index 0000000..d4c065b --- /dev/null +++ b/scripts/ExportOptions.plist @@ -0,0 +1,12 @@ + + + + + method + mac-application + signingStyle + manual + stripSwiftSymbols + + + diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..04a1dba --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# +# 로컬 릴리스 빌드 — Storefront.xcarchive → Storefront.app (ad-hoc 서명) +# +# 사전 요건: +# brew install xcodegen create-dmg +# xcodegen generate +# +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +BUILD_DIR="$ROOT/build" +ARCHIVE_PATH="$BUILD_DIR/Storefront.xcarchive" +EXPORT_PATH="$BUILD_DIR/export" +EXPORT_OPTIONS="$ROOT/scripts/ExportOptions.plist" + +rm -rf "$ARCHIVE_PATH" "$EXPORT_PATH" + +echo "==> xcodebuild archive" +xcodebuild archive \ + -project Storefront.xcodeproj \ + -scheme Storefront \ + -configuration Release \ + -destination 'platform=macOS' \ + -archivePath "$ARCHIVE_PATH" \ + -derivedDataPath "$BUILD_DIR" \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + ENABLE_HARDENED_RUNTIME=NO \ + | xcbeautify 2>/dev/null || true + +if [ ! -d "$ARCHIVE_PATH" ]; then + echo "ERROR: archive not produced at $ARCHIVE_PATH" >&2 + exit 1 +fi + +echo "==> xcodebuild -exportArchive" +xcodebuild -exportArchive \ + -archivePath "$ARCHIVE_PATH" \ + -exportPath "$EXPORT_PATH" \ + -exportOptionsPlist "$EXPORT_OPTIONS" \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + | xcbeautify 2>/dev/null || true + +APP="$EXPORT_PATH/Storefront.app" +if [ ! -d "$APP" ]; then + echo "ERROR: export missing $APP" >&2 + exit 1 +fi + +echo "==> ad-hoc codesign" +codesign --force --deep --sign - "$APP" +codesign --verify --verbose=2 "$APP" + +echo "==> Storefront.app ready at: $APP" diff --git a/scripts/make-dmg.sh b/scripts/make-dmg.sh new file mode 100755 index 0000000..e0eacb7 --- /dev/null +++ b/scripts/make-dmg.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# Storefront.app → Storefront-.dmg +# create-dmg가 있으면 더 예쁜 DMG (Applications 심볼릭 링크, 배경, 레이아웃) +# 없으면 hdiutil fallback. +# +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +BUILD_DIR="$ROOT/build" +APP="$BUILD_DIR/export/Storefront.app" +VERSION=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist" 2>/dev/null || echo "dev") +DMG="$BUILD_DIR/Storefront-${VERSION}.dmg" + +if [ ! -d "$APP" ]; then + echo "ERROR: $APP not found. Run scripts/build.sh first." >&2 + exit 1 +fi + +rm -f "$DMG" + +if command -v create-dmg >/dev/null 2>&1; then + echo "==> create-dmg" + create-dmg \ + --volname "Storefront ${VERSION}" \ + --window-pos 200 120 \ + --window-size 640 380 \ + --icon-size 100 \ + --icon "Storefront.app" 180 180 \ + --app-drop-link 460 180 \ + --hide-extension "Storefront.app" \ + --no-internet-enable \ + --skip-jenkins \ + "$DMG" \ + "$APP" \ + || { + # create-dmg는 가끔 cosmetic 에러로 실패하지만 DMG는 생성됨 — 존재 여부로 재검증 + if [ ! -f "$DMG" ]; then + echo "ERROR: create-dmg 실패, DMG 미생성" >&2 + exit 1 + fi + } +else + echo "==> hdiutil (create-dmg 미설치)" + STAGING="$BUILD_DIR/dmg-staging" + rm -rf "$STAGING" + mkdir -p "$STAGING" + cp -R "$APP" "$STAGING/" + ln -s /Applications "$STAGING/Applications" + hdiutil create \ + -volname "Storefront ${VERSION}" \ + -srcfolder "$STAGING" \ + -ov \ + -format UDZO \ + "$DMG" + rm -rf "$STAGING" +fi + +echo "==> DMG ready: $DMG" +ls -lh "$DMG" diff --git a/scripts/make-icon.sh b/scripts/make-icon.sh new file mode 100755 index 0000000..e2a640a --- /dev/null +++ b/scripts/make-icon.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# +# SF Symbol 조합으로 임시 1024x1024 PNG 앱 아이콘을 만든 뒤, +# AppIcon.appiconset에 필요한 모든 해상도를 sips로 산출한다. +# +# 요구사항: macOS + sips + Python3 (Pillow optional) +# +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ICONSET="$ROOT/Storefront/Resources/Assets.xcassets/AppIcon.appiconset" +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +MASTER="$TMPDIR/icon_1024.png" + +# SF Symbol → PNG (macOS 15+에서 가능한 가장 단순한 경로는 없으므로, +# 여기서는 단색 그라디언트 배경 + 텍스트 "S"를 sips/coreimage 대신 Python으로 렌더) +python3 - < "$MASTER.b64" +from __future__ import annotations +import base64, sys +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError: + print("ERROR: Pillow 필요 — pip install Pillow", file=sys.stderr) + sys.exit(1) + +SIZE = 1024 +img = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0)) + +# 둥근 사각형 배경 + 그라디언트 (Sky → Orange 대각선) +grad = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0)) +for y in range(SIZE): + for x in range(SIZE): + t = (x + y) / (2 * SIZE) + r = int(0x5A * (1 - t) + 0xFF * t) + g = int(0xA7 * (1 - t) + 0x9F * t) + b = int(0xE6 * (1 - t) + 0x5A * t) + grad.putpixel((x, y), (r, g, b, 255)) + +# 라운드 마스크 +mask = Image.new("L", (SIZE, SIZE), 0) +md = ImageDraw.Draw(mask) +radius = 220 +md.rounded_rectangle([(0, 0), (SIZE, SIZE)], radius=radius, fill=255) +img.paste(grad, (0, 0), mask) + +# 중앙에 "S" 로고 +draw = ImageDraw.Draw(img) +try: + font = ImageFont.truetype("/System/Library/Fonts/SFCompactRounded.ttf", 620) +except OSError: + font = ImageFont.load_default() +text = "S" +bbox = draw.textbbox((0, 0), text, font=font) +tw = bbox[2] - bbox[0] +th = bbox[3] - bbox[1] +x = (SIZE - tw) // 2 - bbox[0] +y = (SIZE - th) // 2 - bbox[1] - 20 +draw.text((x, y), text, fill=(255, 255, 255, 255), font=font) + +import io +buf = io.BytesIO() +img.save(buf, format="PNG") +sys.stdout.write(base64.b64encode(buf.getvalue()).decode()) +PY + +base64 -D < "$MASTER.b64" > "$MASTER" + +declare -a SPECS=( + "16:1x:16" + "16:2x:32" + "32:1x:32" + "32:2x:64" + "128:1x:128" + "128:2x:256" + "256:1x:256" + "256:2x:512" + "512:1x:512" + "512:2x:1024" +) + +for spec in "${SPECS[@]}"; do + IFS=":" read -r base scale px <<< "$spec" + out="$ICONSET/icon_${base}x${base}@${scale}.png" + sips -z "$px" "$px" "$MASTER" --out "$out" > /dev/null + echo "wrote $out (${px}px)" +done + +# Contents.json 업데이트 (파일명 매핑) +cat > "$ICONSET/Contents.json" < AppIcon.appiconset 업데이트 완료" From 2ed136bc2a16218e2fbb2b15eb2addcd98f5691d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=AB=E1=84=8C=E1=85=AE=E1=86=AB?= Date: Thu, 16 Apr 2026 16:52:23 +0900 Subject: [PATCH 08/15] feat: GitHub Actions CI/CD (Phase 7) + grid top-anchor via minHeight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 7 — GitHub Actions 워크플로우 - .github/workflows/build.yml: PR/push(paths-ignore: Docs/README/LICENSE)에서 macos-latest runner + xcodegen + make test/build. concurrency 취소, 25분 타임아웃 - .github/workflows/release.yml: on tags ['v*'] + workflow_dispatch. make test 게이트 → make dmg → softprops/action-gh-release@v2로 DMG + SHA256SUMS.txt 업로드. body에 Gatekeeper 우회 안내 포함, prerelease flag는 태그에 '-' 있으면 자동 - .github/ISSUE_TEMPLATE/bug_report.yml: macOS 버전 / Storefront 버전 / DB 종류 / 로그 필드 구성 그리드 최상단 고정 (이전 `.frame(maxHeight: .infinity)` 불충분) - ScrollView 컨텐츠의 LazyVStack에 `minHeight: geo.size.height` 추가 → 데이터 적을 때도 프레임이 뷰포트 전체 높이를 먹어서 .topLeading 정렬이 최상단을 보장. 이전엔 프레임이 intrinsic 높이였어서 ScrollView가 콘텐츠를 수직 센터로 배치했음. --- .github/ISSUE_TEMPLATE/bug_report.yml | 51 +++++++++++ .github/workflows/build.yml | 49 +++++++++++ .github/workflows/release.yml | 85 +++++++++++++++++++ Docs/PROGRESS.md | 16 ++-- .../Features/Browser/DynamicRowGrid.swift | 8 +- 5 files changed, 198 insertions(+), 11 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..52c7630 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,51 @@ +name: Bug report +description: Storefront에서 발견한 문제를 알려주세요 +labels: ["bug"] +body: + - type: textarea + id: summary + attributes: + label: 무슨 일이 일어났나요? + description: 재현 단계와 기대한 동작을 알려주세요. + placeholder: | + 1. 앱을 열고 `.sqlite` 파일을 선택했더니… + 2. 기대: … + 3. 실제: … + validations: + required: true + + - type: input + id: macos + attributes: + label: macOS 버전 + placeholder: "macOS 26.0 / Tahoe" + validations: + required: true + + - type: input + id: storefront-version + attributes: + label: Storefront 버전 + description: 메뉴바 Storefront → About Storefront 또는 DMG 파일명에서 확인 + placeholder: v0.1.0 + validations: + required: true + + - type: dropdown + id: db-type + attributes: + label: 대상 DB 종류 + options: + - SQLite (일반) + - SwiftData 스토어 (.store) + - iOS 시뮬레이터 앱 DB + - 기타 + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Console 로그 또는 스크린샷 + description: 에러 메시지나 스크린샷이 있으면 붙여주세요. + render: shell diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..71a1b02 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,49 @@ +name: Build & Test + +on: + push: + branches: [master, main] + paths-ignore: + - "Docs/**" + - "README.md" + - "LICENSE" + pull_request: + branches: [master, main] + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-test: + name: xcodebuild test + runs-on: macos-latest + timeout-minutes: 25 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode.app + + - name: Show versions + run: | + xcodebuild -version + swift --version + sw_vers + + - name: Install tools + run: brew install xcodegen + + - name: Generate Xcode project + run: xcodegen generate + + - name: Resolve Swift Package Manager deps + run: xcodebuild -resolvePackageDependencies -project Storefront.xcodeproj + + - name: Run tests + run: make test + + - name: Debug build + run: make build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2602e68 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,85 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: "태그 이름 (예: v0.1.0)" + required: true + +permissions: + contents: write + +jobs: + release: + name: Build DMG & publish release + runs-on: macos-latest + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode.app + + - name: Install tools + run: brew install xcodegen create-dmg + + - name: Generate Xcode project + run: xcodegen generate + + - name: Resolve SPM deps + run: xcodebuild -resolvePackageDependencies -project Storefront.xcodeproj + + - name: Run tests (gate) + run: make test + + - name: Build DMG + run: make dmg + + - name: Locate DMG + id: locate + run: | + DMG=$(ls build/Storefront-*.dmg | head -n 1) + echo "dmg=$DMG" >> "$GITHUB_OUTPUT" + echo "tag=${GITHUB_REF_NAME:-${{ github.event.inputs.tag }}}" >> "$GITHUB_OUTPUT" + echo "Built: $DMG" + ls -lh "$DMG" + + - name: Compute SHA256 + id: shasum + run: | + cd build + shasum -a 256 "$(basename "${{ steps.locate.outputs.dmg }}")" | tee SHA256SUMS.txt + echo "file=build/SHA256SUMS.txt" >> "$GITHUB_OUTPUT" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.locate.outputs.tag }} + name: Storefront ${{ steps.locate.outputs.tag }} + draft: false + prerelease: ${{ contains(steps.locate.outputs.tag, '-') }} + generate_release_notes: true + body: | + ## 설치 + + 1. `Storefront-*.dmg` 다운로드 → 더블클릭 → `Applications/`로 드래그 + 2. **첫 실행 시 Gatekeeper 우회**: + - Finder → `Storefront.app` 우클릭 → 열기 → 열기 + - 또는: `xattr -cr /Applications/Storefront.app` + + **Storefront는 서명되지 않은 오픈소스 빌드**입니다. Apple Developer 프로그램을 사용하지 않으므로 첫 실행 시 보안 경고가 표시됩니다. + + ## 검증 + + SHA-256 해시는 첨부된 `SHA256SUMS.txt`를 참조하세요. + files: | + ${{ steps.locate.outputs.dmg }} + ${{ steps.shasum.outputs.file }} diff --git a/Docs/PROGRESS.md b/Docs/PROGRESS.md index ed4c965..cf3e608 100644 --- a/Docs/PROGRESS.md +++ b/Docs/PROGRESS.md @@ -38,7 +38,7 @@ open Storefront.xcodeproj # Xcode에서 ⌘R | **4. 시뮬레이터 앱 자동 탐색** | ✅ 완료 | SimulatorScanner(simctl JSON + FS 글로빙) / SimulatorClient / SimulatorPickerFeature(DisclosureGroup 트리) / Welcome의 "시뮬레이터" 버튼(⌘L) + Welcome 드래그&드롭 | | **5. SwiftData 스토어 지원** | ✅ 완료 | SwiftDataDetector(Z_METADATA/Z_PRIMARYKEY 판별) + SwiftDataDecoder(Z 접두어 정규화) + TableInfo.Classification(swiftDataEntity/swiftDataSystem) + 사이드바 Entities/Tables/Views/System 섹션 분리 + 원본명 tooltip + DynamicRowGrid 전체 폭 flex-fill | | **6. DMG 빌드 파이프라인** | ✅ 완료 | Makefile(setup/generate/build/test/archive/dmg/icon/clean) + scripts/build.sh (xcodebuild archive + exportArchive + ad-hoc codesign) + scripts/make-dmg.sh (create-dmg 우선, hdiutil fallback) + scripts/make-icon.sh (sips로 전 해상도) + scripts/ExportOptions.plist. `make dmg` → build/Storefront-0.1.0.dmg 4.8MB 생성 검증됨 | -| **7. GitHub Actions 릴리스** | ⏳ 대기 | .github/workflows/build.yml, release.yml (매크로 검증 스킵 플래그 포함) | +| **7. GitHub Actions 릴리스** | ✅ 완료 | .github/workflows/build.yml (PR/push 시 test+build), release.yml (tag v* → make dmg → softprops/action-gh-release@v2, SHA256SUMS 자동 생성), .github/ISSUE_TEMPLATE/bug_report.yml | ## 설계 참조 @@ -53,13 +53,13 @@ open Storefront.xcodeproj # Xcode에서 ⌘R ## 다음 작업 시작 지점 -**Phase 7 — GitHub Actions 릴리스** +**MVP v0.1.0 완료** 🎉 — 모든 Phase 통과. 남은 액션: -파일 생성 순서: -1. `.github/workflows/build.yml` — PR/push 시 macos-latest runner에서 `xcodegen generate` + `make test` + `make build`. `-skipMacroValidation` 필수 -2. `.github/workflows/release.yml` — `on: push: tags: ['v*']` 에 트리거, `make dmg` → `softprops/action-gh-release@v2`로 `.dmg` Release 업로드, 태그 메시지를 release notes로 -3. `.github/ISSUE_TEMPLATE/bug_report.yml` — 간단한 버그 리포트 템플릿 -4. 로컬에서 `git tag v0.1.0-rc1 && git push origin v0.1.0-rc1` → Actions 로그 확인 → Release에 DMG 업로드 검증 +1. **릴리스 검증**: 로컬에서 `git tag v0.1.0-rc1 && git push origin v0.1.0-rc1` → Actions `release.yml` 실행 로그 확인 → GitHub Releases에 DMG+SHA256 업로드 검증 +2. **프리뷰 피드백 반영**: 필요 시 색상/레이아웃/문구 조정 +3. **feat/mvp-v0.1.0 → master merge**: 모든 Phase 안정되면 PR 오픈 or 직접 merge +4. **Public 전환**: 릴리스 준비되면 `gh repo edit --visibility public` +5. **v0.2 로드맵**: 실제 iPhone 지원 / 커스텀 앱 아이콘 / Homebrew Cask / SQL 콘솔 등 ## 저장소 상태 @@ -67,4 +67,4 @@ open Storefront.xcodeproj # Xcode에서 ⌘R - Visibility: **Private** (v0.1.0 릴리스 전까지) - Default branch: `master` - Active branch: `feat/mvp-v0.1.0` -- Last commit on feat/mvp-v0.1.0: Phase 6 완료 (DMG 파이프라인) + 그리드 topLeading 고정 / 전체 좌측 정렬 +- Last commit on feat/mvp-v0.1.0: Phase 7 완료 (GitHub Actions) + 그리드 minHeight 상단 고정 — MVP 전체 완료 diff --git a/Storefront/Features/Browser/DynamicRowGrid.swift b/Storefront/Features/Browser/DynamicRowGrid.swift index a735cd9..19319a8 100644 --- a/Storefront/Features/Browser/DynamicRowGrid.swift +++ b/Storefront/Features/Browser/DynamicRowGrid.swift @@ -23,10 +23,12 @@ struct DynamicRowGrid: View { header(columnWidth: columnWidth, totalWidth: totalWidth) } } - .frame(minWidth: geo.size.width, alignment: .topLeading) - .frame(maxHeight: .infinity, alignment: .top) + .frame( + minWidth: geo.size.width, + minHeight: geo.size.height, + alignment: .topLeading + ) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } From 017ec66d84d9493aad4afe337aa448c029085bf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=AB=E1=84=8C=E1=85=AE=E1=86=AB?= Date: Thu, 16 Apr 2026 16:54:46 +0900 Subject: [PATCH 09/15] docs: expand install section with three clear paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A. DMG (end users) — drag to Applications + 3 Gatekeeper bypass methods - B. Xcode clone + ⌘R (developers, fastest) - C. CLI via make build / make dmg - Makefile 명령 표, Architecture 한 섹션 요약 - 왜 Gatekeeper 경고 뜨는지 설명 (Apple Developer Program 비가입 오픈소스) --- README.md | 107 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 75 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 3d2a6b1..f0c3211 100644 --- a/README.md +++ b/README.md @@ -18,60 +18,103 @@ - 🎨 **Native macOS feel** — Sky Blue × Sunset Orange palette, dark mode first-class - 🔒 **Read-only** — your databases are never written to +## Requirements + +- macOS 26 Tahoe 이상 +- (개발자) Xcode 26 이상, Homebrew + +--- + ## Install -### 1. Download the DMG +Storefront를 설치하는 방법은 세 가지입니다. 일반 사용자는 **A**로, 소스에서 빌드하고 싶은 개발자는 **B/C**로 가세요. -Grab the latest DMG from [Releases](https://github.com/jun7680/Storefront/releases). +### A. DMG 다운로드 (일반 사용자) ⭐ 추천 -> ⚠️ **Storefront is unsigned** (no Apple Developer Program). macOS will show a Gatekeeper warning on first launch. Pick one of the bypasses below. +1. [Releases 페이지](https://github.com/jun7680/Storefront/releases)에서 `Storefront-*.dmg` 다운로드 +2. DMG를 더블클릭 → `Storefront.app`을 `Applications` 폴더로 드래그 +3. **첫 실행 — Gatekeeper 우회 필요** (아래 세 가지 중 편한 방법): -### 2. First run — bypass Gatekeeper + **방법 1: Finder (가장 간단)** + - `Applications` 폴더에서 `Storefront.app` **우클릭 → 열기 → 열기** + - macOS 15+에선 **시스템 설정 › 개인정보 보호 및 보안 › "그래도 열기"** 한 번만 눌러주면 됩니다 -**Option A — Finder (easiest):** -1. Drag `Storefront.app` to `/Applications` -2. Right-click `Storefront.app` → **Open** → **Open** in the dialog -3. (macOS 15+) You may need **System Settings › Privacy & Security › "Open Anyway"** + **방법 2: 터미널 한 줄** + ```bash + xattr -cr /Applications/Storefront.app + ``` + 이후부터는 그냥 더블클릭해서 열립니다. -**Option B — Terminal (one-liner):** -```bash -xattr -cr /Applications/Storefront.app -``` -This removes the `com.apple.quarantine` attribute. Double-click from that point on. + **방법 3: DMG 자체의 격리 속성 제거 (마운트 전)** + ```bash + xattr -d com.apple.quarantine ~/Downloads/Storefront-*.dmg + ``` + +> **왜 경고가 뜨나요?** Storefront는 Apple Developer Program ($99/년) 없이 배포되는 순수 오픈소스 프로젝트라 Apple의 공증(notarization)을 거치지 않았습니다. 코드는 [GitHub](https://github.com/jun7680/Storefront)에서 그대로 확인 가능합니다. + +### B. Xcode로 클론 + 실행 (개발자, 가장 빠른 방법) -**Option C — Remove quarantine from DMG before opening:** ```bash -xattr -d com.apple.quarantine ~/Downloads/Storefront.dmg +# 1. 도구 설치 (한 번만) +brew install xcodegen + +# 2. 소스 받기 +git clone https://github.com/jun7680/Storefront.git +cd Storefront + +# 3. Xcode 프로젝트 생성 (gitignore되어 있어서 매번 필요) +xcodegen generate + +# 4. Xcode 열기 +open Storefront.xcodeproj ``` -## Build from source +Xcode에서 **⌘R** 누르면 실행됩니다. + +> **첫 실행 시**: Xcode가 TCA 매크로(ComposableArchitecture, CasePaths, Perception, Dependencies) "Trust & Enable" 프롬프트를 띄웁니다 — 전부 **Trust & Enable** 눌러주세요. + +### C. 터미널로 빌드 + 실행 (CLI 선호) ```bash -# Requirements: macOS 26 Tahoe, Xcode 26+, Homebrew brew install xcodegen create-dmg git clone https://github.com/jun7680/Storefront.git cd Storefront -xcodegen generate # regenerate Storefront.xcodeproj from project.yml -open Storefront.xcodeproj # ⌘R to run in Xcode + +make build # Debug 빌드 +open build/Build/Products/Debug/Storefront.app # 실행 +``` + +로컬에서 배포용 DMG까지 만들고 싶다면: +```bash +make dmg # → build/Storefront-0.1.0.dmg +open build/Storefront-0.1.0.dmg # 더블클릭으로 확인 ``` -On first open in Xcode, you will be prompted to **trust Swift macros** from TCA (ComposableArchitecture, CasePaths, Perception, Dependencies) — click "Trust & Enable". For CLI builds, pass `-skipMacroValidation`. +--- -### Architecture +## Makefile targets -Built with [The Composable Architecture (TCA)](https://github.com/pointfreeco/swift-composable-architecture) — every feature is a `@Reducer` with `@ObservableState`, composed into a single `Store`. Navigation, dependencies, and side effects flow through reducers; views are thin projections of state. +| 명령 | 하는 일 | +|---|---| +| `make setup` | `xcodegen`, `create-dmg` 설치 (Homebrew) | +| `make generate` | `project.yml` → `Storefront.xcodeproj` 재생성 | +| `make build` | Debug 빌드 | +| `make test` | 단위 테스트 실행 | +| `make archive` | Release 아카이브 + ad-hoc 서명 | +| `make dmg` | 전체 빌드 후 `build/Storefront-.dmg` 패키징 | +| `make icon` | SF 로고 기반 앱 아이콘 자동 산출 | +| `make clean` | `build/` 정리 | -Or build a DMG locally: -```bash -make dmg # produces build/Storefront.dmg -``` +--- + +## Architecture + +Built with [The Composable Architecture (TCA)](https://github.com/pointfreeco/swift-composable-architecture) — 모든 피처는 `@Reducer` + `@ObservableState` 쌍으로 구성되며, 루트 `AppFeature`에 `Scope`로 합성됩니다. 사이드이펙트(DB 읽기, 파일 감시, 시뮬레이터 스캔)는 `@Dependency` 클라이언트로 격리되어 `TestStore`로 단위 테스트됩니다. -Makefile targets: -- `make build` — Debug build for the current arch -- `make test` — run unit tests -- `make archive` — Release archive (ad-hoc signed) -- `make dmg` — build + package into a DMG -- `make clean` — remove `build/` +- `Storefront/Features/` — TCA 피처 (App, Welcome, Browser, SimulatorPicker) +- `Storefront/Dependencies/` — `DatabaseClient`, `FileWatcherClient`, `SimulatorClient` +- `Storefront/Core/` — UI-독립 도메인 (GRDB 기반 SQLite/SwiftData 파싱) +- `Docs/PLAN.md`, `Docs/PROGRESS.md` — 설계·진행 상태 ## Roadmap From 2a7009543f82ee771716f54da380a7827dafd95e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=AB=E1=84=8C=E1=85=AE=E1=86=AB?= Date: Fri, 17 Apr 2026 08:16:22 +0900 Subject: [PATCH 10/15] ci: pin runners to macos-26 for macOS 26 deployment target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macos-latest는 2026-04 기준 아직 macOS 15.7.4 러너를 가리키고 있어 앱의 macOS 26 deployment target과 불일치한다. xcodebuild test가 'My Mac'에서 SDK 버전 mismatch로 실패하므로 두 workflow 모두 macos-26으로 고정. before: xcodebuild: error: Cannot test target 'StorefrontTests' on 'My Mac': My Mac's macOS 15.7.4 doesn't match StorefrontTests's macOS 26.0 deployment target. --- .github/workflows/build.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 71a1b02..a52a45b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,7 +17,7 @@ concurrency: jobs: build-test: name: xcodebuild test - runs-on: macos-latest + runs-on: macos-26 timeout-minutes: 25 steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2602e68..fcf788e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ permissions: jobs: release: name: Build DMG & publish release - runs-on: macos-latest + runs-on: macos-26 timeout-minutes: 45 steps: From 52637eed8870a47ee9a4399a0df37d3e4ba27df4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=AB=E1=84=8C=E1=85=AE=E1=86=AB?= Date: Fri, 17 Apr 2026 08:31:59 +0900 Subject: [PATCH 11/15] docs: mark v0.1.0-rc1 released --- Docs/PROGRESS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Docs/PROGRESS.md b/Docs/PROGRESS.md index cf3e608..f385cc2 100644 --- a/Docs/PROGRESS.md +++ b/Docs/PROGRESS.md @@ -67,4 +67,5 @@ open Storefront.xcodeproj # Xcode에서 ⌘R - Visibility: **Private** (v0.1.0 릴리스 전까지) - Default branch: `master` - Active branch: `feat/mvp-v0.1.0` -- Last commit on feat/mvp-v0.1.0: Phase 7 완료 (GitHub Actions) + 그리드 minHeight 상단 고정 — MVP 전체 완료 +- Last commit on feat/mvp-v0.1.0: `ci: pin runners to macos-26` (`2a70095`) +- **v0.1.0-rc1 릴리스 완료** (2026-04-17 08:31 KST): https://github.com/jun7680/Storefront/releases/tag/v0.1.0-rc1 — Storefront-0.1.0.dmg (5.0MB) + SHA256SUMS.txt, prerelease 마킹 From fb2d884d10aa58e0afd3f5d6575f9a38923bf050 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=AB=E1=84=8C=E1=85=AE=E1=86=AB?= Date: Fri, 17 Apr 2026 09:13:21 +0900 Subject: [PATCH 12/15] fix(db): handle WAL mode with query_only + parent dir watch - DatabaseClient: replace Configuration.readonly with PRAGMA query_only. readonly=true denies writes to the -shm file, which is required for readers of WAL-mode databases; the switch fixes SQLITE_CANTOPEN (14) the moment journal_mode flips to WAL. - FileWatcherClient: watch the parent directory in addition to the existing sibling files so -wal/-shm files created after the DB is opened are picked up, and INSERTs in WAL mode (which only touch the -wal sidecar) surface to the UI as expected. --- Storefront/Dependencies/DatabaseClient.swift | 4 +- .../Dependencies/FileWatcherClient.swift | 45 ++++++++++++++----- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/Storefront/Dependencies/DatabaseClient.swift b/Storefront/Dependencies/DatabaseClient.swift index 5bfca21..1703c10 100644 --- a/Storefront/Dependencies/DatabaseClient.swift +++ b/Storefront/Dependencies/DatabaseClient.swift @@ -40,7 +40,9 @@ private actor DatabaseRegistry { func queue(for url: URL) throws -> DatabaseQueue { if let existing = queues[url] { return existing } var config = Configuration() - config.readonly = true + config.prepareDatabase { db in + try db.execute(sql: "PRAGMA query_only = ON") + } let q = try DatabaseQueue(path: url.path, configuration: config) queues[url] = q return q diff --git a/Storefront/Dependencies/FileWatcherClient.swift b/Storefront/Dependencies/FileWatcherClient.swift index 3272348..265c5f9 100644 --- a/Storefront/Dependencies/FileWatcherClient.swift +++ b/Storefront/Dependencies/FileWatcherClient.swift @@ -51,14 +51,19 @@ private final class FileWatcher: @unchecked Sendable { private func watch() { guard !isCancelled else { return } cancelSources() - sources = Self.candidateURLs(for: url).compactMap { candidate in - makeSource(for: candidate) + for path in Self.siblingPaths(for: url) where FileManager.default.fileExists(atPath: path) { + if let s = makeFileSource(path: path) { + sources.append(s) + } + } + if let dirSource = makeDirectorySource(path: url.deletingLastPathComponent().path) { + sources.append(dirSource) } sources.forEach { $0.resume() } } - private func makeSource(for url: URL) -> DispatchSourceFileSystemObject? { - let fd = open(url.path, O_EVTONLY) + private func makeFileSource(path: String) -> DispatchSourceFileSystemObject? { + let fd = open(path, O_EVTONLY) guard fd >= 0 else { return nil } let source = DispatchSource.makeFileSystemObjectSource( fileDescriptor: fd, @@ -73,9 +78,30 @@ private final class FileWatcher: @unchecked Sendable { self.watch() } } - source.setCancelHandler { - close(fd) + source.setCancelHandler { close(fd) } + return source + } + + private func makeDirectorySource(path: String) -> DispatchSourceFileSystemObject? { + let fd = open(path, O_EVTONLY) + guard fd >= 0 else { return nil } + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .extend], + queue: queue + ) + source.setEventHandler { [weak self] in + guard let self, !self.isCancelled else { return } + let watchedCount = self.sources.count - 1 + let existingCount = Self.siblingPaths(for: self.url).filter { + FileManager.default.fileExists(atPath: $0) + }.count + if existingCount != watchedCount { + self.watch() + self.onChange() + } } + source.setCancelHandler { close(fd) } return source } @@ -91,11 +117,8 @@ private final class FileWatcher: @unchecked Sendable { } } - private static func candidateURLs(for url: URL) -> [URL] { + private static func siblingPaths(for url: URL) -> [String] { let base = url.path - let siblings = [base, base + "-wal", base + "-shm"] - return siblings - .filter { FileManager.default.fileExists(atPath: $0) } - .map { URL(fileURLWithPath: $0) } + return [base, base + "-wal", base + "-shm"] } } From 5b2db8caeade0e8a316e68264225ae49c3800935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=AB=E1=84=8C=E1=85=AE=E1=86=AB?= Date: Fri, 17 Apr 2026 09:13:39 +0900 Subject: [PATCH 13/15] feat(make): add star prompt and install target - make install: build Debug and copy into /Applications/Storefront.app for a one-command local install. - make star: opt-in GitHub star prompt that uses gh CLI when authenticated (silent PUT user/starred/REPO), otherwise falls back to opening the repo in the browser. - dmg and install targets now invoke star at the end. --- Makefile | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 00ba41c..b8003ae 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,6 @@ -.PHONY: help setup generate build test archive dmg icon clean +.PHONY: help setup generate build test archive dmg icon clean star install + +REPO := jun7680/Storefront # xcodebuild 공통 플래그 (TCA 매크로 실행 허용) XCFLAGS := \ @@ -29,6 +31,33 @@ archive: generate ## Release 아카이브 + 익스포트 (ad-hoc 서명) dmg: archive ## build/Storefront-.dmg 생성 @bash scripts/make-dmg.sh + @$(MAKE) --no-print-directory star + +install: build ## 로컬 /Applications 에 Debug 빌드 복사 + @cp -R build/Build/Products/Debug/Storefront.app /Applications/ + @echo "✅ /Applications/Storefront.app 설치 완료" + @$(MAKE) --no-print-directory star + +star: ## ⭐ GitHub star 유도 (gh CLI 있으면 즉시 star, 없으면 링크) + @echo "" + @echo "────────────────────────────────────────────" + @echo " 🙏 Storefront 가 도움이 됐다면," + @echo " GitHub 에서 ⭐ 하나 눌러주실래요?" + @echo " https://github.com/$(REPO)" + @echo "────────────────────────────────────────────" + @printf " [y/N] > "; read ans; \ + case "$$ans" in \ + y|Y|yes|YES) \ + if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then \ + gh api -X PUT user/starred/$(REPO) >/dev/null 2>&1 \ + && echo "⭐ 별 눌러주셔서 감사합니다!" \ + || { echo "⚠️ API 실패 — 브라우저로 엽니다"; open https://github.com/$(REPO); }; \ + else \ + echo "ℹ️ gh CLI 미설치 또는 미로그인 — 브라우저로 엽니다"; \ + open https://github.com/$(REPO); \ + fi ;; \ + *) echo " 괜찮아요, 나중에 생각나시면 눌러주세요 💙" ;; \ + esac icon: ## AppIcon.appiconset 전 해상도 산출 (임시 SF S 로고) @bash scripts/make-icon.sh From 4192e5363be63848962846e782c4e87d7d5fc978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=AB=E1=84=8C=E1=85=AE=E1=86=AB?= Date: Fri, 17 Apr 2026 09:13:39 +0900 Subject: [PATCH 14/15] docs(readme): rewrite install in English with prerequisites - Add a Requirements table and a one-time Prerequisites block (Homebrew, Xcode 26+, xcodegen, optional gh). - Rewrite paths A/B/C in English and document the Xcode macro Trust & Enable prompt plus the `defaults write` fallback. - Refresh the Makefile targets table and include make install and make star. --- README.md | 129 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 88 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index f0c3211..3d88639 100644 --- a/README.md +++ b/README.md @@ -20,90 +20,137 @@ ## Requirements -- macOS 26 Tahoe 이상 -- (개발자) Xcode 26 이상, Homebrew +| | Version | Needed for | +|---|---|---| +| **macOS** | 26 Tahoe or later | Running the app | +| **Xcode** | 26 or later | Building from source (B, C) | +| **Homebrew** | latest | Installing `xcodegen`, `create-dmg` (B, C) | +| **GitHub CLI** (`gh`) | optional | Auto-star after `make install` (C) | --- ## Install -Storefront를 설치하는 방법은 세 가지입니다. 일반 사용자는 **A**로, 소스에서 빌드하고 싶은 개발자는 **B/C**로 가세요. +Three paths depending on who you are. End users go with **A**. Developers who want to build from source pick **B** (Xcode GUI) or **C** (command line). -### A. DMG 다운로드 (일반 사용자) ⭐ 추천 +### Prerequisites — one-time tool setup -1. [Releases 페이지](https://github.com/jun7680/Storefront/releases)에서 `Storefront-*.dmg` 다운로드 -2. DMG를 더블클릭 → `Storefront.app`을 `Applications` 폴더로 드래그 -3. **첫 실행 — Gatekeeper 우회 필요** (아래 세 가지 중 편한 방법): +> Skip this block if you only plan to use path **A**. - **방법 1: Finder (가장 간단)** - - `Applications` 폴더에서 `Storefront.app` **우클릭 → 열기 → 열기** - - macOS 15+에선 **시스템 설정 › 개인정보 보호 및 보안 › "그래도 열기"** 한 번만 눌러주면 됩니다 +**1. Install Homebrew** (macOS package manager): - **방법 2: 터미널 한 줄** +```bash +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" +``` + +**2. Install Xcode 26+** — from the [Mac App Store](https://apps.apple.com/us/app/xcode/id497799835) or [Apple Developer portal](https://developer.apple.com/download/applications/). After installing, register the command-line tools: + +```bash +sudo xcode-select -s /Applications/Xcode.app +xcodebuild -license accept # accept the SDK license +``` + +**3. Install build helpers**: + +```bash +brew install xcodegen create-dmg +``` + +**4. (Optional) Install GitHub CLI** — enables the one-click star prompt at the end of `make install` / `make dmg`: + +```bash +brew install gh +gh auth login +``` + +--- + +### A. Download the DMG (end users) ⭐ Recommended + +1. Grab `Storefront-*.dmg` from the [Releases page](https://github.com/jun7680/Storefront/releases) +2. Double-click the DMG → drag `Storefront.app` into `Applications` +3. **First launch — bypass Gatekeeper** (pick whichever is easiest): + + **Option 1: Finder (simplest)** + - In `Applications`, **right-click `Storefront.app` → Open → Open** + - On macOS 15+ you may also see a button under **System Settings › Privacy & Security › "Open Anyway"** — click it once. + + **Option 2: One-liner in Terminal** ```bash xattr -cr /Applications/Storefront.app ``` - 이후부터는 그냥 더블클릭해서 열립니다. + Afterwards the app opens on regular double-click forever. - **방법 3: DMG 자체의 격리 속성 제거 (마운트 전)** + **Option 3: Strip the quarantine attribute from the DMG before mounting** ```bash xattr -d com.apple.quarantine ~/Downloads/Storefront-*.dmg ``` -> **왜 경고가 뜨나요?** Storefront는 Apple Developer Program ($99/년) 없이 배포되는 순수 오픈소스 프로젝트라 Apple의 공증(notarization)을 거치지 않았습니다. 코드는 [GitHub](https://github.com/jun7680/Storefront)에서 그대로 확인 가능합니다. +> **Why the warning?** Storefront ships without Apple notarization because it is a pure open-source side project — no Apple Developer Program ($99/year) is involved. The source on [GitHub](https://github.com/jun7680/Storefront) is exactly what you run. -### B. Xcode로 클론 + 실행 (개발자, 가장 빠른 방법) +### B. Clone & run in Xcode (fastest for developers) -```bash -# 1. 도구 설치 (한 번만) -brew install xcodegen +Requires the prerequisites above (Homebrew, Xcode, xcodegen). -# 2. 소스 받기 +```bash git clone https://github.com/jun7680/Storefront.git cd Storefront -# 3. Xcode 프로젝트 생성 (gitignore되어 있어서 매번 필요) -xcodegen generate - -# 4. Xcode 열기 -open Storefront.xcodeproj +xcodegen generate # regenerate the .xcodeproj (it is gitignored) +open Storefront.xcodeproj # then press ⌘R in Xcode ``` -Xcode에서 **⌘R** 누르면 실행됩니다. +> **First build only**: Xcode will prompt **"Trust & Enable"** for the TCA macro plugins (`ComposableArchitectureMacros`, `CasePathsMacros`, `DependenciesMacros`, `PerceptionMacros`). Click **Trust & Enable** for each — this is a one-time security prompt. +> +> If Xcode blocks every build with a macro error, disable macro fingerprint validation globally (then restart Xcode): +> ```bash +> defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES +> defaults write com.apple.dt.Xcode IDESkipPackagePluginFingerprintValidatation -bool YES +> ``` -> **첫 실행 시**: Xcode가 TCA 매크로(ComposableArchitecture, CasePaths, Perception, Dependencies) "Trust & Enable" 프롬프트를 띄웁니다 — 전부 **Trust & Enable** 눌러주세요. +### C. Build from the command line -### C. 터미널로 빌드 + 실행 (CLI 선호) +Requires the prerequisites above. ```bash -brew install xcodegen create-dmg git clone https://github.com/jun7680/Storefront.git cd Storefront -make build # Debug 빌드 -open build/Build/Products/Debug/Storefront.app # 실행 +make build # Debug build +open build/Build/Products/Debug/Storefront.app # launch it ``` -로컬에서 배포용 DMG까지 만들고 싶다면: +Or copy the Debug build directly into `/Applications`: + +```bash +make install # builds and copies to /Applications/Storefront.app +``` + +Want the distributable DMG locally? + ```bash make dmg # → build/Storefront-0.1.0.dmg -open build/Storefront-0.1.0.dmg # 더블클릭으로 확인 +open build/Storefront-0.1.0.dmg # mount and verify ``` +Both `make install` and `make dmg` finish with an opt-in star prompt — if you answer `y` and have `gh` authenticated, Storefront will be starred on your behalf; otherwise it opens the repo in your browser. + --- ## Makefile targets -| 명령 | 하는 일 | +| Command | What it does | |---|---| -| `make setup` | `xcodegen`, `create-dmg` 설치 (Homebrew) | -| `make generate` | `project.yml` → `Storefront.xcodeproj` 재생성 | -| `make build` | Debug 빌드 | -| `make test` | 단위 테스트 실행 | -| `make archive` | Release 아카이브 + ad-hoc 서명 | -| `make dmg` | 전체 빌드 후 `build/Storefront-.dmg` 패키징 | -| `make icon` | SF 로고 기반 앱 아이콘 자동 산출 | -| `make clean` | `build/` 정리 | +| `make setup` | Install `xcodegen` and `create-dmg` via Homebrew | +| `make generate` | Regenerate `Storefront.xcodeproj` from `project.yml` | +| `make build` | Debug build | +| `make install` | Debug build → copy to `/Applications/Storefront.app` | +| `make test` | Run unit tests | +| `make archive` | Release archive with ad-hoc codesign | +| `make dmg` | Full archive + package `build/Storefront-.dmg` | +| `make icon` | Regenerate `AppIcon.appiconset` (SF Symbol placeholder) | +| `make star` | Opt-in GitHub star prompt (uses `gh` if logged in, else opens browser) | +| `make clean` | Remove `build/` and derived data | --- From fd4b5bd2b052c3cb1f8dd179f67a94676c317480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=AB=E1=84=8C=E1=85=AE=E1=86=AB?= Date: Fri, 17 Apr 2026 09:16:28 +0900 Subject: [PATCH 15/15] docs(readme): add hero banner, badges, and feature grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Docs/assets/hero.svg: Sky Blue → Sunset Orange gradient banner with the title and three capability chips (live reload, SwiftData aware, simulator auto-discovery). - README: top block becomes hero SVG + shields.io badges (release, build, license, macOS 26+, Swift 6, stars). Features rewritten as a two-column emoji grid. Architecture/Roadmap/Contributing/License sections translated and tightened; a centered footer invites stars. --- Docs/assets/hero.svg | 59 +++++++++++++++++++++++ README.md | 110 +++++++++++++++++++++++++++++++++---------- 2 files changed, 145 insertions(+), 24 deletions(-) create mode 100644 Docs/assets/hero.svg diff --git a/Docs/assets/hero.svg b/Docs/assets/hero.svg new file mode 100644 index 0000000..b01b3c8 --- /dev/null +++ b/Docs/assets/hero.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Storefront + + + Modern SQLite & SwiftData viewer for macOS + + + + + Live reload + + + + SwiftData aware + + + + Simulator auto-discovery + + + + diff --git a/README.md b/README.md index 3d88639..340fd77 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,71 @@ -# Storefront +

+ Storefront — Modern SQLite and SwiftData viewer for macOS +

-> Modern SQLite / SwiftData viewer for macOS — native SwiftUI, live reload, built for iOS developers. +

+ + Latest release + + + Build status + + + MIT License + + macOS 26+ + Swift 6 + + GitHub stars + +

+ +

+ Modern SQLite & SwiftData viewer for macOS.
+ Native SwiftUI · Live reload · Built for iOS developers +

- 🚧 Pre-alpha — v0.1.0 is under active development. + 🚧 Pre-alpha — v0.1.0 is under active development.

--- -## Features +## ✨ Features + + + + + + +
+ +**📂 Open any SQLite store** +Drag & drop or ⌘O for `.sqlite`, `.db`, `.store` files. + +**🗂 Browse tables and rows** +3-column split view — sortable, resizable, dynamic. + +**🔄 Live reload** +Auto-refresh on file change, fully WAL-aware. + +**🔒 Read-only by design** +Your databases are never written to. + + -- 📂 **Open .sqlite / .db / .store files** — drag-and-drop or File › Open (⌘O) -- 🗂 **Browse tables and rows** — 3-column split view with dynamic columns, sortable, resizable -- 🔄 **Live reload** — automatically refresh when the file changes (WAL-aware) -- 📱 **iOS Simulator auto-discovery** — list installed apps and open their databases in one click -- 🍂 **SwiftData store support** — automatic `Z_` prefix normalization, metadata-table awareness -- 🎨 **Native macOS feel** — Sky Blue × Sunset Orange palette, dark mode first-class -- 🔒 **Read-only** — your databases are never written to +**📱 Simulator auto-discovery** +One click to open any installed iOS simulator app's DB. + +**🍂 SwiftData native** +Automatic `Z_` prefix normalization + metadata awareness. + +**🎨 Native macOS feel** +Sky Blue × Sunset Orange palette, dark mode first-class. + +**⚡ Built in SwiftUI + TCA** +Modern reactive stack — snappy, testable, observable. + +
## Requirements @@ -154,25 +203,38 @@ Both `make install` and `make dmg` finish with an opt-in star prompt — if you --- -## Architecture +## 🏛 Architecture + +Built with [The Composable Architecture (TCA)](https://github.com/pointfreeco/swift-composable-architecture). Every feature is a `@Reducer` + `@ObservableState` pair composed into the root `AppFeature` via `Scope`. Side effects — DB reads, file watching, simulator scanning — are isolated behind `@Dependency` clients and unit-tested with `TestStore`. -Built with [The Composable Architecture (TCA)](https://github.com/pointfreeco/swift-composable-architecture) — 모든 피처는 `@Reducer` + `@ObservableState` 쌍으로 구성되며, 루트 `AppFeature`에 `Scope`로 합성됩니다. 사이드이펙트(DB 읽기, 파일 감시, 시뮬레이터 스캔)는 `@Dependency` 클라이언트로 격리되어 `TestStore`로 단위 테스트됩니다. +``` +Storefront/ +├── App/ # Entry point (StorefrontApp) +├── Features/ # TCA features — App, Welcome, Browser, SimulatorPicker +├── Dependencies/ # DatabaseClient, FileWatcherClient, SimulatorClient +├── Core/ # UI-agnostic domain — GRDB SQLite + SwiftData parsing +└── Resources/ # Assets, entitlements, Info.plist +``` -- `Storefront/Features/` — TCA 피처 (App, Welcome, Browser, SimulatorPicker) -- `Storefront/Dependencies/` — `DatabaseClient`, `FileWatcherClient`, `SimulatorClient` -- `Storefront/Core/` — UI-독립 도메인 (GRDB 기반 SQLite/SwiftData 파싱) -- `Docs/PLAN.md`, `Docs/PROGRESS.md` — 설계·진행 상태 +See [`Docs/PLAN.md`](./Docs/PLAN.md) for the full design doc and [`Docs/PROGRESS.md`](./Docs/PROGRESS.md) for phase status. -## Roadmap +## 🗺 Roadmap -- [x] v0.1.0 — core viewer MVP (SQLite + SwiftData + live reload + simulator scan) -- [ ] v0.2.0 — custom app icon, SQL read-only console, Homebrew Cask -- [ ] v0.3.0 — export to CSV/JSON, BLOB image preview, column filters +- [x] **v0.1.0** — core viewer MVP (SQLite + SwiftData + live reload + simulator scan) +- [ ] **v0.2.0** — custom app icon, SQL read-only console, Homebrew Cask +- [ ] **v0.3.0** — export to CSV/JSON, BLOB image preview, column filters -## Contributing +## 🤝 Contributing -Issues and PRs welcome. This is my first open-source project, so please be gentle 🙏. For substantial changes, open an issue first to discuss. +Issues and PRs are welcome. This is my first open-source project, so please be gentle 🙏. For substantial changes, open an issue first so we can align on direction. -## License +## 📜 License [MIT](./LICENSE) © 2026 Injun Mo + +--- + +

+ Built with 💙 and ☀️ on macOS
+ If Storefront saves you a few minutes, give it a ⭐ — it really helps. +