diff --git a/apps/swift-ios/App/Platform/PlatformDeepLinks.swift b/apps/swift-ios/App/Platform/PlatformDeepLinks.swift index 94d96d94164..27ec3c6345e 100644 --- a/apps/swift-ios/App/Platform/PlatformDeepLinks.swift +++ b/apps/swift-ios/App/Platform/PlatformDeepLinks.swift @@ -83,11 +83,12 @@ enum PlatformDeepLinkError: LocalizedError, Equatable { } enum PlatformDeepLinkParser { - private static let trustedWebHosts: Set = [ - "app.t3.codes", - "t3.codes", - "www.t3.codes", + private static let t3Schemes: Set = [ + "t3", "t3code", "t3code-dev", "t3code-swiftui", "t3code-swiftui-dev", ] + private static let appWebHost = "app.t3.codes" + private static let marketingWebHosts: Set = ["t3.codes", "www.t3.codes"] + private static let trustedWebHosts = marketingWebHosts.union([appWebHost]) static func parse(_ url: URL) throws -> PlatformRoute { guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), @@ -97,7 +98,7 @@ enum PlatformDeepLinkParser { } let query = queryValues(components.queryItems ?? []) - if ["t3", "t3code", "t3code-swiftui", "t3code-swiftui-dev"].contains(scheme) { + if t3Schemes.contains(scheme) { let segments = customSchemeSegments(components) if isConnectionRoute(segments: segments, query: query) { return try connectionRoute(url) @@ -139,6 +140,64 @@ enum PlatformDeepLinkParser { return try parse(url) } + /// Resolves links tapped inside the app. In addition to normal deep links, + /// chat can contain a thread URL copied from the web or Electron client. + /// A loopback URL points back at the machine that rendered the message, not + /// at the phone, so translate its route locally instead of opening Safari. + static func parseInAppLink( + _ url: URL, + knownEnvironmentIDs: Set + ) throws -> PlatformRoute { + let route: PlatformRoute + if let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let scheme = components.scheme?.lowercased() { + if t3Schemes.contains(scheme) { + route = if components.host?.lowercased() == "app" { + try webThreadRoute(components) + } else { + try parse(url) + } + return try validatedInAppThreadRoute(route, knownEnvironmentIDs: knownEnvironmentIDs) + } + + if ["http", "https"].contains(scheme), + let host = components.host?.lowercased(), + isLoopbackHost(host) { + route = try webThreadRoute(components) + return try validatedInAppThreadRoute(route, knownEnvironmentIDs: knownEnvironmentIDs) + } + + if ["http", "https"].contains(scheme) { + route = try parse(url) + return try validatedInAppThreadRoute(route, knownEnvironmentIDs: knownEnvironmentIDs) + } + } + + throw PlatformDeepLinkError.unsupportedURL + } + + /// Whether a failed in-app parse belongs exclusively to T3 Code and must + /// not fall through to the system. Marketing-host failures remain normal + /// web links; valid thread routes have already succeeded before this runs. + static func shouldDiscardFailedInAppURL(_ url: URL) -> Bool { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let scheme = components.scheme?.lowercased() else { return false } + if t3Schemes.contains(scheme) { return true } + guard ["http", "https"].contains(scheme), + let host = components.host?.lowercased() else { return false } + return isLoopbackHost(host) || host == appWebHost + } + + static func parseInAppLink( + _ value: String, + knownEnvironmentIDs: Set + ) throws -> PlatformRoute { + guard let url = URL(string: value.trimmingCharacters(in: .whitespacesAndNewlines)) else { + throw PlatformDeepLinkError.unsupportedURL + } + return try parseInAppLink(url, knownEnvironmentIDs: knownEnvironmentIDs) + } + private static func navigationRoute( segments: [String], query: [String: String] @@ -214,6 +273,48 @@ enum PlatformDeepLinkParser { return (try queryEnvironment.map(validatedIdentifier), try validatedIdentifier(destination)) } + private static func webThreadRoute(_ components: URLComponents) throws -> PlatformRoute { + let segments = pathSegments(components.percentEncodedPath) + guard segments.count >= 2 else { + throw PlatformDeepLinkError.unsupportedURL + } + return .thread( + environmentID: try validatedIdentifier(segments[0]), + threadID: try validatedIdentifier(segments[1]) + ) + } + + private static func validatedInAppThreadRoute( + _ route: PlatformRoute, + knownEnvironmentIDs: Set + ) throws -> PlatformRoute { + switch route { + case let .thread(environmentID?, _): + guard knownEnvironmentIDs.contains(environmentID) else { + throw PlatformDeepLinkError.unsupportedURL + } + return route + case let .thread(nil, threadID): + guard knownEnvironmentIDs.count == 1, + let environmentID = knownEnvironmentIDs.first else { + throw PlatformDeepLinkError.unsupportedURL + } + return .thread(environmentID: environmentID, threadID: threadID) + default: + throw PlatformDeepLinkError.unsupportedURL + } + } + + private static func isLoopbackHost(_ host: String) -> Bool { + if host == "localhost" || host == "::1" || host == "[::1]" { + return true + } + let octets = host.split(separator: ".", omittingEmptySubsequences: false) + return octets.count == 4 + && octets.first == "127" + && octets.allSatisfy { UInt8($0) != nil } + } + private static func connectionRoute(_ url: URL) throws -> PlatformRoute { do { let details = try ConnectionDetailsParser.parse(url.absoluteString) diff --git a/apps/swift-ios/App/Platform/PlatformRootView.swift b/apps/swift-ios/App/Platform/PlatformRootView.swift index 23a3d6e22b8..95be9a7d8fd 100644 --- a/apps/swift-ios/App/Platform/PlatformRootView.swift +++ b/apps/swift-ios/App/Platform/PlatformRootView.swift @@ -29,6 +29,20 @@ struct PlatformRootView: View { .onOpenURL { url in handle(url: url, letOnboardingConfirmConnection: true) } + .environment(\.openURL, OpenURLAction { url in + do { + let route = try PlatformDeepLinkParser.parseInAppLink( + url, + knownEnvironmentIDs: Set(model.snapshot.environments.map(\.id)) + ) + handle(route) + return .handled + } catch { + return PlatformDeepLinkParser.shouldDiscardFailedInAppURL(url) + ? .discarded + : .systemAction(url) + } + }) .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in guard let url = activity.webpageURL else { return } handle(url: url, letOnboardingConfirmConnection: false) diff --git a/apps/swift-ios/Tests/PlatformTests/PlatformDeepLinkTests.swift b/apps/swift-ios/Tests/PlatformTests/PlatformDeepLinkTests.swift index 7aad550bf37..9818989c1f6 100644 --- a/apps/swift-ios/Tests/PlatformTests/PlatformDeepLinkTests.swift +++ b/apps/swift-ios/Tests/PlatformTests/PlatformDeepLinkTests.swift @@ -47,6 +47,105 @@ struct PlatformDeepLinkTests { #expect(route == .thread(environmentID: "environment-1", threadID: "thread-7")) } + @Test + func resolvesLoopbackWebThreadLinksTappedInApp() throws { + for value in [ + "http://127.0.0.1:3773/environment-1/thread-7", + "http://127.12.34.56:3773/environment-1/thread-7", + "http://localhost:3773/environment-1/thread-7", + "http://[::1]:3773/environment-1/thread-7", + ] { + #expect( + try PlatformDeepLinkParser.parseInAppLink( + try #require(URL(string: value)), + knownEnvironmentIDs: ["environment-1"] + ) + == .thread(environmentID: "environment-1", threadID: "thread-7") + ) + } + } + + @Test + func resolvesElectronThreadLinksTappedInApp() throws { + #expect( + try PlatformDeepLinkParser.parseInAppLink( + try #require(URL(string: "t3code://app/environment-1/thread-7")), + knownEnvironmentIDs: ["environment-1"] + ) == .thread(environmentID: "environment-1", threadID: "thread-7") + ) + #expect( + try PlatformDeepLinkParser.parseInAppLink( + try #require(URL(string: "t3code-dev://app/environment-1/thread-7")), + knownEnvironmentIDs: ["environment-1"] + ) == .thread(environmentID: "environment-1", threadID: "thread-7") + ) + } + + @Test + func resolvesEnvironmentlessNativeThreadLinkWhenOnlyOneEnvironmentIsKnown() throws { + #expect( + try PlatformDeepLinkParser.parseInAppLink( + "t3code-swiftui://threads?thread=thread-7", + knownEnvironmentIDs: ["environment-1"] + ) == .thread(environmentID: "environment-1", threadID: "thread-7") + ) + } + + @Test + func leavesOrdinaryWebLinksForTheSystem() { + for value in [ + "https://example.com/environment/thread", + "http://127.example.com/environment/thread", + ] { + #expect(throws: PlatformDeepLinkError.unsupportedURL) { + try PlatformDeepLinkParser.parseInAppLink( + value, + knownEnvironmentIDs: ["environment"] + ) + } + } + #expect(!PlatformDeepLinkParser.shouldDiscardFailedInAppURL( + URL(string: "https://t3.codes/privacy")! + )) + #expect(!PlatformDeepLinkParser.shouldDiscardFailedInAppURL( + URL(string: "https://www.t3.codes/docs")! + )) + #expect(!PlatformDeepLinkParser.shouldDiscardFailedInAppURL( + URL(string: "https://t3.codes/docs/getting-started")! + )) + #expect(!PlatformDeepLinkParser.shouldDiscardFailedInAppURL( + URL(string: "https://www.t3.codes/connect?endpoint=https://example.com")! + )) + #expect(PlatformDeepLinkParser.shouldDiscardFailedInAppURL( + URL(string: "https://app.t3.codes/environment/thread")! + )) + } + + @Test + func rejectsInAppLinksOutsideKnownThreadRoutes() { + for value in [ + "t3code://connect?url=ws%3A%2F%2Fhost.example&token=secret", + "http://localhost:3773/settings/general", + "t3code://app/unknown/thread-7", + ] { + #expect(throws: PlatformDeepLinkError.unsupportedURL) { + try PlatformDeepLinkParser.parseInAppLink( + value, + knownEnvironmentIDs: ["environment-1"] + ) + } + } + #expect(PlatformDeepLinkParser.shouldDiscardFailedInAppURL( + URL(string: "t3code-swiftui://connect?endpoint=https://attacker.example")! + )) + #expect(PlatformDeepLinkParser.shouldDiscardFailedInAppURL( + URL(string: "https://app.t3.codes/connect?endpoint=https://attacker.example")! + )) + #expect(!PlatformDeepLinkParser.shouldDiscardFailedInAppURL( + URL(string: "https://example.com/connect")! + )) + } + @Test func rejectsUntrustedWebNavigationRoute() { #expect(throws: PlatformDeepLinkError.unsupportedURL) {