Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,14 @@ actor LibraryHydrationWorker: LibraryHydrationWorking {
init(
detailsCacheURL: URL,
sectionsCacheURL: URL,
repositoryStoreURL: URL? = nil,
homeMerchandisingCacheURL _: URL
) {
let repository: any LibraryRepository
do {
repository = try SwiftDataLibraryRepository(storeURL: sectionsCacheURL)
let storeURL = repositoryStoreURL
?? sectionsCacheURL.deletingPathExtension().appendingPathExtension("swiftdata")
repository = try SwiftDataLibraryRepository(storeURL: storeURL)
} catch {
fatalError("Failed to initialize library repository: \(error)")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,22 +39,10 @@ actor SwiftDataLibraryRepository: LibraryRepository {
isStoredInMemoryOnly: Bool = false
) throws {
let schema = Schema([UnifiedLibraryCacheRecord.self])
let configuration = if let storeURL {
ModelConfiguration(
Self.storeName,
schema: schema,
url: storeURL
)
} else {
ModelConfiguration(
Self.storeName,
schema: schema,
isStoredInMemoryOnly: isStoredInMemoryOnly
)
}
self.modelContainer = try ModelContainer(
for: schema,
configurations: [configuration]
self.modelContainer = try Self.makeModelContainer(
schema: schema,
storeURL: storeURL,
isStoredInMemoryOnly: isStoredInMemoryOnly
)
}

Expand Down Expand Up @@ -152,6 +140,70 @@ actor SwiftDataLibraryRepository: LibraryRepository {
ModelContext(modelContainer)
}

private static func makeModelContainer(
schema: Schema,
storeURL: URL?,
isStoredInMemoryOnly: Bool
) throws -> ModelContainer {
if let storeURL, !isStoredInMemoryOnly {
try ensureParentDirectoryExists(for: storeURL)
}

let configuration = if let storeURL {
ModelConfiguration(
Self.storeName,
schema: schema,
url: storeURL
)
} else {
ModelConfiguration(
Self.storeName,
schema: schema,
isStoredInMemoryOnly: isStoredInMemoryOnly
)
}

do {
return try ModelContainer(
for: schema,
configurations: [configuration]
)
} catch {
guard let storeURL, !isStoredInMemoryOnly else {
throw error
}

// The unified library snapshot is a disposable cache. If the on-disk store is
// unreadable or incompatible, clear it once and rebuild the container.
resetStoreArtifacts(at: storeURL)
try ensureParentDirectoryExists(for: storeURL)
return try ModelContainer(
for: schema,
configurations: [configuration]
)
}
}

private static func ensureParentDirectoryExists(for storeURL: URL) throws {
try FileManager.default.createDirectory(
at: storeURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
}

private static func resetStoreArtifacts(at storeURL: URL) {
let fileManager = FileManager.default
let artifactURLs = [
storeURL,
URL(fileURLWithPath: storeURL.path + "-shm"),
URL(fileURLWithPath: storeURL.path + "-wal"),
]

for artifactURL in artifactURLs {
try? fileManager.removeItem(at: artifactURL)
}
}

/// Preserves the untouched portion of the unified snapshot when only one cache fragment changed.
private func mergedSnapshot(
sections: [CloudLibrarySection]?,
Expand Down
15 changes: 9 additions & 6 deletions Packages/StratixCore/Sources/StratixCore/InputController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ public final class InputController {
@ObservationIgnored private var startupHapticsProbeControllers: Set<ObjectIdentifier> = []
@ObservationIgnored private var didRunAppLaunchHapticsProbe = false
@ObservationIgnored private var hapticsProbeTask: Task<Void, Never>?
@ObservationIgnored private var activeStreamingSession: (any StreamingSessionFacade)?
@ObservationIgnored private var activeInputQueue: InputQueue?
@ObservationIgnored private var didConfigureControllerObservers = false
init() {}
Expand Down Expand Up @@ -152,8 +153,9 @@ public final class InputController {
settingsStore.diagnostics.startupHapticsProbeEnabled
}

func setupControllerObservation(inputQueue: InputQueue) {
activeInputQueue = inputQueue
func setupControllerObservation(streamingSession: any StreamingSessionFacade) {
activeStreamingSession = streamingSession
activeInputQueue = streamingSession.inputQueueRef

for controller in GCController.controllers() {
attachController(controller)
Expand Down Expand Up @@ -212,7 +214,7 @@ public final class InputController {
}

func injectNeutralGamepadFrame(index: UInt8 = 0) {
guard let session = dependencies?.currentStreamingSession() else { return }
guard let session = activeStreamingSession ?? dependencies?.currentStreamingSession() else { return }
let frame = GamepadInputFrame(
gamepadIndex: index,
buttons: [],
Expand All @@ -224,7 +226,7 @@ public final class InputController {
}

func injectPauseMenuTap(index: UInt8 = 0) {
guard let session = dependencies?.currentStreamingSession() else { return }
guard let session = activeStreamingSession ?? dependencies?.currentStreamingSession() else { return }
guard case .connected = session.lifecycle else { return }

let pressed = GamepadInputFrame(
Expand Down Expand Up @@ -276,6 +278,7 @@ public final class InputController {
}

func clearStreamingInputBindings() {
activeStreamingSession = nil
activeInputQueue = nil
}

Expand Down Expand Up @@ -383,7 +386,7 @@ public final class InputController {
queue.enqueueGamepadFrame(frame)
}

dependencies?.currentStreamingSession()?.setGamepadConnectionState(index: 0, connected: true)
activeStreamingSession?.setGamepadConnectionState(index: 0, connected: true)
logger.info("Controller attached: \(controller.vendorName ?? "Unknown")")
}

Expand Down Expand Up @@ -468,7 +471,7 @@ public final class InputController {
comboInterpreters.removeValue(forKey: controllerID)?.cancelAll()
startupHapticsProbeControllers.remove(controllerID)
let isAnyExtendedControllerConnected = GCController.controllers().contains { $0.extendedGamepad != nil }
dependencies?.currentStreamingSession()?.setGamepadConnectionState(
activeStreamingSession?.setGamepadConnectionState(
index: 0,
connected: isAnyExtendedControllerConnected
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,26 @@ public final class LibraryController {
struct CacheLocations: Sendable {
let details: URL
let sections: URL
let repository: URL
let homeMerchandising: URL

var repository: URL {
sections.deletingPathExtension().appendingPathExtension("swiftdata")
init(
details: URL,
sections: URL,
repository: URL? = nil,
homeMerchandising: URL
) {
self.details = details
self.sections = sections
self.repository = repository ?? sections.deletingPathExtension().appendingPathExtension("swiftdata")
self.homeMerchandising = homeMerchandising
}

static var live: Self {
Self(
details: LibraryController.detailsCacheURL,
sections: LibraryController.sectionsCacheURL,
repository: LibraryController.libraryRepositoryStoreURL,
homeMerchandising: LibraryController.homeMerchandisingCacheURL
)
}
Expand Down Expand Up @@ -373,6 +383,10 @@ extension LibraryController {
MetadataCacheStore.url(for: "stratix.cloudLibrarySections.json")
}

nonisolated static var libraryRepositoryStoreURL: URL {
MetadataCacheStore.cacheURL(for: "stratix.cloudLibrarySections.swiftdata")
}

nonisolated static var homeMerchandisingCacheURL: URL {
MetadataCacheStore.url(for: "stratix.homeMerchandising.v1.json")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,10 @@ actor LibraryHydrationPersistenceStore {
) {
let repository: any LibraryRepository
do {
repository = try SwiftDataLibraryRepository(storeURL: sectionsURL)
let storeURL = sectionsURL
.deletingPathExtension()
.appendingPathExtension("swiftdata")
repository = try SwiftDataLibraryRepository(storeURL: storeURL)
} catch {
fatalError("Failed to initialize library repository: \(error)")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,24 @@ extension LibraryController {
/// Removes all persisted library caches so tests or sign-out flows start from a clean disk state.
func clearPersistedLibraryCaches() {
let repositoryURL = cacheLocations.repository
let persistedCacheURLs = [
let legacyRepositoryURL = cacheLocations.sections
.deletingPathExtension()
.appendingPathExtension("swiftdata")
var persistedCacheURLs = [
cacheLocations.details,
cacheLocations.sections,
repositoryURL,
URL(fileURLWithPath: repositoryURL.path + "-shm"),
URL(fileURLWithPath: repositoryURL.path + "-wal"),
cacheLocations.homeMerchandising
]
if legacyRepositoryURL != repositoryURL {
persistedCacheURLs.append(contentsOf: [
legacyRepositoryURL,
URL(fileURLWithPath: legacyRepositoryURL.path + "-shm"),
URL(fileURLWithPath: legacyRepositoryURL.path + "-wal"),
])
}

for cacheURL in persistedCacheURLs {
try? FileManager.default.removeItem(at: cacheURL)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ enum MetadataCacheStore {
cachesDirectory().appendingPathComponent(filename)
}

static func cacheURL(for filename: String) -> URL {
let dir = cachesDirectory().appendingPathComponent(directoryName, isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent(filename)
}

private static func cachesDirectory() -> URL {
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -461,8 +461,8 @@ public final class StreamController {
private func makeRuntimeAttachmentEnvironment() -> StreamRuntimeAttachmentEnvironment {
StreamRuntimeAttachmentEnvironment(
input: StreamRuntimeInputEnvironment(
setupControllerObservation: { [weak self] inputQueue in
self?.dependencies?.inputController.setupControllerObservation(inputQueue: inputQueue)
setupControllerObservation: { [weak self] session in
self?.dependencies?.inputController.setupControllerObservation(streamingSession: session)
},
clearStreamingInputBindings: { [weak self] in
self?.dependencies?.inputController.clearStreamingInputBindings()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import InputBridge
import StreamingCore

struct StreamRuntimeInputEnvironment {
let setupControllerObservation: @Sendable @MainActor (InputQueue) -> Void
let setupControllerObservation: @Sendable @MainActor (any StreamingSessionFacade) -> Void
let clearStreamingInputBindings: @Sendable @MainActor () -> Void
let routeVibration: @Sendable @MainActor (VibrationReport) -> Void
}
Expand Down Expand Up @@ -50,7 +50,7 @@ final class StreamRuntimeAttachmentService {
]
}

environment.input.setupControllerObservation(session.inputQueueRef)
environment.input.setupControllerObservation(session)
session.setVibrationHandler { report in
Task { @MainActor in
environment.input.routeVibration(report)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,58 @@ struct SwiftDataLibraryRepositoryTests {

#expect(await repository.loadUnifiedSectionsSnapshot() == nil)
}

@Test
func init_resetsUnreadablePersistentStoreAndRetries() async throws {
let cacheRoot = FileManager.default.temporaryDirectory
.appendingPathComponent("swiftdata-library-repository-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: cacheRoot, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: cacheRoot) }

let storeURL = cacheRoot.appendingPathComponent("sections.swiftdata")
try Data("not a sqlite database".utf8).write(to: storeURL)
try Data("stale wal".utf8).write(to: URL(fileURLWithPath: storeURL.path + "-wal"))
try Data("stale shm".utf8).write(to: URL(fileURLWithPath: storeURL.path + "-shm"))

let repository = try SwiftDataLibraryRepository(storeURL: storeURL)
let snapshot = LibraryHydrationPersistenceStore.makeUnifiedSectionsSnapshot(
sections: [TestHydrationFixtures.section(items: [TestHydrationFixtures.item(titleId: "title-1", productId: "product-1")])],
homeMerchandising: nil,
discovery: nil,
savedAt: .now,
isUnifiedHomeReady: false
)

await repository.saveUnifiedSectionsSnapshot(snapshot)

let decoded = try #require(await repository.loadUnifiedSectionsSnapshot())
#expect(decoded.sections.map(\.id) == ["library"])
#expect(FileManager.default.fileExists(atPath: storeURL.path))
}

@Test
func init_createsMissingParentDirectoriesBeforeOpeningStore() async throws {
let cacheRoot = FileManager.default.temporaryDirectory
.appendingPathComponent("swiftdata-library-repository-parent-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: cacheRoot) }

let storeURL = cacheRoot
.appendingPathComponent("nested", isDirectory: true)
.appendingPathComponent("sections.swiftdata")
let repository = try SwiftDataLibraryRepository(storeURL: storeURL)
let snapshot = LibraryHydrationPersistenceStore.makeUnifiedSectionsSnapshot(
sections: [TestHydrationFixtures.section(items: [TestHydrationFixtures.item(titleId: "title-1", productId: "product-1")])],
homeMerchandising: nil,
discovery: nil,
savedAt: .now,
isUnifiedHomeReady: false
)

await repository.saveUnifiedSectionsSnapshot(snapshot)

let decoded = try #require(await repository.loadUnifiedSectionsSnapshot())
#expect(decoded.sections.map(\.id) == ["library"])
#expect(FileManager.default.fileExists(atPath: storeURL.deletingLastPathComponent().path))
#expect(FileManager.default.fileExists(atPath: storeURL.path))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -243,13 +243,15 @@ struct LibraryControllerTests {
defer { try? FileManager.default.removeItem(at: cacheRoot) }

let sectionsURL = cacheRoot.appendingPathComponent("sections.json")
let repositoryURL = cacheRoot.appendingPathComponent("sections.swiftdata")
let snapshot = makeUnifiedSectionsSnapshot(savedAt: Date())
let repository = try SwiftDataLibraryRepository(storeURL: sectionsURL)
let repository = try SwiftDataLibraryRepository(storeURL: repositoryURL)
await repository.saveUnifiedSectionsSnapshot(snapshot)

let worker = LibraryHydrationWorker(
detailsCacheURL: cacheRoot.appendingPathComponent("details.json"),
sectionsCacheURL: sectionsURL,
repositoryStoreURL: repositoryURL,
homeMerchandisingCacheURL: cacheRoot.appendingPathComponent("home.json")
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,10 @@ struct LibraryHydrationPersistenceMetadataTests {

private func makeRepository(cacheRoot: URL) throws -> SwiftDataLibraryRepository {
try SwiftDataLibraryRepository(
storeURL: cacheRoot.appendingPathComponent("sections.json")
storeURL: cacheRoot
.appendingPathComponent("sections.json")
.deletingPathExtension()
.appendingPathExtension("swiftdata")
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,10 @@ struct LibraryHydrationPersistenceTests {

private func makeRepository(cacheRoot: URL) throws -> SwiftDataLibraryRepository {
try SwiftDataLibraryRepository(
storeURL: cacheRoot.appendingPathComponent("sections.json")
storeURL: cacheRoot
.appendingPathComponent("sections.json")
.deletingPathExtension()
.appendingPathExtension("swiftdata")
)
}

Expand Down
Loading
Loading