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:
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)
+ }
}