@moifort/stack 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +26 -2
- package/bin/stack.ts +109 -0
- package/hooks/shutdown-simulators-after-push.sh +19 -0
- package/package.json +40 -4
- package/routines/daily-health.md +78 -0
- package/routines/deprecations.md +23 -0
- package/routines/sentry-autofix.md +76 -0
- package/routines/stack-upgrade.md +45 -0
- package/rules/00-collaboration.md +81 -0
- package/rules/01-workflow.md +81 -0
- package/rules/02-commits.md +71 -0
- package/rules/03-code-review.md +25 -0
- package/rules/04-push.md +75 -0
- package/rules/05-release.md +40 -0
- package/rules/06-changelog.md +48 -0
- package/rules/07-appstore.md +77 -0
- package/rules/08-copy.md +52 -0
- package/rules/09-backend.md +132 -0
- package/rules/10-ios.md +206 -0
- package/rules/11-ops.md +114 -0
- package/rules/12-ci.md +32 -0
- package/rules/13-ai.md +71 -0
- package/rules/14-infra.md +90 -0
- package/rules/15-tooling.md +81 -0
- package/rules/16-routines.md +69 -0
- package/rules/17-landing.md +76 -0
- package/skills/appstore/SKILL.md +53 -0
- package/skills/domain/SKILL.md +136 -0
- package/skills/graphql/SKILL.md +83 -0
- package/skills/ios-feature/SKILL.md +94 -0
- package/skills/stack-install/SKILL.md +35 -0
- package/skills/stack-upgrade/SKILL.md +27 -0
- package/src/catalogue.ts +33 -0
- package/src/config.ts +20 -0
- package/src/init.ts +63 -0
- package/src/migrations.ts +72 -0
- package/src/migrations.unit.test.ts +44 -0
- package/src/package.ts +8 -0
- package/src/render.ts +13 -0
- package/src/render.unit.test.ts +17 -0
- package/src/sync.int.test.ts +103 -0
- package/src/sync.ts +161 -0
- package/src/templates.ts +121 -0
- package/src/validate-content.ts +65 -0
- package/templates/backend/.env.example +10 -0
- package/templates/backend/.github/workflows/deploy.yml +151 -0
- package/templates/backend/.github/workflows/schema.yml +59 -0
- package/templates/backend/.github/workflows/test-feat.yml +40 -0
- package/templates/backend/.github/workflows/test-int.yml +40 -0
- package/templates/backend/.github/workflows/test-unit.yml +64 -0
- package/templates/backend/docs/api-evolution.md +67 -0
- package/templates/backend/firebase.json +21 -0
- package/templates/backend/firestore.indexes.json +4 -0
- package/templates/backend/firestore.rules +11 -0
- package/templates/backend/infra/auth.tf +54 -0
- package/templates/backend/infra/backend.tf +6 -0
- package/templates/backend/infra/cicd.tf +63 -0
- package/templates/backend/infra/firebase.tf +26 -0
- package/templates/backend/infra/firestore.tf +53 -0
- package/templates/backend/infra/function.tf +171 -0
- package/templates/backend/infra/main.tf +26 -0
- package/templates/backend/infra/observer.tf +18 -0
- package/templates/backend/infra/outputs.tf +30 -0
- package/templates/backend/infra/project.tf +64 -0
- package/templates/backend/infra/scheduler.tf +28 -0
- package/templates/backend/infra/secrets.tf +44 -0
- package/templates/backend/infra/state.tf +13 -0
- package/templates/backend/infra/terraform.tfvars.example +16 -0
- package/templates/backend/infra/variables.tf +104 -0
- package/templates/backend/infra/versions.tf +30 -0
- package/templates/backend/nitro.config.ts +40 -0
- package/templates/backend/package.json +51 -0
- package/templates/backend/scripts/deprecations/inspector-rule.ts +29 -0
- package/templates/backend/scripts/deprecations/status.ts +50 -0
- package/templates/backend/scripts/deprecations/status.unit.test.ts +57 -0
- package/templates/backend/scripts/deprecations/usage.ts +79 -0
- package/templates/backend/scripts/deprecations/usage.unit.test.ts +64 -0
- package/templates/backend/scripts/deprecations.ts +224 -0
- package/templates/backend/scripts/export-graphql-schema.ts +11 -0
- package/templates/backend/scripts/generate-changelog-asset.ts +27 -0
- package/templates/backend/scripts/install-terraform.sh +61 -0
- package/templates/backend/scripts/tf +11 -0
- package/templates/backend/server/domain/changelog/business-rules.ts +60 -0
- package/templates/backend/server/domain/changelog/business-rules.unit.test.ts +88 -0
- package/templates/backend/server/domain/changelog/infrastructure/graphql/queries.ts +17 -0
- package/templates/backend/server/domain/changelog/infrastructure/graphql/types.ts +23 -0
- package/templates/backend/server/domain/changelog/primitives.ts +19 -0
- package/templates/backend/server/domain/changelog/query.ts +13 -0
- package/templates/backend/server/domain/changelog/query.unit.test.ts +19 -0
- package/templates/backend/server/domain/changelog/types.ts +9 -0
- package/templates/backend/server/domain/shared/graphql/builder.ts +52 -0
- package/templates/backend/server/domain/shared/graphql/errors.ts +19 -0
- package/templates/backend/server/domain/shared/graphql/scalars.ts +51 -0
- package/templates/backend/server/domain/shared/graphql/schema.ts +21 -0
- package/templates/backend/server/domain/shared/language.ts +26 -0
- package/templates/backend/server/domain/shared/page.ts +25 -0
- package/templates/backend/server/domain/shared/page.unit.test.ts +51 -0
- package/templates/backend/server/domain/shared/primitives.ts +50 -0
- package/templates/backend/server/domain/shared/primitives.unit.test.ts +25 -0
- package/templates/backend/server/domain/shared/types.ts +14 -0
- package/templates/backend/server/domain/user/command.ts +16 -0
- package/templates/backend/server/domain/user/infrastructure/graphql/inputs.ts +12 -0
- package/templates/backend/server/domain/user/infrastructure/graphql/mutations.ts +40 -0
- package/templates/backend/server/domain/user/infrastructure/graphql/queries.ts +13 -0
- package/templates/backend/server/domain/user/infrastructure/graphql/types.ts +35 -0
- package/templates/backend/server/domain/user/infrastructure/repository.ts +59 -0
- package/templates/backend/server/domain/user/mutations.feat.test.ts +42 -0
- package/templates/backend/server/domain/user/query.int.test.ts +49 -0
- package/templates/backend/server/domain/user/query.ts +34 -0
- package/templates/backend/server/domain/user/types.ts +20 -0
- package/templates/backend/server/domain/user/use-case.ts +18 -0
- package/templates/backend/server/middleware/auth.ts +54 -0
- package/templates/backend/server/plugins/00-sentry.ts +74 -0
- package/templates/backend/server/plugins/01-request-log.ts +28 -0
- package/templates/backend/server/plugins/02-graphql.ts +69 -0
- package/templates/backend/server/plugins/03-boot-time.ts +21 -0
- package/templates/backend/server/routes/admin/migrate.post.ts +9 -0
- package/templates/backend/server/routes/app-config.get.ts +15 -0
- package/templates/backend/server/routes/graphql.ts +52 -0
- package/templates/backend/server/system/app-support.ts +17 -0
- package/templates/backend/server/system/config/index.ts +18 -0
- package/templates/backend/server/system/config/primitives.ts +20 -0
- package/templates/backend/server/system/config/primitives.unit.test.ts +13 -0
- package/templates/backend/server/system/config/types.ts +8 -0
- package/templates/backend/server/system/firebase.ts +19 -0
- package/templates/backend/server/system/identity.ts +18 -0
- package/templates/backend/server/system/logger.ts +119 -0
- package/templates/backend/server/system/logger.unit.test.ts +109 -0
- package/templates/backend/server/system/migration/migrations/index.ts +6 -0
- package/templates/backend/server/system/migration/primitives.ts +18 -0
- package/templates/backend/server/system/migration/runner.ts +52 -0
- package/templates/backend/server/system/migration/types.ts +16 -0
- package/templates/backend/server/system/read-meter.ts +100 -0
- package/templates/backend/server/system/read-meter.unit.test.ts +97 -0
- package/templates/backend/server/system/request-cache.ts +139 -0
- package/templates/backend/server/system/request-cache.unit.test.ts +166 -0
- package/templates/backend/server/system/secret-box.ts +49 -0
- package/templates/backend/server/system/secret-box.unit.test.ts +46 -0
- package/templates/backend/server/test/bdd.ts +12 -0
- package/templates/backend/server/test/fake-firestore.ts +483 -0
- package/templates/backend/server/utils/apollo.ts +13 -0
- package/templates/backend/server/utils/fetch.ts +5 -0
- package/templates/backend/server/utils/firestore.ts +91 -0
- package/templates/backend/server/utils/input.ts +36 -0
- package/templates/backend/server/utils/input.unit.test.ts +43 -0
- package/templates/backend/server/utils/slug.ts +21 -0
- package/templates/backend/tsconfig.json +16 -0
- package/templates/base/.github/workflows/renovate.yml +17 -0
- package/templates/base/CHANGELOG.fr.md +3 -0
- package/templates/base/CHANGELOG.md +7 -0
- package/templates/base/CLAUDE.md +34 -0
- package/templates/base/README.md +12 -0
- package/templates/base/biome.json +47 -0
- package/templates/base/bunfig.toml +2 -0
- package/templates/base/docs/adr/0001-built-on-the-polyforms-stack.md +21 -0
- package/templates/base/docs/data-recovery.md +116 -0
- package/templates/base/docs/ideas.md +4 -0
- package/templates/base/project-context.md +43 -0
- package/templates/base/renovate.json +37 -0
- package/templates/ios/.github/workflows/build-ios.yml +168 -0
- package/templates/ios/.github/workflows/ios-upload.yml +308 -0
- package/templates/ios/.github/workflows/release-ios.yml +178 -0
- package/templates/ios/.github/workflows/testflight-ios.yml +57 -0
- package/templates/ios/infra/Gemfile +5 -0
- package/templates/ios/infra/fastlane/Appfile +1 -0
- package/templates/ios/infra/fastlane/Fastfile +54 -0
- package/templates/ios/infra/fastlane/metadata/copyright.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/en-US/description.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/en-US/keywords.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/en-US/name.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/en-US/privacy_url.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/en-US/promotional_text.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/en-US/subtitle.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/en-US/support_url.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/description.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/keywords.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/name.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/privacy_url.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/promotional_text.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/subtitle.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/support_url.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/primary_category.txt +1 -0
- package/templates/ios/infra/ios.tf +28 -0
- package/templates/ios/ios/ExportOptions.plist +26 -0
- package/templates/ios/ios/apollo-codegen-config.json +16 -0
- package/templates/ios/ios/project.yml +94 -0
- package/templates/ios/ios/{{APP_NAME}}/App.swift +46 -0
- package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
- package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
- package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/Contents.json +6 -0
- package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/LaunchBackground.colorset/Contents.json +38 -0
- package/templates/ios/ios/{{APP_NAME}}/ContentView.swift +17 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AppleNonce.swift +33 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AuthRoot.swift +74 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AuthSession.swift +86 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Auth/LoginScreen.swift +83 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Home/HomeScreen.swift +21 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Home/components/pages/HomePage.swift +40 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/AccountEnvironment.swift +7 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/GraphQL/Onboarding.graphql +16 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingAPI.swift +28 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingClient.swift +26 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingGate.swift +50 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingScreen.swift +69 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/components/pages/FirstNamePage.swift +66 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/components/pages/WelcomePage.swift +37 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ChangelogScreen.swift +25 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ChangelogViewModel.swift +27 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/GraphQL/Settings.graphql +11 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ProfileSettingsScreen.swift +30 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ProfileSettingsViewModel.swift +51 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsAPI.swift +37 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsClient.swift +40 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsHomeScreen.swift +39 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/DeleteAccountButton.swift +30 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/SettingsRow.swift +56 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/SignOutButton.swift +23 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/ChangelogPage.swift +59 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/FeedbackSheet.swift +118 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/ProfileSettingsPage.swift +90 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/SettingsHomePage.swift +70 -0
- package/templates/ios/ios/{{APP_NAME}}/Info.plist +14 -0
- package/templates/ios/ios/{{APP_NAME}}/Localizable.xcstrings +1030 -0
- package/templates/ios/ios/{{APP_NAME}}/PrivacyInfo.xcprivacy +122 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/APIClient.swift +23 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/AcceptLanguageInterceptor.swift +20 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/AsyncButton.swift +73 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/AsyncToolbarButton.swift +69 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/ToolbarIconButton.swift +46 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/molecules/EmptyState.swift +132 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/molecules/LabeledInfoRow.swift +29 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/CaptionLabelStyle.swift +45 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/Copyable.swift +32 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/EdgeToEdgeSeparator.swift +14 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/RowLabelStyle.swift +58 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/CursorPage.swift +26 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/DataChanges.swift +19 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/ErrorPresenter.swift +62 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/ErrorReporting.swift +35 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/FeedbackReporting.swift +42 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/FirebaseTokenInterceptor.swift +49 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLClient.swift +54 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLHelpers.swift +128 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLLoggingInterceptor.swift +28 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/LoadGate.swift +30 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Secrets.swift.example +9 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Services/AppSupportGate.swift +50 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Shell/StartupLoadingView.swift +23 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Shell/UpdateRequiredView.swift +24 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Showcase.swift +13 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/SnapshotCache.swift +78 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/UITestEnvironment.swift +70 -0
- package/templates/ios/ios/{{APP_NAME}}/{{APP_NAME}}.entitlements +10 -0
- package/templates/ios/ios/{{APP_NAME}}Tests/Onboarding/OnboardingGateTests.swift +38 -0
- package/templates/ios/ios/{{APP_NAME}}Tests/Settings/SettingsViewModelTests.swift +63 -0
- package/templates/ios/ios/{{APP_NAME}}Tests/Support/TestDoubles.swift +35 -0
- package/templates/ios/package.merge.json +10 -0
- package/templates/ios/scripts/apollo-codegen.ts +31 -0
- package/templates/ios/scripts/check-ios-components.ts +72 -0
- package/templates/ios/scripts/check-strings.ts +103 -0
- package/templates/ios/scripts/install-device.sh +29 -0
- package/templates/ios/scripts/testflight.ts +213 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// One page of a list the server cuts by cursor: its rows, whether more
|
|
4
|
+
/// follow, and the cursor to ask the next page after. The cursor is the
|
|
5
|
+
/// server's to make; the app only hands it back.
|
|
6
|
+
struct CursorPage<Item: Sendable>: Sendable {
|
|
7
|
+
let items: [Item]
|
|
8
|
+
let hasMore: Bool
|
|
9
|
+
/// Nil on an empty page.
|
|
10
|
+
let endCursor: String?
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
extension CursorPage {
|
|
14
|
+
/// A page cut from rows already in memory, as the server cuts its own:
|
|
15
|
+
/// after the row the cursor names, from the top when it names none. For
|
|
16
|
+
/// the showcase, the previews and the tests.
|
|
17
|
+
init(_ rows: [Item], limit: Int, after: String?, cursor: (Item) -> String) {
|
|
18
|
+
let start = after.flatMap { after in rows.firstIndex { cursor($0) == after }.map { $0 + 1 } } ?? 0
|
|
19
|
+
let page = Array(rows.dropFirst(start).prefix(limit))
|
|
20
|
+
self.init(
|
|
21
|
+
items: page,
|
|
22
|
+
hasMore: start + page.count < rows.count,
|
|
23
|
+
endCursor: page.last.map(cursor)
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
extension Notification.Name {
|
|
4
|
+
/// Something changed on the server. Posted by `GraphQLHelpers.perform` after
|
|
5
|
+
/// every mutation that came back, so a list left open on another tab redraws
|
|
6
|
+
/// itself before the user gets back to it.
|
|
7
|
+
///
|
|
8
|
+
/// One name for everything on purpose: lists are cheap to refetch, and naming
|
|
9
|
+
/// what changed for each screen to decide whether it cares is where stale rows
|
|
10
|
+
/// come from. Only screens listen; a page never subscribes.
|
|
11
|
+
static let dataDidChange = Notification.Name("DataDidChange")
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/// What a single write changed, when it changed one thing: a list that has that
|
|
15
|
+
/// row open patches it instead of reloading. A notice with no object may have
|
|
16
|
+
/// changed anything. Add a case per kind of object the app edits one at a time.
|
|
17
|
+
enum DataChange: Hashable, Sendable {
|
|
18
|
+
case item(id: String)
|
|
19
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
/// Captures errors from async API mutations so they can be surfaced in an alert
|
|
4
|
+
/// instead of being silently discarded with `try?`.
|
|
5
|
+
///
|
|
6
|
+
/// Usage:
|
|
7
|
+
/// ```swift
|
|
8
|
+
/// @State private var errorPresenter = ErrorPresenter()
|
|
9
|
+
///
|
|
10
|
+
/// // In an action closure:
|
|
11
|
+
/// await errorPresenter.run {
|
|
12
|
+
/// try await BookAPI.rate(...)
|
|
13
|
+
/// } onSuccess: {
|
|
14
|
+
/// showFavorite = false
|
|
15
|
+
/// }
|
|
16
|
+
///
|
|
17
|
+
/// // On the view (inside the sheet content if the action runs in a sheet,
|
|
18
|
+
/// // so the sheet stays open on failure):
|
|
19
|
+
/// .errorAlert(errorPresenter)
|
|
20
|
+
/// ```
|
|
21
|
+
@MainActor
|
|
22
|
+
@Observable
|
|
23
|
+
final class ErrorPresenter {
|
|
24
|
+
var message: String?
|
|
25
|
+
/// True while `run` is awaiting the network call — bind a button's spinner /
|
|
26
|
+
/// disabled state to this so every mutation shows a loader.
|
|
27
|
+
private(set) var isRunning = false
|
|
28
|
+
|
|
29
|
+
/// Runs `operation`. On success, calls `onSuccess` (dismiss the sheet, refresh, ...).
|
|
30
|
+
/// On failure, reports the error and stores its message so `.errorAlert` presents it;
|
|
31
|
+
/// `onSuccess` is NOT called, so the sheet stays open and the user can retry or cancel.
|
|
32
|
+
func run(
|
|
33
|
+
_ operation: @MainActor () async throws -> Void,
|
|
34
|
+
onSuccess: @MainActor () -> Void = {}
|
|
35
|
+
) async {
|
|
36
|
+
isRunning = true
|
|
37
|
+
defer { isRunning = false }
|
|
38
|
+
do {
|
|
39
|
+
try await operation()
|
|
40
|
+
onSuccess()
|
|
41
|
+
} catch {
|
|
42
|
+
message = reportError(error)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
extension View {
|
|
48
|
+
/// Presents an alert whenever the given presenter holds an error message.
|
|
49
|
+
func errorAlert(_ presenter: ErrorPresenter) -> some View {
|
|
50
|
+
alert(
|
|
51
|
+
"Une erreur est survenue",
|
|
52
|
+
isPresented: Binding(
|
|
53
|
+
get: { presenter.message != nil },
|
|
54
|
+
set: { if !$0 { presenter.message = nil } }
|
|
55
|
+
)
|
|
56
|
+
) {
|
|
57
|
+
Button("OK") { presenter.message = nil }
|
|
58
|
+
} message: {
|
|
59
|
+
Text(presenter.message ?? "")
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Sentry
|
|
3
|
+
|
|
4
|
+
/// Reports an error to Sentry, unless it is a cancellation or a lost network,
|
|
5
|
+
/// and returns the sentence to show the user.
|
|
6
|
+
func reportError(_ error: Error) -> String {
|
|
7
|
+
let nsError = error as NSError
|
|
8
|
+
if !isCancellation(error) && !isConnectivityLoss(nsError) {
|
|
9
|
+
SentrySDK.capture(error: error)
|
|
10
|
+
}
|
|
11
|
+
return error.localizedDescription
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/// The request was called off rather than refused: its task was cancelled, the
|
|
15
|
+
/// user left the screen that asked. Nothing failed.
|
|
16
|
+
func isCancellation(_ error: Error) -> Bool {
|
|
17
|
+
let nsError = error as NSError
|
|
18
|
+
return error is CancellationError
|
|
19
|
+
|| (nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// The device lost its network, not the app its way. The user is told; Sentry
|
|
23
|
+
/// has nothing to fix. The URL error may come wrapped, as the Firebase token
|
|
24
|
+
/// fetch wraps its own network failure.
|
|
25
|
+
private func isConnectivityLoss(_ error: NSError) -> Bool {
|
|
26
|
+
if error.domain == NSURLErrorDomain && [
|
|
27
|
+
NSURLErrorTimedOut,
|
|
28
|
+
NSURLErrorNetworkConnectionLost,
|
|
29
|
+
NSURLErrorNotConnectedToInternet,
|
|
30
|
+
].contains(error.code) {
|
|
31
|
+
return true
|
|
32
|
+
}
|
|
33
|
+
guard let underlying = error.userInfo[NSUnderlyingErrorKey] as? NSError else { return false }
|
|
34
|
+
return isConnectivityLoss(underlying)
|
|
35
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import OSLog
|
|
3
|
+
import Sentry
|
|
4
|
+
|
|
5
|
+
/// What the message is about. Travels as a Sentry tag so the inbox can be
|
|
6
|
+
/// filtered on it.
|
|
7
|
+
enum FeedbackKind: String {
|
|
8
|
+
case bug
|
|
9
|
+
case idea
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/// Sends a message written by the user to the Sentry feedback inbox.
|
|
13
|
+
///
|
|
14
|
+
/// Only the message travels: no name, no email. `AuthSession` reports the uid
|
|
15
|
+
/// and nothing else, and that uid is already on the scope, which is enough to
|
|
16
|
+
/// reach the account behind the message.
|
|
17
|
+
func sendFeedback(kind: FeedbackKind, message: String) {
|
|
18
|
+
#if DEBUG
|
|
19
|
+
// Sentry never starts in a Debug build, so `capture` below would silently
|
|
20
|
+
// drop the message. Logging it keeps the screen testable on the simulator.
|
|
21
|
+
// `notice` rather than `debug`: debug entries are not kept in the log store,
|
|
22
|
+
// so they cannot be read back after the fact with `log show`.
|
|
23
|
+
log.notice("feedback (\(kind.rawValue, privacy: .public)): \(message, privacy: .public)")
|
|
24
|
+
#endif
|
|
25
|
+
|
|
26
|
+
// The feedback envelope carries no tags of its own; the hub captures it with
|
|
27
|
+
// the current scope, so the tag has to sit there for the length of the call.
|
|
28
|
+
SentrySDK.configureScope { $0.setTag(value: kind.rawValue, key: feedbackKindTag) }
|
|
29
|
+
SentrySDK.capture(feedback: SentryFeedback(
|
|
30
|
+
message: message,
|
|
31
|
+
name: nil,
|
|
32
|
+
email: nil,
|
|
33
|
+
source: .custom
|
|
34
|
+
))
|
|
35
|
+
SentrySDK.configureScope { $0.removeTag(key: feedbackKindTag) }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private let feedbackKindTag = "feedback.kind"
|
|
39
|
+
|
|
40
|
+
#if DEBUG
|
|
41
|
+
private let log = Logger(subsystem: "{{BUNDLE_ID}}", category: "feedback")
|
|
42
|
+
#endif
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import Apollo
|
|
2
|
+
import FirebaseAuth
|
|
3
|
+
import Foundation
|
|
4
|
+
|
|
5
|
+
/// Injects the current Firebase user's ID token into every GraphQL request as
|
|
6
|
+
/// `Authorization: Bearer <token>`. Firebase Auth refreshes the token under the
|
|
7
|
+
/// hood so this is always fresh. Runs at the HTTP layer (Apollo 2.x) where the
|
|
8
|
+
/// request is a plain `URLRequest`.
|
|
9
|
+
///
|
|
10
|
+
/// A refused token is where a dead session shows: when the account behind it
|
|
11
|
+
/// is gone, the user is signed out rather than left on screens that can no
|
|
12
|
+
/// longer load.
|
|
13
|
+
struct FirebaseTokenInterceptor: HTTPInterceptor {
|
|
14
|
+
func intercept(
|
|
15
|
+
request: URLRequest,
|
|
16
|
+
next: NextHTTPInterceptorFunction
|
|
17
|
+
) async throws -> HTTPResponse {
|
|
18
|
+
var request = request
|
|
19
|
+
if let currentUser = Auth.auth().currentUser {
|
|
20
|
+
do {
|
|
21
|
+
let token = try await currentUser.idToken()
|
|
22
|
+
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
23
|
+
} catch {
|
|
24
|
+
await AuthSession.dropSessionIfAccountIsGone()
|
|
25
|
+
throw error
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
let response = try await next(request)
|
|
29
|
+
if response.response.statusCode == 401 {
|
|
30
|
+
await AuthSession.dropSessionIfAccountIsGone()
|
|
31
|
+
}
|
|
32
|
+
return response
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private extension User {
|
|
37
|
+
/// Async wrapper around Firebase's callback-based ID token fetch.
|
|
38
|
+
func idToken() async throws -> String {
|
|
39
|
+
try await withCheckedThrowingContinuation { continuation in
|
|
40
|
+
getIDToken { token, error in
|
|
41
|
+
if let error {
|
|
42
|
+
continuation.resume(throwing: error)
|
|
43
|
+
} else {
|
|
44
|
+
continuation.resume(returning: token ?? "")
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import Apollo
|
|
2
|
+
import ApolloAPI
|
|
3
|
+
import Foundation
|
|
4
|
+
|
|
5
|
+
/// Singleton ApolloClient configured for the Firebase Functions /graphql endpoint.
|
|
6
|
+
/// Each request goes through:
|
|
7
|
+
/// 1. GraphQL interceptors — Apollo defaults + GraphQLLoggingInterceptor (logs errors)
|
|
8
|
+
/// 2. HTTP interceptors — FirebaseTokenInterceptor (injects Authorization: Bearer <ID Token>)
|
|
9
|
+
/// then Apollo's ResponseCodeInterceptor
|
|
10
|
+
final class GraphQLClient: @unchecked Sendable {
|
|
11
|
+
static let shared = GraphQLClient()
|
|
12
|
+
|
|
13
|
+
// Built once: recreating the store/transport per request would discard
|
|
14
|
+
// connection reuse. The base URL is read at init — changing the server URL
|
|
15
|
+
// in the debug settings requires an app restart.
|
|
16
|
+
let apollo: ApolloClient
|
|
17
|
+
|
|
18
|
+
private init() {
|
|
19
|
+
let url = APIClient.shared.baseURL.appendingPathComponent("graphql")
|
|
20
|
+
let store = ApolloStore()
|
|
21
|
+
let transport = RequestChainNetworkTransport(
|
|
22
|
+
urlSession: URLSession(configuration: .default),
|
|
23
|
+
interceptorProvider: AuthenticatedInterceptorProvider(),
|
|
24
|
+
store: store,
|
|
25
|
+
endpointURL: url
|
|
26
|
+
)
|
|
27
|
+
apollo = ApolloClient(networkTransport: transport, store: store)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// Adds our Firebase auth (HTTP) and error logging (GraphQL) interceptors on top
|
|
32
|
+
/// of Apollo's default chain. Apollo 2.x makes `DefaultInterceptorProvider` final,
|
|
33
|
+
/// so we conform to `InterceptorProvider` directly and re-list the defaults.
|
|
34
|
+
struct AuthenticatedInterceptorProvider: InterceptorProvider {
|
|
35
|
+
func graphQLInterceptors<Operation: GraphQLOperation>(
|
|
36
|
+
for operation: Operation
|
|
37
|
+
) -> [any GraphQLInterceptor] {
|
|
38
|
+
[
|
|
39
|
+
MaxRetryInterceptor(),
|
|
40
|
+
AutomaticPersistedQueryInterceptor(),
|
|
41
|
+
GraphQLLoggingInterceptor(),
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func httpInterceptors<Operation: GraphQLOperation>(
|
|
46
|
+
for operation: Operation
|
|
47
|
+
) -> [any HTTPInterceptor] {
|
|
48
|
+
[
|
|
49
|
+
FirebaseTokenInterceptor(),
|
|
50
|
+
AcceptLanguageInterceptor(),
|
|
51
|
+
ResponseCodeInterceptor(),
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import Apollo
|
|
2
|
+
import ApolloAPI
|
|
3
|
+
import Foundation
|
|
4
|
+
|
|
5
|
+
enum GraphQLHelpers {
|
|
6
|
+
/// Cache fully disabled: `.networkOnly` never reads the normalized store, and
|
|
7
|
+
/// nothing in the app watches or reads it, which avoids a class of stale and
|
|
8
|
+
/// normalization bugs. `requestTimeout` overrides the session's 60 s idle
|
|
9
|
+
/// limit for a query that may legitimately keep the server silent longer.
|
|
10
|
+
static func fetch<Q: GraphQLQuery>(
|
|
11
|
+
_ client: ApolloClient,
|
|
12
|
+
query: Q,
|
|
13
|
+
requestTimeout: TimeInterval? = nil
|
|
14
|
+
) async throws -> Q.Data
|
|
15
|
+
where Q.ResponseFormat == SingleResponseFormat {
|
|
16
|
+
let response = try await calledOffWhenCancelled {
|
|
17
|
+
try await client.fetch(
|
|
18
|
+
query: query,
|
|
19
|
+
cachePolicy: .networkOnly,
|
|
20
|
+
requestConfiguration: requestTimeout.map { RequestConfiguration(requestTimeout: $0) }
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
return try unwrap(response)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// Every mutation goes through here, and posts the one change notice once the
|
|
27
|
+
/// write landed, so no screen has to remember to.
|
|
28
|
+
///
|
|
29
|
+
/// `changesData` is false for a mutation that changes nothing a list shows (a
|
|
30
|
+
/// sign-in, an account deletion): every open screen refetches on the notice,
|
|
31
|
+
/// so posting it for those costs requests for nothing. On by default: a new
|
|
32
|
+
/// mutation that forgets to say stays correct, just dearer.
|
|
33
|
+
///
|
|
34
|
+
/// `concerning` names the one object a mutation changed, sent as the notice's
|
|
35
|
+
/// object: a list that knows what it has open can patch that row.
|
|
36
|
+
static func perform<M: GraphQLMutation>(
|
|
37
|
+
_ client: ApolloClient,
|
|
38
|
+
concerning change: DataChange? = nil,
|
|
39
|
+
mutation: M,
|
|
40
|
+
requestTimeout: TimeInterval? = nil,
|
|
41
|
+
changesData: Bool = true
|
|
42
|
+
) async throws -> M.Data
|
|
43
|
+
where M.ResponseFormat == SingleResponseFormat {
|
|
44
|
+
let response = try await calledOffWhenCancelled {
|
|
45
|
+
try await client.perform(
|
|
46
|
+
mutation: mutation,
|
|
47
|
+
requestConfiguration: requestTimeout.map { RequestConfiguration(requestTimeout: $0) }
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
let data = try unwrap(response)
|
|
51
|
+
guard changesData else { return data }
|
|
52
|
+
await MainActor.run {
|
|
53
|
+
NotificationCenter.default.post(name: .dataDidChange, object: change)
|
|
54
|
+
}
|
|
55
|
+
return data
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// A request whose task was cancelled fails as a cancellation, whatever
|
|
59
|
+
/// Apollo made of it: Apollo 2.x may report `noResults` before the outer
|
|
60
|
+
/// stream closed on the cancellation.
|
|
61
|
+
private static func calledOffWhenCancelled<Response>(
|
|
62
|
+
_ request: () async throws -> Response
|
|
63
|
+
) async throws -> Response {
|
|
64
|
+
do {
|
|
65
|
+
return try await request()
|
|
66
|
+
} catch ApolloClient.Error.noResults where Task.isCancelled {
|
|
67
|
+
throw CancellationError()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// Turn a `GraphQLResponse` into its `Data`, surfacing GraphQL errors and
|
|
72
|
+
/// missing data as `APIError`.
|
|
73
|
+
private static func unwrap<O: GraphQLOperation>(_ response: GraphQLResponse<O>) throws -> O.Data {
|
|
74
|
+
if let errors = response.errors, !errors.isEmpty {
|
|
75
|
+
// The server names what went wrong in `extensions.code`: screens that
|
|
76
|
+
// react to a specific outcome match on it.
|
|
77
|
+
if let code = errors.compactMap({ $0.extensions?["code"] as? String }).first {
|
|
78
|
+
throw APIError.domain(code: code, messages: errors.compactMap(\.message))
|
|
79
|
+
}
|
|
80
|
+
throw APIError.graphQL(messages: errors.compactMap(\.message))
|
|
81
|
+
}
|
|
82
|
+
guard let data = response.data else { throw APIError.invalidResponse }
|
|
83
|
+
return data
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// Decode an ISO-8601 date string from a GraphQL DateTime scalar.
|
|
87
|
+
static func parseISO8601(_ string: String) -> Date? {
|
|
88
|
+
let withFraction = ISO8601DateFormatter()
|
|
89
|
+
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
90
|
+
let withoutFraction = ISO8601DateFormatter()
|
|
91
|
+
withoutFraction.formatOptions = [.withInternetDateTime]
|
|
92
|
+
return withFraction.date(from: string) ?? withoutFraction.date(from: string)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/// An empty or blank optional string is absent (`.none`), never sent as `""`:
|
|
96
|
+
/// branded server fields refuse an empty value.
|
|
97
|
+
static func graphQLNullable(_ value: String?) -> GraphQLNullable<String> {
|
|
98
|
+
guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
99
|
+
else { return .none }
|
|
100
|
+
return .some(value)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
static func graphQLNullable<T>(_ value: T?) -> GraphQLNullable<T> {
|
|
104
|
+
value.map { .some($0) } ?? .none
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
enum APIError: LocalizedError {
|
|
109
|
+
case invalidResponse
|
|
110
|
+
case graphQL(messages: [String])
|
|
111
|
+
/// A refusal the server named, e.g. `NOT_FOUND`. Carries the code so a screen
|
|
112
|
+
/// can act on it rather than only show its message.
|
|
113
|
+
case domain(code: String, messages: [String])
|
|
114
|
+
|
|
115
|
+
var domainCode: String? {
|
|
116
|
+
if case .domain(let code, _) = self { return code }
|
|
117
|
+
return nil
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
var errorDescription: String? {
|
|
121
|
+
switch self {
|
|
122
|
+
case .invalidResponse:
|
|
123
|
+
return String(localized: "Réponse invalide du serveur")
|
|
124
|
+
case .graphQL(let messages), .domain(_, let messages):
|
|
125
|
+
return messages.joined(separator: " · ")
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import Apollo
|
|
2
|
+
import ApolloAPI
|
|
3
|
+
import Foundation
|
|
4
|
+
import OSLog
|
|
5
|
+
|
|
6
|
+
private let log = Logger(subsystem: "{{BUNDLE_ID}}", category: "graphql")
|
|
7
|
+
|
|
8
|
+
/// Logs any GraphQL errors returned for an operation. Post-flight work happens in
|
|
9
|
+
/// the `.map` of the result stream (Apollo 2.x interceptor model).
|
|
10
|
+
struct GraphQLLoggingInterceptor: GraphQLInterceptor {
|
|
11
|
+
func intercept<Request: GraphQLRequest>(
|
|
12
|
+
request: Request,
|
|
13
|
+
next: NextInterceptorFunction<Request>
|
|
14
|
+
) async throws -> InterceptorResultStream<Request> {
|
|
15
|
+
await next(request).map { parsed in
|
|
16
|
+
if let errors = parsed.result.errors, !errors.isEmpty {
|
|
17
|
+
for error in errors {
|
|
18
|
+
let code = (error.extensions?["code"] as? String) ?? "<no code>"
|
|
19
|
+
let message = error.message ?? "<no message>"
|
|
20
|
+
log.error(
|
|
21
|
+
"\(Request.Operation.operationName, privacy: .public) [\(code, privacy: .public)] \(message, privacy: .public)"
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return parsed
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/// One load of each kind at a time. A screen asks for the same section from
|
|
2
|
+
/// several places — its appearance, a change notice, a sheet closing — and
|
|
3
|
+
/// each used to start a request of its own, side by side with the one already
|
|
4
|
+
/// on its way.
|
|
5
|
+
///
|
|
6
|
+
/// Asked while that kind is running, a load either rides on it (`again:
|
|
7
|
+
/// false`: what is coming is what was wanted) or has it run once more when it
|
|
8
|
+
/// ends (`again: true`: a write landed since it set out), however many times
|
|
9
|
+
/// it was asked meanwhile.
|
|
10
|
+
@MainActor
|
|
11
|
+
final class LoadGate<Kind: Hashable> {
|
|
12
|
+
private var running: Set<Kind> = []
|
|
13
|
+
private var stale: Set<Kind> = []
|
|
14
|
+
|
|
15
|
+
/// A write landed since the load running for that kind set out: what it
|
|
16
|
+
/// brings back is already behind.
|
|
17
|
+
func isStale(_ kind: Kind) -> Bool { stale.contains(kind) }
|
|
18
|
+
|
|
19
|
+
func run(_ kind: Kind, again: Bool, _ load: () async -> Void) async {
|
|
20
|
+
guard running.insert(kind).inserted else {
|
|
21
|
+
if again { stale.insert(kind) }
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
defer { running.remove(kind) }
|
|
25
|
+
repeat {
|
|
26
|
+
stale.remove(kind)
|
|
27
|
+
await load()
|
|
28
|
+
} while stale.contains(kind) && !Task.isCancelled
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Local-only secrets. Copy to Secrets.swift (gitignored) and fill in.
|
|
4
|
+
/// Auth uses Firebase ID tokens; the legacy REST API token is only used by
|
|
5
|
+
/// tooling/UITests.
|
|
6
|
+
enum Secrets {
|
|
7
|
+
static let apiToken = "" // legacy REST API token, leave empty if unused
|
|
8
|
+
static let sentryDsn = "" // Sentry DSN for crash/error reporting, leave empty to disable
|
|
9
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Observation
|
|
3
|
+
|
|
4
|
+
/// Blocks the app when the running build is older than the backend's minimum
|
|
5
|
+
/// supported iOS build (`GET /app-config`, public). Checked at launch and on
|
|
6
|
+
/// every return to the foreground, so a backend deploy that raises the floor
|
|
7
|
+
/// also catches apps that were already open. Fails open: a network or decoding
|
|
8
|
+
/// error never blocks the app.
|
|
9
|
+
@MainActor
|
|
10
|
+
@Observable
|
|
11
|
+
final class AppSupportGate {
|
|
12
|
+
enum State: Equatable {
|
|
13
|
+
case supported
|
|
14
|
+
case updateRequired(appStoreURL: URL)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/// Defaults to `.supported` so launch never waits on the check; a blocked
|
|
18
|
+
/// build may flash the regular UI for one round-trip before the gate closes.
|
|
19
|
+
private(set) var state: State = .supported
|
|
20
|
+
|
|
21
|
+
private struct AppConfig: Decodable {
|
|
22
|
+
let minimumSupportedIOSBuild: Int
|
|
23
|
+
let appStoreUrl: URL
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func check() async {
|
|
27
|
+
// A Debug build, from Xcode or scripts/install-device.sh, carries build 1:
|
|
28
|
+
// the first raised floor would lock the developer out of their own app.
|
|
29
|
+
#if DEBUG
|
|
30
|
+
return
|
|
31
|
+
#else
|
|
32
|
+
guard let build = Self.currentBuild else { return }
|
|
33
|
+
let url = APIClient.shared.baseURL.appendingPathComponent("app-config")
|
|
34
|
+
// Bypass URLCache: a stale floor would defeat the foreground re-check.
|
|
35
|
+
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
|
|
36
|
+
guard let (data, _) = try? await URLSession.shared.data(for: request),
|
|
37
|
+
let config = try? JSONDecoder().decode(AppConfig.self, from: data)
|
|
38
|
+
else { return }
|
|
39
|
+
state = build < config.minimumSupportedIOSBuild
|
|
40
|
+
? .updateRequired(appStoreURL: config.appStoreUrl)
|
|
41
|
+
: .supported
|
|
42
|
+
#endif
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// `CFBundleVersion` is `git rev-list --count HEAD` at release time, so a
|
|
46
|
+
/// plain integer comparison is enough — no marketing-version parsing.
|
|
47
|
+
private static var currentBuild: Int? {
|
|
48
|
+
(Bundle.main.infoDictionary?["CFBundleVersion"] as? String).flatMap(Int.init)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
/// The launch wait, centred in all available space. Every other wait (a screen
|
|
4
|
+
/// loading, a list refreshing, a button working) takes the system `ProgressView`
|
|
5
|
+
/// where it happens, never this.
|
|
6
|
+
struct StartupLoadingView: View {
|
|
7
|
+
var label: LocalizedStringKey = "Chargement…"
|
|
8
|
+
|
|
9
|
+
var body: some View {
|
|
10
|
+
VStack(spacing: 16) {
|
|
11
|
+
ProgressView()
|
|
12
|
+
.controlSize(.large)
|
|
13
|
+
Text(label)
|
|
14
|
+
.font(.subheadline)
|
|
15
|
+
.foregroundStyle(.secondary)
|
|
16
|
+
}
|
|
17
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#Preview {
|
|
22
|
+
StartupLoadingView()
|
|
23
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
/// Blocking screen shown when the running build is below the backend's minimum
|
|
4
|
+
/// supported build. No dismissal: the only way forward is the App Store update.
|
|
5
|
+
struct UpdateRequiredView: View {
|
|
6
|
+
let appStoreURL: URL
|
|
7
|
+
|
|
8
|
+
var body: some View {
|
|
9
|
+
ContentUnavailableView {
|
|
10
|
+
Label("Mise à jour requise", systemImage: "arrow.down.app")
|
|
11
|
+
} description: {
|
|
12
|
+
Text("Cette version de {{APP_NAME}} n'est plus compatible avec le serveur. La dernière version est disponible sur l'App Store.")
|
|
13
|
+
} actions: {
|
|
14
|
+
Link("Mettre à jour", destination: appStoreURL)
|
|
15
|
+
.font(.headline)
|
|
16
|
+
.buttonStyle(.borderedProminent)
|
|
17
|
+
.controlSize(.large)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#Preview {
|
|
23
|
+
UpdateRequiredView(appStoreURL: URL(string: "https://apps.apple.com/app/id6789688303")!)
|
|
24
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#if DEBUG
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// The App Store captures: launched with `-showcase`, the app skips the Apple
|
|
5
|
+
/// sign-in and every read the captured screens make answers from fixtures here
|
|
6
|
+
/// rather than from the server. Debug-only by construction: the Release archive
|
|
7
|
+
/// compiles none of it. Grows with the app: each captured screen's data is added
|
|
8
|
+
/// here, derived from one list so no screen contradicts another.
|
|
9
|
+
enum Showcase {
|
|
10
|
+
static let isOn = ProcessInfo.processInfo.arguments.contains("-showcase")
|
|
11
|
+
static let firstName = "Ada"
|
|
12
|
+
}
|
|
13
|
+
#endif
|