Add a rule by hand on the split rules sheet - #342
Merged
Ryanmello07 merged 2 commits intoSep 9, 2026
Conversation
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
force-pushed
the
feat/ios-manual-split-rule
branch
from
September 9, 2026 01:19
f7a3038 to
06fee85
Compare
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.
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: noSectionheader 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 theForEach, so the existing.onDeleteindex math is untouched.The field's shape follows
EditableValueListin the siblingDnsSettingsView— sameplus.circle.filltinted 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
newBlockActionMatcherclassifies a host by prefix and falls through toexactHosts[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.SplitRuleHostInputtherefore refuses what would be dead, and says why in a line under the field:münchen.decould only ever be a dead rulelocalhostparses fine as an exact host in Go, but nothing resolves to itexample..com,*.,10.0.0.0/33It 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:
192.168.1.42/24→192.168.1.0/24, shown as "Saved as …"), because the matcher storesprefix.Masked()::ffff:1.2.3.4→1.2.3.4), matchingnetip'sUnmap()And one set-level check the host-level ones miss: a value already covered by a wildcard in the same rule is refused, because
CollapseHostNameswould 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,
DeviceRemotebuilds 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 inDeviceManager.initDevicestates exactly this consequence.Today that is unreachable by accident:
createRuleneeds 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
createRulerefuses until this process has seen a list it can vouch for — the mirror exists, or the device has connected. Three deliberate choices:guardclause is cheap insurance against a future second call site.updateOverrides, not insetup. The change listeners are registered before the first read, so a connect landing between agetConnected()check and the listener registration cannot be missed.The
localStateis 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.mergereplaces 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
device.adduntil a vouched read lands, which is real new machinery because the SDK has no additive offline op.NormalizeBlockActionHostinconnect, called by the matcher itself and bound through gomobile — exactly howCollapseHostNamesalready 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.*.example.comis 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
SplitRuleHostInputTestscovers 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 innetworkTestsconstructs 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
example.com, tap +, tap Create. The rule appears with a Local chip.münchen.de,localhost,example..com,10.0.0.0/33— each is refused with a reason and the + stays grey.192.168.1.42/24— accepted, "Saved as 192.168.1.0/24", and the chip shows the masked form.*.example.com, try to adda.example.com— refused as already covered.🤖 Generated with Claude Code
https://claude.ai/code/session_01AWV4MaF9JBcQppSX9UxATa