Repository navigation
Fix expired sessions, item tag list race, permissions state and time zone default - #94
Merged
Merged
Conversation
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH
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 <executable>.debug.dylib. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 2 (bug fixes) for iOS Free. Each fix is its own commit with a reproduction test that was seen red before the fix.
Changes
Time zone default picked a zone at another offset (91c98cf).
Utility.currentTimeZonelooked the identifier's city up by substring in the whole table. Australia/West (+08:00) preselected "International Date Line West" (-12:00), Pacific/Easter picked "Eastern Time (US & Canada)", and America/Indiana/Petersburg picked "St. Petersburg". I scanned everyTimeZone.knownTimeZoneIdentifiersentry to find these. A whole city name still matches at any offset, because the table's Dublin and Casablanca offsets differ from the system's. Part of a name now matches only among the zones at the device's standard offset. (The Android paid PR found the same bug.)Item tag list showed another shop's tags (66d12f3).
reloadPagereturned early while any shop's page was loading. If you opened shop B during shop A's load, B's request was dropped and A's tags were listed under B. A's late next page was also appended to B's list. Reloads now use a generation counter, so stale pages are dropped and a stale next page no longer blocks paging. Ported from paid #113.New-password content type (7a7ca71). The sign-up password, the new password and its confirmation now use
.newPassword; the current password stays.password. Ported from paid #113.Sign out when the API rejects the session's token (c7be614). An expired or revoked devise_token_auth token made every request fail with a 401. At launch that showed the permissions error screen.
Serviceposts.apiSessionDidExpireonly for a 401 from an authenticated request whose body has noerror_message. That is devise's{"errors":["You need to sign in or sign up before continuing."]}, which I checked with curl against the local API using a bogus token.BaseController#user_not_authorized, Pundit) carries anerror_messageand keeps the session.RootPathServiceroutes (sign-in, sign-out, registrations) are not affected.SessionControllersigns out only if the rejected token is its current one, and MainView shows a "Session Expired" alert (en + ja)..error.Permissions fetched twice; previous shopkeeper's permissions survived sign-out (277605d).
fetchPermissionsapplied its response by reassigningshopkeeper. That didSet still sawshopLimitCount == 0, so it requested the permissions again. Every launch and sign-in sent two requests and synced the locale twice, and with a shop limit of 0 it would never stop. The response now setspermissionState = .loadeditself.shouldUpdatePrivacy/Terms/AppandshopLimitCount. On the next sign-in, the didSet saw the oldshopLimitCountand marked the permissions loaded. That showed the previous shopkeeper's privacy or terms prompt until the new shopkeeper's permissions arrived, or showed it instead of the error screen if that fetch failed. Sign-out now resets these values.No Local Network permission prompt (39e5b3d). On an iPhone running iOS 27, the Free app never asked for local network access, even after a fresh install. It was missing from Settings > Privacy > Local Network, and signing in to the development API at http://192.168.1.11:3000 failed with -1009 "Local network prohibited". Info.plist now declares
NSLocalNetworkUsageDescription(en + ja in InfoPlist.xcstrings), so iOS has a purpose string to show and asks when the app first connects to a LAN host. The networking code (ephemeralURLSession,NWPathMonitor) is the same as the paid app's. Nothing in it can fail before iOS checks local network privacy, because the -1009 comes from the OS. The built bundle has the key in Info.plist and in ja.lproj/InfoPlist.strings.Local network usage description (39e5b3d, added by the coordinator). Without
NSLocalNetworkUsageDescription, iOS denied local network access to the development API without showing a prompt.Launch screen was white in light mode (7a9fd0a, ported from paid #115).
UILaunchScreen'sUIColorNamenamedbackgroundColor. No color set has that name (the colors are defined in code), so iOS ignored it and used the system background. The fix removes the color name and setsUIUserInterfaceStyle = Dark. The newInfoPlistTestchecks the color name, the dark style and the local network description:Free Debug build shared the paid app's executable UUID, so local network stayed denied (48ee8cd). This is the root cause of the missing prompt in item 6.
ENABLE_DEBUG_DYLIB(Xcode's Debug default), the main executable is a tiny stub determined only by the product name, and the code lives inNativeAppTemplate.debug.dylib.NativeAppTemplate, so their Debug executables had the same UUID,4B6D9CE1-C279-37E3-9ED2-4325B95E2B0F(dwarfdump --uuid). The paid app already held the local network entry for it, so the Free app was silently denied and never prompted.ENABLE_DEBUG_DYLIB = NOfor the app target's Debug configuration only. The Free Debug executable is now7F216CE0-4CCB-3656-936A-AB98B3477DBB. Release never used the debug dylib. A simulatorxcodebuild buildsucceeds; SwiftUI previews fall back to the older mechanism, which builds previews without the debug dylib.InfoPlistTest.mainExecutableIsNotADebugDylibStubasserts the host app bundle has no<executable>.debug.dylib. It failed on the iPhone before the setting change and passes after.Tests and red checks
Every new test failed against broken code on the iPhone:
Utility.swift,ItemTagRepository.swiftand the permissions changes, set the content types back to.password, and turned off theServicepost. Red: the 3 time zone cases, both item tag race tests, both content-type tests,rejectedTokenIsAnnouncedAsAnExpiredSession,expiredTokenAtLaunchSignsOut,permissionsAreFetchedOnceandsigningOutForgetsThePermissions.expiryOfAnotherTokenKeepsTheSessionfail.errorMessage == nilmadeauthorizationFailureWithAnErrorMessageIsNotAnExpiredSessionfail.sessionDidExpiremadeexpiredTokenSignsOutfail.currentTimeZoneMatchesPartOfANameAtTheSameOffset(Cape Verde) is a guard that the partial match still works at the same offset. It passes on old and new code.Local checks
make lint: SwiftLint 0 violations; SwiftFormat 0/137 files need formatting.signingOutRemovesCachedResponses. It passed 3 times alone and in the next full run. It looks like a race betweenURLCachestore and remove, so I left it for the test-hardening phase.Device behavior check
Done on the iPhone with the 48ee8cd build. 48ee8cd gives the Free Debug build its own executable UUID; Local Network now works. The user signed in as
free-ios-qa@example.com. I relaunched the app withdevicectl, took screenshots withdevicectl device capture screenshot, and read the API's server log. The app was not reinstalled.GET .../shopkeeper/permissions.GET .../permissions(200) and oneGET .../shops. Before this fix every launch sent two permissions requests.Shopkeeper.find_by(email: "free-ios-qa@example.com").update!(tokens: {})and relaunched.GET .../permissions, answered401 Unauthorized, and nothing after it.Asia/TokyogivesUtility.currentTimeZone()=Tokyo. I could not open the create-shop sheet: the iPhone can't be tapped from here, and a test run would reinstall the app.error_message401.{"errors":[...]}, with noerror_message.🤖 Generated with Claude Code
https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH