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
40 changes: 40 additions & 0 deletions OpenASO.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion OpenASO/App/AppServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ final class AppServices {
observedAt: .now,
source: .appStorePage
)
appStorefrontRatingService.persist(
try appStorefrontRatingService.persist(
AppStorefrontRatingRefreshOutcome(
storefront: request.storefront,
result: result,
Expand Down
73 changes: 69 additions & 4 deletions OpenASO/App/OpenASOApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ struct OpenASOApp: App {
@State private var updaterController = SparkleUpdaterController()
@State private var launchAlert: AppLaunchAlertContext?

private let startupState: OpenASOStartupState
@State private var startupState: OpenASOStartupState = .preparing
@State private var isPreparingStartup = false
@State private var preparationProgress = StoragePreparationProgress.opening

init() {
let executionMode = OpenASOExecutionMode(
Expand All @@ -21,20 +23,51 @@ struct OpenASOApp: App {
Self.runBackgroundRefreshAndExit()
}

let startupState = Self.makeStartupState()
if executionMode == .mcpStdio {
switch startupState {
switch Self.makeStartupState() {
case .ready(_, let services):
Self.runMCPStdioAndExit(serverProvider: services.mcpServerProvider)
case .storeUnavailable(let error):
Self.exitMCPStdio(with: error.diagnosticReport)
case .preparing:
preconditionFailure("Synchronous startup cannot return a preparing state.")
}
}

self.startupState = startupState
_launchAlert = State(initialValue: nil)
}

private func prepareStartupIfNeeded() async {
guard case .preparing = startupState, !isPreparingStartup else { return }
isPreparingStartup = true
defer { isPreparingStartup = false }
let (updates, continuation) = AsyncStream<StoragePreparationProgress>.makeStream(bufferingPolicy: .bufferingNewest(1))
async let progressUpdates: Void = observePreparationProgress(updates)
let result = await Task.detached(priority: .userInitiated) {
() -> Result<ModelContainer, PersistentStoreError> in
defer { continuation.finish() }
do {
return .success(try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: false,
progress: { continuation.yield($0) }))
} catch let error as PersistentStoreError {
return .failure(error)
} catch {
return .failure(.unexpected(diagnosticDescription: String(reflecting: error)))
}
}.value
await progressUpdates
switch result {
case .success(let container):
startupState = .ready(modelContainer: container, services: AppServices.appLaunch(modelContainer: container))
case .failure(let error):
startupState = .storeUnavailable(error)
}
}

private func observePreparationProgress(_ updates: AsyncStream<StoragePreparationProgress>) async {
for await progress in updates { preparationProgress = progress }
}

private static func makeStartupState() -> OpenASOStartupState {
do {
let modelContainer = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: false)
Expand Down Expand Up @@ -84,6 +117,10 @@ struct OpenASOApp: App {
var body: some Scene {
WindowGroup("OpenASO") {
switch startupState {
case .preparing:
StoragePreparationView(progress: preparationProgress)
.frame(idealWidth: 1000, idealHeight: 760)
.task { await prepareStartupIfNeeded() }
case .ready(let modelContainer, let services):
RootView()
.environment(services)
Expand Down Expand Up @@ -123,6 +160,10 @@ struct OpenASOApp: App {

Settings {
switch startupState {
case .preparing:
StoragePreparationView(progress: preparationProgress)
.frame(idealWidth: 680, idealHeight: 480)
.task { await prepareStartupIfNeeded() }
case .ready(let modelContainer, let services):
SettingsView()
.environment(services)
Expand Down Expand Up @@ -167,6 +208,7 @@ struct OpenASOApp: App {
}

private enum OpenASOStartupState {
case preparing
case ready(modelContainer: ModelContainer, services: AppServices)
case storeUnavailable(PersistentStoreError)
}
Expand All @@ -176,3 +218,26 @@ private struct AppLaunchAlertContext: Identifiable {
let title: String
let message: String
}

private struct StoragePreparationView: View {
let progress: StoragePreparationProgress

var body: some View {
VStack(spacing: 12) {
switch progress {
case .archiving(let completed, let total):
ProgressView("Compressing rating history…", value: Double(completed), total: Double(max(total, 1)))
Text("\(completed.formatted()) of \(total.formatted()) rating records processed")
.font(.caption).foregroundStyle(.secondary)
case .compacting:
ProgressView("Reclaiming database space…")
case .waitingForClients:
ProgressView("Waiting for another OpenASO process to finish preparing the database…")
case .opening, .ready:
ProgressView("Opening your workspace…")
}
}
.padding(32)
.frame(maxWidth: 440)
}
}
14 changes: 6 additions & 8 deletions OpenASO/Features/AppDetail/AppDetailCSVTransfer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ struct AppDetailRatingSnapshotValue: Sendable {
let observedAt: Date

init(_ snapshot: AppDailyRating) {
self.init(RatingHistoryRecord(snapshot))
}

init(_ snapshot: RatingHistoryRecord) {
self.appStoreID = snapshot.appStoreID
self.storefront = snapshot.storefront
self.ratingDate = snapshot.ratingDate
Expand Down Expand Up @@ -187,17 +191,11 @@ struct AppDetailRatingsCSVExporter {
},
sortBy: [SortDescriptor(\.storefront, order: .forward)]
)
var snapshotDescriptor = FetchDescriptor<AppDailyRating>(
predicate: #Predicate { snapshot in
snapshot.appStoreID == targetAppStoreID
},
sortBy: [SortDescriptor(\.observedAt, order: .reverse)]
)
snapshotDescriptor.fetchLimit = 1_000
let snapshots = try RatingHistoryStore.recent(appStoreIDs: [appStoreID], limitPerApp: 1_000, in: modelContext)

return AppDetailRatingsExportData(
latestRatings: try modelContext.fetch(latestDescriptor).map(AppDetailRatingLatestValue.init),
ratingSnapshots: try modelContext.fetch(snapshotDescriptor).map(AppDetailRatingSnapshotValue.init)
ratingSnapshots: snapshots.map(AppDetailRatingSnapshotValue.init)
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,19 +239,15 @@ enum KeywordRankingListLoader {
snapshotsByID.reserveCapacity(uniqueAppStoreIDs.count)

guard !uniqueAppStoreIDs.isEmpty else { return [:] }
let descriptor = FetchDescriptor<AppDailyRating>(
predicate: #Predicate { snapshot in
snapshot.storefront == targetStorefront
&& uniqueAppStoreIDs.contains(snapshot.appStoreID)
&& snapshot.ratingCount != nil
},
sortBy: [
SortDescriptor(\.appStoreID, order: .forward),
SortDescriptor(\.ratingDate, order: .reverse),
SortDescriptor(\.observedAt, order: .reverse)
]
)
for (index, snapshot) in try modelContext.fetch(descriptor).enumerated() {
let snapshots = try RatingHistoryStore.recent(
appStoreIDs: uniqueAppStoreIDs, storefronts: [targetStorefront], limitPerApp: ratingSnapshotLimit,
order: .ratingDate, requiresRatingCount: true, in: modelContext
).sorted {
if $0.appStoreID != $1.appStoreID { return $0.appStoreID < $1.appStoreID }
if $0.ratingDate != $1.ratingDate { return $0.ratingDate > $1.ratingDate }
return $0.observedAt > $1.observedAt
}
for (index, snapshot) in snapshots.enumerated() {
if snapshotsByID[snapshot.appStoreID, default: []].count < ratingSnapshotLimit {
snapshotsByID[snapshot.appStoreID, default: []].append(
RatingSnapshotDisplayValue(snapshot)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,10 @@ struct RatingSnapshotDisplayValue: Sendable {
let observedAt: Date

init(_ snapshot: AppDailyRating) {
self.init(RatingHistoryRecord(snapshot))
}

init(_ snapshot: RatingHistoryRecord) {
self.appStoreID = snapshot.appStoreID
self.ratingDate = snapshot.ratingDate
self.ratingCount = snapshot.ratingCount
Expand Down
52 changes: 35 additions & 17 deletions OpenASO/Features/AppDetail/Ratings/AppRatingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ struct AppRatingsView: View {
@State private var latestRatings: [RatingLatestValue] = []
@State private var ratingSnapshots: [RatingSnapshotValue] = []
@State private var isLoadingRatings = false
@State private var activeRatingsLoad: UUID?
@State private var loadedRatingsAppID: Int64?
@State private var ratingsLoadError: String?
@State private var appStoreConnectStatus = RatingsAppStoreConnectStatus.notConnected
@State private var metric = RatingsMetric.ratingCount

Expand Down Expand Up @@ -64,11 +67,26 @@ struct AppRatingsView: View {
)
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
}
.safeAreaInset(edge: .top) {
if let ratingsLoadError {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Label("Rating history couldn’t be loaded", systemImage: "exclamationmark.triangle")
Text(ratingsLoadError).font(.caption).foregroundStyle(.secondary)
}
Spacer()
Button("Retry") { Task { await loadRatings() } }
.disabled(isLoadingRatings)
}
.padding()
.background(.bar)
}
}
.task {
loadStorefrontDefinitionsIfNeeded()
}
.task(id: ratingsReloadID) {
await loadRatings(reset: true)
await loadRatings()
}
.task(id: appStoreConnectReloadID) {
await updateAppStoreConnectStatus()
Expand Down Expand Up @@ -102,15 +120,19 @@ struct AppRatingsView: View {
}
}

private func loadRatings(reset: Bool) async {
guard let backgroundModelStore = services.backgroundModelStore, !isLoadingRatings else {
return
}

private func loadRatings() async {
guard let backgroundModelStore = services.backgroundModelStore else { return }
let loadID = UUID()
activeRatingsLoad = loadID
isLoadingRatings = true
defer { isLoadingRatings = false }
ratingsLoadError = nil
defer { if activeRatingsLoad == loadID { isLoadingRatings = false } }

let targetAppStoreID = appStoreID
if loadedRatingsAppID != targetAppStoreID {
latestRatings = []
ratingSnapshots = []
}
do {
let data = try await backgroundModelStore.read { modelContext in
try Self.fetchRatingsDashboardData(
Expand All @@ -119,11 +141,13 @@ struct AppRatingsView: View {
)
}

guard !Task.isCancelled, activeRatingsLoad == loadID, appStoreID == targetAppStoreID else { return }
latestRatings = data.latestRatings
ratingSnapshots = data.ratingSnapshots.sorted { $0.observedAt < $1.observedAt }
loadedRatingsAppID = targetAppStoreID
} catch {
latestRatings = reset ? [] : latestRatings
ratingSnapshots = reset ? [] : ratingSnapshots
guard !Task.isCancelled, activeRatingsLoad == loadID, appStoreID == targetAppStoreID else { return }
ratingsLoadError = error.localizedDescription
}
}

Expand Down Expand Up @@ -159,15 +183,9 @@ struct AppRatingsView: View {
},
sortBy: [SortDescriptor(\.storefront, order: .forward)]
)
let snapshotDescriptor = FetchDescriptor<AppDailyRating>(
predicate: #Predicate { snapshot in
snapshot.appStoreID == targetAppStoreID
},
sortBy: [SortDescriptor(\.observedAt, order: .reverse)]
)

let latestRatings = try modelContext.fetch(latestDescriptor).map(RatingLatestValue.init)
let ratingSnapshots = try modelContext.fetch(snapshotDescriptor).map(RatingSnapshotValue.init)
let ratingSnapshots = try RatingHistoryStore.fetch(appStoreIDs: [appStoreID], in: modelContext)
.map(RatingSnapshotValue.init)

return RatingsDashboardData(
latestRatings: latestRatings,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ struct RatingSnapshotValue: Identifiable, Hashable, Sendable {
var id: String { identityKey }

init(_ snapshot: AppDailyRating) {
self.init(RatingHistoryRecord(snapshot))
}

init(_ snapshot: RatingHistoryRecord) {
self.identityKey = snapshot.identityKey
self.appStoreID = snapshot.appStoreID
self.storefront = snapshot.storefront
Expand Down
7 changes: 6 additions & 1 deletion OpenASO/Models/OpenASOSchemaV1.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ enum OpenASOMigrationPlan: SchemaMigrationPlan {
OpenASOSchemaV3.self,
OpenASOSchemaV4.self,
OpenASOSchemaV5.self,
OpenASOSchemaV6.self
OpenASOSchemaV6.self,
OpenASOSchemaV7.self
]
}

Expand Down Expand Up @@ -73,6 +74,10 @@ enum OpenASOMigrationPlan: SchemaMigrationPlan {
.lightweight(
fromVersion: OpenASOSchemaV5.self,
toVersion: OpenASOSchemaV6.self
),
.lightweight(
fromVersion: OpenASOSchemaV6.self,
toVersion: OpenASOSchemaV7.self
)
]
}
Expand Down
Loading
Loading