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..a52a45b --- /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-26 + 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..fcf788e --- /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-26 + 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/.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..0466def --- /dev/null +++ b/Docs/PLAN.md @@ -0,0 +1,263 @@ +# 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/ # 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 +│ │ └── SimulatorPicker/ +│ │ ├── 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) +│ │ │ ├── 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 +``` + +**아키텍처**: **The Composable Architecture (TCA)** v1.15+. 각 피처는 `@Reducer` + `@ObservableState` 쌍. 상위 루트 `AppFeature`가 자식 피처를 `Scope`로 합성. 사이드이펙트·의존성은 `@Dependency`로 주입 (`DatabaseClient`, `FileWatcherClient`, `SimulatorClient`, `RecentFilesClient`). View는 `StoreOf` 또는 `@Bindable var store` 로 State 바인딩. 테스트는 `TestStore` 기반. + +--- + +## 기술 선택 + +| 영역 | 선택 | 근거 | +|---|---|---| +| **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 표시. | +| **아키텍처** | TCA v1.15+ (@Reducer / @ObservableState) | Redux-like 단방향 흐름, TestStore로 액션 단위 테스트, @Dependency로 사이드이펙트 격리. 뷰어 앱이지만 멀티 피처(Welcome/Browser/Simulator) 조합이라 TCA 가치 있음. CLI 빌드 시 `-skipMacroValidation` 필요. | + +**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..f385cc2 --- /dev/null +++ b/Docs/PROGRESS.md @@ -0,0 +1,71 @@ +# 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 # 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 + TCA 1.15 SPM | +| **초기 문서 · 라이선스 · 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 / 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(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 (PR/push 시 test+build), release.yml (tag v* → make dmg → softprops/action-gh-release@v2, SHA256SUMS 자동 생성), .github/ISSUE_TEMPLATE/bug_report.yml | + +## 설계 참조 + +- 전체 설계: [Docs/PLAN.md](./PLAN.md) +- 색상: Sky Blue `#5AA7E6` + Sunset Orange `#FF9F5A` + +## 최근 검증 (2026-04-16) + +- **Phase 5 빌드/테스트**: 13/13 통과 (AppFeature 3 + BrowserFeature 4 + SimulatorPicker 3 + SwiftDataDecoder 3) +- DynamicRowGrid: GeometryReader 기반 flex/고정 폭 자동 전환 (컬럼수 × 140pt > 가용폭이면 horizontal scroll, 아니면 균등 분배) +- LazyVStack + pinned section header로 상단 고정 (스크롤 시 헤더 유지) + +## 다음 작업 시작 지점 + +**MVP v0.1.0 완료** 🎉 — 모든 Phase 통과. 남은 액션: + +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 콘솔 등 + +## 저장소 상태 + +- GitHub: https://github.com/jun7680/Storefront +- Visibility: **Private** (v0.1.0 릴리스 전까지) +- Default branch: `master` +- Active branch: `feat/mvp-v0.1.0` +- 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 마킹 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/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/Makefile b/Makefile new file mode 100644 index 0000000..b8003ae --- /dev/null +++ b/Makefile @@ -0,0 +1,67 @@ +.PHONY: help setup generate build test archive dmg icon clean star install + +REPO := jun7680/Storefront + +# 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 + @$(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 + +clean: ## 빌드 결과물 제거 + rm -rf build + rm -rf DerivedData diff --git a/README.md b/README.md index c09af9d..340fd77 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,240 @@ -# Storefront - Modern SQLite/SwiftData viewer for macOS — native SwiftUI, live reload, built for iOS developers +

+ Storefront — Modern SQLite and SwiftData viewer for macOS +

+ +

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

+ +--- + +## ✨ 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. + + + +**📱 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 + +| | 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 + +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). + +### Prerequisites — one-time tool setup + +> Skip this block if you only plan to use path **A**. + +**1. Install Homebrew** (macOS package manager): + +```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. + + **Option 3: Strip the quarantine attribute from the DMG before mounting** + ```bash + xattr -d com.apple.quarantine ~/Downloads/Storefront-*.dmg + ``` + +> **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. Clone & run in Xcode (fastest for developers) + +Requires the prerequisites above (Homebrew, Xcode, xcodegen). + +```bash +git clone https://github.com/jun7680/Storefront.git +cd Storefront + +xcodegen generate # regenerate the .xcodeproj (it is gitignored) +open Storefront.xcodeproj # then press ⌘R in Xcode +``` + +> **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 +> ``` + +### C. Build from the command line + +Requires the prerequisites above. + +```bash +git clone https://github.com/jun7680/Storefront.git +cd Storefront + +make build # Debug build +open build/Build/Products/Debug/Storefront.app # launch it +``` + +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 # 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` | 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 | + +--- + +## 🏛 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`. + +``` +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 +``` + +See [`Docs/PLAN.md`](./Docs/PLAN.md) for the full design doc and [`Docs/PROGRESS.md`](./Docs/PROGRESS.md) for phase status. + +## 🗺 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 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 + +[MIT](./LICENSE) © 2026 Injun Mo + +--- + +

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

