Compare commits

...

5 Commits

Author SHA1 Message Date
cc32d7acad fix reconnect socket 2025-10-21 19:12:25 +03:00
da5f198d8c add refresh chat msg 2025-10-21 19:02:36 +03:00
49ac88c23c edit chat list 2025-10-21 18:34:27 +03:00
7c8940da5b add cache 2025-10-21 18:10:25 +03:00
6fd82e25c1 add database encrypt 2025-10-21 17:56:12 +03:00
11 changed files with 464 additions and 26 deletions

View File

@ -28,12 +28,12 @@ struct TopBarView: View {
} }
private var statusMessage: String? { private var statusMessage: String? {
if viewModel.socketState != .connected {
return NSLocalizedString("Подключение", comment: "")
}
if viewModel.chatLoadingState == .loading { if viewModel.chatLoadingState == .loading {
return NSLocalizedString("Загрузка чатов", comment: "") return NSLocalizedString("Загрузка чатов", comment: "")
} }
if viewModel.socketState == .connecting {
return NSLocalizedString("Подключение", comment: "")
}
return nil return nil
} }

View File

@ -0,0 +1,233 @@
import Foundation
import CoreData
final class ChatCacheWriter {
static let shared = ChatCacheWriter()
private let persistenceController: PersistenceController
private let userDefaults: UserDefaults
private let keychainService: KeychainService
init(
persistenceController: PersistenceController = .shared,
userDefaults: UserDefaults = .standard,
keychainService: KeychainService = .shared
) {
self.persistenceController = persistenceController
self.userDefaults = userDefaults
self.keychainService = keychainService
}
func storePrivateChatList(_ data: PrivateChatListData) {
guard !AppConfig.DISABLE_DB else { return }
guard let accountUserId = resolveCurrentAccountUserId() else { return }
let context = persistenceController.newBackgroundContext()
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
context.perform { [weak self] in
guard let self else { return }
do {
try self.sync(chatList: data, accountUserId: accountUserId, context: context)
if context.hasChanges {
try context.save()
}
} catch {
if AppConfig.DEBUG {
print("[ChatCacheWriter] Failed to store private chat list: \(error)")
}
}
}
}
private func resolveCurrentAccountUserId() -> String? {
guard let login = userDefaults.string(forKey: "currentUser") else {
return nil
}
return keychainService.get(forKey: "userId", service: login)
}
private func sync(chatList: PrivateChatListData, accountUserId: String, context: NSManagedObjectContext) throws {
var profileCache: [String: CDProfile] = [:]
var chatCache: [String: CDPrivateChat] = [:]
var messageCache: [String: CDMessage] = [:]
for item in chatList.items {
let chat = try upsertChat(item, accountUserId: accountUserId, context: context, cache: &chatCache)
let previousLastMessage = chat.lastMessage
if let profileData = item.chatData {
let profile = try upsertProfile(profileData, accountUserId: accountUserId, context: context, cache: &profileCache)
chat.chatData = profile
} else {
chat.chatData = nil
}
if let lastMessageData = item.lastMessage {
let message = try upsertMessage(
lastMessageData,
accountUserId: accountUserId,
chat: chat,
context: context,
profileCache: &profileCache,
messageCache: &messageCache
)
if let previous = previousLastMessage, previous.objectID != message.objectID {
previous.chatAsLastMessage = nil
}
chat.lastMessage = message
} else {
previousLastMessage?.chatAsLastMessage = nil
chat.lastMessage = nil
}
}
}
private func upsertChat(
_ chatItem: PrivateChatListItem,
accountUserId: String,
context: NSManagedObjectContext,
cache: inout [String: CDPrivateChat]
) throws -> CDPrivateChat {
if let cached = cache[chatItem.chatId] {
apply(chatItem: chatItem, to: cached, accountUserId: accountUserId)
return cached
}
let chat = try fetchChat(chatId: chatItem.chatId, accountUserId: accountUserId, context: context) ?? CDPrivateChat(context: context)
apply(chatItem: chatItem, to: chat, accountUserId: accountUserId)
cache[chatItem.chatId] = chat
return chat
}
private func apply(chatItem: PrivateChatListItem, to chat: CDPrivateChat, accountUserId: String) {
chat.accountUserId = accountUserId
chat.chatId = chatItem.chatId
chat.chatType = chatItem.chatType.rawValue
chat.createdAt = chatItem.createdAt
chat.unreadCount = Int32(chatItem.unreadCount)
}
private func upsertProfile(
_ profileData: ChatProfile,
accountUserId: String,
context: NSManagedObjectContext,
cache: inout [String: CDProfile]
) throws -> CDProfile {
if let cached = cache[profileData.userId] {
apply(profile: profileData, to: cached, accountUserId: accountUserId)
return cached
}
let profile = try fetchProfile(userId: profileData.userId, accountUserId: accountUserId, context: context) ?? CDProfile(context: context)
apply(profile: profileData, to: profile, accountUserId: accountUserId)
cache[profileData.userId] = profile
return profile
}
private func apply(profile profileData: ChatProfile, to profile: CDProfile, accountUserId: String) {
profile.accountUserId = accountUserId
profile.userId = profileData.userId
profile.login = profileData.login
profile.fullName = profileData.fullName
profile.customName = profileData.customName
profile.bio = profileData.bio
if let lastSeen = profileData.lastSeen {
profile.setValue(NSNumber(value: lastSeen), forKey: "lastSeen")
} else {
profile.setValue(nil, forKey: "lastSeen")
}
profile.createdAt = profileData.createdAt
profile.isOfficial = profileData.isOfficial
}
private func upsertMessage(
_ messageData: MessageItem,
accountUserId: String,
chat: CDPrivateChat,
context: NSManagedObjectContext,
profileCache: inout [String: CDProfile],
messageCache: inout [String: CDMessage]
) throws -> CDMessage {
if let cached = messageCache[messageData.messageId] {
apply(message: messageData, to: cached, accountUserId: accountUserId, chat: chat, context: context, profileCache: &profileCache)
return cached
}
let message = try fetchMessage(messageId: messageData.messageId, accountUserId: accountUserId, context: context) ?? CDMessage(context: context)
apply(message: messageData, to: message, accountUserId: accountUserId, chat: chat, context: context, profileCache: &profileCache)
messageCache[messageData.messageId] = message
return message
}
private func apply(
message messageData: MessageItem,
to message: CDMessage,
accountUserId: String,
chat: CDPrivateChat,
context: NSManagedObjectContext,
profileCache: inout [String: CDProfile]
) {
message.accountUserId = accountUserId
message.messageId = messageData.messageId
message.chatId = messageData.chatId
message.messageType = messageData.messageType
message.content = messageData.content
message.mediaLink = messageData.mediaLink
message.createdAt = messageData.createdAt
message.updatedAt = messageData.updatedAt
message.senderId = messageData.senderId
message.chat = chat
message.chatAsLastMessage = chat
if let isViewed = messageData.isViewed {
message.setValue(NSNumber(value: isViewed), forKey: "isViewed")
} else {
message.setValue(nil, forKey: "isViewed")
}
if let forward = messageData.forwardMetadata {
message.forwardType = forward.forwardType
message.forwardSenderId = forward.forwardSenderId
message.forwardMessageId = forward.forwardMessageId
} else {
message.forwardType = nil
message.forwardSenderId = nil
message.forwardMessageId = nil
}
if let senderProfileData = messageData.senderData {
if let profile = try? upsertProfile(
senderProfileData,
accountUserId: accountUserId,
context: context,
cache: &profileCache
) {
message.sender = profile
}
} else {
message.sender = nil
}
}
private func fetchChat(chatId: String, accountUserId: String, context: NSManagedObjectContext) throws -> CDPrivateChat? {
let request = NSFetchRequest<CDPrivateChat>(entityName: "CDPrivateChat")
request.fetchLimit = 1
request.predicate = NSPredicate(format: "accountUserId == %@ AND chatId == %@", accountUserId, chatId)
return try context.fetch(request).first
}
private func fetchProfile(userId: String, accountUserId: String, context: NSManagedObjectContext) throws -> CDProfile? {
let request = NSFetchRequest<CDProfile>(entityName: "CDProfile")
request.fetchLimit = 1
request.predicate = NSPredicate(format: "accountUserId == %@ AND userId == %@", accountUserId, userId)
return try context.fetch(request).first
}
private func fetchMessage(messageId: String, accountUserId: String, context: NSManagedObjectContext) throws -> CDMessage? {
let request = NSFetchRequest<CDMessage>(entityName: "CDMessage")
request.fetchLimit = 1
request.predicate = NSPredicate(format: "accountUserId == %@ AND messageId == %@", accountUserId, messageId)
return try context.fetch(request).first
}
}

