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 <noreply@anthropic.com>
This commit is contained in:
41
.github/workflows/ci.yml
vendored
41
.github/workflows/ci.yml
vendored
@@ -1,14 +1,15 @@
|
|||||||
# Gitea Actions reads this path (.github/workflows) as well as .gitea/workflows.
|
# Gitea Actions reads .github/workflows/ (as well as .gitea/workflows/).
|
||||||
# iOS builds require a macOS runner: register a Gitea act_runner on a Mac with
|
# Keeping everything here avoids running two duplicate pipelines.
|
||||||
# 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.
|
# 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
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [main, develop]
|
||||||
|
push:
|
||||||
|
branches: [develop]
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.ref }}
|
group: ci-${{ github.ref }}
|
||||||
@@ -16,13 +17,12 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build · ${{ matrix.os }} · ${{ matrix.configuration }}
|
name: Build · ${{ matrix.configuration }}
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: [self-hosted, macos]
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
os: [ macos-14, macos-15 ]
|
configuration: [Debug, Release]
|
||||||
configuration: [ Debug, Release ]
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
run: xcodebuild -version
|
run: xcodebuild -version
|
||||||
|
|
||||||
- name: Install XcodeGen
|
- name: Install XcodeGen
|
||||||
run: brew install xcodegen
|
run: brew list xcodegen &>/dev/null || brew install xcodegen
|
||||||
|
|
||||||
- name: Generate Xcode project
|
- name: Generate Xcode project
|
||||||
run: xcodegen generate
|
run: xcodegen generate
|
||||||
@@ -49,3 +49,20 @@ jobs:
|
|||||||
CODE_SIGNING_ALLOWED=NO \
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
CODE_SIGNING_REQUIRED=NO \
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
build
|
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
65
.github/workflows/release.yml
vendored
Normal 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
123
CONTRIBUTING.md
Normal 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
38
ExportOptions.plist
Normal 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><none></string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -45,12 +45,16 @@ final class StoryStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Persist the current feed to UserDefaults so it survives process kills.
|
/// 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() {
|
private func persistQuickCache() {
|
||||||
let encoder = JSONEncoder()
|
|
||||||
encoder.dateEncodingStrategy = .iso8601
|
|
||||||
let payload = QuickCachePayload(stories: Array(stories.prefix(100)), savedAt: Date())
|
let payload = QuickCachePayload(stories: Array(stories.prefix(100)), savedAt: Date())
|
||||||
if let data = try? encoder.encode(payload) {
|
let key = quickCacheKey
|
||||||
UserDefaults.standard.set(data, forKey: 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() {
|
func loadSectionSupplements() {
|
||||||
guard selectedTags.isEmpty else { return } // only for "All" pill
|
guard selectedTags.isEmpty else { return } // only for "All" pill
|
||||||
for sec in supplementSections {
|
let sections = supplementSections
|
||||||
let id = sec.id
|
Task { [weak self] in
|
||||||
let tags = sec.tags
|
guard let self else { return }
|
||||||
Task { [weak self] in
|
var merged: [String: [StorySummary]] = [:]
|
||||||
guard let self else { return }
|
await withTaskGroup(of: (String, [StorySummary]?).self) { group in
|
||||||
if let result = try? await api.fetchStories(tags: tags, limit: 8) {
|
for sec in sections {
|
||||||
await MainActor.run { self.sectionSupplement[id] = result.data }
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ struct SignalFeedView: View {
|
|||||||
@EnvironmentObject var ws: WebSocketManager
|
@EnvironmentObject var ws: WebSocketManager
|
||||||
@EnvironmentObject var settings: ServerSettings
|
@EnvironmentObject var settings: ServerSettings
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.modelContainer) private var modelContainer
|
||||||
|
|
||||||
@Query private var cachedStories: [CachedStory]
|
@Query private var cachedStories: [CachedStory]
|
||||||
@Query private var cachedArticles: [CachedArticle]
|
@Query private var cachedArticles: [CachedArticle]
|
||||||
@@ -723,22 +724,25 @@ struct SignalFeedView: View {
|
|||||||
|
|
||||||
private func cacheStories(_ stories: [StorySummary]) {
|
private func cacheStories(_ stories: [StorySummary]) {
|
||||||
guard !stories.isEmpty else { return }
|
guard !stories.isEmpty else { return }
|
||||||
// One fetch + an in-memory index, instead of a query per story.
|
let container = modelContainer
|
||||||
let existing = (try? modelContext.fetch(FetchDescriptor<CachedStory>())) ?? []
|
Task.detached {
|
||||||
var byId = Dictionary(existing.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
|
let ctx = ModelContext(container)
|
||||||
for summary in stories {
|
let existing = (try? ctx.fetch(FetchDescriptor<CachedStory>())) ?? []
|
||||||
if let found = byId[summary.id] {
|
var byId = Dictionary(existing.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
|
||||||
found.signalScore = summary.signalScore
|
for summary in stories {
|
||||||
found.sourceCount = summary.sourceCount
|
if let found = byId[summary.id] {
|
||||||
found.tags = summary.tags ?? []
|
found.signalScore = summary.signalScore
|
||||||
found.updatedAt = summary.updatedAt
|
found.sourceCount = summary.sourceCount
|
||||||
} else {
|
found.tags = summary.tags ?? []
|
||||||
let c = CachedStory(from: summary)
|
found.updatedAt = summary.updatedAt
|
||||||
modelContext.insert(c)
|
} else {
|
||||||
byId[summary.id] = c
|
let c = CachedStory(from: summary)
|
||||||
|
ctx.insert(c)
|
||||||
|
byId[summary.id] = c
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
try? ctx.save()
|
||||||
}
|
}
|
||||||
try? modelContext.save()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user