diff --git a/Storefront/App/StorefrontApp.swift b/Storefront/App/StorefrontApp.swift new file mode 100644 index 0000000..195301b --- /dev/null +++ b/Storefront/App/StorefrontApp.swift @@ -0,0 +1,29 @@ +import ComposableArchitecture +import SwiftUI + +@main +struct StorefrontApp: App { + @State private var store = Store(initialState: AppFeature.State()) { + AppFeature() + ._printChanges() + } + + var body: some Scene { + WindowGroup { + AppView(store: store) + .frame(minWidth: 900, minHeight: 560) + } + .windowStyle(.titleBar) + .windowToolbarStyle(.unified) + .commands { + CommandGroup(replacing: .newItem) { + Button("Open…") { store.send(.openButtonTapped) } + .keyboardShortcut("o", modifiers: .command) + } + CommandGroup(after: .toolbar) { + Button("Reload") { store.send(.reloadMenuSelected) } + .keyboardShortcut("r", modifiers: .command) + } + } + } +} diff --git a/Storefront/Core/Database/RowFetcher.swift b/Storefront/Core/Database/RowFetcher.swift new file mode 100644 index 0000000..7b767e2 --- /dev/null +++ b/Storefront/Core/Database/RowFetcher.swift @@ -0,0 +1,108 @@ +import Foundation +import GRDB + +struct ColumnInfo: Equatable, Identifiable, Sendable { + let name: String + 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 { + 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, 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 + ColumnInfo( + name: row["name"], + declaredType: (row["type"] as String?) ?? "", + isPrimaryKey: ((row["pk"] as Int?) ?? 0) > 0, + isNotNull: ((row["notnull"] as Int?) ?? 0) != 0, + isSwiftDataEntity: isSwiftDataEntity + ) + } + } + + 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 + + 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/Core/Database/SchemaInspector.swift b/Storefront/Core/Database/SchemaInspector.swift new file mode 100644 index 0000000..35464ac --- /dev/null +++ b/Storefront/Core/Database/SchemaInspector.swift @@ -0,0 +1,61 @@ +import Foundation +import GRDB + +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 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') + 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 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 + let classification = SwiftDataDetector.classify(tableName: name, kind: kind) + return TableInfo(name: name, kind: tkind, rowCount: count, classification: classification) + } + } +} 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/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 new file mode 100644 index 0000000..1703c10 --- /dev/null +++ b/Storefront/Dependencies/DatabaseClient.swift @@ -0,0 +1,68 @@ +import ComposableArchitecture +import Foundation +import GRDB + +struct DatabaseClient: Sendable { + 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 +} + +extension DatabaseClient: DependencyKey { + static let liveValue: DatabaseClient = { + let registry = DatabaseRegistry() + return DatabaseClient( + 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( + inspect: unimplemented("DatabaseClient.inspect"), + page: unimplemented("DatabaseClient.page"), + 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.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 + } + + func inspect(url: URL) async throws -> DatabaseSchema { + let q = try queue(for: url) + return try await q.read { db in + try SchemaInspector.inspect(db) + } + } + + 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, isSwiftDataEntity: isEntity) + } + } + + 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..265c5f9 --- /dev/null +++ b/Storefront/Dependencies/FileWatcherClient.swift @@ -0,0 +1,124 @@ +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() + 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 makeFileSource(path: String) -> DispatchSourceFileSystemObject? { + let fd = open(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 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 + } + + private func cancelSources() { + sources.forEach { $0.cancel() } + sources.removeAll() + } + + func cancel() { + queue.async { [weak self] in + self?.isCancelled = true + self?.cancelSources() + } + } + + private static func siblingPaths(for url: URL) -> [String] { + let base = url.path + return [base, base + "-wal", base + "-shm"] + } +} 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 new file mode 100644 index 0000000..1fa77de --- /dev/null +++ b/Storefront/Features/App/AppFeature.swift @@ -0,0 +1,77 @@ +import ComposableArchitecture +import Foundation + +@Reducer +struct AppFeature { + @ObservableState + 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 { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .openButtonTapped: + 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 + + case .fileImportFailed: + state.isFileImporterPresented = false + return .none + + case .closeDocument: + state.browser = nil + return .none + + 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 new file mode 100644 index 0000000..731304c --- /dev/null +++ b/Storefront/Features/App/AppView.swift @@ -0,0 +1,59 @@ +import ComposableArchitecture +import SwiftUI +import UniformTypeIdentifiers + +struct AppView: View { + @Bindable var store: StoreOf + + var body: some View { + Group { + if let browserStore = store.scope(state: \.browser, action: \.browser) { + BrowserView(store: browserStore) + } else { + WelcomeView(store: store) + } + } + .background(Color("AppBackground")) + .fileImporter( + isPresented: $store.isFileImporterPresented, + allowedContentTypes: Self.allowedContentTypes + ) { result in + switch result { + case let .success(url): + store.send(.fileImported(url)) + case let .failure(error): + 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] = { + var types: [UTType] = [.database] + for ext in ["sqlite", "sqlite3", "db", "store"] { + if let type = UTType(filenameExtension: ext) { + types.append(type) + } + } + return types + }() +} + +#Preview("Welcome — Light") { + AppView( + store: Store(initialState: AppFeature.State()) { AppFeature() } + ) + .frame(width: 900, height: 560) + .preferredColorScheme(.light) +} diff --git a/Storefront/Features/Browser/BrowserFeature.swift b/Storefront/Features/Browser/BrowserFeature.swift new file mode 100644 index 0000000..cec9416 --- /dev/null +++ b/Storefront/Features/Browser/BrowserFeature.swift @@ -0,0 +1,157 @@ +import ComposableArchitecture +import Foundation + +@Reducer +struct BrowserFeature { + @ObservableState + struct State: Equatable { + let databaseURL: URL + var databaseKind: DatabaseKind = .standard + var tables: [TableInfo] = [] + 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 schemaLoaded(DatabaseSchema) + case schemaFailedToLoad(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: + state.isLoading = true + state.loadErrorMessage = nil + let url = state.databaseURL + return .merge( + loadSchema(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 = resolveSelectedTable(in: state) + return .merge( + loadSchema(url: url), + selected.map { loadRows(url: url, table: $0.name, isEntity: $0.classification == .swiftDataEntity) } ?? .none + ) + + case let .schemaLoaded(schema): + state.tables = schema.tables + state.databaseKind = schema.kind + state.isLoading = false + let autoSelectedID: String? + if let current = state.selectedTableID, schema.tables.contains(where: { $0.id == current }) { + autoSelectedID = current + } else { + // Prefer non-system tables on SwiftData stores + autoSelectedID = schema.tables.first(where: { $0.classification != .swiftDataSystem })?.id + ?? schema.tables.first?.id + } + 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.name, isEntity: table.classification == .swiftDataEntity) + } + return .none + + case let .schemaFailedToLoad(message): + state.loadErrorMessage = message + state.isLoading = false + return .none + + case let .tableSelected(id): + state.selectedTableID = id + state.currentPage = nil + state.rowLoadError = nil + guard let id, let table = state.tables.first(where: { $0.id == id }) else { return .none } + state.isLoadingRows = true + return loadRows(url: state.databaseURL, table: table.name, isEntity: table.classification == .swiftDataEntity) + + 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 = resolveSelectedTable(in: state) + return .merge( + loadSchema(url: url), + selected.map { loadRows(url: url, table: $0.name, isEntity: $0.classification == .swiftDataEntity) } ?? .none + ) + } + } + } + + 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 schema = try await database.inspect(url) + await send(.schemaLoaded(schema)) + } catch { + await send(.schemaFailedToLoad(error.localizedDescription)) + } + } + } + + 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, isEntity) + 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 new file mode 100644 index 0000000..1b4eb8b --- /dev/null +++ b/Storefront/Features/Browser/BrowserView.swift @@ -0,0 +1,227 @@ +import ComposableArchitecture +import SwiftUI + +struct BrowserView: View { + @Bindable var store: StoreOf + @State private var showReloadToast: Bool = false + + var body: some View { + NavigationSplitView { + sidebar + .navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 360) + } detail: { + detail + .overlay(alignment: .top) { reloadToast } + } + .navigationTitle(store.databaseURL.lastPathComponent) + .task { + store.send(.onAppear) + } + .onChange(of: store.liveReloadToast) { _, _ in + triggerToast() + } + } + + @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 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 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(standardTables) { 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)) + } + } + } + if !systemTables.isEmpty { + Section("System") { + ForEach(systemTables) { table in + TableRow(table: table).tag(Optional(table.id)) + } + } + } + } + .listStyle(.sidebar) + } + + @ViewBuilder + private var detail: some View { + 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( + "테이블 선택", + systemImage: "sidebar.left", + description: Text("왼쪽 사이드바에서 테이블을 선택하세요.") + ) + } + } + + private func detailToolbar(page: RowPage) -> some View { + 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) + 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 { + 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: 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()) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.15)) + .clipShape(Capsule()) + } + } +} diff --git a/Storefront/Features/Browser/DynamicRowGrid.swift b/Storefront/Features/Browser/DynamicRowGrid.swift new file mode 100644 index 0000000..19319a8 --- /dev/null +++ b/Storefront/Features/Browser/DynamicRowGrid.swift @@ -0,0 +1,98 @@ +import SwiftUI + +struct DynamicRowGrid: View { + let page: RowPage + + private static let minColumnWidth: CGFloat = 140 + + var body: some View { + 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 { + 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, + minHeight: geo.size.height, + alignment: .topLeading + ) + } + } + } + + private func header(columnWidth: CGFloat, totalWidth: CGFloat) -> 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")) + } + 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) + .foregroundStyle(.tertiary) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(width: columnWidth, alignment: .leading) + + if column.id != page.columns.last?.id { + Divider() + } + } + } + .frame(width: totalWidth, alignment: .leading) + .background(.regularMaterial) + .overlay(alignment: .bottom) { + Divider() + } + } + + 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 + 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) + } + } + } + .frame(width: totalWidth, alignment: .leading) + .background(zebra ? Color.secondary.opacity(0.04) : Color.clear) + } +} 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 new file mode 100644 index 0000000..6802656 --- /dev/null +++ b/Storefront/Features/Welcome/WelcomeView.swift @@ -0,0 +1,77 @@ +import ComposableArchitecture +import SwiftUI + +struct WelcomeView: View { + let store: StoreOf + + 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) + } + + 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) + } + + Text("📦 .sqlite · .db · .store 파일을 끌어다 놓아보세요") + .font(.callout) + .foregroundStyle(.secondary) + .padding(.top, 8) + + Spacer() + } + .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 + } + } +} + +#Preview { + WelcomeView( + store: Store(initialState: AppFeature.State()) { AppFeature() } + ) + .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/Storefront/UI/CellView.swift b/Storefront/UI/CellView.swift new file mode 100644 index 0000000..9f8424c --- /dev/null +++ b/Storefront/UI/CellView.swift @@ -0,0 +1,45 @@ +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() + + case let .double(v): + Text(String(v)) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(Color.blue) + .monospacedDigit() + + 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/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..76ad863 --- /dev/null +++ b/StorefrontTests/BrowserFeatureTests.swift @@ -0,0 +1,135 @@ +import ComposableArchitecture +import XCTest +@testable import Storefront + +@MainActor +final class BrowserFeatureTests: XCTestCase { + func testOnAppearLoadsTablesAndAutoSelectsFirst() async { + let sampleTables = [ + 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, isSwiftDataEntity: false)], + rows: [RowSnapshot(index: 0, values: [.integer(1)])], + totalRows: 1, + offset: 0, + limit: 200 + ) + let url = URL(fileURLWithPath: "/tmp/sample.sqlite") + + let store = TestStore( + initialState: BrowserFeature.State(databaseURL: url) + ) { + BrowserFeature() + } withDependencies: { + $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(\.schemaLoaded) { + $0.isLoading = false + $0.tables = sampleTables + $0.databaseKind = .standard + $0.selectedTableID = "artists" + $0.isLoadingRows = true + } + await store.receive(\.rowsLoaded) { + $0.isLoadingRows = false + $0.currentPage = samplePage + } + await store.send(.onDisappear) + } + + 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.inspect = { @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(\.schemaFailedToLoad) { + $0.isLoading = false + $0.loadErrorMessage = "disk corrupted" + } + await store.send(.onDisappear) + } + + func testTableSelectionTriggersRowLoad() async { + let samplePage = RowPage( + columns: [ColumnInfo(name: "id", declaredType: "INTEGER", isPrimaryKey: true, isNotNull: true, isSwiftDataEntity: false)], + 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, + tables: [TableInfo(name: "a", kind: .table, rowCount: 1, classification: .standard)], + selectedTableID: nil + ) + ) { + 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, classification: .standard)], + selectedTableID: "a" + ) + ) { + BrowserFeature() + } withDependencies: { + $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) + } + } + store.exhaustivity = .off + + await store.send(.fileChanged) { + $0.liveReloadToast = 1 + } + } +} 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) + } +} 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 + ) + } +} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..14fca38 --- /dev/null +++ b/project.yml @@ -0,0 +1,124 @@ +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 + ComposableArchitecture: + url: https://github.com/pointfreeco/swift-composable-architecture + from: 1.15.0 + +targets: + Storefront: + type: application + platform: macOS + sources: + - path: Storefront + excludes: + - "Resources/Storefront.entitlements" + resources: + - path: Storefront/Resources/Assets.xcassets + dependencies: + - package: GRDB + - package: ComposableArchitecture + product: ComposableArchitecture + 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 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 업데이트 완료"