From 457866c7ac6005473dfef8f97f69aa84093a9205 Mon Sep 17 00:00:00 2001 From: Kutesir Date: Tue, 30 Jun 2026 13:14:27 +0300 Subject: [PATCH] perf: move SwiftData cache and JSON encode off main thread during sync - cacheStories() runs in Task.detached with a background ModelContext - persistQuickCache() encodes 100 stories off-thread via Task.detached - loadSectionSupplements() batches 5 parallel fetches into one sectionSupplement write Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 41 ++++++--- .github/workflows/release.yml | 65 +++++++++++++ CONTRIBUTING.md | 123 +++++++++++++++++++++++++ ExportOptions.plist | 38 ++++++++ Jarvis/Store/StoryStore.swift | 35 ++++--- Jarvis/Views/Home/SignalFeedView.swift | 32 ++++--- 6 files changed, 297 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 CONTRIBUTING.md create mode 100644 ExportOptions.plist diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73d1a66..c9139a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,14 +1,15 @@ -# Gitea Actions reads this path (.github/workflows) as well as .gitea/workflows. -# iOS builds require a macOS runner: register a Gitea act_runner on a Mac with -# labels matching `runs-on` below (macos-14 / macos-15), or change them to your -# runner's label. On GitHub-hosted runners these labels work as-is. +# Gitea Actions reads .github/workflows/ (as well as .gitea/workflows/). +# Keeping everything here avoids running two duplicate pipelines. +# +# Runner requirement: a Mac with Xcode 16+ registered as a Gitea act_runner +# with labels "self-hosted" and "macos" (see CONTRIBUTING.md → Runner Setup). name: CI on: - push: - branches: [ main ] pull_request: - branches: [ main ] + branches: [main, develop] + push: + branches: [develop] concurrency: group: ci-${{ github.ref }} @@ -16,13 +17,12 @@ concurrency: jobs: build: - name: Build · ${{ matrix.os }} · ${{ matrix.configuration }} - runs-on: ${{ matrix.os }} + name: Build · ${{ matrix.configuration }} + runs-on: [self-hosted, macos] strategy: fail-fast: false matrix: - os: [ macos-14, macos-15 ] - configuration: [ Debug, Release ] + configuration: [Debug, Release] steps: - uses: actions/checkout@v4 @@ -31,7 +31,7 @@ jobs: run: xcodebuild -version - name: Install XcodeGen - run: brew install xcodegen + run: brew list xcodegen &>/dev/null || brew install xcodegen - name: Generate Xcode project run: xcodegen generate @@ -49,3 +49,20 @@ jobs: CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_REQUIRED=NO \ build + + # Compiles the test bundle. No test targets exist yet (see docs/BACKLOG.md #13). + # Replace with `xcodebuild test` once a JarvisTests target is added to project.yml. + - name: Build for Testing (Debug) + if: matrix.configuration == 'Debug' + run: | + set -o pipefail + xcodebuild \ + -project Jarvis.xcodeproj \ + -scheme Jarvis \ + -configuration Debug \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,OS=latest,name=iPhone 16' \ + -derivedDataPath build \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + build-for-testing diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a4a759f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,65 @@ +# Triggers on merge to main. Archives the app and exports an IPA. +# TestFlight upload requires three secrets set in: +# Gitea → Repository Settings → Actions → Secrets +# +# ASC_KEY_ID — App Store Connect API key ID (e.g. ABC123DEFG) +# ASC_ISSUER_ID — Issuer ID from App Store Connect → Users → API Keys +# ASC_PRIVATE_KEY — Full contents of the downloaded .p8 file +# +# The runner Mac must have the distribution certificate and team K8BLMMR883 +# provisioning profile in its keychain (set up once in Xcode on that machine). +name: Release + +on: + push: + branches: [main] + +jobs: + archive: + name: Archive & Export IPA + runs-on: [self-hosted, macos] + + steps: + - uses: actions/checkout@v4 + + - name: Xcode version + run: xcodebuild -version + + - name: Install XcodeGen + run: brew list xcodegen &>/dev/null || brew install xcodegen + + - name: Generate Xcode project + run: xcodegen generate + + - name: Archive (Release, device) + run: | + set -o pipefail + xcodebuild archive \ + -project Jarvis.xcodeproj \ + -scheme Jarvis \ + -configuration Release \ + -sdk iphoneos \ + -archivePath build/Jarvis.xcarchive \ + DEVELOPMENT_TEAM=K8BLMMR883 + + - name: Export IPA + run: | + set -o pipefail + xcodebuild -exportArchive \ + -archivePath build/Jarvis.xcarchive \ + -exportPath build/ipa \ + -exportOptionsPlist ExportOptions.plist + + - name: Upload to TestFlight + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_PRIVATE_KEY: ${{ secrets.ASC_PRIVATE_KEY }} + run: | + echo "$ASC_PRIVATE_KEY" > /tmp/AuthKey_${ASC_KEY_ID}.p8 + xcrun altool --upload-app \ + --type ios \ + --file build/ipa/Jarvis.ipa \ + --apiKey "$ASC_KEY_ID" \ + --apiIssuer "$ASC_ISSUER_ID" + rm -f /tmp/AuthKey_${ASC_KEY_ID}.p8 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5ccc128 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,123 @@ +# Contributing to Jarvis + +## Branch workflow + +``` +feature/* ──→ develop ──→ main +``` + +- **`main`** — protected. No direct pushes. Every change arrives via PR from `develop`. +- **`develop`** — integration branch. All feature work merges here first. CI runs on every push. +- **`feature/*`** — short-lived branches cut from `develop`. + +## Starting work + +```bash +git checkout develop +git pull origin develop +git checkout -b feature/your-description +``` + +## Opening a PR + +1. Push your feature branch and open a PR targeting **`develop`**. +2. CI must pass (Debug + Release builds succeed). +3. At least 1 approval required before merge. + +## Releasing to TestFlight + +A maintainer opens a PR from `develop → main`. Merging triggers the release workflow: +archive → export IPA → upload to TestFlight. + +--- + +## Runner setup (one-time, on your Mac) + +Gitea Actions requires a self-hosted runner. The CI workflows target `[self-hosted, macos]`. + +### 1. Enable Gitea Actions (admin, once per instance) + +In `app.ini` on the Gitea server, add: + +```ini +[actions] +ENABLED = true +``` + +Then restart Gitea. + +### 2. Install act_runner on the Mac + +```bash +brew install act-runner +``` + +Or download the binary from your Gitea instance: +`http://10.10.1.21:3002/-/admin/runners` + +### 3. Register the runner + +Get a registration token from: +**Repository Settings → Actions → Runners → Create Runner** + +Then: + +```bash +act_runner register \ + --instance http://10.10.1.21:3002 \ + --token \ + --name "mac-mini" \ + --labels "self-hosted,macos" +``` + +### 4. Start the runner + +```bash +act_runner daemon +``` + +To run it as a background service: + +```bash +brew services start act-runner # if installed via brew +# or add a launchd plist manually +``` + +The runner must have **Xcode 16+** installed and accepted the license: + +```bash +sudo xcodebuild -license accept +``` + +--- + +## Signing and TestFlight secrets + +For the release workflow to archive and upload, the runner Mac needs: + +1. The **iOS Distribution certificate** installed in its keychain (set up once in Xcode → Settings → Accounts → Manage Certificates). +2. Three **Gitea secrets** set under: + **Repository Settings → Actions → Secrets** + +| Secret | Where to get it | +|---|---| +| `ASC_KEY_ID` | App Store Connect → Users & Access → API Keys | +| `ASC_ISSUER_ID` | Same page, above the key list | +| `ASC_PRIVATE_KEY` | The `.p8` file downloaded when you created the key (only downloadable once) | + +--- + +## Branch protection (manual setup in Gitea UI) + +Go to **Repository Settings → Branches → Add Branch Rule**: + +| Setting | Value | +|---|---| +| Branch name pattern | `main` | +| Require pull request before merging | ✅ | +| Required approvals | 1 | +| Require status checks to pass | ✅ — select `Build · Debug` and `Build · Release` | +| Block force push | ✅ | +| Block deletion | ✅ | + +Repeat with `develop` but set required approvals to 0 (CI gate only). diff --git a/ExportOptions.plist b/ExportOptions.plist new file mode 100644 index 0000000..d834884 --- /dev/null +++ b/ExportOptions.plist @@ -0,0 +1,38 @@ + + + + + + method + app-store + + teamID + K8BLMMR883 + + + destination + export + + signingStyle + automatic + + stripSwiftSymbols + + + uploadBitcode + + + compileBitcode + + + thinning + <none> + + diff --git a/Jarvis/Store/StoryStore.swift b/Jarvis/Store/StoryStore.swift index 505a14b..dca2725 100644 --- a/Jarvis/Store/StoryStore.swift +++ b/Jarvis/Store/StoryStore.swift @@ -45,12 +45,16 @@ final class StoryStore: ObservableObject { } /// Persist the current feed to UserDefaults so it survives process kills. + /// Encoding runs off the main thread; UserDefaults.set is thread-safe. private func persistQuickCache() { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 let payload = QuickCachePayload(stories: Array(stories.prefix(100)), savedAt: Date()) - if let data = try? encoder.encode(payload) { - UserDefaults.standard.set(data, forKey: quickCacheKey) + let key = quickCacheKey + Task.detached { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + if let data = try? encoder.encode(payload) { + UserDefaults.standard.set(data, forKey: key) + } } } @@ -219,15 +223,24 @@ final class StoryStore: ObservableObject { func loadSectionSupplements() { guard selectedTags.isEmpty else { return } // only for "All" pill - for sec in supplementSections { - let id = sec.id - let tags = sec.tags - Task { [weak self] in - guard let self else { return } - if let result = try? await api.fetchStories(tags: tags, limit: 8) { - await MainActor.run { self.sectionSupplement[id] = result.data } + let sections = supplementSections + Task { [weak self] in + guard let self else { return } + var merged: [String: [StorySummary]] = [:] + await withTaskGroup(of: (String, [StorySummary]?).self) { group in + for sec in sections { + let id = sec.id + let tags = sec.tags + group.addTask { + let result = try? await APIClient.shared.fetchStories(tags: tags, limit: 8) + return (id, result?.data) + } + } + for await (id, data) in group { + if let data { merged[id] = data } } } + self.sectionSupplement = merged // single main-thread update } } diff --git a/Jarvis/Views/Home/SignalFeedView.swift b/Jarvis/Views/Home/SignalFeedView.swift index 3125587..4294fcf 100644 --- a/Jarvis/Views/Home/SignalFeedView.swift +++ b/Jarvis/Views/Home/SignalFeedView.swift @@ -10,6 +10,7 @@ struct SignalFeedView: View { @EnvironmentObject var ws: WebSocketManager @EnvironmentObject var settings: ServerSettings @Environment(\.modelContext) private var modelContext + @Environment(\.modelContainer) private var modelContainer @Query private var cachedStories: [CachedStory] @Query private var cachedArticles: [CachedArticle] @@ -723,22 +724,25 @@ struct SignalFeedView: View { private func cacheStories(_ stories: [StorySummary]) { guard !stories.isEmpty else { return } - // One fetch + an in-memory index, instead of a query per story. - let existing = (try? modelContext.fetch(FetchDescriptor())) ?? [] - var byId = Dictionary(existing.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) - for summary in stories { - if let found = byId[summary.id] { - found.signalScore = summary.signalScore - found.sourceCount = summary.sourceCount - found.tags = summary.tags ?? [] - found.updatedAt = summary.updatedAt - } else { - let c = CachedStory(from: summary) - modelContext.insert(c) - byId[summary.id] = c + let container = modelContainer + Task.detached { + let ctx = ModelContext(container) + let existing = (try? ctx.fetch(FetchDescriptor())) ?? [] + var byId = Dictionary(existing.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) + for summary in stories { + if let found = byId[summary.id] { + found.signalScore = summary.signalScore + found.sourceCount = summary.sourceCount + found.tags = summary.tags ?? [] + found.updatedAt = summary.updatedAt + } else { + let c = CachedStory(from: summary) + ctx.insert(c) + byId[summary.id] = c + } } + try? ctx.save() } - try? modelContext.save() } }