From 040a17089f761e1aed5b8b00249196794247898a Mon Sep 17 00:00:00 2001 From: dadachi Date: Sun, 18 Oct 2026 09:21:33 +0900 Subject: [PATCH] Read the App Store page from a build setting and fix rename leaks Strings.appStoreUrl was hardcoded to MyTurnTag Creator's App Store page, so the forced-update screen's Update App button sent users of every app built from the template there. It now comes from APP_STORE_ID, the app's Apple ID, in the new AppStore.xcconfig (included by the new Project.xcconfig, now the project-level base configuration; overridable in the gitignored AppStore.local.xcconfig), built into Info.plist as AppStoreID: https://apps.apple.com/app/id. Empty, as committed, or not all digits hides the button; the screen still asks users to install the new version. An ID avoids the xcconfig "//" comment trap a whole URL would hit. Rename safety (SUBSTRATE-CONTRACT rules 1, 5 and 6): - JSONAPIDocumentTest used "itemTag" as an unknown type; the agent renames item_tag and itemTag to the same word, so it became a known type in generated apps. It now checks account (not in EntityType), shopkeeperSignIn (camelCase; sign_in is not renamed, so it stays different from shopkeeper_sign_in) and Shop (capitalized). - NSLocalNetworkUsageDescription had an article before the product name; it now reads "Connects to the app's API server running on your local network during development." LocalizationTest fails on "a"/"an" right before a renamed word in the English strings. - Comments drop the article before domain words and say "the agent" instead of the agent's name, which the rename turns into -agent. Closes #109 Closes #110 Closes #111 Closes #112 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UZJxUoubntzu7Ec7DPVfAr --- .gitignore | 2 + AppStore.xcconfig | 24 ++++++++ CHANGELOG.md | 5 +- NativeAppTemplate.xcodeproj/project.pbxproj | 6 ++ NativeAppTemplate/Constants.swift | 17 +++++- NativeAppTemplate/Info.plist | 4 +- NativeAppTemplate/InfoPlist.xcstrings | 2 +- NativeAppTemplate/UI/App Root/MainView.swift | 4 +- .../UI/Empty States/NeedAppUpdatesView.swift | 20 +++++-- .../App/InfoPlistTest.swift | 20 +++++++ .../App/LocalizationTest.swift | 38 +++++++++++-- .../Data/DataStateListContentTest.swift | 2 +- .../Repositories/ItemTagRepositoryTest.swift | 2 +- .../Repositories/ShopRepositoryTest.swift | 2 +- .../Networking/APIEndpointTest.swift | 2 +- .../JSONAPI/JSONAPIDocumentTest.swift | 8 ++- .../Networking/RootPathServicesTest.swift | 2 +- .../Empty States/NeedAppUpdatesViewTest.swift | 56 +++++++++++++++++++ Project.xcconfig | 7 +++ README.md | 10 ++++ 20 files changed, 209 insertions(+), 24 deletions(-) create mode 100644 AppStore.xcconfig create mode 100644 NativeAppTemplateTests/UI/Empty States/NeedAppUpdatesViewTest.swift create mode 100644 Project.xcconfig diff --git a/.gitignore b/.gitignore index 0e45a10..32e948c 100644 --- a/.gitignore +++ b/.gitignore @@ -96,3 +96,5 @@ docs-private/ # Local API endpoint for Debug builds (see API.xcconfig) API.local.xcconfig +# Local App Store ID (see AppStore.xcconfig) +AppStore.local.xcconfig diff --git a/AppStore.xcconfig b/AppStore.xcconfig new file mode 100644 index 0000000..c5fe213 --- /dev/null +++ b/AppStore.xcconfig @@ -0,0 +1,24 @@ +// +// AppStore.xcconfig +// +// Your app's App Store page, which the forced-update screen (NeedAppUpdatesView) +// opens from its Update App button. APP_STORE_ID is your app's Apple ID, the +// number at the end of its App Store URL (https://apps.apple.com/app/id; +// App Store Connect > your app > App Information > Apple ID). It is built into +// Info.plist (AppStoreID) and read at launch. +// +// Empty, as committed, hides the Update App button: the screen still asks users +// to install the new version, which they get from the App Store app. A value +// that is not all digits also hides it, and InfoPlistTest fails on it. +// +// Set it here in your own app's repository, or in AppStore.local.xcconfig +// (gitignored), e.g. +// +// APP_STORE_ID = 1234567890 +// +// Applies to both Debug and Release (the project-level base configuration, +// through Project.xcconfig). + +APP_STORE_ID = + +#include? "AppStore.local.xcconfig" diff --git a/CHANGELOG.md b/CHANGELOG.md index d801f3f..ac08b19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Error messages are localized (English and Japanese). A request that fails without the API's `error_message` shows "Something went wrong while communicating with the server. Please try again later." (`NATIVEAPPTEMPLATE-2001`); developer-only errors (`NATIVEAPPTEMPLATE-1001`, `2002`–`2008`, `4001`–`4004`) show "An unexpected error occurred." The API's `error_message` is shown without the " [Status: nnn]" suffix. Error codes are unchanged; status codes, field names and underlying errors go to the log (`CodedError.debugDescription`) ### Fixed +- The update screen shown when the API requires a newer version opened MyTurnTag Creator's App Store page from its **Update App** button (hardcoded in `Strings.appStoreUrl`). It now opens `https://apps.apple.com/app/id`, where `APP_STORE_ID` is a build setting in the new `AppStore.xcconfig` (included by the new `Project.xcconfig`, the project-level base configuration of Debug and Release; override it in the gitignored `AppStore.local.xcconfig`), built into `Info.plist` as `AppStoreID`. It is empty as committed, which hides the button; the screen still asks users to install the new version. See README "App Store page" +- The Local Network permission prompt (`NSLocalNetworkUsageDescription`) had an article before the product name, which reads wrong after a rename. It now reads "Connects to the app's API server running on your local network during development."; the Japanese is unchanged. `LocalizationTest` now fails on "a" or "an" right before a word the agent renames in the English strings +- Rename safety in tests and comments: `JSONAPIDocumentTest` no longer uses `itemTag` as a type the app does not know (the agent renames `item_tag` and `itemTag` to the same word, so it became a known type in generated apps), and comments drop the article before domain words and say "the agent" instead of the agent's name - The alert after resending confirmation instructions read "An email has been sent the email containing instructions for confirming your email address." It now reads "An email with instructions for confirming your email address has been sent.", as in NativeAppTemplate-Free-Android; the Japanese is unchanged - The message bar no longer covers the tab bar and blocks tab taps until it is closed: with the tabs showing, it sits above the tab bar. Its close button is a labeled "Close" button (new string, Japanese 閉じる) with a 32 pt target (the symbol stays 16 pt); the tabs' accessibility label no longer replaces the message's labels - VoiceOver read validation messages that weren't shown, e.g. "Name is required." under a filled-in name: a valid form keeps the message's space with a clear color, which still left it an accessibility element. The message is now also hidden from accessibility until it is shown (`validationMessage(isShown:)`, used by every form that hid its message this way: sign-up, profile, password, shop create/basic settings, item tag create/edit) @@ -19,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Debug builds lost the local API when launched other than from Xcode, e.g. from the Home Screen or when iOS relaunched the app after its language was changed in Settings: the endpoint came only from the scheme's environment variables, which only Xcode passes. It now comes from `API.xcconfig` (override it in the gitignored `API.local.xcconfig`) and is built into `Info.plist`; `NATIVEAPPTEMPLATE_API_*` environment variables still win. The scheme no longer sets them ### Added -- Japanese localization, ported from NativeAppTemplate-iOS #102 with the same Japanese for every shared string. Every user-facing string goes through `Localizable.xcstrings` (`String(localized:)` in `Constants.swift`), with Japanese for all keys using the API's terms (店舗, アイテムタグ). `InfoPlist.xcstrings` keeps the app name as is. `LocalizationTest` fails on a key without Japanese, a translation with different format specifiers, ショップ, or an ItemTag not written as アイテムタグ in full +- Japanese localization, ported from NativeAppTemplate-iOS #102 with the same Japanese for every shared string. Every user-facing string goes through `Localizable.xcstrings` (`String(localized:)` in `Constants.swift`), with Japanese for all keys using the API's terms (店舗, アイテムタグ). `InfoPlist.xcstrings` keeps the app name as is. `LocalizationTest` fails on a key without Japanese, a translation with different format specifiers, ショップ, or ItemTag not written as アイテムタグ in full - "App Language" on Edit Profile shows the language the app is shown in, with a button to change it in iOS Settings; emails follow it. Sign-up sends that language as `locale`, and after permissions are fetched (every launch and sign-in) the app sends `PATCH /shopkeeper_auth {locale}` when `shopkeepers.locale` differs (nativeapptemplateapi #150) - `Shopkeeper.locale`, read from the sign-in, sign-up and profile responses and kept with the saved session. A session saved by an earlier version reads as `en`, the API's default diff --git a/NativeAppTemplate.xcodeproj/project.pbxproj b/NativeAppTemplate.xcodeproj/project.pbxproj index 7292d99..59bfecf 100644 --- a/NativeAppTemplate.xcodeproj/project.pbxproj +++ b/NativeAppTemplate.xcodeproj/project.pbxproj @@ -258,6 +258,8 @@ 01A133A42E08BB1B000AD24A /* SignInEmailAndPasswordViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInEmailAndPasswordViewModel.swift; sourceTree = ""; }; 01A133A62E08BB66000AD24A /* SignUpViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpViewModel.swift; sourceTree = ""; }; 01A9E1F02EA0000100000001 /* API.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = API.xcconfig; sourceTree = ""; }; + A99570000000000000000001 /* AppStore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppStore.xcconfig; sourceTree = ""; }; + 5E7A10000000000000000105 /* Project.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Project.xcconfig; sourceTree = ""; }; 01B37C7529B0960700BF5B2D /* ForgotPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForgotPasswordView.swift; sourceTree = ""; }; 01B526532AF4E36400655131 /* MainTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTab.swift; sourceTree = ""; }; 01B526552AF4E82A00655131 /* ScrollToTopID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollToTopID.swift; sourceTree = ""; }; @@ -408,6 +410,8 @@ isa = PBXGroup; children = ( 01A9E1F02EA0000100000001 /* API.xcconfig */, + A99570000000000000000001 /* AppStore.xcconfig */, + 5E7A10000000000000000105 /* Project.xcconfig */, 0135E8E22D7E4478004AD8FA /* SampleCode.xcconfig */, ); name = Configuration; @@ -1044,6 +1048,7 @@ /* Begin XCBuildConfiguration section */ 011F6E10259EF16600BED22E /* Debug configuration for PBXProject "NativeAppTemplate" */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 5E7A10000000000000000105 /* Project.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -1114,6 +1119,7 @@ }; 011F6E11259EF16600BED22E /* Release configuration for PBXProject "NativeAppTemplate" */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 5E7A10000000000000000105 /* Project.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; diff --git a/NativeAppTemplate/Constants.swift b/NativeAppTemplate/Constants.swift index d5dcf1f..62d8db5 100644 --- a/NativeAppTemplate/Constants.swift +++ b/NativeAppTemplate/Constants.swift @@ -106,8 +106,21 @@ enum Strings { static let appName: String = "Native App Template" - /// This is for MyTurnTag Creator. Replace this. - static let appStoreUrl: String = "https://apps.apple.com/app/myturntag-creator/id1516198303" + // MARK: App Store page + + /// APP_STORE_ID from AppStore.xcconfig (built into Info.plist): the app's Apple ID, the + /// number in its App Store URL. Empty, as committed, hides NeedAppUpdatesView's Update App + /// button. See README "App Store page". + static let appStoreId: String = Bundle.main.object(forInfoDictionaryKey: "AppStoreID") as? String ?? "" + /// The page NeedAppUpdatesView's Update App button opens; nil while APP_STORE_ID is empty + static let appStoreUrl: URL? = appStorePage(appStoreId: appStoreId) + + /// https://apps.apple.com/app/id, or nil unless `appStoreId` is all digits + /// (an Apple ID is a number; anything else, e.g. a pasted URL, would open a wrong page) + static func appStorePage(appStoreId: String) -> URL? { + guard !appStoreId.isEmpty, appStoreId.allSatisfy({ ("0"..."9").contains($0) }) else { return nil } + return URL(string: "https://apps.apple.com/app/id\(appStoreId)") + } static let placeholderFullName: String = .init(localized: "John Smith") static let placeholderEmail: String = "you@example.com" diff --git a/NativeAppTemplate/Info.plist b/NativeAppTemplate/Info.plist index 61d817f..1b70ee9 100644 --- a/NativeAppTemplate/Info.plist +++ b/NativeAppTemplate/Info.plist @@ -2,6 +2,8 @@ + AppStoreID + $(APP_STORE_ID) CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -25,7 +27,7 @@ LSRequiresIPhoneOS NSLocalNetworkUsageDescription - Connects to a NativeAppTemplate API server running on your local network during development. + Connects to the app's API server running on your local network during development. NativeAppTemplateAPIDomain $(NATIVEAPPTEMPLATE_API_DOMAIN) NativeAppTemplateAPIPort diff --git a/NativeAppTemplate/InfoPlist.xcstrings b/NativeAppTemplate/InfoPlist.xcstrings index 4ef09fd..41a29f7 100644 --- a/NativeAppTemplate/InfoPlist.xcstrings +++ b/NativeAppTemplate/InfoPlist.xcstrings @@ -44,7 +44,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "Connects to a NativeAppTemplate API server running on your local network during development." + "value" : "Connects to the app's API server running on your local network during development." } }, "ja" : { diff --git a/NativeAppTemplate/UI/App Root/MainView.swift b/NativeAppTemplate/UI/App Root/MainView.swift index 5d817cf..df077af 100644 --- a/NativeAppTemplate/UI/App Root/MainView.swift +++ b/NativeAppTemplate/UI/App Root/MainView.swift @@ -95,8 +95,8 @@ private extension MainView { .environment(tabViewModel) case .needsAppUpdate: AppTabView( - shopListView: NeedAppUpdatesView.init, - settingsView: NeedAppUpdatesView.init + shopListView: { NeedAppUpdatesView() }, + settingsView: { NeedAppUpdatesView() } ) .environment(tabViewModel) case .tabs: diff --git a/NativeAppTemplate/UI/Empty States/NeedAppUpdatesView.swift b/NativeAppTemplate/UI/Empty States/NeedAppUpdatesView.swift index 789ee64..f2bf801 100644 --- a/NativeAppTemplate/UI/Empty States/NeedAppUpdatesView.swift +++ b/NativeAppTemplate/UI/Empty States/NeedAppUpdatesView.swift @@ -8,6 +8,14 @@ import SwiftUI struct NeedAppUpdatesView: View { @Environment(\.openURL) var openURL + /// The app's App Store page (APP_STORE_ID in AppStore.xcconfig). nil, as committed, hides + /// the Update App button; the text still asks users to install the new version. + private let appStoreUrl: URL? + + init(appStoreUrl: URL? = Strings.appStoreUrl) { + self.appStoreUrl = appStoreUrl + } + var body: some View { VStack { Spacer() @@ -27,12 +35,14 @@ struct NeedAppUpdatesView: View { Text(Strings.installNewVersionApp) .foregroundStyle(.contentText) .padding(.top, NativeAppTemplateConstants.Spacing.xxxs) - Button { - openURL(URL(string: Strings.appStoreUrl)!) - } label: { - Text(Strings.updateApp) + if let appStoreUrl { + Button { + openURL(appStoreUrl) + } label: { + Text(Strings.updateApp) + } + .padding(.top) } - .padding(.top) } } .padding(.horizontal, NativeAppTemplateConstants.Spacing.lg) diff --git a/NativeAppTemplateTests/App/InfoPlistTest.swift b/NativeAppTemplateTests/App/InfoPlistTest.swift index 4970d51..a8146b0 100644 --- a/NativeAppTemplateTests/App/InfoPlistTest.swift +++ b/NativeAppTemplateTests/App/InfoPlistTest.swift @@ -45,6 +45,26 @@ struct InfoPlistTest { #expect(info["UIUserInterfaceStyle"] as? String == "Dark") } + /// AppStore.xcconfig's APP_STORE_ID reaches the app through Info.plist; an empty value (as + /// committed) hides the forced-update screen's Update App button. A missing entry would hide + /// it even with an ID. + @Test + func declaresTheAppStoreID() throws { + let info = try #require(Bundle.main.infoDictionary) + + #expect(info["AppStoreID"] is String) + } + + /// Empty (as committed) or an Apple ID (digits only), which makes an App Store page. Anything + /// else, e.g. the whole App Store URL, would hide the Update App button without a word. + @Test + func theAppStoreIDIsEmptyOrAnAppleID() { + #expect( + Strings.appStoreId.isEmpty || Strings.appStoreUrl != nil, + "APP_STORE_ID is \"\(Strings.appStoreId)\"; see AppStore.xcconfig" + ) + } + /// iOS local network privacy identifies an app by its main executable's /// LC_UUID (Apple TN3179). With ENABLE_DEBUG_DYLIB the Debug main executable /// is a tiny stub that depends only on the product name, so this app and the diff --git a/NativeAppTemplateTests/App/LocalizationTest.swift b/NativeAppTemplateTests/App/LocalizationTest.swift index 9c11775..780d79f 100644 --- a/NativeAppTemplateTests/App/LocalizationTest.swift +++ b/NativeAppTemplateTests/App/LocalizationTest.swift @@ -63,8 +63,8 @@ struct LocalizationTest { } } - /// Shop is 店舗 throughout, as in the API's ja.yml (店舗名). nativeapptemplate-agent - /// renames 店舗 as a substring, so ショップ, 店 or お店 would survive a rename. + /// Shop is 店舗 throughout, as in the API's ja.yml (店舗名). The agent renames + /// 店舗 as a substring, so ショップ, 店 or お店 would survive a rename. @Test(.enabled(if: canReadSourceCatalogs, "Reads the .xcstrings sources on the Mac")) func shopIsAlways店舗() throws { let catalog = try StringCatalog(named: "Localizable.xcstrings") @@ -74,8 +74,8 @@ struct LocalizationTest { #expect(offenders.isEmpty, "Use 店舗: \(offenders)") } - /// An ItemTag is アイテムタグ in full: nativeapptemplate-agent renames アイテムタグ - /// as a substring, so a shortened タグ or アイテム would survive a rename. + /// ItemTag is アイテムタグ in full: the agent renames アイテムタグ as a substring, + /// so a shortened タグ or アイテム would survive a rename. @Test(.enabled(if: canReadSourceCatalogs, "Reads the .xcstrings sources on the Mac")) func itemTagIsAlwaysアイテムタグInFull() throws { let catalog = try StringCatalog(named: "Localizable.xcstrings") @@ -86,6 +86,28 @@ struct LocalizationTest { #expect(offenders.isEmpty, "Use アイテムタグ: \(offenders)") } + // MARK: - Rename safety + + /// No "a" or "an" right before a word the agent renames (SUBSTRATE-CONTRACT rule 1), which + /// would read "a OrangeBaton" or "an patient" after a rename. The Local Network prompt + /// (NSLocalNetworkUsageDescription) had one before the product name. + @Test(.enabled(if: canReadSourceCatalogs, "Reads the .xcstrings sources on the Mac")) + func noArticleBeforeARenamedWord() throws { + let localizable = try StringCatalog(named: "Localizable.xcstrings") + let infoPlist = try StringCatalog(named: "InfoPlist.xcstrings") + let info = try #require(Bundle.main.infoDictionary) + let usageDescriptions = info.filter { $0.key.hasSuffix("UsageDescription") }.compactMap { $0.value as? String } + let english = Array(localizable.strings.keys) + localizable.englishValues + infoPlist.englishValues + + usageDescriptions + #expect(english.count > 100) // guards against reading nothing + + let articleBeforeRenamedWord = + /\b(?:a|an) (?:shops?|shopkeepers?|item tags?|itemtags?|nativeapptemplate|native app template)\b/ + .ignoresCase() + let offenders = english.filter { $0.contains(articleBeforeRenamedWord) } + #expect(offenders.isEmpty, "Drop the article: \(offenders)") + } + // MARK: - The built app carries Japanese @Test @@ -192,6 +214,14 @@ private struct StringCatalog { } } + /// English values, where the catalog has them (InfoPlist.xcstrings's keys are Info.plist keys) + var englishValues: [String] { + strings.values.compactMap { entry in + let english = (entry["localizations"] as? [String: Any])?["en"] as? [String: Any] + return (english?["stringUnit"] as? [String: Any])?["value"] as? String + } + } + var keysMissingJapanese: [String] { strings.compactMap { key, entry in if entry["shouldTranslate"] as? Bool == false { return nil } diff --git a/NativeAppTemplateTests/Data/DataStateListContentTest.swift b/NativeAppTemplateTests/Data/DataStateListContentTest.swift index 8b2819a..bf5b82c 100644 --- a/NativeAppTemplateTests/Data/DataStateListContentTest.swift +++ b/NativeAppTemplateTests/Data/DataStateListContentTest.swift @@ -9,7 +9,7 @@ import Testing /// List screens switch on `listContent(isEmpty:)`. A reload of a list already on screen /// must stay `.list`, the same case as `.hasData`: as a branch of its own, SwiftUI rebuilds /// the list on every reload and re-runs its `.task`. In MyTurnTagCreator 2.1.0 that made the -/// Shops tab reload about twice a second while a shop was open. +/// Shops tab reload about twice a second while shop detail was open. @Suite struct DataStateListContentTest { @Test diff --git a/NativeAppTemplateTests/Data/Repositories/ItemTagRepositoryTest.swift b/NativeAppTemplateTests/Data/Repositories/ItemTagRepositoryTest.swift index 859e225..8d2ef62 100644 --- a/NativeAppTemplateTests/Data/Repositories/ItemTagRepositoryTest.swift +++ b/NativeAppTemplateTests/Data/Repositories/ItemTagRepositoryTest.swift @@ -87,7 +87,7 @@ struct ItemTagRepositoryTest { #expect(repository.state == .hasData) } - /// An item tag resource as the API's ItemTagSerializer writes it (ItemTagAdapter's attributes) + /// Item tag resource as the API's ItemTagSerializer writes it (ItemTagAdapter's attributes) private nonisolated static func itemTagResource(id: String, shopId: String = "A") -> [String: Any] { [ "id": id, diff --git a/NativeAppTemplateTests/Data/Repositories/ShopRepositoryTest.swift b/NativeAppTemplateTests/Data/Repositories/ShopRepositoryTest.swift index 8d3f717..e7d28dd 100644 --- a/NativeAppTemplateTests/Data/Repositories/ShopRepositoryTest.swift +++ b/NativeAppTemplateTests/Data/Repositories/ShopRepositoryTest.swift @@ -71,7 +71,7 @@ struct ShopRepositoryTest { #expect(repository.shops.isEmpty) } - /// fetchDetail upserts: appends a shop it does not have, replaces one it has + /// fetchDetail upserts: appends what it does not have, replaces what it has @Test func fetchDetailAppendsOrReplaces() async throws { let server = try await TestHTTPServer.start { request in diff --git a/NativeAppTemplateTests/Networking/APIEndpointTest.swift b/NativeAppTemplateTests/Networking/APIEndpointTest.swift index 43ef918..a6e442a 100644 --- a/NativeAppTemplateTests/Networking/APIEndpointTest.swift +++ b/NativeAppTemplateTests/Networking/APIEndpointTest.swift @@ -24,7 +24,7 @@ struct APIEndpointTest { #expect(endpoint == APIEndpoint(scheme: "http", domain: "192.168.1.21", port: "3000")) } - /// nativeapptemplate-agent passes the endpoint through `simctl launch` + /// The agent passes the endpoint through `simctl launch` /// (SIMCTL_CHILD_*), which must keep working @Test func environmentVariablesWinOverInfoPlist() { diff --git a/NativeAppTemplateTests/Networking/JSONAPI/JSONAPIDocumentTest.swift b/NativeAppTemplateTests/Networking/JSONAPI/JSONAPIDocumentTest.swift index 5feb5c1..9e68e2e 100644 --- a/NativeAppTemplateTests/Networking/JSONAPI/JSONAPIDocumentTest.swift +++ b/NativeAppTemplateTests/Networking/JSONAPI/JSONAPIDocumentTest.swift @@ -79,9 +79,11 @@ struct JSONAPIDocumentTest { #expect(resource.id == "7") } - /// Only those exact names: camelCase, capitalized, or a type EntityType does not - /// list (the paid app's account) has none - @Test(arguments: ["itemTag", "Shop", "account"]) + /// Only those exact names: a type EntityType does not list (the paid app's account), + /// camelCase, or capitalized has none. The camelCase one keeps a part the agent does + /// not rename: it renames item_tag and itemTag to the same word, so a camelCase + /// item tag type would become a known one in the generated app. + @Test(arguments: ["account", "shopkeeperSignIn", "Shop"]) func otherTypeNamesHaveNoEntityType(type: String) throws { let doc = try document(#"{"data": {"id": "1", "type": "\#(type)"}}"#) diff --git a/NativeAppTemplateTests/Networking/RootPathServicesTest.swift b/NativeAppTemplateTests/Networking/RootPathServicesTest.swift index 3552bc0..f4b83e2 100644 --- a/NativeAppTemplateTests/Networking/RootPathServicesTest.swift +++ b/NativeAppTemplateTests/Networking/RootPathServicesTest.swift @@ -29,7 +29,7 @@ struct RootPathServicesTest { ) } - /// A shopkeeper resource as ShopkeeperSignInAdapter reads it + /// Shopkeeper resource as ShopkeeperSignInAdapter reads it private nonisolated(unsafe) static let signInBody: [String: Any] = [ "data": [ "id": "s1", diff --git a/NativeAppTemplateTests/UI/Empty States/NeedAppUpdatesViewTest.swift b/NativeAppTemplateTests/UI/Empty States/NeedAppUpdatesViewTest.swift new file mode 100644 index 0000000..4068aa3 --- /dev/null +++ b/NativeAppTemplateTests/UI/Empty States/NeedAppUpdatesViewTest.swift @@ -0,0 +1,56 @@ +// +// NeedAppUpdatesViewTest.swift +// NativeAppTemplate +// + +import Foundation +@testable import NativeAppTemplate +import SwiftUI +import Testing + +/// The forced-update screen's Update App button opens the app's App Store page, which comes +/// from APP_STORE_ID (AppStore.xcconfig). It used to open a hardcoded page of another app +/// (MyTurnTag Creator, id1516198303), so every app built from the template sent its users there. +@MainActor +struct NeedAppUpdatesViewTest { + // MARK: - App Store page + + /// Empty (as committed) or not an Apple ID: no page, so the button is hidden rather than + /// opening someone else's page + @Test(arguments: ["", " ", "id1234567890", "https://apps.apple.com/app/id1234567890", "12a4"]) + func noAppleIdHasNoAppStorePage(appStoreId: String) { + #expect(Strings.appStorePage(appStoreId: appStoreId) == nil) + } + + /// Apple's short App Store URL form is https://apps.apple.com/app/id + @Test + func appleIdOpensItsAppStorePage() { + #expect( + Strings.appStorePage(appStoreId: "1234567890") + == URL(string: "https://apps.apple.com/app/id1234567890") + ) + } + + // MARK: - Update App button + + private func updateAppButtons(appStoreUrl: URL?) throws -> [AccessibilityElements.Element] { + try AccessibilityElements.of(NeedAppUpdatesView(appStoreUrl: appStoreUrl)) + .filter { $0.traits.contains(.button) } + } + + @Test + func appStorePageShowsTheUpdateAppButton() throws { + let buttons = try updateAppButtons(appStoreUrl: URL(string: "https://apps.apple.com/app/id1234567890")) + + #expect(buttons.map(\.label) == ["Update App"]) // Strings.updateApp; the tests run in English + } + + /// Without a page there is no button, but the screen still says what to do + @Test + func noAppStorePageHidesTheUpdateAppButton() throws { + #expect(try updateAppButtons(appStoreUrl: nil).isEmpty) + + let labels = try AccessibilityElements.of(NeedAppUpdatesView(appStoreUrl: nil)).map(\.label) + #expect(labels.contains("Please install new version app.")) + } +} diff --git a/Project.xcconfig b/Project.xcconfig new file mode 100644 index 0000000..761517e --- /dev/null +++ b/Project.xcconfig @@ -0,0 +1,7 @@ +// +// Project.xcconfig +// +// The project-level base configuration of Debug and Release. Each setting keeps +// to its own file; see the comments there. + +#include "AppStore.xcconfig" diff --git a/README.md b/README.md index 1a5c24f..7c93b01 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,16 @@ The values are built into the app's `Info.plist`, so a Debug build reaches the s Release builds always use the code default (`https://api.nativeapptemplate.com`, which has been shut down), so change `APIEndpoint.production` to your own API server before shipping a Release build. +## App Store page + +When the API says this version must be updated (an `AppVersion` with `forced_update`), the app shows the update screen, whose **Update App** button opens your app's App Store page. Set `APP_STORE_ID` in `AppStore.xcconfig` to your app's Apple ID, the number at the end of its App Store URL (App Store Connect → your app → **App Information** → **Apple ID**), or put it in `AppStore.local.xcconfig` next to it (gitignored): + +``` +APP_STORE_ID = 1234567890 +``` + +It is empty as committed, which hides the button: the screen still asks users to install the new version, which they get from the App Store app. The ID is built into `Info.plist` as `AppStoreID` and read at launch (`Strings.appStoreUrl`, `https://apps.apple.com/app/id`). A value that is not all digits also hides the button, and `InfoPlistTest` fails on it. + ## SwiftLint SwiftLint runs as part of the build process in Xcode, and errors/warnings are surfaced in Xcode as well. Please ensure that you run SwiftLint before submitting a pull request.