From 91c98cf211c130ef5438d0f358771c1a8faa9314 Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 13 Oct 2026 08:47:49 +0900 Subject: [PATCH 1/8] Match part of a city name only among zones at the device's offset currentTimeZone looked the identifier's city up by substring in the whole table, so Australia/West (+08:00) preselected International Date Line West (-12:00), Pacific/Easter picked Eastern Time and America/Indiana/Petersburg picked St. Petersburg. A whole name still matches at any offset (the table's Dublin and Casablanca differ from the system's offset); part of a name now matches only at the device's standard offset. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- NativeAppTemplate/Utilities/Utility.swift | 15 +++++++++-- .../Utilities/UtilityTest.swift | 27 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/NativeAppTemplate/Utilities/Utility.swift b/NativeAppTemplate/Utilities/Utility.swift index a7ad2cc..61f7fbb 100644 --- a/NativeAppTemplate/Utilities/Utility.swift +++ b/NativeAppTemplate/Utilities/Utility.swift @@ -8,7 +8,8 @@ import os enum Utility { /// The zone (a key of `timeZones`) to preselect for this device: the one - /// named after the identifier's city, else the first with its standard + /// named exactly after the identifier's city, else one at the device's + /// standard offset whose name contains the city, else the first at that /// offset. The table labels zones by standard offset, so summer time must /// not move the match. static func currentTimeZone(_ timeZone: TimeZone = .current, date: Date = .now) -> String { @@ -22,7 +23,17 @@ enum Utility { let city = (timeZone.identifier.components(separatedBy: "/").last ?? "") .replacingOccurrences(of: "_", with: " ") - if !city.isEmpty, let zone = timeZones.first(where: { $0.key.contains(city) }) { + guard !city.isEmpty else { return firstZoneWithOffset.key } + + // The whole name, at any offset: the table's offset for a city can differ + // from the system's (Dublin, Casablanca), and the city still names the zone + if let zone = timeZones.first(where: { $0.key == city }) { + return zone.key + } + + // Part of a name only among zones at this offset: "West" (Australia/West, + // +08:00) is in "International Date Line West" (-12:00) + if let zone = zonesWithOffset.first(where: { $0.key.contains(city) }) { return zone.key } diff --git a/NativeAppTemplateTests/Utilities/UtilityTest.swift b/NativeAppTemplateTests/Utilities/UtilityTest.swift index fac44fc..bd529f5 100644 --- a/NativeAppTemplateTests/Utilities/UtilityTest.swift +++ b/NativeAppTemplateTests/Utilities/UtilityTest.swift @@ -34,6 +34,33 @@ struct UtilityTest { #expect(Utility.currentTimeZone(timeZone, date: date) == expected) } + /// A city that is only part of a zone's name at another offset must not pick + /// that zone. The expected key is the first at the device's standard offset in + /// TimeZoneData.swift, as none at that offset contains the city: + /// - Australia/West is +08:00 ("West" is in International Date Line West, -12:00); + /// the first +08:00 zone is Beijing + /// - Pacific/Easter (Easter Island) is -06:00 ("Easter" is in Eastern Time, -05:00); + /// the first -06:00 zone is Central America + /// - America/Indiana/Petersburg is -05:00 ("Petersburg" is in St. Petersburg, +03:00); + /// the first -05:00 zone is Bogota + @Test(arguments: [ + ("Australia/West", "Beijing"), + ("Pacific/Easter", "Central America"), + ("America/Indiana/Petersburg", "Bogota") + ]) + func currentTimeZoneIgnoresACityInAZoneAtAnotherOffset(identifier: String, expected: String) throws { + let timeZone = try #require(TimeZone(identifier: identifier)) + #expect(Utility.currentTimeZone(timeZone, date: Self.winter) == expected) + } + + /// Part of a name still picks the zone when it is at the device's offset: + /// Atlantic/Cape_Verde (-01:00) is "Cape Verde Is." (-01:00) in the table + @Test + func currentTimeZoneMatchesPartOfANameAtTheSameOffset() throws { + let timeZone = try #require(TimeZone(identifier: "Atlantic/Cape_Verde")) + #expect(Utility.currentTimeZone(timeZone, date: Self.winter) == "Cape Verde Is.") + } + /// New York is not in the table, so the zone falls back to the offset. /// That must be its standard offset, -05:00 (America/New_York's base UTC /// offset), not the summer offset -04:00, which is Atlantic Time. From 66d12f3bff60f39de6bb53ec4f896ed6438d5eac Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 13 Oct 2026 08:47:49 +0900 Subject: [PATCH 2/8] Show the opened shop's item tags when another shop's page is loading reloadPage returned early while any shop's page was loading. Opening shop B during shop A's load dropped B's request and then listed A's tags under B, and A's late next page was appended to B's list. Reloads now bump a generation counter, so stale pages are dropped and a stale next page no longer blocks paging. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- .../Data/Repositories/ItemTagRepository.swift | 22 ++++- .../ItemTagRepositoryPaginationTest.swift | 81 +++++++++++++++++++ .../Testing/JSONAPIFixtures.swift | 5 +- 3 files changed, 105 insertions(+), 3 deletions(-) diff --git a/NativeAppTemplate/Data/Repositories/ItemTagRepository.swift b/NativeAppTemplate/Data/Repositories/ItemTagRepository.swift index 025c3f5..16c2801 100644 --- a/NativeAppTemplate/Data/Repositories/ItemTagRepository.swift +++ b/NativeAppTemplate/Data/Repositories/ItemTagRepository.swift @@ -12,6 +12,11 @@ import SwiftUI var state: DataState = .initial var paginationMeta: PaginationMeta? var isLoadingMore = false + /// The shop whose pages are on screen or loading + private var listedShopId: String? + /// Bumped by every reloadPage, so a page that arrives after another one was + /// asked for (the user opened another shop meanwhile) is dropped + private var listGeneration = 0 required init(itemTagsService: ItemTagsService) { self.itemTagsService = itemTagsService @@ -26,7 +31,8 @@ import SwiftUI return } - if state == .loading { + // A reload of this shop is already on its way + if state == .loading, listedShopId == shopId { return } @@ -37,15 +43,24 @@ import SwiftUI paginationMeta = nil } + // A next page still loading belongs to the old list and will be dropped + isLoadingMore = false + listedShopId = shopId + listGeneration += 1 + let generation = listGeneration state = .loading Task { @MainActor in do { let response = try await itemTagsService.allItemTags(shopId: shopId, page: page) + guard generation == listGeneration else { return } + itemTags = response.itemTags paginationMeta = response.paginationMeta state = .hasData } catch { + guard generation == listGeneration else { return } + state = .failed Failure .fetch(from: Self.self, reason: error.codedDescription) @@ -62,10 +77,14 @@ import SwiftUI } isLoadingMore = true + let generation = listGeneration Task { @MainActor in do { let response = try await itemTagsService.allItemTags(shopId: shopId, page: meta.currentPage + 1) + // The list was reloaded (or another shop opened) meanwhile + guard generation == listGeneration else { return } + // A list that shifted since the last page can repeat tags already // listed; keep the first occurrence so each id appears once, in order let listedIds = Set(itemTags.map(\.id)) @@ -76,6 +95,7 @@ import SwiftUI .fetch(from: Self.self, reason: error.codedDescription) .log() } + guard generation == listGeneration else { return } isLoadingMore = false } diff --git a/NativeAppTemplateTests/Data/Repositories/ItemTagRepositoryPaginationTest.swift b/NativeAppTemplateTests/Data/Repositories/ItemTagRepositoryPaginationTest.swift index 919e26f..3ccc798 100644 --- a/NativeAppTemplateTests/Data/Repositories/ItemTagRepositoryPaginationTest.swift +++ b/NativeAppTemplateTests/Data/Repositories/ItemTagRepositoryPaginationTest.swift @@ -126,4 +126,85 @@ struct ItemTagRepositoryPaginationTest { #expect(repository.state == .failed) #expect(repository.itemTags.isEmpty) } + + // MARK: - Switching shops while a page is loading + + /// Holds shop-A's requests until `releaseA` is signalled; shop-B's are answered + /// at once. Each shop's page lists its own tags (a1, a2 then a3 / b1). + private nonisolated static func shopServer(releaseA: DispatchSemaphore) async throws -> TestHTTPServer { + try await TestHTTPServer.start { request in + if request.path.contains("/shops/shop-A/") { + releaseA.wait() + let page = request.queryItems.first { $0.name == "page" }?.value ?? "1" + return TestHTTPServer.json(JSONAPIFixtures.itemTagPage( + ids: page == "1" ? ["a1", "a2"] : ["a3"], + currentPage: Int(page) ?? 1, + totalPages: 2, + shopId: "shop-A" + )) + } + return TestHTTPServer.json(JSONAPIFixtures.itemTagPage( + ids: ["b1"], + currentPage: 1, + totalPages: 1, + shopId: "shop-B" + )) + } + } + + /// Opening shop B while shop A's first page is still loading: B's list must + /// load B's tags, and A's late page must not replace them + @Test + func reloadingAnotherShopWhileOneIsLoadingShowsThatShop() async throws { + let releaseA = DispatchSemaphore(value: 0) + let server = try await Self.shopServer(releaseA: releaseA) + defer { server.stop() } + let repository = makeRepository(server) + + repository.reloadPage(shopId: "shop-A", page: 1) + await waitUntil { server.requests.count == 1 } + repository.reloadPage(shopId: "shop-B", page: 1) + await waitUntil(timeout: .seconds(10)) { repository.state == .hasData } + + #expect(repository.itemTags.map(\.id) == ["b1"]) + + // A's answer arrives late. Nothing on screen shows it was handled, so give it time + releaseA.signal() + try await Task.sleep(for: .milliseconds(500)) + + #expect(repository.itemTags.map(\.id) == ["b1"]) + #expect(repository.itemTags.allSatisfy { $0.shopId == "shop-B" }) + #expect(repository.state == .hasData) + } + + /// Shop A's next page arriving after the user opened shop B must not be + /// appended to B's list + @Test + func nextPageOfThePreviousShopIsNotAppendedToAnotherShop() async throws { + // Starts at 0 and ends at 0: libdispatch crashes when a semaphore is freed + // below its starting value + let releaseA = DispatchSemaphore(value: 0) + let server = try await Self.shopServer(releaseA: releaseA) + defer { server.stop() } + let repository = makeRepository(server) + // Let page 1 through; page 2 waits + releaseA.signal() + repository.reloadPage(shopId: "shop-A", page: 1) + await waitUntil { repository.state == .hasData } + repository.loadNextPage(shopId: "shop-A") + await waitUntil { server.requests.count == 2 } + + repository.reloadPage(shopId: "shop-B", page: 1) + await waitUntil { repository.state == .hasData && repository.itemTags.contains { $0.id == "b1" } } + releaseA.signal() + // Nothing on screen shows A's late page was handled, so give it time + try await Task.sleep(for: .milliseconds(500)) + + #expect(repository.itemTags.map(\.id) == ["b1"]) + // B's own meta (1 of 1), not A's page 2 + #expect(repository.paginationMeta?.currentPage == 1) + #expect(repository.paginationMeta?.totalPages == 1) + // B's list can load its own next pages + #expect(!repository.isLoadingMore) + } } diff --git a/NativeAppTemplateTests/Testing/JSONAPIFixtures.swift b/NativeAppTemplateTests/Testing/JSONAPIFixtures.swift index 11442ba..9749b15 100644 --- a/NativeAppTemplateTests/Testing/JSONAPIFixtures.swift +++ b/NativeAppTemplateTests/Testing/JSONAPIFixtures.swift @@ -34,10 +34,11 @@ enum JSONAPIFixtures { currentPage: Int, totalPages: Int, totalCount: Int? = nil, - limit: Int = 20 + limit: Int = 20, + shopId: String = "shop-1" ) -> [String: Any] { [ - "data": ids.map { itemTag(id: $0) }, + "data": ids.map { itemTag(id: $0, shopId: shopId) }, "meta": [ "current_page": currentPage, "total_pages": totalPages, From 7a7ca7153e2310d62b25ed05ee2145f8e30c6a00 Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 13 Oct 2026 08:47:49 +0900 Subject: [PATCH 3/8] Mark the sign-up and new password fields as new passwords They were .password, so iOS offered only saved passwords and did not suggest a strong one or update the saved one. The current password stays .password. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- NativeAppTemplate/UI/App Root/SignUpView.swift | 2 +- NativeAppTemplate/UI/App Root/SignUpViewModel.swift | 4 ++++ NativeAppTemplate/UI/Settings/PasswordEditView.swift | 6 +++--- .../UI/Settings/PasswordEditViewModel.swift | 6 ++++++ .../UI/App Root/SignUpViewModelTest.swift | 9 +++++++++ .../UI/Settings/PasswordEditViewModelTest.swift | 10 ++++++++++ 6 files changed, 33 insertions(+), 4 deletions(-) diff --git a/NativeAppTemplate/UI/App Root/SignUpView.swift b/NativeAppTemplate/UI/App Root/SignUpView.swift index b4e522f..fb69f5f 100644 --- a/NativeAppTemplate/UI/App Root/SignUpView.swift +++ b/NativeAppTemplate/UI/App Root/SignUpView.swift @@ -77,7 +77,7 @@ private extension SignUpView { Section { SecureField(Strings.placeholderPassword, text: $viewModel.password) - .textContentType(.password) + .textContentType(SignUpViewModel.passwordContentType) .autocapitalization(.none) .autocorrectionDisabled(true) } header: { diff --git a/NativeAppTemplate/UI/App Root/SignUpViewModel.swift b/NativeAppTemplate/UI/App Root/SignUpViewModel.swift index aae2116..aa536f2 100644 --- a/NativeAppTemplate/UI/App Root/SignUpViewModel.swift +++ b/NativeAppTemplate/UI/App Root/SignUpViewModel.swift @@ -9,6 +9,10 @@ import SwiftUI @Observable @MainActor final class SignUpViewModel { + /// The password field's content type. `.newPassword` lets iOS suggest a strong + /// password and save it with the email; `.password` offers only saved passwords. + static let passwordContentType: UITextContentType = .newPassword + var name = "" var email = "" var password = "" diff --git a/NativeAppTemplate/UI/Settings/PasswordEditView.swift b/NativeAppTemplate/UI/Settings/PasswordEditView.swift index e8125e0..3c058e9 100644 --- a/NativeAppTemplate/UI/Settings/PasswordEditView.swift +++ b/NativeAppTemplate/UI/Settings/PasswordEditView.swift @@ -42,7 +42,7 @@ private extension PasswordEditView { Form { Section { SecureField(Strings.currentPassword, text: $viewModel.currentPassword) - .textContentType(.password) + .textContentType(PasswordEditViewModel.currentPasswordContentType) .autocapitalization(.none) .autocorrectionDisabled(true) } header: { @@ -58,7 +58,7 @@ private extension PasswordEditView { } Section { SecureField(Strings.newPassword, text: $viewModel.password) - .textContentType(.password) + .textContentType(PasswordEditViewModel.newPasswordContentType) .autocapitalization(.none) .autocorrectionDisabled(true) } header: { @@ -81,7 +81,7 @@ private extension PasswordEditView { } Section { SecureField(Strings.confirmNewPassword, text: $viewModel.passwordConfirmation) - .textContentType(.password) + .textContentType(PasswordEditViewModel.newPasswordContentType) .autocapitalization(.none) .autocorrectionDisabled(true) } header: { diff --git a/NativeAppTemplate/UI/Settings/PasswordEditViewModel.swift b/NativeAppTemplate/UI/Settings/PasswordEditViewModel.swift index 842cd48..f6e7e75 100644 --- a/NativeAppTemplate/UI/Settings/PasswordEditViewModel.swift +++ b/NativeAppTemplate/UI/Settings/PasswordEditViewModel.swift @@ -9,6 +9,12 @@ import SwiftUI @Observable @MainActor final class PasswordEditViewModel { + /// The current password is one iOS may have saved; the new password and its + /// confirmation are `.newPassword`, so iOS suggests a strong one and updates the + /// saved password instead of autofilling the old one into them + static let currentPasswordContentType: UITextContentType = .password + static let newPasswordContentType: UITextContentType = .newPassword + var currentPassword = "" var password = "" var passwordConfirmation = "" diff --git a/NativeAppTemplateTests/UI/App Root/SignUpViewModelTest.swift b/NativeAppTemplateTests/UI/App Root/SignUpViewModelTest.swift index 1d601a3..e2f9d49 100644 --- a/NativeAppTemplateTests/UI/App Root/SignUpViewModelTest.swift +++ b/NativeAppTemplateTests/UI/App Root/SignUpViewModelTest.swift @@ -6,6 +6,7 @@ import Foundation @testable import NativeAppTemplate import Testing +import UIKit @MainActor @Suite @@ -13,6 +14,14 @@ struct SignUpViewModelTest { let signUpRepository = TestSignUpRepository() let messageBus = MessageBus() + /// Sign-up creates a password, so the field is .newPassword (UITextContentType docs: + /// "the new password ... when creating an account or changing a password"), which + /// lets iOS suggest a strong one and save it + @Test + func passwordFieldIsANewPassword() { + #expect(SignUpViewModel.passwordContentType == .newPassword) + } + @Test func initialState() { let viewModel = SignUpViewModel( diff --git a/NativeAppTemplateTests/UI/Settings/PasswordEditViewModelTest.swift b/NativeAppTemplateTests/UI/Settings/PasswordEditViewModelTest.swift index 01b7ea4..659f9f6 100644 --- a/NativeAppTemplateTests/UI/Settings/PasswordEditViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Settings/PasswordEditViewModelTest.swift @@ -6,6 +6,7 @@ import Foundation @testable import NativeAppTemplate import Testing +import UIKit @MainActor @Suite @@ -15,6 +16,15 @@ struct PasswordEditViewModelTest { ) let messageBus = MessageBus() + /// The current password is an existing one (.password); the new password and its + /// confirmation are .newPassword (UITextContentType docs: "when creating an + /// account or changing a password") + @Test + func passwordFieldContentTypes() { + #expect(PasswordEditViewModel.currentPasswordContentType == .password) + #expect(PasswordEditViewModel.newPasswordContentType == .newPassword) + } + @Test func initializesCorrectly() { let viewModel = PasswordEditViewModel( From c7be6141bd750f8d595274e50fef2780920bf69b Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 13 Oct 2026 08:49:44 +0900 Subject: [PATCH 4/8] Sign out when the API rejects the session's token An expired or revoked devise_token_auth token made every request fail with a 401. At launch that showed the permissions error screen, and mid-session every screen failed until the user signed out by hand. Service now posts .apiSessionDidExpire with the rejected token for a 401 from an authenticated request whose body has no error_message: devise's {"errors": [...]} authenticate failure (checked with curl against the local API). The API's own 401 (Pundit's user_not_authorized) carries an error_message and keeps the session. SessionController signs out locally only when the token is its current one, so a late 401 from an older session is ignored, and MainView shows a Session Expired alert (en + ja). A permissions fetch that fails after this sign-out no longer sets .error. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- NativeAppTemplate/App.swift | 1 + NativeAppTemplate/Constants.swift | 2 + NativeAppTemplate/Localizable.xcstrings | 20 +++++ .../Networking/Services/Service.swift | 37 ++++++--- .../Sessions/SessionController.swift | 49 +++++++++++- .../Sessions/SessionControllerProtocol.swift | 3 + NativeAppTemplate/UI/App Root/MainView.swift | 19 +++++ .../Networking/ServiceTest.swift | 80 +++++++++++++++++++ .../SessionControllerPermissionsTest.swift | 29 +++++++ .../Sessions/SessionControllerTest.swift | 77 ++++++++++++++++++ .../Repositories/TestSessionController.swift | 1 + .../Testing/TestHTTPServer.swift | 4 +- 12 files changed, 308 insertions(+), 14 deletions(-) diff --git a/NativeAppTemplate/App.swift b/NativeAppTemplate/App.swift index 572ae70..0fdcb9e 100644 --- a/NativeAppTemplate/App.swift +++ b/NativeAppTemplate/App.swift @@ -25,6 +25,7 @@ private final class NullSessionController: SessionControllerProtocol { } var shouldPopToRootView: Bool = false + var didExpireSession: Bool = false var shouldUpdateApp: Bool = false var shouldUpdatePrivacy: Bool = false var shouldUpdateTerms: Bool = false diff --git a/NativeAppTemplate/Constants.swift b/NativeAppTemplate/Constants.swift index f9e4d48..ce72d37 100644 --- a/NativeAppTemplate/Constants.swift +++ b/NativeAppTemplate/Constants.swift @@ -297,6 +297,8 @@ enum Strings { static let signInToYourAccount = String(localized: "Sign In to Your Account") static let email = String(localized: "Email") static let password = String(localized: "Password") + static let sessionExpired = String(localized: "Session Expired") + static let sessionExpiredDescription = String(localized: "Your session has expired. Please sign in again.") static var welcomeToApp: String { String(localized: "Welcome to \(appName)") diff --git a/NativeAppTemplate/Localizable.xcstrings b/NativeAppTemplate/Localizable.xcstrings index 71658da..d936b0b 100644 --- a/NativeAppTemplate/Localizable.xcstrings +++ b/NativeAppTemplate/Localizable.xcstrings @@ -1019,6 +1019,16 @@ } } }, + "Session Expired" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "セッションの有効期限切れ" + } + } + } + }, "Settings" : { "localizations" : { "ja" : { @@ -1428,6 +1438,16 @@ } } } + }, + "Your session has expired. Please sign in again." : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "セッションの有効期限が切れました。もう一度サインインしてください。" + } + } + } } }, "version" : "1.0" diff --git a/NativeAppTemplate/Networking/Services/Service.swift b/NativeAppTemplate/Networking/Services/Service.swift index 97b679d..417dfd6 100644 --- a/NativeAppTemplate/Networking/Services/Service.swift +++ b/NativeAppTemplate/Networking/Services/Service.swift @@ -62,17 +62,20 @@ extension Service { let statusCode = (response as? HTTPURLResponse)?.statusCode guard statusCode.map((200 ..< 300).contains) == true else { - var errorMessage: String? - - do { - if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let theErrorMessage = json["error_message"] as? String { - errorMessage = theErrorMessage - } - } catch { - // Not JSON (e.g. a proxy's HTML error page): no message to show, - // so the error falls back to its generic description - throw NativeAppTemplateAPIError.requestFailed(nil, statusCode ?? 0, nil) + // Not JSON (e.g. a proxy's HTML error page) or no error_message: no message + // to show, so the error falls back to its generic description + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + let errorMessage = json?["error_message"] as? String + + // devise_token_auth rejects a missing, wrong or expired token with a 401 + // whose body has no error_message. The API's own 401 (a Pundit denial) + // has one and keeps the session. + if statusCode == 401, errorMessage == nil, !networkClient.authToken.isEmpty { + NotificationCenter.default.post( + name: .apiSessionDidExpire, + object: nil, + userInfo: [Notification.authTokenKey: networkClient.authToken] + ) } throw NativeAppTemplateAPIError.requestFailed(nil, statusCode ?? 0, errorMessage) @@ -81,3 +84,15 @@ extension Service { return try request.handle(response: data) } } + +extension Notification.Name { + /// Posted on the main actor when the API rejects a request's access token: a 401 + /// from devise_token_auth, e.g. once the token's lifespan is over. The rejected + /// token is in `userInfo[Notification.authTokenKey]`, so a session that has + /// since signed in again can tell the notice is not about it. + static let apiSessionDidExpire = Notification.Name("NativeAppTemplate.apiSessionDidExpire") +} + +extension Notification { + static let authTokenKey = "authToken" +} diff --git a/NativeAppTemplate/Sessions/SessionController.swift b/NativeAppTemplate/Sessions/SessionController.swift index db8eca5..baab7a0 100644 --- a/NativeAppTemplate/Sessions/SessionController.swift +++ b/NativeAppTemplate/Sessions/SessionController.swift @@ -14,6 +14,8 @@ import Observation private(set) var permissionState: PermissionState = .notLoaded private(set) var didFetchPermissions = false var shouldPopToRootView = false + /// Set when the API rejected the session's token and the app signed out + var didExpireSession = false var shouldUpdateApp = false var shouldUpdatePrivacy = false @@ -50,6 +52,9 @@ import Observation private let connectionMonitor = NWPathMonitor() private(set) var permissionsService: PermissionsService private(set) var meService: MeService + private let notificationCenter: NotificationCenter + /// Removed in deinit, which is nonisolated + @ObservationIgnored private nonisolated(unsafe) var sessionExpiryObserver: (any NSObjectProtocol)? var isLoggedIn: Bool { userState == .loggedIn @@ -68,13 +73,16 @@ import Observation /// - monitorsConnection: Starts `NWPathMonitor`; tests pass `false` and call /// `connectionChanged(to:)` themselves, so no permission requests go out on their own. /// - environment: The API the client talks to. Tests point it at a local server. + /// - notificationCenter: Where `Service` announces `.apiSessionDidExpire` init( loginRepository: LoginRepositoryProtocol, monitorsConnection: Bool = true, - environment: NativeAppTemplateEnvironment = .prod + environment: NativeAppTemplateEnvironment = .prod, + notificationCenter: NotificationCenter = .default ) { self.loginRepository = loginRepository self.environment = environment + self.notificationCenter = notificationCenter let shopkeeper = loginRepository.currentShopkeeper let client = Self.makeClient(shopkeeper: shopkeeper, environment: environment) @@ -85,6 +93,25 @@ import Observation if monitorsConnection { prepareConnectionMonitor() } + + // Service posts on the main actor, and a nil queue runs this at once, so the + // session is gone before the failed request's caller sees its error + sessionExpiryObserver = notificationCenter.addObserver( + forName: .apiSessionDidExpire, + object: nil, + queue: nil + ) { [weak self] notification in + let authToken = notification.userInfo?[Notification.authTokenKey] as? String + MainActor.assumeIsolated { + self?.sessionDidExpire(authToken: authToken) + } + } + } + + deinit { + if let sessionExpiryObserver { + notificationCenter.removeObserver(sessionExpiryObserver) + } } func login(email: String, password: String) async throws { @@ -94,6 +121,7 @@ import Observation do { shopkeeper = try await loginRepository.login(email: email, password: password) + didExpireSession = false Event .login(from: Self.self) .log() @@ -131,6 +159,22 @@ import Observation } } + /// The API rejected `authToken` (see `.apiSessionDidExpire`). When that is this + /// session's token, every further request would fail too, so sign out here: the + /// server has no session left to destroy, and the user signs in again. + func sessionDidExpire(authToken: String?) { + guard isLoggedIn, let authToken, authToken == client.authToken else { return } + + Failure + .logout(from: Self.self, reason: "The API rejected the session's token") + .log() + + shouldPopToRootView = true + try? loginRepository.updateShopkeeper(shopkeeper: nil) + shopkeeper = nil + didExpireSession = true + } + func fetchPermissionsIfNeeded() { guard !hasPermissions else { return } @@ -178,6 +222,9 @@ import Observation .fetch(from: Permissions.self, reason: error.codedDescription) .log() + // An expired session has signed out already; that is not a permissions error + guard isLoggedIn else { return } + self.permissionState = .error } } diff --git a/NativeAppTemplate/Sessions/SessionControllerProtocol.swift b/NativeAppTemplate/Sessions/SessionControllerProtocol.swift index d36dd1a..d75296e 100644 --- a/NativeAppTemplate/Sessions/SessionControllerProtocol.swift +++ b/NativeAppTemplate/Sessions/SessionControllerProtocol.swift @@ -32,6 +32,9 @@ protocol SessionControllerProtocol: AnyObject, Observable, Sendable { var permissionState: PermissionState { get } var shouldPopToRootView: Bool { get set } + /// True once the API rejected the session's token and the app signed out; + /// MainView tells the user and resets it + var didExpireSession: Bool { get set } var shouldUpdateApp: Bool { get set } var shouldUpdatePrivacy: Bool { get set } diff --git a/NativeAppTemplate/UI/App Root/MainView.swift b/NativeAppTemplate/UI/App Root/MainView.swift index d51909b..74d0dfe 100644 --- a/NativeAppTemplate/UI/App Root/MainView.swift +++ b/NativeAppTemplate/UI/App Root/MainView.swift @@ -19,6 +19,18 @@ struct MainView: View { contentView .background(Color.backgroundColor) .overlay(MessageBarView(messageBus: messageBus), alignment: .bottom) + .alert(Strings.sessionExpired, isPresented: sessionExpiredAlertBinding) { + Button(Strings.ok) {} + } message: { + Text(Strings.sessionExpiredDescription) + } + .onChange(of: sessionController.didExpireSession) { + // The alert says why; the failed request's own error would only + // repeat it as a status code + if sessionController.didExpireSession { + messageBus.dismiss() + } + } .onAppear { if viewModel == nil { viewModel = MainViewModel( @@ -34,6 +46,13 @@ struct MainView: View { // MARK: - private private extension MainView { + var sessionExpiredAlertBinding: Binding { + Binding( + get: { sessionController.didExpireSession }, + set: { sessionController.didExpireSession = $0 } + ) + } + @ViewBuilder var contentView: some View { switch MainViewModel.content( sessionController: sessionController, diff --git a/NativeAppTemplateTests/Networking/ServiceTest.swift b/NativeAppTemplateTests/Networking/ServiceTest.swift index 67bdf4c..39adb5d 100644 --- a/NativeAppTemplateTests/Networking/ServiceTest.swift +++ b/NativeAppTemplateTests/Networking/ServiceTest.swift @@ -180,6 +180,63 @@ struct ServiceTest { #expect(error.codedDescription == "[NATIVEAPPTEMPLATE-2001] NativeAppTemplateAPIError::RequestFailed[Status: 502 | Error: UNKNOWN]") } + + // MARK: - Expired session + + /// devise_token_auth's authenticate_shopkeeper! answers a missing, wrong or expired + /// token with 401 and this body; the local API sends it for + /// `curl -H "access-token: bogus" .../api/v1/shopkeeper/permissions` + private nonisolated(unsafe) static let deviseUnauthenticatedBody: [String: Any] = [ + "errors": ["You need to sign in or sign up before continuing."] + ] + + @Test + func rejectedTokenIsAnnouncedAsAnExpiredSession() async throws { + let server = try await TestHTTPServer.start { _ in + TestHTTPServer.json(status: 401, Self.deviseUnauthenticatedBody) + } + defer { server.stop() } + // Unique, so another test's 401 can't be mistaken for this one's + let token = "expired-\(UUID().uuidString)" + + var error: NativeAppTemplateAPIError? + let tokens = await expiredSessionTokens { + error = try? await requestFailed { + _ = try await ItemTagsService(networkClient: server.client(authToken: token)).itemTagDetail(id: "t1") + } + } + + #expect(tokens.filter { $0 == token }.count == 1) + // The request still fails as before, for its caller to handle + guard case let .requestFailed(_, statusCode, _) = error else { + Issue.record("Expected requestFailed, got \(String(describing: error))") + return + } + #expect(statusCode == 401) + } + + /// The API's own 401 (BaseController#user_not_authorized for a Pundit denial) + /// carries an error_message and comes with a valid session; it must not sign + /// the user out + @Test + func authorizationFailureWithAnErrorMessageIsNotAnExpiredSession() async throws { + let server = try await TestHTTPServer.start { _ in + TestHTTPServer.json( + status: 401, + ["code": 401, "error_message": "You are not authorized to perform this action."] + ) + } + defer { server.stop() } + let token = "valid-\(UUID().uuidString)" + + let tokens = await expiredSessionTokens { + _ = try? await requestFailed { + _ = try await ItemTagsService(networkClient: server.client(authToken: token)).itemTagDetail(id: "t1") + } + } + + #expect(!tokens.contains(token)) + } } /// Runs `body` and returns the NativeAppTemplateAPIError it throws @@ -193,3 +250,26 @@ func requestFailed(_ body: () async throws -> Void) async throws -> NativeAppTem Issue.record("Expected a NativeAppTemplateAPIError") throw CancellationError() } + +/// The tokens posted with `.apiSessionDidExpire` on NotificationCenter.default while `body` runs. +/// Service posts on the main actor and the observer has no queue, so it runs synchronously. +@MainActor +func expiredSessionTokens(_ body: () async -> Void) async -> [String] { + final class Tokens: @unchecked Sendable { + var values: [String] = [] + } + let tokens = Tokens() + let observer = NotificationCenter.default.addObserver( + forName: .apiSessionDidExpire, + object: nil, + queue: nil + ) { notification in + if let token = notification.userInfo?[Notification.authTokenKey] as? String { + tokens.values.append(token) + } + } + defer { NotificationCenter.default.removeObserver(observer) } + + await body() + return tokens.values +} diff --git a/NativeAppTemplateTests/Sessions/SessionControllerPermissionsTest.swift b/NativeAppTemplateTests/Sessions/SessionControllerPermissionsTest.swift index 9196de1..a6b5e81 100644 --- a/NativeAppTemplateTests/Sessions/SessionControllerPermissionsTest.swift +++ b/NativeAppTemplateTests/Sessions/SessionControllerPermissionsTest.swift @@ -74,6 +74,35 @@ struct SessionControllerPermissionsTest { #expect(sessionController.shopLimitCount == 3) } + /// End to end: launching with an expired token. The permissions request gets + /// devise_token_auth's 401, and the app signs out instead of showing + /// MainView's permissions error screen. + @Test + func expiredTokenAtLaunchSignsOut() async throws { + let server = try await TestHTTPServer.start { _ in + TestHTTPServer.json(status: 401, ["errors": ["You need to sign in or sign up before continuing."]]) + } + defer { server.stop() } + var shopkeeper = Self.shopkeeper + // Unique, so the notice can't sign out another test's SessionController + shopkeeper.token = "expired-\(UUID().uuidString)" + loginRepository.currentShopkeeper = shopkeeper + let sessionController = SessionController( + loginRepository: loginRepository, + monitorsConnection: false, + environment: server.client().environment + ) + + sessionController.connectionChanged(to: .online) + await waitUntil { !sessionController.isLoggedIn } + // The failed fetch's catch runs after the sign-out; give it time to land + try await Task.sleep(for: .milliseconds(200)) + + #expect(sessionController.didExpireSession) + #expect(loginRepository.currentShopkeeper == nil) + #expect(sessionController.permissionState == .notLoaded) + } + @Test func failedFetchIsAPermissionError() async throws { let server = try await TestHTTPServer.start { _ in diff --git a/NativeAppTemplateTests/Sessions/SessionControllerTest.swift b/NativeAppTemplateTests/Sessions/SessionControllerTest.swift index e1cfde5..b0181cd 100644 --- a/NativeAppTemplateTests/Sessions/SessionControllerTest.swift +++ b/NativeAppTemplateTests/Sessions/SessionControllerTest.swift @@ -78,4 +78,81 @@ struct SessionControllerTest { #expect(URLCache.shared.cachedResponse(for: request) == nil) } + + // MARK: - Expired session + + /// A signed-in SessionController listening on its own NotificationCenter, so + /// notices from other tests can't reach it + private func signedInSessionController( + token: String, + notificationCenter: NotificationCenter + ) throws -> (SessionController, TestLoginRepository) { + let loginRepository = TestLoginRepository() + loginRepository.currentShopkeeper = try #require(Shopkeeper( + id: "shopkeeper-1", + accountId: "account-1", + personalAccountId: "account-1", + accountOwnerId: "shopkeeper-1", + accountName: "Account", + email: "test@example.com", + name: "Test User", + timeZone: "Tokyo", + locale: "en", + uid: "test@example.com", + token: token, + client: "client-1", + expiry: "expiry-1" + )) + let sessionController = SessionController( + loginRepository: loginRepository, + monitorsConnection: false, + notificationCenter: notificationCenter + ) + return (sessionController, loginRepository) + } + + /// Every request fails once the token has expired, so the app signs out + /// locally and says why + @Test + func expiredTokenSignsOut() throws { + let notificationCenter = NotificationCenter() + let (sessionController, loginRepository) = try signedInSessionController( + token: "token-1", + notificationCenter: notificationCenter + ) + #expect(sessionController.isLoggedIn) + + notificationCenter.post( + name: .apiSessionDidExpire, + object: nil, + userInfo: [Notification.authTokenKey: "token-1"] + ) + + #expect(!sessionController.isLoggedIn) + #expect(sessionController.shopkeeper == nil) + // The saved session is gone too, so the next launch starts signed out + #expect(loginRepository.currentShopkeeper == nil) + #expect(sessionController.didExpireSession) + } + + /// A 401 for a token this session no longer uses (a request sent before the + /// user signed in again) is not about the current session + @Test + func expiryOfAnotherTokenKeepsTheSession() throws { + let notificationCenter = NotificationCenter() + let (sessionController, loginRepository) = try signedInSessionController( + token: "token-2", + notificationCenter: notificationCenter + ) + + notificationCenter.post( + name: .apiSessionDidExpire, + object: nil, + userInfo: [Notification.authTokenKey: "old-token"] + ) + + #expect(sessionController.isLoggedIn) + #expect(loginRepository.currentShopkeeper != nil) + #expect(!sessionController.didExpireSession) + } } diff --git a/NativeAppTemplateTests/Testing/Repositories/TestSessionController.swift b/NativeAppTemplateTests/Testing/Repositories/TestSessionController.swift index 456f1a0..66bcdc3 100644 --- a/NativeAppTemplateTests/Testing/Repositories/TestSessionController.swift +++ b/NativeAppTemplateTests/Testing/Repositories/TestSessionController.swift @@ -14,6 +14,7 @@ import Foundation public var permissionState: PermissionState = .notLoaded public var shouldPopToRootView: Bool = false + public var didExpireSession: Bool = false public var shouldUpdateApp: Bool = false public var shouldUpdatePrivacy: Bool = false diff --git a/NativeAppTemplateTests/Testing/TestHTTPServer.swift b/NativeAppTemplateTests/Testing/TestHTTPServer.swift index 3f7831e..c9ea8f4 100644 --- a/NativeAppTemplateTests/Testing/TestHTTPServer.swift +++ b/NativeAppTemplateTests/Testing/TestHTTPServer.swift @@ -83,12 +83,12 @@ final class TestHTTPServer: @unchecked Sendable { } /// An API client whose requests go to this server - func client(accountId: String = "account-id") -> NativeAppTemplateAPI { + func client(accountId: String = "account-id", authToken: String = "token") -> NativeAppTemplateAPI { NativeAppTemplateAPI( // NativeAppTemplateEnvironment.swift is also compiled into the test target environment: NativeAppTemplate .NativeAppTemplateEnvironment(baseURL: URL(string: "http://127.0.0.1:\(port)")!), - authToken: "token", + authToken: authToken, client: "client", expiry: "expiry", uid: "uid", From 277605df224d12333c78bae22a8d1ba299520dd8 Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 13 Oct 2026 08:50:25 +0900 Subject: [PATCH 5/8] Fetch the permissions once and forget them on sign-out fetchPermissions applied its response by reassigning shopkeeper, whose didSet still saw shopLimitCount 0 (it is set afterwards) and requested the permissions again, so every launch and sign-in sent two requests and synced the locale twice; with a shop limit of 0 it would never stop. The response now sets permissionState itself. Signing out kept the shopkeeper's shouldUpdatePrivacy/Terms/App and shopLimitCount. The next sign-in's didSet saw the old shopLimitCount, marked the permissions loaded, and showed the previous shopkeeper's privacy or terms prompt until its own permissions arrived (or instead of the error screen when they failed). Sign-out now resets them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- .../Sessions/SessionController.swift | 14 ++++-- .../SessionControllerPermissionsTest.swift | 50 ++++++++++++++++++- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/NativeAppTemplate/Sessions/SessionController.swift b/NativeAppTemplate/Sessions/SessionController.swift index baab7a0..c3ae4a3 100644 --- a/NativeAppTemplate/Sessions/SessionController.swift +++ b/NativeAppTemplate/Sessions/SessionController.swift @@ -38,6 +38,13 @@ import Observation } else { userState = .notLoggedIn permissionState = .notLoaded + // These were the signed-out shopkeeper's; the next one's are fetched + // on sign-in, so none of them may show meanwhile + didFetchPermissions = false + shouldUpdateApp = false + shouldUpdatePrivacy = false + shouldUpdateTerms = false + shopLimitCount = 0 // URLSession.api keeps no cache, but versions before it used // URLCache.shared, which holds their API responses on disk URLCache.shared.removeAllCachedResponses() @@ -203,15 +210,16 @@ import Observation // Check that we have a logged in user. Otherwise this is pointless guard let shopkeeper = self.shopkeeper else { return } - // Update the user - self.shopkeeper = shopkeeper // Ensure loginRepository is aware, and hence the keychain is updated - try self.loginRepository.updateShopkeeper(shopkeeper: self.shopkeeper) + try self.loginRepository.updateShopkeeper(shopkeeper: shopkeeper) shouldUpdateApp = Int(Bundle.main.appBuild)! < permissionsResponse.iosAppVersion shouldUpdatePrivacy = permissionsResponse.shouldUpdatePrivacy shouldUpdateTerms = permissionsResponse.shouldUpdateTerms shopLimitCount = permissionsResponse.shopLimitCount + // Set here, not by reassigning shopkeeper: its didSet saw the old + // shopLimitCount (0 on the first fetch) and requested them again + permissionState = .loaded didFetchPermissions = true diff --git a/NativeAppTemplateTests/Sessions/SessionControllerPermissionsTest.swift b/NativeAppTemplateTests/Sessions/SessionControllerPermissionsTest.swift index a6b5e81..e878c9a 100644 --- a/NativeAppTemplateTests/Sessions/SessionControllerPermissionsTest.swift +++ b/NativeAppTemplateTests/Sessions/SessionControllerPermissionsTest.swift @@ -58,8 +58,6 @@ struct SessionControllerPermissionsTest { let sessionController = makeSessionController(server: server) sessionController.connectionChanged(to: .online) - // The first response reassigns shopkeeper before shopLimitCount is set, so its - // didSet starts a second request; the state is .loaded once that one lands await waitUntil { sessionController.didFetchPermissions && sessionController.hasPermissions } let request = try #require(server.requests.first) @@ -74,6 +72,54 @@ struct SessionControllerPermissionsTest { #expect(sessionController.shopLimitCount == 3) } + /// Going online asks for the permissions once. The response used to be applied + /// by reassigning shopkeeper, whose didSet still saw shopLimitCount 0 and sent + /// a second request (and synced the locale twice). + @Test + func permissionsAreFetchedOnce() async throws { + let server = try await TestHTTPServer.start { request in + request.path.hasSuffix("/shopkeeper/permissions") + ? TestHTTPServer.json(["data": [], "meta": Self.meta]) + : TestHTTPServer.json([String: Any]()) + } + defer { server.stop() } + let sessionController = makeSessionController(server: server) + + sessionController.connectionChanged(to: .online) + await waitUntil { sessionController.hasPermissions } + // A second request would be sent as the first response is applied; give it time to arrive + try await Task.sleep(for: .milliseconds(500)) + + #expect(server.requests.filter { $0.path.hasSuffix("/shopkeeper/permissions") }.count == 1) + } + + /// Signing out forgets the permissions of the shopkeeper who left. Otherwise the + /// next sign-in starts with them: its didSet saw shopLimitCount 3 and marked the + /// permissions loaded, so the previous shopkeeper's privacy prompt showed. + @Test + func signingOutForgetsThePermissions() async throws { + let server = try await TestHTTPServer.start { request in + request.path.hasSuffix("/shopkeeper/permissions") + ? TestHTTPServer.json(["data": [], "meta": Self.meta]) + : TestHTTPServer.json([String: Any]()) + } + defer { server.stop() } + let sessionController = makeSessionController(server: server) + sessionController.connectionChanged(to: .online) + await waitUntil { sessionController.hasPermissions } + // From meta above + try #require(sessionController.shouldUpdatePrivacy) + + try await sessionController.logout() + + // Nothing is known without a session: the properties' initial values + #expect(!sessionController.shouldUpdatePrivacy) + #expect(!sessionController.shouldUpdateTerms) + #expect(!sessionController.shouldUpdateApp) + #expect(sessionController.shopLimitCount == 0) + #expect(!sessionController.didFetchPermissions) + } + /// End to end: launching with an expired token. The permissions request gets /// devise_token_auth's 401, and the app signs out instead of showing /// MainView's permissions error screen. From 39e5b3db00acdfd7693df127bc07ef86b47da1c7 Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 13 Oct 2026 11:07:52 +0900 Subject: [PATCH 6/8] Add a local network usage description On an iPhone running iOS 27, the Free app never showed the Local Network permission prompt, even after a fresh install; it was missing from Settings > Privacy > Local Network, and signing in to a development API server on the LAN (http://192.168.1.11:3000) failed with NSURLErrorNotConnectedToInternet (-1009) "Local network prohibited". Without NSLocalNetworkUsageDescription the system has no purpose string to show, so it can deny local network access instead of asking. Declare the purpose string (English and Japanese) so iOS asks the user when the app first connects to a LAN host. It only affects local development builds; the app makes no local network connections when it talks to a public API host. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- NativeAppTemplate/Info.plist | 2 ++ NativeAppTemplate/InfoPlist.xcstrings | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/NativeAppTemplate/Info.plist b/NativeAppTemplate/Info.plist index 673fc62..7daadec 100644 --- a/NativeAppTemplate/Info.plist +++ b/NativeAppTemplate/Info.plist @@ -24,6 +24,8 @@ LSRequiresIPhoneOS + NSLocalNetworkUsageDescription + Connects to a NativeAppTemplate 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 520d07c..4ef09fd 100644 --- a/NativeAppTemplate/InfoPlist.xcstrings +++ b/NativeAppTemplate/InfoPlist.xcstrings @@ -36,6 +36,24 @@ } } } + }, + "NSLocalNetworkUsageDescription" : { + "comment" : "Privacy - Local Network Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Connects to a NativeAppTemplate API server running on your local network during development." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "開発中に、ローカルネットワーク上で動作している NativeAppTemplate API サーバーに接続します。" + } + } + } } }, "version" : "1.0" From 7a9fd0aab4723d4c0f206a2cbec5a7d0385ed940 Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 13 Oct 2026 11:20:55 +0900 Subject: [PATCH 7/8] Keep the launch screen dark UILaunchScreen's UIColorName named backgroundColor, which is not in the asset catalog (the app's colors are defined in code), so iOS ignored it and showed the system background: white in light mode, before SwiftUI's .preferredColorScheme(.dark) applied. Drop the color name and set UIUserInterfaceStyle to Dark. InfoPlistTest also checks the local network usage description. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- NativeAppTemplate/Info.plist | 7 ++- .../App/InfoPlistTest.swift | 48 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 NativeAppTemplateTests/App/InfoPlistTest.swift diff --git a/NativeAppTemplate/Info.plist b/NativeAppTemplate/Info.plist index 7daadec..61d817f 100644 --- a/NativeAppTemplate/Info.plist +++ b/NativeAppTemplate/Info.plist @@ -40,10 +40,7 @@ UIApplicationSupportsIndirectInputEvents UILaunchScreen - - UIColorName - backgroundColor - + UIRequiredDeviceCapabilities arm64 @@ -59,5 +56,7 @@ UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown + UIUserInterfaceStyle + Dark diff --git a/NativeAppTemplateTests/App/InfoPlistTest.swift b/NativeAppTemplateTests/App/InfoPlistTest.swift new file mode 100644 index 0000000..7d54290 --- /dev/null +++ b/NativeAppTemplateTests/App/InfoPlistTest.swift @@ -0,0 +1,48 @@ +// +// InfoPlistTest.swift +// NativeAppTemplate +// + +import Foundation +@testable import NativeAppTemplate +import Testing +import UIKit + +/// Checks the built app's Info.plist (Bundle.main is the app the tests are hosted in) +@Suite +struct InfoPlistTest { + /// Without NSLocalNetworkUsageDescription, iOS denied the app local network + /// access to a development API server on the LAN (NSURLErrorNotConnectedToInternet, + /// "Local network prohibited") without ever showing the permission prompt. + @Test + func declaresTheLocalNetworkUsageDescription() throws { + let info = try #require(Bundle.main.infoDictionary) + let description = try #require(info["NSLocalNetworkUsageDescription"] as? String) + + #expect(!description.isEmpty) + } + + /// UILaunchScreen's UIColorName must name a color set in the asset catalog + /// (Apple's UILaunchScreen reference); a name that is not there is ignored + /// silently and the launch screen falls back to the system background. + /// The app defines its colors in Color+Extensions.swift, not in the asset catalog. + @Test + func everyLaunchScreenColorNameResolves() throws { + let info = try #require(Bundle.main.infoDictionary) + let launchScreen = try #require(info["UILaunchScreen"] as? [String: Any]) + + if let colorName = launchScreen["UIColorName"] as? String { + #expect(UIColor(named: colorName, in: .main, compatibleWith: nil) != nil, "\(colorName) is not in the asset catalog") + } + } + + /// The app is dark mode only (App.swift applies .preferredColorScheme(.dark)), but + /// that only takes effect once SwiftUI is on screen. UIUserInterfaceStyle = Dark + /// keeps the launch screen's system background dark in light mode too. + @Test + func launchesInDarkMode() throws { + let info = try #require(Bundle.main.infoDictionary) + + #expect(info["UIUserInterfaceStyle"] as? String == "Dark") + } +} From 48ee8cdda43df6e25e9ce561c93dc1e867ae03b0 Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 13 Oct 2026 12:27:42 +0900 Subject: [PATCH 8/8] Give the Free Debug build its own executable UUID Local network access to a development API server on the LAN was silently denied in Debug builds of this app, and the permission prompt never appeared, even with NSLocalNetworkUsageDescription declared. iOS local network privacy identifies an app by the LC_UUID of its main executable (TN3179). With ENABLE_DEBUG_DYLIB (Xcode's default for Debug), the main executable is a small stub whose contents depend only on the product name, and the app code lives in NativeAppTemplate.debug.dylib. This app and the paid NativeAppTemplate app share the product name NativeAppTemplate, so their Debug executables had the identical UUID 4B6D9CE1-C279-37E3-9ED2-4325B95E2B0F. The paid app already held the local network entry for that UUID, so the Free app inherited its state and was never asked. Set ENABLE_DEBUG_DYLIB = NO for the app target's Debug configuration so the code is linked into the main executable again and its UUID is the app's own (now 7F216CE0-4CCB-3656-936A-AB98B3477DBB). Release builds never used the debug dylib and are unaffected. SwiftUI previews fall back to the older mechanism of building previews without the dylib. InfoPlistTest.mainExecutableIsNotADebugDylibStub checks that the host app bundle contains no .debug.dylib. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- NativeAppTemplate.xcodeproj/project.pbxproj | 1 + NativeAppTemplateTests/App/InfoPlistTest.swift | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/NativeAppTemplate.xcodeproj/project.pbxproj b/NativeAppTemplate.xcodeproj/project.pbxproj index d52b23d..638b575 100644 --- a/NativeAppTemplate.xcodeproj/project.pbxproj +++ b/NativeAppTemplate.xcodeproj/project.pbxproj @@ -1196,6 +1196,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_ASSET_PATHS = "\"NativeAppTemplate/Preview Content\""; + ENABLE_DEBUG_DYLIB = NO; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; INFOPLIST_FILE = NativeAppTemplate/Info.plist; diff --git a/NativeAppTemplateTests/App/InfoPlistTest.swift b/NativeAppTemplateTests/App/InfoPlistTest.swift index 7d54290..090b154 100644 --- a/NativeAppTemplateTests/App/InfoPlistTest.swift +++ b/NativeAppTemplateTests/App/InfoPlistTest.swift @@ -45,4 +45,19 @@ struct InfoPlistTest { #expect(info["UIUserInterfaceStyle"] as? String == "Dark") } + + /// 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 + /// paid NativeAppTemplate app (same product name) had the same UUID: the Free + /// app was silently denied local network access and never prompted because + /// the paid app already held the entry. Debug builds must link the code into + /// the main executable, so no .debug.dylib may be in the bundle. + @Test + func mainExecutableIsNotADebugDylibStub() throws { + let executableName = try #require(Bundle.main.executableURL?.lastPathComponent) + let debugDylib = Bundle.main.bundleURL.appendingPathComponent("\(executableName).debug.dylib") + + #expect(!FileManager.default.fileExists(atPath: debugDylib.path), "\(debugDylib.lastPathComponent) is in the app bundle") + } }