field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit e5d2c60526112c39480e83103791dbddcdf101cf
parent 6762e6ee388de37228dbaedec2fc8af8edd7d2f8
Author: triesap <tyson@radroots.org>
Date:   Fri, 28 Aug 2026 03:51:31 +0000

feat(ios): enforce canonical root navigation

Diffstat:
MRadroots/App/RadrootsRootShell.swift | 18+++++++++++-------
MRadrootsTests/RadrootsRootShellTests.swift | 67++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
MRadrootsUITests/RadrootsRootShellUITests.swift | 24++++++++++++++++++++++++
3 files changed, 89 insertions(+), 20 deletions(-)

diff --git a/Radroots/App/RadrootsRootShell.swift b/Radroots/App/RadrootsRootShell.swift @@ -10,16 +10,20 @@ enum RadrootsRootTab: String, CaseIterable, Sendable { } static func resolve(url: URL) -> Self? { - guard url.scheme?.lowercased() == "radroots", - url.user == nil, - url.password == nil, - url.query == nil, - url.fragment == nil + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.scheme?.lowercased() == "radroots", + components.user == nil, + components.password == nil, + components.port == nil, + components.query == nil, + components.fragment == nil, + components.percentEncodedPath.isEmpty, + let encodedHost = components.percentEncodedHost, + !encodedHost.contains("%") else { return nil } - let candidate = url.host ?? url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - return Self(rawValue: candidate.lowercased()) + return Self(rawValue: encodedHost.lowercased()) } } diff --git a/RadrootsTests/RadrootsRootShellTests.swift b/RadrootsTests/RadrootsRootShellTests.swift @@ -13,21 +13,62 @@ final class RadrootsRootShellTests: XCTestCase { XCTAssertEqual(RadrootsRootTab.resolve("settings"), .today) } - func testDeepLinksCanSelectOnlyRootTabs() throws { - XCTAssertEqual( - try RadrootsRootTab.resolve(url: XCTUnwrap(URL(string: "radroots://today"))), - .today - ) - XCTAssertEqual( - try RadrootsRootTab.resolve(url: XCTUnwrap(URL(string: "radroots://add"))), - .add - ) - for removed in ["capture", "activity", "settings", "search", "me"] { + func testDeepLinksAcceptOnlyCanonicalRootAuthorities() throws { + for (rawValue, expected) in [ + ("radroots://today", RadrootsRootTab.today), + ("RADROOTS://TODAY", RadrootsRootTab.today), + ("radroots://add", RadrootsRootTab.add), + ("RadRoots://AdD", RadrootsRootTab.add), + ] { + XCTAssertEqual( + try RadrootsRootTab.resolve(url: XCTUnwrap(URL(string: rawValue))), + expected, + "Expected canonical root deep link: \(rawValue)" + ) + } + } + + func testDeepLinksRejectRemovedAndStructurallyAmbiguousRoutes() throws { + for rawValue in [ + "radroots://capture", + "radroots://activity", + "radroots://settings", + "radroots://search", + "radroots://me", + "radroots:today", + "radroots:/today", + "radroots:///today", + "radroots://host/today", + "radroots://today/", + "radroots://today/add", + "radroots://add/today", + ] { XCTAssertNil( - try RadrootsRootTab.resolve( - url: XCTUnwrap(URL(string: "radroots://\(removed)")) - ) + try RadrootsRootTab.resolve(url: XCTUnwrap(URL(string: rawValue))), + "Expected noncanonical deep link rejection: \(rawValue)" ) } } + + func testDeepLinksRejectAuthorityDecorationsAndEncodedAliases() throws { + for rawValue in [ + "radroots://user@today", + "radroots://user:password@add", + "radroots://today:7447", + "radroots://today?", + "radroots://today?source=widget", + "radroots://today#", + "radroots://add#composer", + "radroots://%74oday", + "radroots://%61dd", + "radroots://today/%61dd", + ] { + if let url = URL(string: rawValue) { + XCTAssertNil( + RadrootsRootTab.resolve(url: url), + "Expected decorated or encoded deep link rejection: \(rawValue)" + ) + } + } + } } diff --git a/RadrootsUITests/RadrootsRootShellUITests.swift b/RadrootsUITests/RadrootsRootShellUITests.swift @@ -25,4 +25,28 @@ final class RadrootsRootShellUITests: XCTestCase { app.descendants(matching: .any)["radroots.today.root"].waitForExistence(timeout: 2) ) } + + @MainActor + func testCanonicalDeepLinksDriveLifecycleNavigation() throws { + let app = XCUIApplication() + app.launchEnvironment["RADROOTS_IOS_UI_TEST_SHELL"] = "1" + app.launch() + + let tabBar = app.tabBars.firstMatch + let today = app.descendants(matching: .any)["radroots.today.root"] + let add = app.descendants(matching: .any)["radroots.add.root"] + XCTAssertTrue(tabBar.waitForExistence(timeout: 5)) + tabBar.buttons["Today"].tap() + XCTAssertTrue(today.waitForExistence(timeout: 5)) + + app.open(try XCTUnwrap(URL(string: "radroots://add"))) + XCTAssertTrue(add.waitForExistence(timeout: 2)) + + app.open(try XCTUnwrap(URL(string: "radroots://today"))) + XCTAssertTrue(today.waitForExistence(timeout: 2)) + + app.open(try XCTUnwrap(URL(string: "radroots://add/extra"))) + XCTAssertTrue(today.waitForExistence(timeout: 2)) + XCTAssertFalse(add.exists) + } }