Skip to content

Add a rule by hand on the split rules sheet - #342

Merged
Ryanmello07 merged 2 commits into
urnetwork:mainfrom
Ryanmello07:feat/ios-manual-split-rule
Sep 9, 2026
Merged

Add a rule by hand on the split rules sheet#342
Ryanmello07 merged 2 commits into
urnetwork:mainfrom
Ryanmello07:feat/ios-manual-split-rule

Conversation

@Ryanmello07

Copy link
Copy Markdown
Contributor

Depends on #340. This branch contains #340's commit as well, because a cross-fork PR cannot use a fork branch as its base. Review only the second commit here; the diff shrinks to it once #340 merges.

The gap

A split rule could only be made by tapping a row in the live Activity list, so a host that had not been seen could not be routed at all. That is backwards for the common case: traffic you want to keep off the tunnel is often traffic you have not sent yet, and the rule you most want to write in advance is the one for the site you are about to open.

The shape

An "Add a rule" row at the top of the Rules section, and a text field as the first row of the editor's host list — so hand-typed values and tapped ones land in the same rule through the same path, and the list is never empty (which is what would otherwise need a whole empty-state branch in the editor).

The row goes inside the Section, not in its header: no Section header anywhere in this app holds a control, and .listStyle(.plain) pins headers, so the affordance would float over the Activity rows while scrolling. It sits as a sibling of the ForEach, so the existing .onDelete index math is untouched.

The field's shape follows EditableValueList in the sibling DnsSettingsView — same plus.circle.fill tinted on validity, same trim/dedupe semantics, same keyboard configuration — so the two sheets read as one app.

Validation, because the matcher has no error channel

newBlockActionMatcher classifies a host by prefix and falls through to exactHosts[host] for anything it cannot parse. There is no error return anywhere on the write path. So a typo produces a rule that is created, persisted, mirrored, seeded, counted in the "N split rules" card — and matched never. Nothing downstream can report that.

SplitRuleHostInput therefore refuses what would be dead, and says why in a line under the field:

  • unicode names — the matcher lowercases and compares bytes, and a resolved name arrives as punycode, so münchen.de could only ever be a dead rule
  • single-label nameslocalhost parses fine as an exact host in Go, but nothing resolves to it
  • malformed names, wildcards and rangesexample..com, *., 10.0.0.0/33

It is deliberately never more permissive than the Go matcher. Over-rejection costs the user a rephrase; over-acceptance costs them a rule they believe is working. That asymmetry is the whole reason the file exists, and it is the thing to keep in mind before loosening any branch in it.

It also normalizes the two things Go rewrites silently, so the chip shows what is actually in force rather than what was typed:

  • a prefix is masked to its network address (192.168.1.42/24192.168.1.0/24, shown as "Saved as …"), because the matcher stores prefix.Masked()
  • an ipv4-mapped ipv6 address is unmapped (::ffff:1.2.3.41.2.3.4), matching netip's Unmap()

And one set-level check the host-level ones miss: a value already covered by a wildcard in the same rule is refused, because CollapseHostNames would absorb it into that wildcard on the way to the chip and the user would see one value where they deliberately typed two.

The safety gate, which is the part that matters

A "+" button removes a protection that was accidental.

With the rpc down, DeviceRemote builds the full list it will push on the next connect from whatever base it can see, and an unseeded base is empty — so a rule created then does not merely fail to save, it replaces the extension's saved rules when the sync lands. #340's own comment in DeviceManager.initDevice states exactly this consequence.

Today that is unreachable by accident: createRule needs an Activity row, and the activity list is empty with no rpc service. A standalone add row is reachable with the tunnel down and nothing on screen.

So createRule refuses until this process has seen a list it can vouch for — the mirror exists, or the device has connected. Three deliberate choices:

  • The gate is in the store, not only on the affordance. The failure it prevents is a silent, permanent, un-undoable erase of the user's rules. One guard clause is cheap insurance against a future second call site.
  • It latches in updateOverrides, not in setup. The change listeners are registered before the first read, so a connect landing between a getConnected() check and the listener registration cannot be missed.
  • It is monotonic. A disconnect clears the remote's service but never its last known list, so a base that was real stays real for the device's life.

The localState is optional (a build with no App Group has none) and that degrades correctly, to "connected only" — the safe half of the test.

New rules default to route-locally, and not arbitrarily: blockActionMatch.merge replaces a route override only when the incoming one is Local, so a first hand-written rule in any other mode could silently do nothing.

