Skip to content

Keep typed text in the connection host field; normalize only the model - #1591

Merged
GianniCarlo merged 2 commits into
developfrom
fix/integration-host-field-echo
Oct 5, 2026
Merged

GianniCarlo merged 2 commits into
developfrom
fix/integration-host-field-echo

Conversation

@GianniCarlo

@GianniCarlo GianniCarlo commented Sep 4, 2026 •

Copy link
Copy Markdown
Collaborator

What this is

The connection flow's address screen (#1577) mirrors the address model back into the Host field after every keystroke, so the field shows the normalized model rather than what was typed. Two of the model's normalizations make that destructive while typing:

  • normalizedPath strips trailing slashes for assembly. Type media.example.com/ and the / is deleted the moment it lands, so a reverse-proxy subpath cannot be typed at all — only pasted. Typing media.example.com/abs character by character ends up as media.example.comabs.
  • normalizedHost bracketed any host containing a colon as an IPv6 literal. myserver.com: on the way to a port became [myserver.com:]; http: on the way to a scheme became [http:], after which the / was eaten again — so typing a URL through was impossible too.

A third case shows up once those two are fixed: rewriting the field per keystroke peels a port typed inline at its first digit. myserver.com:8 becomes host myserver.com + port 8, and the next digits land in the host — myserver.com:8096 ends as https://myserver.com096:8. A URL typed through breaks the same way.

Found while bringing Android to parity; Android kept the typed text verbatim from the start.

The fix

  • The field keeps what the user typed. IntegrationServerAddress.applyHostField(_:) applies the text to the model and returns what the row should display: the input itself, unless the edit moved something into another field — a full URL whose scheme and port go to their controls, or a scheme-less host:port whose port is peeled off — in which case it returns the remaining host + subpath so the port is not shown twice. The hostField setter delegates to it, so existing callers and tests are unchanged.
  • The model takes every keystroke; the field is rewritten only on a paste or when it loses focus. A paste lands several characters at once and is laid out across the fields right away. Typed text stays as typed while the port row and the footer's URL follow each keystroke (8 → 80 → 809 → 8096); it is laid out when the field loses focus (@FocusState), so myserver.com:8096 then reads myserver.com with port 8096.
  • The port row follows a port that arrives through the host field from the host field's handler, replacing the .onChange(of: address.host) sync — ordinary port typing still never rewrites the port text.
  • Every other normalization is model-only (trailing/leading slash, percent-encoding, IPv6 brackets) and shows where it belongs: the footer's assembled URL.
  • normalizedHost brackets only IPv6-looking text (two or more colons; hex digits, dots, colons, an optional %zone). A hostname with a stray colon stays as typed and url stays nil, so Connect is disabled until the text resolves — no more [myserver.com:].

Testing

  • IntegrationServerAddressTests +9: subpath slash kept in the field and dropped only in the model; colon toward a port neither bracketed nor assembled; port peels off a pasted host:port; scheme typed through; pasted full URL; bare IPv6 as typed but assembled bracketed (incl. zone id); typed space encoded in the URL, not the field; bracket rule (::1 yes, myserver.com: / http: / my:host:name no); and an address typed character by character ends where its paste does (host:port/path, a full http://…:port/path, [::1]:port).
  • Full Unit Tests plan: 572/572 green (IntegrationServerAddressTests 33/33).
  • Simulator (iPhone 17 Pro, iOS 27), one character per keystroke into the Jellyfin Host field, A/B against develop and this PR's first commit:
Typed develop first commit this PR
media.example.com/abs / stripped → media.example.comabs ✅ ✅
myserver.com:8096 [myserver.com:]8096, Connect disabled https://myserver.com096:8 ✅ https://myserver.com:8096; field → myserver.com on leaving it
http://10.0.0.5:13378/abs [http:]10.0.0.5:13378abs http://1.0.0.53378:1/abs ✅ http://10.0.0.5:13378/abs; field → 10.0.0.5/abs, scheme http

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

✅ Claude PR Review — PASS

Pure UI/form-level refactor of IntegrationServerAddress host-field editing for the Jellyfin/AudiobookShelf connection screen. The hostField setter now delegates to a new applyHostField(_:) -> String that returns the text the Host row should render, and the SwiftUI view uses a @FocusState to decide when to echo the normalized form (on paste or focus loss) rather than mid-typing — fixing a just-typed / or port digit being eaten. normalizedHost now wraps IPv6 literals only when the text actually looks like one. No concurrency, player/AVAudioSession, CoreData/SwiftData, auth/entitlement, secrets, or BookPlayerKit-boundary surfaces are touched; it's main-actor @State value editing in the app layer, backed by thorough new unit tests asserting typed-vs-pasted equivalence. Low risk.

Findings: 1 info

Model claude-opus-4-8 · run log · 1 new · 0 carried over · 0 resolved · advisory (a human should still review). Duplicate findings are de-duplicated and stale ones auto-resolved across pushes.

The address screen re-echoed the normalized model into the Host field on
every keystroke. The model strips trailing slashes for assembly, so the `/`
a user had just typed was deleted at once — a reverse-proxy subpath could
not be typed at all, only pasted. It also bracketed any host containing a
colon as an IPv6 literal, so `myserver.com:` on the way to a port and
`http:` on the way to a scheme showed up as `[myserver.com:]` / `[http:]`,
and typing a URL through was impossible.

The field now keeps what was typed. `IntegrationServerAddress.applyHostField`
applies the text to the model and returns what the row should display: the
input itself, unless the edit moved something into another field — a full
URL whose scheme and port were distributed, or a scheme-less `host:port`
whose port was peeled — in which case the remaining host + subpath, so the
port is not shown twice. Every other normalization (slashes, encoding, IPv6
brackets) applies to the model only and shows in the footer's assembled
URL, as on Android.

`normalizedHost` brackets only text that looks like an IPv6 literal (two or
more colons, hex digits, dots, an optional zone) instead of anything with
a colon, so a hostname with a stray colon stays as typed and `url` stays
nil until it resolves.

Tests: eight new cases on the typing path (subpath slash kept, colon not
bracketed, port peel rewrites the field, scheme typed through, pasted URL,
bare IPv6, typed space, bracket rule).
The field was rewritten after every keystroke whose text moved something into
another field, so typing `myserver.com:8096` peeled `:8` into the port row at
the first digit and sent `096` into the host. A URL typed through broke the
same way.

The model still takes every edit, so the port row and the footer's URL stay
current. The field keeps the typed text and is rewritten only on a paste,
which lands several characters at once, or when it loses focus. The port row
now follows a port that arrives through the host field from the host field's
handler.
@GianniCarlo
GianniCarlo force-pushed the fix/integration-host-field-echo branch from 7df48df to 3953fd9 Compare October 5, 2026 21:18
if address.port != port {
portText = address.port.map(String.init) ?? ""
}
if display != newValue, newValue.count > oldValue.count + 1 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 INFO — The paste heuristic newValue.count > oldValue.count + 1 won't recognize a paste that replaces a selection with shorter text (e.g. select-all over a long value, paste host:8096), so the host row keeps showing host:8096 until focus leaves even though the port was already peeled into address.port/portText. This is only cosmetic — the onChange(of: isHostFocused) handler normalizes it on focus loss, and the comment already acknowledges "typed text when the field loses focus." No change required; flagging only so the heuristic's limitation is understood.

@GianniCarlo
GianniCarlo merged commit 8489a8c into develop Oct 5, 2026
2 checks passed
@GianniCarlo
GianniCarlo deleted the fix/integration-host-field-echo branch October 5, 2026 21:46
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