1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
import AppIntents
import Nuke
import os
import SwiftUI
private let appSignposter = OSSignposter(subsystem: "social.grain.grain", category: "AppLaunch")
private let appLogger = Logger(subsystem: "social.grain.grain", category: "AppLaunch")
@main
struct GrainApp: App {
init() {
LaunchMetrics.beginTFP()
LaunchMetrics.beginPreBody()
appSignposter.emitEvent("GrainAppInitBegin")
// Defer Nuke DataCache setup off the main-thread init path — no images
// load during the ~800ms before MainTabView.task fires, so this is safe.
Task.detached(priority: .userInitiated) {
let spid = appSignposter.makeSignpostID()
let state = appSignposter.beginInterval("NukePipelineSetup", id: spid)
appLogger.debug("[NukePipelineSetup] begin")
var config = ImagePipeline.Configuration.withDataCache
if let dataCache = try? DataCache(name: "social.grain.images") {
config.dataCache = dataCache
}
await MainActor.run { ImagePipeline.shared = ImagePipeline(configuration: config, delegate: GrainImagePipelineDelegate()) }
appSignposter.endInterval("NukePipelineSetup", state)
appLogger.debug("[NukePipelineSetup] end")
}
Task.detached(priority: .userInitiated) {
let spid = appSignposter.makeSignpostID()
let state = appSignposter.beginInterval("ConnectionPreheat", id: spid)
var req = URLRequest(url: AuthManager.serverURL.appendingPathComponent("xrpc/_health"))
req.httpMethod = "GET"
req.timeoutInterval = 5
_ = try? await NetworkEnvironment.session.data(for: req)
appSignposter.endInterval("ConnectionPreheat", state)
}
appSignposter.emitEvent("GrainAppInitEnd")
}
@UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
@State private var authManager = AuthManager()
@State private var pushManager = PushManager()
@State private var storyStatusCache = StoryStatusCache()
@State private var viewedStoryStorage = ViewedStoryStorage()
@State private var labelDefsCache = LabelDefinitionsCache()
@State private var uploadCenter = GalleryUploadCenter()
@State private var pendingDeepLink: DeepLink?
@State private var updateGate = AppUpdateGate()
@Environment(\.scenePhase) private var scenePhase
@AppStorage("appearance") private var appearance: String = "auto"
private var colorScheme: ColorScheme? {
switch appearance {
case "light": .light
case "dark": .dark
default: nil
}
}
var body: some Scene {
WindowGroup {
let _ = appSignposter.emitEvent("WindowGroupBodyBegin")
let isAuthed = authManager.isAuthenticated
let _ = appSignposter.emitEvent("AuthGateResolved")
Group {
if updateGate.isUpdateRequired {
// Replaces the tree rather than covering it, so no sheet or
// cover the app had open can sit above it.
UpdateRequiredView(storeURL: updateGate.storeURL)
} else if isAuthed {
// Keyed on the account: switching rebuilds the whole tree so
// no view model, scroll position, or half-loaded feed
// carries over from the account being left.
MainTabView(pendingDeepLink: $pendingDeepLink)
.id(authManager.userDID)
.environment(authManager)
.environment(pushManager)
.environment(storyStatusCache)
.environment(viewedStoryStorage)
.environment(labelDefsCache)
.environment(uploadCenter)
.tint(Color.accentColor)
.onAppear {
appSignposter.emitEvent("WindowOnAppear")
Task {
viewedStoryStorage.cleanup()
storyStatusCache.purgeExpired()
}
pushManager.configure(authManager: authManager)
appDelegate.pushManager = pushManager
appDelegate.onNotificationTap = { deepLink in
pendingDeepLink = deepLink
}
pushManager.registerIfNeeded()
}
} else {
LoginView()
.environment(authManager)
.tint(Color.accentColor)
}
}
.onAppear {
// Account-switch plumbing, wired outside the auth gate so it's
// in place for the first sign-in as well as later switches.
authManager.onAccountWillDeactivate = { [pushManager] auth in
await pushManager.unregisterToken(auth: auth)
}
authManager.onAccountDidActivate = { [pushManager, storyStatusCache, viewedStoryStorage, uploadCenter] did in
viewedStoryStorage.switchAccount(did: did)
storyStatusCache.clear()
uploadCenter.accountChanged(to: did)
// Nil means the last account signed out — don't ask a
// logged-out user for notification permission.
if did != nil {
pushManager.registerIfNeeded()
}
}
}
.onOpenURL { url in
if let deepLink = DeepLink.from(url: url) {
pendingDeepLink = deepLink
}
}
.task(priority: .background) {
GrainShortcuts.updateAppShortcutParameters()
}
.task {
await updateGate.check()
}
.onChange(of: scenePhase) { _, phase in
if phase == .active {
Task { await updateGate.check() }
}
}
.preferredColorScheme(colorScheme)
}
.environment(authManager)
}
}