@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,78 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// What a screen showed last time, kept on disk so a relaunch opens on it
|
|
4
|
+
/// instead of on a loader. The screens it is used for change
|
|
5
|
+
/// slowly: the snapshot written last time is almost always what the server
|
|
6
|
+
/// is about to send, and a screen one session old is worth far more than an
|
|
7
|
+
/// empty one. The snapshot draws first, the server's answer replaces it a
|
|
8
|
+
/// moment later, under a spinner at the top of the screen.
|
|
9
|
+
///
|
|
10
|
+
/// Every snapshot lives in one folder of the caches directory, which the
|
|
11
|
+
/// system may reclaim at will — that is what a cache is for, and losing it
|
|
12
|
+
/// costs one loader. The screen decides what goes in: only what it *opens*
|
|
13
|
+
/// on, never a filtered state the user asked for.
|
|
14
|
+
struct SnapshotCache<Value: Codable & Sendable>: Sendable {
|
|
15
|
+
/// The file's name, one per screen.
|
|
16
|
+
private let name: String
|
|
17
|
+
/// Bump whenever `Value` changes shape. An older file is then ignored
|
|
18
|
+
/// instead of decoded into something that no longer means the same thing.
|
|
19
|
+
private let version: Int
|
|
20
|
+
|
|
21
|
+
init(_ name: String, version: Int) {
|
|
22
|
+
self.name = name
|
|
23
|
+
self.version = version
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private var file: URL {
|
|
27
|
+
SnapshotCaches.folder.appending(path: "\(name).json")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// The last snapshot, or `nil` when there is no usable file: a first
|
|
31
|
+
/// launch, a cache the system reclaimed, or a file written by an older shape.
|
|
32
|
+
func read() -> Value? {
|
|
33
|
+
#if DEBUG
|
|
34
|
+
// The captures draw the showcase alone, never a session left on the
|
|
35
|
+
// simulator.
|
|
36
|
+
if Showcase.isOn { return nil }
|
|
37
|
+
#endif
|
|
38
|
+
guard let data = try? Data(contentsOf: file),
|
|
39
|
+
let stored = try? JSONDecoder().decode(Stored.self, from: data),
|
|
40
|
+
stored.version == version
|
|
41
|
+
else { return nil }
|
|
42
|
+
return stored.value
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// Overwrite the snapshot with what is on screen. A failure is swallowed
|
|
46
|
+
/// on purpose: a snapshot that cannot be written costs a loader on the
|
|
47
|
+
/// next launch, nothing more.
|
|
48
|
+
func write(_ value: Value) {
|
|
49
|
+
#if DEBUG
|
|
50
|
+
if Showcase.isOn { return }
|
|
51
|
+
#endif
|
|
52
|
+
guard let data = try? JSONEncoder().encode(Stored(version: version, value: value))
|
|
53
|
+
else { return }
|
|
54
|
+
try? FileManager.default.createDirectory(
|
|
55
|
+
at: SnapshotCaches.folder, withIntermediateDirectories: true
|
|
56
|
+
)
|
|
57
|
+
try? data.write(to: file, options: .atomic)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private struct Stored: Codable {
|
|
61
|
+
let version: Int
|
|
62
|
+
let value: Value
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// Forget every screen's snapshot. Called when the session ends: whoever opens
|
|
67
|
+
/// the app next must not read the previous account's data before the
|
|
68
|
+
/// server has said a word.
|
|
69
|
+
enum SnapshotCaches {
|
|
70
|
+
/// The one folder every snapshot lives in, so the session's end is a single delete.
|
|
71
|
+
static var folder: URL {
|
|
72
|
+
URL.cachesDirectory.appending(path: "snapshots", directoryHint: .isDirectory)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
static func clear() {
|
|
76
|
+
try? FileManager.default.removeItem(at: folder)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#if DEBUG
|
|
2
|
+
import FirebaseAuth
|
|
3
|
+
import Foundation
|
|
4
|
+
|
|
5
|
+
/// Signs the app in without Sign in with Apple, so the UI tests can reach the
|
|
6
|
+
/// screens behind the login wall. Apple's sheet is a system process XCUITest
|
|
7
|
+
/// cannot drive, so an end-to-end run points Firebase Auth at the local
|
|
8
|
+
/// emulator instead and creates its own account there.
|
|
9
|
+
///
|
|
10
|
+
/// Debug-only by construction: the whole file is compiled out of the Release
|
|
11
|
+
/// configuration the App Store archive is built with.
|
|
12
|
+
///
|
|
13
|
+
/// Driven by two launch arguments, both required for anything to happen:
|
|
14
|
+
/// -uiTestAuthEmulator 127.0.0.1:9099
|
|
15
|
+
/// -uiTestAccount e2e-<uuid>@{{APP_SLUG}}.test
|
|
16
|
+
///
|
|
17
|
+
/// The server URL rides the plain `-serverURL` argument, which `APIClient`
|
|
18
|
+
/// already reads through `UserDefaults`.
|
|
19
|
+
enum UITestEnvironment {
|
|
20
|
+
/// Any password satisfies the emulator; it verifies nothing.
|
|
21
|
+
private static let password = "e2e-password"
|
|
22
|
+
|
|
23
|
+
/// Called from `App.init()` right after `FirebaseApp.configure()`,
|
|
24
|
+
/// before anything touches `Auth.auth()`.
|
|
25
|
+
static func bootstrapIfNeeded() {
|
|
26
|
+
guard
|
|
27
|
+
let emulator = value(for: "-uiTestAuthEmulator"),
|
|
28
|
+
let email = value(for: "-uiTestAccount"),
|
|
29
|
+
let (host, port) = split(emulator)
|
|
30
|
+
else { return }
|
|
31
|
+
|
|
32
|
+
Auth.auth().useEmulator(withHost: host, port: port)
|
|
33
|
+
// A previous run leaves its session in the simulator's keychain. Signing
|
|
34
|
+
// out first means the app never shows the old account's library while the
|
|
35
|
+
// new sign-in is still in flight.
|
|
36
|
+
try? Auth.auth().signOut()
|
|
37
|
+
|
|
38
|
+
Task {
|
|
39
|
+
do {
|
|
40
|
+
// The emulator starts empty on every run, so the account is
|
|
41
|
+
// created here. Falling back to a sign-in covers the app being
|
|
42
|
+
// re-launched inside one test, where the account already exists.
|
|
43
|
+
do {
|
|
44
|
+
_ = try await Auth.auth().createUser(withEmail: email, password: password)
|
|
45
|
+
} catch {
|
|
46
|
+
_ = try await Auth.auth().signIn(withEmail: email, password: password)
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// No XCTFail available here: the test fails on its own when the
|
|
50
|
+
// login screen never goes away, and this line says why.
|
|
51
|
+
print("[UITestEnvironment] sign-in failed for \(email): \(error)")
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// Reads the value following `name` in the launch arguments.
|
|
57
|
+
private static func value(for name: String) -> String? {
|
|
58
|
+
let arguments = ProcessInfo.processInfo.arguments
|
|
59
|
+
guard let index = arguments.firstIndex(of: name), index + 1 < arguments.count
|
|
60
|
+
else { return nil }
|
|
61
|
+
return arguments[index + 1]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private static func split(_ hostPort: String) -> (String, Int)? {
|
|
65
|
+
let parts = hostPort.split(separator: ":")
|
|
66
|
+
guard parts.count == 2, let port = Int(parts[1]) else { return nil }
|
|
67
|
+
return (String(parts[0]), port)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
#endif
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>com.apple.developer.applesignin</key>
|
|
6
|
+
<array>
|
|
7
|
+
<string>Default</string>
|
|
8
|
+
</array>
|
|
9
|
+
</dict>
|
|
10
|
+
</plist>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Testing
|
|
3
|
+
@testable import {{APP_NAME}}
|
|
4
|
+
|
|
5
|
+
@MainActor
|
|
6
|
+
struct OnboardingGateTests {
|
|
7
|
+
@Test func aFreshAccountIsSentToOnboarding() async {
|
|
8
|
+
var client = OnboardingClient.preview
|
|
9
|
+
client.launch = { LaunchState(firstName: nil, onboardingCompleted: false) }
|
|
10
|
+
let gate = OnboardingGate(client: client)
|
|
11
|
+
|
|
12
|
+
await gate.refresh()
|
|
13
|
+
|
|
14
|
+
#expect(gate.state == .required)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@Test func anOnboardedAccountOpensTheAppWithItsName() async {
|
|
18
|
+
let gate = OnboardingGate(client: .preview)
|
|
19
|
+
|
|
20
|
+
await gate.refresh()
|
|
21
|
+
|
|
22
|
+
#expect(gate.state == .ready)
|
|
23
|
+
#expect(gate.firstName == "Ada")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@Test func aFailedLaunchIsSaidAndCanBeRetried() async {
|
|
27
|
+
var client = OnboardingClient.preview
|
|
28
|
+
client.launch = { throw Boom() }
|
|
29
|
+
let gate = OnboardingGate(client: client)
|
|
30
|
+
|
|
31
|
+
await gate.refresh()
|
|
32
|
+
|
|
33
|
+
guard case .failed = gate.state else {
|
|
34
|
+
Issue.record("expected a failure, got \(gate.state)")
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Testing
|
|
3
|
+
@testable import {{APP_NAME}}
|
|
4
|
+
|
|
5
|
+
@MainActor
|
|
6
|
+
struct ProfileSettingsViewModelTests {
|
|
7
|
+
@Test func deletionEndsTheSession() async {
|
|
8
|
+
let ended = Flag()
|
|
9
|
+
let viewModel = ProfileSettingsViewModel(client: .preview)
|
|
10
|
+
|
|
11
|
+
await viewModel.deleteAccount { ended.isOn = true }
|
|
12
|
+
|
|
13
|
+
#expect(ended.isOn)
|
|
14
|
+
#expect(viewModel.errorMessage == nil)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@Test func lostAnswerOverAGoneAccountSaysNothing() async {
|
|
18
|
+
var client = SettingsClient.preview
|
|
19
|
+
client.deleteAccount = { throw Boom() }
|
|
20
|
+
client.accountIsGone = { true }
|
|
21
|
+
let viewModel = ProfileSettingsViewModel(client: client)
|
|
22
|
+
|
|
23
|
+
await viewModel.deleteAccount {}
|
|
24
|
+
|
|
25
|
+
#expect(viewModel.errorMessage == nil)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@Test func refusedDeletionSaysSoAndStops() async {
|
|
29
|
+
let ended = Flag()
|
|
30
|
+
var client = SettingsClient.preview
|
|
31
|
+
client.deleteAccount = { throw Boom() }
|
|
32
|
+
let viewModel = ProfileSettingsViewModel(client: client)
|
|
33
|
+
|
|
34
|
+
await viewModel.deleteAccount { ended.isOn = true }
|
|
35
|
+
|
|
36
|
+
#expect(ended.isOn == false)
|
|
37
|
+
#expect(viewModel.errorMessage != nil)
|
|
38
|
+
#expect(viewModel.isDeletingAccount == false)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@MainActor
|
|
43
|
+
struct ChangelogViewModelTests {
|
|
44
|
+
@Test func loadShowsTheEntries() async {
|
|
45
|
+
let viewModel = ChangelogViewModel(client: .preview)
|
|
46
|
+
|
|
47
|
+
await viewModel.load()
|
|
48
|
+
|
|
49
|
+
#expect(viewModel.entries.map(\.version) == ["1.1", "1.0"])
|
|
50
|
+
#expect(viewModel.isLoading == false)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@Test func failureIsSaid() async {
|
|
54
|
+
var client = SettingsClient.preview
|
|
55
|
+
client.changelog = { throw Boom() }
|
|
56
|
+
let viewModel = ChangelogViewModel(client: client)
|
|
57
|
+
|
|
58
|
+
await viewModel.load()
|
|
59
|
+
|
|
60
|
+
#expect(viewModel.entries.isEmpty)
|
|
61
|
+
#expect(viewModel.errorMessage != nil)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// An error no client really throws, for a call that fails.
|
|
4
|
+
struct Boom: Error {}
|
|
5
|
+
|
|
6
|
+
/// A switch a test flips between two calls of the same client.
|
|
7
|
+
final class Flag: @unchecked Sendable {
|
|
8
|
+
private let lock = NSLock()
|
|
9
|
+
private var value = false
|
|
10
|
+
|
|
11
|
+
var isOn: Bool {
|
|
12
|
+
get { lock.withLock { value } }
|
|
13
|
+
set { lock.withLock { value = newValue } }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/// What a client was asked, in order.
|
|
18
|
+
final class Calls: @unchecked Sendable {
|
|
19
|
+
private let lock = NSLock()
|
|
20
|
+
private var items: [String] = []
|
|
21
|
+
|
|
22
|
+
func record(_ call: String) { lock.withLock { items.append(call) } }
|
|
23
|
+
var all: [String] { lock.withLock { items } }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// Waits until `condition` holds, or `timeout` runs out: a state a timer or a
|
|
27
|
+
/// debounce brings about is checked once it is there, not after a fixed sleep a
|
|
28
|
+
/// slow CI machine overshoots.
|
|
29
|
+
@MainActor
|
|
30
|
+
func eventually(within timeout: Duration = .seconds(5), _ condition: () -> Bool) async {
|
|
31
|
+
let deadline = ContinuousClock.now + timeout
|
|
32
|
+
while !condition(), ContinuousClock.now < deadline {
|
|
33
|
+
try? await Task.sleep(for: .milliseconds(10))
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"scripts": {
|
|
3
|
+
"ios:project": "cd ios && xcodegen generate",
|
|
4
|
+
"ios:codegen": "bun scripts/apollo-codegen.ts",
|
|
5
|
+
"ios:build": "bun run ios:project && bun run ios:codegen && DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -project ios/{{APP_NAME}}.xcodeproj -scheme {{APP_NAME}} -destination 'generic/platform=iOS Simulator' -derivedDataPath build/derived SWIFT_ENABLE_EXPLICIT_MODULES=NO build",
|
|
6
|
+
"ios:test": "DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -project ios/{{APP_NAME}}.xcodeproj -scheme {{APP_NAME}} -destination \"platform=iOS Simulator,name=${IOS_SIMULATOR:-iPhone 17}\" -derivedDataPath build/derived SWIFT_ENABLE_EXPLICIT_MODULES=NO test",
|
|
7
|
+
"ios:strings": "DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer bun scripts/check-strings.ts build/derived",
|
|
8
|
+
"ios:components": "bun scripts/check-ios-components.ts"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Generates the app's GraphQL types from the committed schema, with the CLI the
|
|
4
|
+
* resolved apollo-ios package ships (never a global install: its version must
|
|
5
|
+
* match the library's). Resolves the packages first when they are not there.
|
|
6
|
+
*
|
|
7
|
+
* bun scripts/apollo-codegen.ts
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { $ } from 'bun'
|
|
12
|
+
|
|
13
|
+
const root = join(import.meta.dir, '..')
|
|
14
|
+
const derived = join(root, 'build/derived')
|
|
15
|
+
const cliDir = join(derived, 'SourcePackages/checkouts/apollo-ios/CLI')
|
|
16
|
+
const env = { ...process.env, DEVELOPER_DIR: '/Applications/Xcode.app/Contents/Developer' }
|
|
17
|
+
|
|
18
|
+
if (!existsSync(join(cliDir, 'apollo-ios-cli.tar.gz'))) {
|
|
19
|
+
await $`xcodebuild -resolvePackageDependencies -project ios/{{APP_NAME}}.xcodeproj -scheme {{APP_NAME}} -derivedDataPath ${derived}`
|
|
20
|
+
.cwd(root)
|
|
21
|
+
.env(env)
|
|
22
|
+
.quiet()
|
|
23
|
+
}
|
|
24
|
+
if (!existsSync(join(cliDir, 'apollo-ios-cli'))) {
|
|
25
|
+
await $`tar -xzf apollo-ios-cli.tar.gz`.cwd(cliDir)
|
|
26
|
+
}
|
|
27
|
+
await $`${join(cliDir, 'apollo-ios-cli')} generate`.cwd(join(root, 'ios')).env(env)
|
|
28
|
+
// A fresh checkout sometimes skips the namespace file on the first run.
|
|
29
|
+
const namespace = join(root, 'ios/{{APP_NAME}}/Generated/GraphQL/{{APP_NAME}}GraphQL.graphql.swift')
|
|
30
|
+
if (!existsSync(namespace)) await $`${join(cliDir, 'apollo-ios-cli')} generate`.cwd(join(root, 'ios')).env(env)
|
|
31
|
+
console.log('GraphQL types generated in ios/{{APP_NAME}}/Generated/GraphQL')
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Keeps the iOS components pure, as the stack rule 10-ios.md asks: everything under a
|
|
4
|
+
* `components/` folder, and under `Shared/Components`, draws
|
|
5
|
+
* what it is given and asks nothing of the server.
|
|
6
|
+
*
|
|
7
|
+
* A component fails the run when it
|
|
8
|
+
* - calls an API enum (`ItemAPI.add`…) or a client's `.live`;
|
|
9
|
+
* - reads a singleton (`Something.shared`…), the system's own
|
|
10
|
+
* (`UIApplication.shared`, `UIPasteboard.general`) aside;
|
|
11
|
+
* - listens to `NotificationCenter`;
|
|
12
|
+
* - holds a ViewModel;
|
|
13
|
+
* - declares a view and no `#Preview`.
|
|
14
|
+
*
|
|
15
|
+
* Connected code — Screens, ViewModels, clients — lives beside the
|
|
16
|
+
* `components/` folder and is not checked.
|
|
17
|
+
*
|
|
18
|
+
* bun scripts/check-ios-components.ts
|
|
19
|
+
*/
|
|
20
|
+
import { readFile } from 'node:fs/promises'
|
|
21
|
+
import { join, relative } from 'node:path'
|
|
22
|
+
import { Glob } from 'bun'
|
|
23
|
+
|
|
24
|
+
const root = join(import.meta.dir, '../ios/{{APP_NAME}}')
|
|
25
|
+
|
|
26
|
+
const RULES: { pattern: RegExp; says: string }[] = [
|
|
27
|
+
{
|
|
28
|
+
pattern: /\b[A-Z][A-Za-z]*API\.[a-z]/,
|
|
29
|
+
says: 'calls an API: hand it the value or an action instead',
|
|
30
|
+
},
|
|
31
|
+
{ pattern: /\b[A-Z][A-Za-z]*Client\.live\b/, says: 'reaches a live client: the Screen owns it' },
|
|
32
|
+
{
|
|
33
|
+
pattern: /\b(?!UIApplication\b|UIPasteboard\b)[A-Z][A-Za-z]*\.shared\b/,
|
|
34
|
+
says: 'reads a singleton: pass the value down, or inject it through @Environment',
|
|
35
|
+
},
|
|
36
|
+
{ pattern: /\bNotificationCenter\b/, says: 'listens to change notices: only Screens do' },
|
|
37
|
+
{
|
|
38
|
+
pattern: /\b[A-Z][A-Za-z]*ViewModel\b/,
|
|
39
|
+
says: 'holds a ViewModel: take its values and actions',
|
|
40
|
+
},
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
const files = [
|
|
44
|
+
...new Glob('Features/*/components/**/*.swift').scanSync(root),
|
|
45
|
+
...new Glob('Shared/Components/**/*.swift').scanSync(root),
|
|
46
|
+
].sort()
|
|
47
|
+
|
|
48
|
+
const failures: string[] = []
|
|
49
|
+
for (const file of files) {
|
|
50
|
+
const source = await readFile(join(root, file), 'utf8')
|
|
51
|
+
// Comments say what the code once did; only the code is checked.
|
|
52
|
+
const code = source
|
|
53
|
+
.split('\n')
|
|
54
|
+
.map((line) => line.replace(/\/\/.*$/, ''))
|
|
55
|
+
.join('\n')
|
|
56
|
+
code.split('\n').forEach((line, index) => {
|
|
57
|
+
for (const rule of RULES) {
|
|
58
|
+
if (rule.pattern.test(line)) failures.push(`${file}:${index + 1}: ${rule.says}`)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
const declaresView = /\bstruct\s+\w+(<[^>]*>)?\s*:\s*[^{]*\bView\b/.test(code)
|
|
62
|
+
if (declaresView && !code.includes('#Preview')) {
|
|
63
|
+
failures.push(`${file}: declares a view and no #Preview`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (failures.length > 0) {
|
|
68
|
+
console.error(`${failures.length} component rule(s) broken (the stack rule 10-ios.md):`)
|
|
69
|
+
for (const failure of failures) console.error(` ${relative(process.cwd(), join(root, failure))}`)
|
|
70
|
+
process.exit(1)
|
|
71
|
+
}
|
|
72
|
+
console.log(`${files.length} components, all pure and previewed.`)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Keeps the iOS string catalogues whole: every string the app shows has its
|
|
4
|
+
* French and its English.
|
|
5
|
+
*
|
|
6
|
+
* The strings are written in French in the Swift code, and the catalogues say
|
|
7
|
+
* so (`sourceLanguage: fr`). English is the app's development language all the
|
|
8
|
+
* same, so that a user whose iPhone speaks neither gets English, as the
|
|
9
|
+
* server answers them. The price of that: a string missing from the French
|
|
10
|
+
* table falls back to the English one. So each string carries an explicit
|
|
11
|
+
* French entry, copied from its key, which this script writes; a string with
|
|
12
|
+
* no English is reported, and fails the run.
|
|
13
|
+
*
|
|
14
|
+
* Reads what the compiler extracted from the last build (`.stringsdata`, under
|
|
15
|
+
* the derived data given), so a string added to the code and absent from the
|
|
16
|
+
* catalogue is caught too.
|
|
17
|
+
*
|
|
18
|
+
* bun scripts/check-strings.ts <derived-data> # check, as CI does
|
|
19
|
+
* bun scripts/check-strings.ts <derived-data> --write # sync the catalogues in place
|
|
20
|
+
*/
|
|
21
|
+
import { copyFile, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
|
|
22
|
+
import { tmpdir } from 'node:os'
|
|
23
|
+
import { basename, dirname, join } from 'node:path'
|
|
24
|
+
import { $, Glob } from 'bun'
|
|
25
|
+
|
|
26
|
+
const ios = join(import.meta.dir, '../ios')
|
|
27
|
+
|
|
28
|
+
/** Each catalogue, and the build products its strings come from. */
|
|
29
|
+
const CATALOGUES = [
|
|
30
|
+
{ path: join(ios, '{{APP_NAME}}/Localizable.xcstrings'), product: '{{APP_NAME}}.build' },
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
type Unit = { stringUnit?: { state: string; value: string }; variations?: unknown }
|
|
34
|
+
type Entry = {
|
|
35
|
+
extractionState?: string
|
|
36
|
+
shouldTranslate?: boolean
|
|
37
|
+
localizations?: Record<string, Unit>
|
|
38
|
+
}
|
|
39
|
+
type Catalogue = { sourceLanguage: string; strings: Record<string, Entry>; version: string }
|
|
40
|
+
|
|
41
|
+
const [derivedData, ...flags] = process.argv.slice(2)
|
|
42
|
+
if (!derivedData) throw new Error('Usage: bun scripts/check-strings.ts <derived-data> [--write]')
|
|
43
|
+
const write = flags.includes('--write')
|
|
44
|
+
|
|
45
|
+
/** Xcode's own layout: two spaces, ` : ` between a key and its value. */
|
|
46
|
+
const serialize = (catalogue: Catalogue) =>
|
|
47
|
+
`${JSON.stringify(catalogue, Object.keys(flatten(catalogue)).sort(), 2).replaceAll('": ', '" : ')}\n`
|
|
48
|
+
|
|
49
|
+
/** Every key at any depth, for `JSON.stringify` to write them sorted. */
|
|
50
|
+
function flatten(value: unknown, keys: Record<string, true> = {}): Record<string, true> {
|
|
51
|
+
if (value && typeof value === 'object')
|
|
52
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
53
|
+
keys[key] = true
|
|
54
|
+
flatten(inner, keys)
|
|
55
|
+
}
|
|
56
|
+
return keys
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const work = await mkdtemp(join(tmpdir(), '{{APP_SLUG}}-strings-'))
|
|
60
|
+
const missing: string[] = []
|
|
61
|
+
|
|
62
|
+
for (const { path, product } of CATALOGUES) {
|
|
63
|
+
// Under the configuration's folder: the project's own folder is named
|
|
64
|
+
// {{APP_NAME}}.build too, and holds the extension's strings.
|
|
65
|
+
const extracted = await Array.fromAsync(
|
|
66
|
+
new Glob(`**/*-iphone*/${product}/**/*.stringsdata`).scan({ cwd: derivedData, absolute: true }),
|
|
67
|
+
)
|
|
68
|
+
if (extracted.length === 0)
|
|
69
|
+
throw new Error(`No ${product} strings under ${derivedData}: build the app first`)
|
|
70
|
+
|
|
71
|
+
// The sync runs on a copy: a check must not touch the working tree. The copy
|
|
72
|
+
// keeps the catalogue's name, which is the table its strings are filed under.
|
|
73
|
+
const synced = join(work, basename(product, '.build'), basename(path))
|
|
74
|
+
await mkdir(dirname(synced), { recursive: true })
|
|
75
|
+
await copyFile(path, synced)
|
|
76
|
+
await $`xcrun xcstringstool sync ${synced} --stringsdata ${extracted}`.quiet()
|
|
77
|
+
|
|
78
|
+
const catalogue: Catalogue = JSON.parse(await readFile(synced, 'utf8'))
|
|
79
|
+
for (const [key, entry] of Object.entries(catalogue.strings)) {
|
|
80
|
+
if (entry.extractionState === 'stale') {
|
|
81
|
+
delete catalogue.strings[key]
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
entry.localizations ??= {}
|
|
85
|
+
const localizations = entry.localizations
|
|
86
|
+
localizations.fr ??= { stringUnit: { state: 'translated', value: key } }
|
|
87
|
+
if (!localizations.en) missing.push(`${basename(product, '.build')}: ${JSON.stringify(key)}`)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const before = await readFile(path, 'utf8')
|
|
91
|
+
const after = serialize(catalogue)
|
|
92
|
+
if (before === after) continue
|
|
93
|
+
if (write) {
|
|
94
|
+
await writeFile(path, after)
|
|
95
|
+
console.log(`${basename(product, '.build')}: catalogue synced`)
|
|
96
|
+
} else missing.push(`${basename(product, '.build')}: catalogue out of date, run with --write`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (missing.length > 0) {
|
|
100
|
+
console.error(`Strings without their English:\n${missing.map((line) => ` ${line}`).join('\n')}`)
|
|
101
|
+
process.exit(1)
|
|
102
|
+
}
|
|
103
|
+
console.log('Every string has its French and its English.')
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Build, install and launch the app on the physical iPhone named in
|
|
4
|
+
# project-context.md, whose CoreDevice id is IOS_DEVICE_ID (in .env or the shell).
|
|
5
|
+
# On request only: see the stack rule 10-ios.md, "Physical device".
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
cd "$(dirname "$0")/.."
|
|
8
|
+
[ -f .env ] && set -a && . ./.env && set +a
|
|
9
|
+
: "${IOS_DEVICE_ID:?IOS_DEVICE_ID is unset: the CoreDevice id from xcrun devicectl list devices}"
|
|
10
|
+
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
|
11
|
+
|
|
12
|
+
bun run ios:project >/dev/null
|
|
13
|
+
bun run ios:codegen
|
|
14
|
+
|
|
15
|
+
echo "==> Building for device ${IOS_DEVICE_ID}"
|
|
16
|
+
# -allowProvisioningUpdates lets automatic signing register the device and mint the
|
|
17
|
+
# profile on first run.
|
|
18
|
+
xcodebuild -project ios/{{APP_NAME}}.xcodeproj -scheme {{APP_NAME}} \
|
|
19
|
+
-destination "platform=iOS,id=${IOS_DEVICE_ID}" \
|
|
20
|
+
-derivedDataPath build/device \
|
|
21
|
+
-allowProvisioningUpdates \
|
|
22
|
+
build
|
|
23
|
+
|
|
24
|
+
APP="build/device/Build/Products/Debug-iphoneos/{{APP_NAME}}.app"
|
|
25
|
+
echo "==> Installing ${APP}"
|
|
26
|
+
xcrun devicectl device install app "$APP" --device "$IOS_DEVICE_ID"
|
|
27
|
+
BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP/Info.plist")
|
|
28
|
+
echo "==> Launching ${BUNDLE_ID}"
|
|
29
|
+
xcrun devicectl device process launch --device "$IOS_DEVICE_ID" "$BUNDLE_ID"
|