View File

@ -0,0 +1,101 @@
import CoreData
enum PersistenceControllerError: Error {
case encryptionKeyUnavailable
case persistentStoreMissing
case rekeyingUnavailable
}
final class PersistenceController {
static let shared: PersistenceController = {
do {
return try PersistenceController()
} catch {
fatalError("Failed to initialize PersistenceController: \(error)")
}
}()
let container: NSPersistentContainer
private let keyManager: DatabaseEncryptionKeyManager
var viewContext: NSManagedObjectContext {
container.viewContext
}
init(
inMemory: Bool = false,
keyManager: DatabaseEncryptionKeyManager = .shared,
fileManager: FileManager = .default
) throws {
self.keyManager = keyManager
let modelName = "YobbleDataModel"
guard let modelURL = Bundle.main.url(forResource: modelName, withExtension: "momd"),
let managedObjectModel = NSManagedObjectModel(contentsOf: modelURL) else {
fatalError("Unable to load Core Data model \(modelName)")
}
container = NSPersistentContainer(name: modelName, managedObjectModel: managedObjectModel)
let description = container.persistentStoreDescriptions.first ?? NSPersistentStoreDescription()
description.type = NSSQLiteStoreType
if inMemory {
description.url = URL(fileURLWithPath: "/dev/null")
} else {
description.url = try Self.makeStoreURL(fileManager: fileManager, fileName: "\(modelName).sqlite")
}
description.shouldInferMappingModelAutomatically = true
description.shouldMigrateStoreAutomatically = true
let key: String
do {
key = try keyManager.currentKey()
} catch {
throw PersistenceControllerError.encryptionKeyUnavailable
}
let pragmas: [String: String] = [
"journal_mode": "WAL",
"cipher_page_size": "4096",
"key": key
]
description.setOption(pragmas as NSDictionary, forKey: NSSQLitePragmasOption)
description.setOption(FileProtectionType.completeUntilFirstUserAuthentication as NSObject, forKey: NSPersistentStoreFileProtectionKey)
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { _, error in
if let error {
fatalError("Unresolved error \(error)")
}
}
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
container.viewContext.automaticallyMergesChangesFromParent = true
}
func newBackgroundContext() -> NSManagedObjectContext {
container.newBackgroundContext()
}
/// Placeholder for a future rekey flow once a password-based key is available.
///
/// On iOS 16 with SQLCipher you typically re-encrypt by running `PRAGMA rekey` via a raw
/// SQLite handle and then persisting the new key to the key manager. This helper keeps
/// the signature in place for when that flow is implemented.
func rekeyStore(to newKey: String) throws {
throw PersistenceControllerError.rekeyingUnavailable
}
private static func makeStoreURL(fileManager: FileManager, fileName: String) throws -> URL {
guard let baseURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
fatalError("Unable to resolve Application Support directory")
}
if !fileManager.fileExists(atPath: baseURL.path) {
try fileManager.createDirectory(at: baseURL, withIntermediateDirectories: true)
}
return baseURL.appendingPathComponent(fileName)
}
}