Known limitations

  • A fresh install that has never connected sees the row disabled, even though its create would be perfectly safe. The app cannot distinguish "new install" from "upgraded install whose rules are still in the extension" — both read nil, which is the distinction Persist split rules in the app's own local state #340 rests on. The note under the row says what to do; the only escape that would remove it is an app-side pending rule held out of device.add until a vouched read lands, which is real new machinery because the SDK has no additive offline op.
  • No validator catches a well-formed typo, or a name that is simply never resolved — a NAME rule matches only through the learned-name index, so it is prospective by construction. The real answer is a "not matched yet" marker on the rule row, which needs an SDK counter; filing separately.
  • The grammar now lives in two places. The durable fix is one NormalizeBlockActionHost in connect, called by the matcher itself and bound through gomobile — exactly how CollapseHostNames already works for this same data type. Left as a follow-up because that matcher has a second production caller (SetBlockActionIgnoreHosts, which compiles the DNS-resolver endpoints that must never be captured by user rules), so routing it through a rejecting normalizer is a live-traffic change to shared transport code and deserves its own PR and its own test for the ignore path.
  • Overlap stays silent. A "Route through VPN" rule for a host already caught by a wildcard "Route locally" rule does nothing, because merge is order-independent with Local winning. The "+" is what makes that reachable by hand for the first time.
  • *.example.com is accepted; so is *.co.uk. A bare public-suffix wildcard is one keystroke from routing a very large surface. The blocker generator rejects these as poison for crowd-sourced feeds, and a user-authored rule is a different judgement — but a confirm belongs in the SDK-validator follow-up.

Tests

SplitRuleHostInputTests covers the grammar directly — it is a pure function with no SwiftUI and no SDK import. Every rejection case is guarding the silent-failure mode above, not tidiness.

The gate itself is not unit-testable here. It needs an SdkDeviceRemote, which is a concrete gomobile class, and no test in networkTests constructs one. BlockActionOverridesMirrorTests (from #340) pins the nil-vs-empty read the gate depends on, but not the gate. It is covered by the manual steps only, and that is worth knowing in review rather than discovering later.

Manual verification

  1. Connected, tap Add a rule, type example.com, tap +, tap Create. The rule appears with a Local chip.
  2. Type münchen.de, localhost, example..com, 10.0.0.0/33 — each is refused with a reason and the + stays grey.
  3. Type 192.168.1.42/24 — accepted, "Saved as 192.168.1.0/24", and the chip shows the masked form.
  4. In a rule holding *.example.com, try to add a.example.com — refused as already covered.
  5. Fresh install, tunnel down, never connected: the row is disabled with the note. Connect once, and it enables.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AWV4MaF9JBcQppSX9UxATa

Ryanmello07 and others added 2 commits September 8, 2026 18:19
A split rule could only be made by tapping a row in the live Activity
list, so a host that had not been seen could not be routed at all -- and
traffic you want to exclude is often traffic you have not sent yet.

Add a rule row at the top of the Rules section, and a text field as the
first row of the editor's host list, so hand-typed values and tapped ones
land in the same rule through the same path.

The grammar is checked before the value is stored, because the matcher has
no error channel: anything it cannot parse as a wildcard, a prefix or an
address is filed as an exact host name, so a typo becomes a rule that is
created, persisted, mirrored, counted on the connect card and matched
never. SplitRuleHostInput refuses what would be dead -- unicode names,
single-label names, malformed ranges -- and says why, in a line under the
field. It also normalizes the two values Go rewrites silently, masking a
prefix to its network address and unmapping an ipv4-mapped address, so the
chip shows what is actually in force. It is deliberately never more
permissive than the matcher: over-rejection costs a rephrase, over-
acceptance costs a rule the user believes is working.

Creation is gated on the store having a list it can vouch for. With the
rpc down the remote builds the list it will push on the next connect from
whatever base it can see, and an unseeded base is empty, so a rule created
then would REPLACE the extension's saved rules rather than join them.
Tapping an activity row could never reach that state -- the activity list
is empty with no rpc -- so the protection was accidental, and a hand-
written rule removes it. The latch lives in the store rather than only on
the affordance, and is monotonic: a disconnect clears the remote's service
but never its last known list.

New rules default to route-locally, the only mode guaranteed to take
effect on its own: a merge replaces an existing route override when the
incoming one is local, so a first hand-written rule in another mode could
silently do nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWV4MaF9JBcQppSX9UxATa
`UInt8(0xff << (8 - keep))` builds the mask in Int: `0xff` is an Int
literal, so for every prefix length that is not a whole number of bytes
the shift produces 510 to 32640 and the conversion traps.

The field is validated on every keystroke, so this was not a corner case
-- typing "10.0.0.0/24" crashed the app at the "2", on the way to a
length the tests did cover. The two range cases that existed, /8 and /24,
are both byte-aligned, so `keep` was always 8, `0xff << 0` fit, and the
suite passed straight over it.

Build the mask in UInt8 throughout, and cover it two ways: the specific
non-aligned lengths with their expected network addresses, and a sweep of
every length 0...32 and 0...128 asserting only that the validator returns
rather than traps. The sweep is the one that generalises -- the user types
through every prefix length on the way to the one they want.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWV4MaF9JBcQppSX9UxATa
@Ryanmello07
Ryanmello07 force-pushed the feat/ios-manual-split-rule branch from f7a3038 to 06fee85 Compare September 9, 2026 01:19
@Ryanmello07
Ryanmello07 merged commit d64da27 into urnetwork:main Sep 9, 2026
1 check failed
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