perf: move SwiftData cache and JSON encode off main thread during sync
Some checks failed
CI / Build · Debug (push) Failing after 10s
CI / Build · Release (push) Failing after 2s

- 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 <noreply@anthropic.com>
This commit is contained in:
Kutesir
2026-06-30 13:14:27 +03:00
parent 217a51b013
commit 457866c7ac
6 changed files with 297 additions and 37 deletions

View File

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

65
.github/workflows/release.yml vendored Normal file
View File

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

123
CONTRIBUTING.md Normal file
View File

@@ -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 <RUNNER_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).

38
ExportOptions.plist Normal file
View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
method: "app-store" to upload to TestFlight/App Store.
"development" for ad-hoc device installs.
"ad-hoc" for distribution outside the App Store.
-->
<key>method</key>
<string>app-store</string>
<key>teamID</key>
<string>K8BLMMR883</string>
<!--
destination: "upload" sends directly to App Store Connect after export.
"export" writes the IPA locally only (upload manually or via altool).
-->
<key>destination</key>
<string>export</string>
<key>signingStyle</key>
<string>automatic</string>
<key>stripSwiftSymbols</key>
<true/>
<key>uploadBitcode</key>
<false/>
<key>compileBitcode</key>
<false/>
<key>thinning</key>
<string>&lt;none&gt;</string>
</dict>
</plist>

View File

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

View File

@@ -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<CachedStory>())) ?? []
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<CachedStory>())) ?? []
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()
}
}