View File

@ -19,12 +19,14 @@ enum ChatServiceError: LocalizedError {
final class ChatService { final class ChatService {
private let client: NetworkClient private let client: NetworkClient
private let decoder: JSONDecoder private let decoder: JSONDecoder
private let cacheWriter: ChatCacheWriter
init(client: NetworkClient = .shared) { init(client: NetworkClient = .shared, cacheWriter: ChatCacheWriter = .shared) {
self.client = client self.client = client
self.decoder = JSONDecoder() self.decoder = JSONDecoder()
self.decoder.keyDecodingStrategy = .convertFromSnakeCase self.decoder.keyDecodingStrategy = .convertFromSnakeCase
self.decoder.dateDecodingStrategy = .custom(Self.decodeDate) self.decoder.dateDecodingStrategy = .custom(Self.decodeDate)
self.cacheWriter = cacheWriter
} }
func fetchPrivateChats( func fetchPrivateChats(
@ -42,7 +44,7 @@ final class ChatService {
method: .get, method: .get,
query: query, query: query,
requiresAuth: true requiresAuth: true
) { [decoder] result in ) { [decoder, weak self] result in
switch result { switch result {
case .success(let response): case .success(let response):
do { do {
@ -52,7 +54,9 @@ final class ChatService {
completion(.failure(ChatServiceError.unexpectedStatus(message))) completion(.failure(ChatServiceError.unexpectedStatus(message)))
return return
} }
completion(.success(apiResponse.data)) let chatData = apiResponse.data
self?.cacheWriter.storePrivateChatList(chatData)
completion(.success(chatData))
} catch { } catch {
let debugMessage = Self.describeDecodingError(error: error, data: response.data) let debugMessage = Self.describeDecodingError(error: error, data: response.data)
if AppConfig.DEBUG { if AppConfig.DEBUG {

View File

@ -2287,4 +2287,4 @@
} }
}, },
"version" : "1.0" "version" : "1.0"
} }

View File

@ -0,0 +1,45 @@
import Foundation
enum DatabaseEncryptionKeyError: Error {
case keyNotAvailable
}
final class DatabaseEncryptionKeyManager {
static let shared = DatabaseEncryptionKeyManager()
private let keychainService: KeychainService
private let serviceName = "yobble.database.encryption"
private let accountName = "sqlcipher_key"
/// Hardcoded dev key used until the user saves their own password-derived key.
private let fallbackKey: String
init(
keychainService: KeychainService = .shared,
fallbackKey: String = AppConfig.DEFAULT_DATABASE_ENCRYPTION_KEY
) {
self.keychainService = keychainService
self.fallbackKey = fallbackKey
}
func currentKey() throws -> String {
if let key = keychainService.get(forKey: accountName, service: serviceName), !key.isEmpty {
return key
}
guard !fallbackKey.isEmpty else {
throw DatabaseEncryptionKeyError.keyNotAvailable
}
return fallbackKey
}
func persistKey(_ key: String) {
keychainService.save(key, forKey: accountName, service: serviceName)
}
func clearPersistedKey() {
keychainService.delete(forKey: accountName, service: serviceName)
}
func hasPersistedKey() -> Bool {
keychainService.get(forKey: accountName, service: serviceName) != nil
}
}

View File

@ -41,6 +41,7 @@ final class SocketService {
private var heartbeatAckInFlight = false private var heartbeatAckInFlight = false
private var lastHeartbeatSentAt: Date? private var lastHeartbeatSentAt: Date?
private var consecutiveHeartbeatMisses = 0 private var consecutiveHeartbeatMisses = 0
private var reconnectWorkItem: DispatchWorkItem?
#endif #endif
private init() {} private init() {}
@ -126,6 +127,7 @@ final class SocketService {
currentToken = token currentToken = token
currentAuthPayload = ["token": token] currentAuthPayload = ["token": token]
setupSocket(with: token) setupSocket(with: token)
cancelScheduledReconnect()
updateConnectionState(.connecting) updateConnectionState(.connecting)
socket?.connect(withPayload: currentAuthPayload) socket?.connect(withPayload: currentAuthPayload)
startHeartbeat() startHeartbeat()
@ -202,11 +204,13 @@ final class SocketService {
socket.on(clientEvent: .disconnect) { data, _ in socket.on(clientEvent: .disconnect) { data, _ in
if AppConfig.DEBUG { print("[SocketService] Disconnected: \(data)") } if AppConfig.DEBUG { print("[SocketService] Disconnected: \(data)") }
self.updateConnectionState(.disconnected) self.updateConnectionState(.disconnected)
self.scheduleReconnect()
} }
socket.on(clientEvent: .error) { data, _ in socket.on(clientEvent: .error) { data, _ in
if AppConfig.DEBUG { print("[SocketService] Error: \(data)") } if AppConfig.DEBUG { print("[SocketService] Error: \(data)") }
self.updateConnectionState(.disconnected) self.updateConnectionState(.disconnected)
self.scheduleReconnect()
} }
socket.on("pong") { [weak self] _, _ in socket.on("pong") { [weak self] _, _ in
@ -223,6 +227,7 @@ final class SocketService {
private func disconnectInternal() { private func disconnectInternal() {
stopHeartbeat() stopHeartbeat()
cancelScheduledReconnect()
socket?.disconnect() socket?.disconnect()
manager?.disconnect() manager?.disconnect()
socket = nil socket = nil
@ -321,6 +326,7 @@ final class SocketService {
consecutiveHeartbeatMisses = 0 consecutiveHeartbeatMisses = 0
heartbeatAckInFlight = false heartbeatAckInFlight = false
lastHeartbeatSentAt = nil lastHeartbeatSentAt = nil
cancelScheduledReconnect()
updateConnectionState(.connected) updateConnectionState(.connected)
} }
@ -339,6 +345,28 @@ final class SocketService {
socket.disconnect() socket.disconnect()
socket.connect(withPayload: currentAuthPayload) socket.connect(withPayload: currentAuthPayload)
} }
private func scheduleReconnect(after delay: TimeInterval = 5) {
cancelScheduledReconnect()
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
if self.refreshAuthTokenIfNeeded(disconnectIfMissing: true) { return }
if let socket = self.socket {
self.updateConnectionState(.connecting)
socket.connect(withPayload: self.currentAuthPayload)
} else {
self.connectForCurrentUser()
}
}
reconnectWorkItem = workItem
syncQueue.asyncAfter(deadline: .now() + delay, execute: workItem)
}
private func cancelScheduledReconnect() {
reconnectWorkItem?.cancel()
reconnectWorkItem = nil
}
#else #else
private func disconnectInternal() { } private func disconnectInternal() { }
#endif #endif

View File

@ -11,7 +11,7 @@ import UIKit
#endif #endif
struct ChatsTab: View { struct ChatsTab: View {
var currentUserId: String? @ObservedObject private var loginViewModel: LoginViewModel
@Binding var searchRevealProgress: CGFloat @Binding var searchRevealProgress: CGFloat
@Binding var searchText: String @Binding var searchText: String
private let searchService = SearchService() private let searchService = SearchService()
@ -33,8 +33,13 @@ struct ChatsTab: View {
private let searchRevealDistance: CGFloat = 90 private let searchRevealDistance: CGFloat = 90
init(currentUserId: String? = nil, searchRevealProgress: Binding<CGFloat>, searchText: Binding<String>) { private var currentUserId: String? {
self.currentUserId = currentUserId let userId = loginViewModel.userId
return userId.isEmpty ? nil : userId
}
init(loginViewModel: LoginViewModel, searchRevealProgress: Binding<CGFloat>, searchText: Binding<String>) {
self._loginViewModel = ObservedObject(wrappedValue: loginViewModel)
self._searchRevealProgress = searchRevealProgress self._searchRevealProgress = searchRevealProgress
self._searchText = searchText self._searchText = searchText
} }
@ -111,11 +116,11 @@ struct ChatsTab: View {
Text(message) Text(message)
.font(.subheadline) .font(.subheadline)
.foregroundColor(.orange) .foregroundColor(.orange)
Spacer(minLength: 0) // Spacer(minLength: 0)
Button(action: { viewModel.refresh() }) { // Button(action: triggerChatsReload) {
Text(NSLocalizedString("Обновить", comment: "")) // Text(NSLocalizedString("Обновить", comment: ""))
.font(.subheadline) // .font(.subheadline)
} // }
} }
.padding(.vertical, 4) .padding(.vertical, 4)
} }
@ -139,9 +144,10 @@ struct ChatsTab: View {
globalSearchContent globalSearchContent
} }
} else { } else {
if let message = viewModel.errorMessage, viewModel.chats.isEmpty { // if let message = viewModel.errorMessage, viewModel.chats.isEmpty {
errorState(message: message) // errorState(message: message)
} else if viewModel.chats.isEmpty { // } else
if viewModel.chats.isEmpty {
emptyState emptyState
} else { } else {
@ -296,9 +302,11 @@ struct ChatsTab: View {
private var globalSearchContent: some View { private var globalSearchContent: some View {
if isGlobalSearchLoading { if isGlobalSearchLoading {
globalSearchLoadingRow globalSearchLoadingRow
} else if let error = globalSearchError { } else
globalSearchErrorRow(message: error) // if let error = globalSearchError {
} else if globalSearchResults.isEmpty { // globalSearchErrorRow(message: error)
// } else
if globalSearchResults.isEmpty {
globalSearchEmptyRow globalSearchEmptyRow
} else { } else {
ForEach(globalSearchResults) { user in ForEach(globalSearchResults) { user in
@ -341,7 +349,7 @@ struct ChatsTab: View {
.font(.body) .font(.body)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundColor(.primary) .foregroundColor(.primary)
Button(action: { viewModel.loadInitialChats(force: true) }) { Button(action: triggerChatsReload) {
Text(NSLocalizedString("Повторить", comment: "")) Text(NSLocalizedString("Повторить", comment: ""))
.font(.headline) .font(.headline)
} }
@ -359,7 +367,7 @@ struct ChatsTab: View {
Text(NSLocalizedString("Пока что у вас нет чатов", comment: "")) Text(NSLocalizedString("Пока что у вас нет чатов", comment: ""))
.font(.body) .font(.body)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Button(action: { viewModel.refresh() }) { Button(action: triggerChatsReload) {
Text(NSLocalizedString("Обновить", comment: "")) Text(NSLocalizedString("Обновить", comment: ""))
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
@ -381,6 +389,13 @@ struct ChatsTab: View {
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
} }
private func triggerChatsReload() {
if loginViewModel.chatLoadingState != .loading {
loginViewModel.chatLoadingState = .loading
}
viewModel.loadInitialChats(force: true)
}
@ViewBuilder @ViewBuilder
private func chatRowItem(for chat: PrivateChatListItem) -> some View { private func chatRowItem(for chat: PrivateChatListItem) -> some View {
Button { Button {
@ -1135,10 +1150,15 @@ struct ChatsTab_Previews: PreviewProvider {
struct Wrapper: View { struct Wrapper: View {
@State private var progress: CGFloat = 1 @State private var progress: CGFloat = 1
@State private var searchText: String = "" @State private var searchText: String = ""
@StateObject private var loginViewModel = LoginViewModel()
var body: some View { var body: some View {
ChatsTab(searchRevealProgress: $progress, searchText: $searchText) ChatsTab(
.environmentObject(ThemeManager()) loginViewModel: loginViewModel,
searchRevealProgress: $progress,
searchText: $searchText
)
.environmentObject(ThemeManager())
} }
} }

View File

@ -53,7 +53,7 @@ struct MainView: View {
.opacity(selectedTab == 1 ? 1 : 0) .opacity(selectedTab == 1 ? 1 : 0)
ChatsTab( ChatsTab(
currentUserId: viewModel.userId.isEmpty ? nil : viewModel.userId, loginViewModel: viewModel,
searchRevealProgress: $chatSearchRevealProgress, searchRevealProgress: $chatSearchRevealProgress,
searchText: $chatSearchText searchText: $chatSearchText
) )

View File

@ -11,6 +11,10 @@ struct AppConfig {
static let USER_AGENT = "yobble ios" static let USER_AGENT = "yobble ios"
static let APP_BUILD = "appstore" // appstore / freestore static let APP_BUILD = "appstore" // appstore / freestore
static let APP_VERSION = "0.1" static let APP_VERSION = "0.1"
static let DISABLE_DB = false
/// Fallback SQLCipher key used until the user sets an application password.
static let DEFAULT_DATABASE_ENCRYPTION_KEY = "yobble_dev_change_me"
} }
struct AppInfo { struct AppInfo {

View File

@ -6,11 +6,13 @@
// //
import SwiftUI import SwiftUI
import CoreData
@main @main
struct yobbleApp: App { struct yobbleApp: App {
@StateObject private var themeManager = ThemeManager() @StateObject private var themeManager = ThemeManager()
@StateObject private var viewModel = LoginViewModel() @StateObject private var viewModel = LoginViewModel()
private let persistenceController = PersistenceController.shared
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
@ -25,6 +27,7 @@ struct yobbleApp: App {
} }
.environmentObject(themeManager) .environmentObject(themeManager)
.preferredColorScheme(themeManager.theme.colorScheme) .preferredColorScheme(themeManager.theme.colorScheme)
.environment(\.managedObjectContext, persistenceController.viewContext)
} }
} }
} }