Skip to content

Fix expired sessions, item tag list race, permissions state and time zone default - #94

Merged
dadachi merged 8 commits into
mainfrom
fix/bug-audit
Oct 13, 2026
Merged

dadachi merged 8 commits into
mainfrom
fix/bug-audit

Conversation

@dadachi

@dadachi dadachi commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

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

  1. Time zone default picked a zone at another offset (91c98cf). Utility.currentTimeZone looked 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 every TimeZone.knownTimeZoneIdentifiers entry 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.)

  2. Item tag list showed another shop's tags (66d12f3). reloadPage returned 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.

  3. 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.

  4. 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.

    • Service posts .apiSessionDidExpire only for a 401 from an authenticated request whose body has no error_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.
    • The API's own 401 (BaseController#user_not_authorized, Pundit) carries an error_message and keeps the session.
    • RootPathService routes (sign-in, sign-out, registrations) are not affected.
    • SessionController signs out only if the rejected token is its current one, and MainView shows a "Session Expired" alert (en + ja).
    • A permissions fetch that fails after this sign-out no longer sets .error.
    • Free has no organizations and no push, so paid #113's rename and push fixes don't apply.
  5. Permissions fetched twice; previous shopkeeper's permissions survived sign-out (277605d).

    • Fetched twice: fetchPermissions applied its response by reassigning shopkeeper. That didSet still saw shopLimitCount == 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 sets permissionState = .loaded itself.
    • Survived sign-out: sign-out kept shouldUpdatePrivacy/Terms/App and shopLimitCount. On the next sign-in, the didSet saw the old shopLimitCount and 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.
  6. 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 (ephemeral URLSession, 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.

  7. Local network usage description (39e5b3d, added by the coordinator). Without NSLocalNetworkUsageDescription, iOS denied local network access to the development API without showing a prompt.

  8. Launch screen was white in light mode (7a9fd0a, ported from paid #115). UILaunchScreen's UIColorName named backgroundColor. 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 sets UIUserInterfaceStyle = Dark. The new InfoPlistTest checks the color name, the dark style and the local network description:

    • It failed on the old plist: both the color-name and dark-style tests went red.
    • Deleting the local network description made its test fail.
  9. 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.

    • iOS local network privacy identifies an app by its main executable's LC_UUID (TN3179). With ENABLE_DEBUG_DYLIB (Xcode's Debug default), the main executable is a tiny stub determined only by the product name, and the code lives in NativeAppTemplate.debug.dylib.
    • Both this app and the paid app are named 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.
    • The fix sets ENABLE_DEBUG_DYLIB = NO for the app target's Debug configuration only. The Free Debug executable is now 7F216CE0-4CCB-3656-936A-AB98B3477DBB. Release never used the debug dylib. A simulator xcodebuild build succeeds; SwiftUI previews fall back to the older mechanism, which builds previews without the debug dylib.
    • InfoPlistTest.mainExecutableIsNotADebugDylibStub asserts 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:

  • I reverted Utility.swift, ItemTagRepository.swift and the permissions changes, set the content types back to .password, and turned off the Service post. Red: the 3 time zone cases, both item tag race tests, both content-type tests, rejectedTokenIsAnnouncedAsAnExpiredSession, expiredTokenAtLaunchSignsOut, permissionsAreFetchedOnce and signingOutForgetsThePermissions.
  • Guard checks:
    • Removing the token comparison made expiryOfAnotherTokenKeepsTheSession fail.
    • Removing errorMessage == nil made authorizationFailureWithAnErrorMessageIsNotAnExpiredSession fail.
    • Disabling sessionDidExpire made expiredTokenSignsOut fail.
  • 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.
  • Full suite on the connected iPhone, after the last commit: 415 tests passed. 6 are skipped: LocalizationTest's simulator-only catalog checks.
    • One earlier full run had a single failure in phase 1's signingOutRemovesCachedResponses. It passed 3 times alone and in the next full run. It looks like a race between URLCache store and remove, so I left it for the test-hardening phase.
  • CI: green.

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 with devicectl, took screenshots with devicectl device capture screenshot, and read the API's server log. The app was not reinstalled.

  • One permissions request per launch.
    • The user's sign-in at 12:42:33 sent one GET .../shopkeeper/permissions.
    • My relaunch at 12:43:03 sent exactly one GET .../permissions (200) and one GET .../shops. Before this fix every launch sent two permissions requests.
    • The shop list showed the Sample Shop.
  • Session expiry.
    • I revoked the tokens with Shopkeeper.find_by(email: "free-ios-qa@example.com").update!(tokens: {}) and relaunched.
    • The app sent one GET .../permissions, answered 401 Unauthorized, and nothing after it.
    • The screen showed the "セッションの有効期限切れ" / "セッションの有効期限が切れました。もう一度サインインしてください。" alert over the onboarding screen, not the permissions error screen.
    • Another relaunch sent no requests, so the saved session was cleared.
  • Time zone default: checked by a device test before Local Network worked. Device zone Asia/Tokyo gives Utility.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.
  • Item tag switching between shops: not run on the device, for the same reason. It needs taps or an on-device test, and a test would reinstall the app. The two unit tests cover it against a local HTTP server.
  • Pundit 401: not reachable from Free. Free has no organization or role screens. With a valid token, another account's id in the path gets a 302 (curl). The unit test covers the error_message 401.
  • Devise 401 body: curl with a bogus token returns {"errors":[...]}, with no error_message.

🤖 Generated with Claude Code

https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH

dadachi and others added 8 commits October 13, 2026 08:47
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
@dadachi
dadachi merged commit 58193e8 into main Oct 13, 2026
2 checks passed
@dadachi
dadachi deleted the fix/bug-audit branch October 13, 2026 03:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant