Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 40 additions & 4 deletions firefox-ios/Client.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -2268,6 +2268,9 @@
EDF567A02C8B51DC00FDB09D /* SiteImageView in Frameworks */ = {isa = PBXBuildFile; productRef = EDF5679F2C8B51DC00FDB09D /* SiteImageView */; };
EDF567A22C8B51E100FDB09D /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = EDF567A12C8B51E100FDB09D /* Kingfisher */; };
EDFEE3F42DE670B8005ADE03 /* gleanProbes.xcfilelist in Resources */ = {isa = PBXBuildFile; fileRef = EDFEE3F32DE670B8005ADE03 /* gleanProbes.xcfilelist */; };
EEB965112FBCCDFC00D6C232 /* PageRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEB9650E2FBCCDFC00D6C232 /* PageRoute.swift */; };
EEB965122FBCCDFC00D6C232 /* ReaderModeSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEB9650F2FBCCDFC00D6C232 /* ReaderModeSchemeHandler.swift */; };
EEB965162FBCCE7B00D6C232 /* ReaderModeSchemeHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEB965132FBCCE7B00D6C232 /* ReaderModeSchemeHandlerTests.swift */; };
F00CA4012F00000000000003 /* WorldCupMatchesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4012F00000000000004 /* WorldCupMatchesResponse.swift */; };
F00CA4012F00000000000005 /* WorldCupLiveResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4012F00000000000006 /* WorldCupLiveResponse.swift */; };
F00CA4012F00000000000007 /* WorldCupAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4012F00000000000008 /* WorldCupAPIClient.swift */; };
Expand All @@ -2277,9 +2280,9 @@
F00CA4012F00000000000013 /* WorldCupTeamsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4012F00000000000014 /* WorldCupTeamsResponse.swift */; };
F00CA4012F00000000000015 /* WorldCupLoadError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4012F00000000000016 /* WorldCupLoadError.swift */; };
F00CA4012F00000000000017 /* WorldCupBaseHostOverrideSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4012F00000000000018 /* WorldCupBaseHostOverrideSetting.swift */; };
F00CA4012F00000000000020 /* WorldCupFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4012F00000000000021 /* WorldCupFeed.swift */; };
F00CA4012F0000000000001E /* WorldCupPollIntervalOverrideSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4012F0000000000001F /* WorldCupPollIntervalOverrideSetting.swift */; };
F00CA4012F0000000000001C /* WorldCupPollingFetchStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4012F0000000000001D /* WorldCupPollingFetchStrategy.swift */; };
F00CA4012F0000000000001E /* WorldCupPollIntervalOverrideSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4012F0000000000001F /* WorldCupPollIntervalOverrideSetting.swift */; };
F00CA4012F00000000000020 /* WorldCupFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4012F00000000000021 /* WorldCupFeed.swift */; };
F00CA4022F00000000000001 /* WorldCupMatchesResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4022F00000000000002 /* WorldCupMatchesResponseTests.swift */; };
F00CA4022F00000000000003 /* WorldCupLiveResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4022F00000000000004 /* WorldCupLiveResponseTests.swift */; };
F00CA4022F00000000000005 /* WorldCupFetchStrategyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4022F00000000000006 /* WorldCupFetchStrategyTests.swift */; };
Expand Down Expand Up @@ -11660,6 +11663,9 @@
EE994F2590D706FC0ADD4B42 /* ur */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ur; path = ur.lproj/AuthenticationManager.strings; sourceTree = "<group>"; };
EEA34C528147F2E7C3AB52C8 /* mr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = mr; path = mr.lproj/Localizable.strings; sourceTree = "<group>"; };
EEAB4DF28099E1BEA98A0B00 /* an */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = an; path = an.lproj/ClearPrivateDataConfirm.strings; sourceTree = "<group>"; };
EEB9650E2FBCCDFC00D6C232 /* PageRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageRoute.swift; sourceTree = "<group>"; };
EEB9650F2FBCCDFC00D6C232 /* ReaderModeSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderModeSchemeHandler.swift; sourceTree = "<group>"; };
EEB965132FBCCE7B00D6C232 /* ReaderModeSchemeHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderModeSchemeHandlerTests.swift; sourceTree = "<group>"; };
EEE8476797612D908D23E6BC /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = "<group>"; };
EEF342E5A045117853806115 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = "<group>"; };
EF0444EEBEA6A45A3E0F654C /* en-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-GB"; path = "en-GB.lproj/PrivateBrowsing.strings"; sourceTree = "<group>"; };
Expand All @@ -11680,9 +11686,9 @@
F00CA4012F00000000000014 /* WorldCupTeamsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorldCupTeamsResponse.swift; sourceTree = "<group>"; };
F00CA4012F00000000000016 /* WorldCupLoadError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorldCupLoadError.swift; sourceTree = "<group>"; };
F00CA4012F00000000000018 /* WorldCupBaseHostOverrideSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorldCupBaseHostOverrideSetting.swift; sourceTree = "<group>"; };
F00CA4012F00000000000021 /* WorldCupFeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorldCupFeed.swift; sourceTree = "<group>"; };
F00CA4012F0000000000001F /* WorldCupPollIntervalOverrideSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorldCupPollIntervalOverrideSetting.swift; sourceTree = "<group>"; };
F00CA4012F0000000000001D /* WorldCupPollingFetchStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorldCupPollingFetchStrategy.swift; sourceTree = "<group>"; };
F00CA4012F0000000000001F /* WorldCupPollIntervalOverrideSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorldCupPollIntervalOverrideSetting.swift; sourceTree = "<group>"; };
F00CA4012F00000000000021 /* WorldCupFeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorldCupFeed.swift; sourceTree = "<group>"; };
F00CA4022F00000000000002 /* WorldCupMatchesResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorldCupMatchesResponseTests.swift; sourceTree = "<group>"; };
F00CA4022F00000000000004 /* WorldCupLiveResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorldCupLiveResponseTests.swift; sourceTree = "<group>"; };
F00CA4022F00000000000006 /* WorldCupFetchStrategyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorldCupFetchStrategyTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -16855,6 +16861,31 @@
path = Sharing;
sourceTree = "<group>";
};
EEB965102FBCCDFC00D6C232 /* SchemeHandler */ = {
isa = PBXGroup;
children = (
EEB9650E2FBCCDFC00D6C232 /* PageRoute.swift */,
EEB9650F2FBCCDFC00D6C232 /* ReaderModeSchemeHandler.swift */,
);
path = SchemeHandler;
sourceTree = "<group>";
};
EEB965142FBCCE7B00D6C232 /* SchemeHandler */ = {
isa = PBXGroup;
children = (
EEB965132FBCCE7B00D6C232 /* ReaderModeSchemeHandlerTests.swift */,
);
path = SchemeHandler;
sourceTree = "<group>";
};
EEB965152FBCCE7B00D6C232 /* ReaderTests */ = {
isa = PBXGroup;
children = (
EEB965142FBCCE7B00D6C232 /* SchemeHandler */,
);
path = ReaderTests;
sourceTree = "<group>";
};
F8324A082649A188007E4BFA /* CredentialProvider */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -16972,6 +17003,7 @@
F84B21D61A090F8100AAB793 /* ClientTests */ = {
isa = PBXGroup;
children = (
EEB965152FBCCE7B00D6C232 /* ReaderTests */,
C24A0D2E2F3A7E7200BF08B7 /* KeyChainAppAttestKeyIDStoreTests.swift */,
2151EF582F2AB962007B67A6 /* BrowserViewController */,
218457C42F22A3FA00B4FF23 /* Downloads */,
Expand Down Expand Up @@ -17196,6 +17228,7 @@
F84B21F51A0910F600AAB793 /* Reader */ = {
isa = PBXGroup;
children = (
EEB965102FBCCDFC00D6C232 /* SchemeHandler */,
2178A69E2914546D002EC290 /* Resources */,
2178A69D291453CC002EC290 /* View */,
E4CD9E901A6897FB00318571 /* ReaderMode.swift */,
Expand Down Expand Up @@ -19204,6 +19237,8 @@
8A2DAD4B2CC02AA00067ECD0 /* LabelButtonHeaderView.swift in Sources */,
8A2DAD4D2CC02AA10067ECD0 /* LabelButtonHeaderCell.swift in Sources */,
8A9B7A8A2F1A120100ABCDEF /* NewsAffordanceHeaderView.swift in Sources */,
EEB965112FBCCDFC00D6C232 /* PageRoute.swift in Sources */,
EEB965122FBCCDFC00D6C232 /* ReaderModeSchemeHandler.swift in Sources */,
8A91D4112F7D6C7800A1B2C3 /* NewsTransitionHeaderCell.swift in Sources */,
8A91D4132F7D6C7900A1B2C3 /* StoryCategoryPickerView.swift in Sources */,
8AF347DE2CADD1B200624036 /* HomepageState.swift in Sources */,
Expand Down Expand Up @@ -20263,6 +20298,7 @@
C28EA9822FA2554900AEC3AE /* WorldCupTelemetryTests.swift in Sources */,
FF0003AB2F000002000BBBBB /* WorldCupMiddlewareTests.swift in Sources */,
FF0003AD2F000004000BBBBB /* WorldCupCellFactoryTests.swift in Sources */,
EEB965162FBCCE7B00D6C232 /* ReaderModeSchemeHandlerTests.swift in Sources */,
FF0004AB2F000002000BBBBB /* WorldCupSectionStateTests.swift in Sources */,
FF0005AB2F000002000BBBBB /* MockWorldCupStore.swift in Sources */,
EDC3D3552CB70A3F00C62DE3 /* OpenSearchEngineTests.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/

import Common
import Foundation
import Shared
import WebEngine

/// Serves the readermode page document at `readermode://app/page?url=<encoded-article-url>`.
///
/// This initial version validates the incoming URL parameters. Content rendering
/// (cache integration, readability extraction, error pages) will be added in
/// FXIOS-15783.
Comment on lines +12 to +14
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for splitting up the work! 👌

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

final class PageRoute: TinyRoute {
private let profile: Profile

init(profile: Profile) {
self.profile = profile
}

// Always erros out for now, will actually handle in next PR
func handle(url: URL, components: URLComponents) async throws -> TinyHTTPReply? {
_ = try extractArticleURL(from: components)
throw TinyRouterError.badResponse
}

// MARK: - URL parsing

private func extractArticleURL(from components: URLComponents) throws -> URL {
guard let raw = components.queryItems?.first(where: { $0.name == "url" })?.value else {
throw TinyRouterError.missingParam("url")
}

guard let parsed = URL(string: raw), parsed.isWebPage(includeDataURIs: false) else {
throw TinyRouterError.invalidParam("url", raw)
}

return parsed
Comment thread
Alex-Bangu marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/

import Common
import Foundation
import Shared
import WebEngine
import WebKit

/// `ReaderModeSchemeHandler` defines a custom URL scheme handler for a `WKWebView` that
/// serves reader-mode pages and their assets. It replaces the legacy GCDWebServer-based
/// reader-mode routes.
///
/// Request flow
/// ------------
/// 1. The browser navigates a tab to `readermode://app/...`.
///
/// 2. WebKit detects the `readermode://` scheme and creates a `WKURLSchemeTask`.
///
/// 3. WebKit calls `ReaderModeSchemeHandler.webView(_:start:)` passing in the task.
///
/// 4. The scheme handler:
/// - reads the URL from `urlSchemeTask.request`
/// - validates that the URL uses the correct scheme and host (`readermode` and `app`)
/// - forwards the URL to `router.route(_:)` (TinyRouter)
///
/// 5. TinyRouter chooses a route handler based on the path. As the migration progresses
/// the registered routes will be:
/// - `/app/page` -> `PageRoute`
///
/// 6. `send(_:for:to:)` converts the `TinyHTTPReply` into an `HTTPURLResponse` and body
/// and completes the `WKURLSchemeTask`.
///
/// 7. WebKit renders the response in the tab.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice documentation 👌

final class ReaderModeSchemeHandler: NSObject, WKURLSchemeHandler {
// These are plain string constants and need to be readable from non-MainActor contexts
// (e.g. `PageRoute.buildReply`, which constructs the CSP off the main actor). The class
// itself is @MainActor by virtue of conforming to `WKURLSchemeHandler`, which would
// otherwise propagate isolation to these statics.
Comment on lines +37 to +40
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


/// The custom scheme this handler is responsible for.
nonisolated static let scheme = "readermode"

/// The host this handler expects for all reader-mode requests.
nonisolated static let host = "app"

/// Canonical base URL for the reader page. Callers that need to construct a reader-mode
/// URL (e.g. `URL.encodeReaderModeURL(_:)`) pass this in place of the legacy
/// `WebServer.sharedInstance.baseReaderModeURL()`.
nonisolated static let baseURL = "readermode://app/page"

private let router: TinyRouter
private let logger: Logger
private var requestTasks = [ObjectIdentifier: Task<Void, Never>]()

init(profile: Profile,
logger: Logger = DefaultLogger.shared) {
// `StaticFileRoute` is the same one Translations uses — given the path it strips
// the leading slash, separates the resource name + extension, and looks the file up
// via `Bundle.main.url(forResource:withExtension:)`. That covers the legacy paths
// `Reader.html` already references (`/reader-mode/styles/Reader.css` and
// `/reader-mode/fonts/*.otf`) without any template edits.
self.router = TinyRouter()
.register("page", PageRoute(profile: profile))
.setDefault(StaticFileRoute())
Comment on lines +64 to +66
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From a security perspective, I don't think we should use StaticFileRoute() as is. For translations, the engine is not part of the content process. It's in the background and we have total control over the assets we inject. That's not the case here for reader mode. I would create a router that only allows loading explicit files from reader-mode/ like styles/Reader.css and so on othewise it fails even if the files exist in that path.

self.logger = logger
super.init()
}

/// Validates incoming requests and forwards them to the router.
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
let id = ObjectIdentifier(urlSchemeTask)
let requestTask = Task { // Closure gets implicit @MainActor since WKURLSchemeTask is annotated as such (cool!)
defer { requestTasks[id] = nil }
do {
let url = try validateRequest(urlSchemeTask)
try Task.checkCancellation()

let reply = try await router.route(url)
try Task.checkCancellation()

try send(reply, for: url, to: urlSchemeTask)
Comment on lines +77 to +83
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit; extra spacing for readability

Suggested change
let url = try validateRequest(urlSchemeTask)
try Task.checkCancellation()
let reply = try await router.route(url)
try Task.checkCancellation()
try send(reply, for: url, to: urlSchemeTask)
let url = try validateRequest(urlSchemeTask)
try Task.checkCancellation()
let reply = try await router.route(url)
try Task.checkCancellation()
try send(reply, for: url, to: urlSchemeTask)

} catch is CancellationError {
self.logger.log("Reader-mode scheme task cancelled.",
level: .debug,
category: .library)
} catch {
urlSchemeTask.didFailWithError(mapError(error))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also log here in this case?

self.logger.log("Reader-mode scheme task failed.",
level: .debug,
category: .library)
}
}
requestTasks[id] = requestTask
}

func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
let id = ObjectIdentifier(urlSchemeTask)
requestTasks[id]?.cancel()
requestTasks[id] = nil
}

/// Bridges a `TinyHTTPReply` into the `WKURLSchemeTask` callbacks.
private func send(_ reply: TinyHTTPReply, for url: URL, to task: WKURLSchemeTask) throws {
guard let httpResponse = reply.httpResponse else {
throw TinyRouterError.badResponse
}
task.didReceive(httpResponse)
task.didReceive(reply.body)
task.didFinish()
}

/// Normalizes any thrown `Error` into a `TinyRouterError`.
private func mapError(_ error: Error) -> TinyRouterError {
if let tinyError = error as? TinyRouterError {
return tinyError
}
return .unknown(String(describing: error))
}
Comment on lines +114 to +120
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if something like this would be better written as a (perhaps static?) method on the TinyRouterError itself.

@issammani, looks like you wrote that file initially, so what do you think?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that would neatly work in the router package itself as a helper.


/// Validates an incoming request and returns a well-formed URL,
/// or throws a typed error if the request is not acceptable.
private func validateRequest(_ task: WKURLSchemeTask) throws -> URL {
guard let url = task.request.url else { throw TinyRouterError.badURL }

guard url.scheme == Self.scheme else {
throw TinyRouterError.unsupportedScheme(expected: Self.scheme, found: url.scheme)
}

guard url.host == Self.host else {
throw TinyRouterError.unsupportedHost(expected: Self.host, found: url.host)
}

return url
}
}
Loading
Loading