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.
|
||||
# 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
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.
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user