Dactyloidae/mobile/ios/Client/Frontend/Reader/ReaderMode.swift
2026-06-26 21:04:09 -07:00

296 lines
9.8 KiB
Swift

/* 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 Foundation
import Shared
import WebKit
import SwiftyJSON
let ReaderModeProfileKeyStyle = "readermode.style"
enum ReaderModeMessageType: String {
case stateChange = "ReaderModeStateChange"
case pageEvent = "ReaderPageEvent"
}
enum ReaderPageEvent: String {
case pageShow = "PageShow"
}
enum ReaderModeState: String {
case available = "Available"
case unavailable = "Unavailable"
case active = "Active"
}
enum ReaderModeTheme: String {
case light = "light"
case dark = "dark"
case sepia = "sepia"
}
enum ReaderModeFontType: String {
case serif = "serif"
case sansSerif = "sans-serif"
}
enum ReaderModeFontSize: Int {
case size1 = 1
case size2 = 2
case size3 = 3
case size4 = 4
case size5 = 5
case size6 = 6
case size7 = 7
case size8 = 8
case size9 = 9
case size10 = 10
case size11 = 11
case size12 = 12
case size13 = 13
func isSmallest() -> Bool {
return self == ReaderModeFontSize.size1
}
func smaller() -> ReaderModeFontSize {
if isSmallest() {
return self
} else {
return ReaderModeFontSize(rawValue: self.rawValue - 1)!
}
}
func isLargest() -> Bool {
return self == ReaderModeFontSize.size13
}
static var defaultSize: ReaderModeFontSize {
switch UIApplication.shared.preferredContentSizeCategory {
case UIContentSizeCategory.extraSmall:
return .size1
case UIContentSizeCategory.small:
return .size2
case UIContentSizeCategory.medium:
return .size3
case UIContentSizeCategory.large:
return .size5
case UIContentSizeCategory.extraLarge:
return .size7
case UIContentSizeCategory.extraExtraLarge:
return .size9
case UIContentSizeCategory.extraExtraExtraLarge:
return .size12
default:
return .size5
}
}
func bigger() -> ReaderModeFontSize {
if isLargest() {
return self
} else {
return ReaderModeFontSize(rawValue: self.rawValue + 1)!
}
}
}
struct ReaderModeStyle {
var theme: ReaderModeTheme
var fontType: ReaderModeFontType
var fontSize: ReaderModeFontSize
/// Encode the style to a JSON dictionary that can be passed to ReaderMode.js
func encode() -> String {
return JSON(["theme": theme.rawValue, "fontType": fontType.rawValue, "fontSize": fontSize.rawValue]).stringValue() ?? ""
}
/// Encode the style to a dictionary that can be stored in the profile
func encodeAsDictionary() -> [String: Any] {
return ["theme": theme.rawValue, "fontType": fontType.rawValue, "fontSize": fontSize.rawValue]
}
init(theme: ReaderModeTheme, fontType: ReaderModeFontType, fontSize: ReaderModeFontSize) {
self.theme = theme
self.fontType = fontType
self.fontSize = fontSize
}
/// Initialize the style from a dictionary, taken from the profile. Returns nil if the object cannot be decoded.
init?(dict: [String: Any]) {
let themeRawValue = dict["theme"] as? String
let fontTypeRawValue = dict["fontType"] as? String
let fontSizeRawValue = dict["fontSize"] as? Int
if themeRawValue == nil || fontTypeRawValue == nil || fontSizeRawValue == nil {
return nil
}
let theme = ReaderModeTheme(rawValue: themeRawValue!)
let fontType = ReaderModeFontType(rawValue: fontTypeRawValue!)
let fontSize = ReaderModeFontSize(rawValue: fontSizeRawValue!)
if theme == nil || fontType == nil || fontSize == nil {
return nil
}
self.theme = theme!
self.fontType = fontType!
self.fontSize = fontSize!
}
}
let DefaultReaderModeStyle = ReaderModeStyle(theme: .light, fontType: .sansSerif, fontSize: ReaderModeFontSize.defaultSize)
/// This struct captures the response from the Readability.js code.
struct ReadabilityResult {
var domain = ""
var url = ""
var content = ""
var title = ""
var credits = ""
init?(object: AnyObject?) {
if let dict = object as? NSDictionary {
if let uri = dict["uri"] as? NSDictionary {
if let url = uri["spec"] as? String {
self.url = url
}
if let host = uri["host"] as? String {
self.domain = host
}
}
if let content = dict["content"] as? String {
self.content = content
}
if let title = dict["title"] as? String {
self.title = title
}
if let credits = dict["byline"] as? String {
self.credits = credits
}
} else {
return nil
}
}
/// Initialize from a JSON encoded string
init?(string: String) {
let object = JSON(parseJSON: string)
let domain = object["domain"].string
let url = object["url"].string
let content = object["content"].string
let title = object["title"].string
let credits = object["credits"].string
if domain == nil || url == nil || content == nil || title == nil || credits == nil {
return nil
}
self.domain = domain!
self.url = url!
self.content = content!
self.title = title!
self.credits = credits!
}
/// Encode to a dictionary, which can then for example be json encoded
func encode() -> [String: Any] {
return ["domain": domain, "url": url, "content": content, "title": title, "credits": credits]
}
/// Encode to a JSON encoded string
func encode() -> String {
let dict: [String: Any] = self.encode()
return JSON(object: dict).stringValue()!
}
}
/// Delegate that contains callbacks that we have added on top of the built-in WKWebViewDelegate
protocol ReaderModeDelegate {
func readerMode(_ readerMode: ReaderMode, didChangeReaderModeState state: ReaderModeState, forTab tab: Tab)
func readerMode(_ readerMode: ReaderMode, didDisplayReaderizedContentForTab tab: Tab)
}
let ReaderModeNamespace = "window.__firefox__.reader"
class ReaderMode: TabContentScript {
var delegate: ReaderModeDelegate?
fileprivate weak var tab: Tab?
var state: ReaderModeState = ReaderModeState.unavailable
fileprivate var originalURL: URL?
class func name() -> String {
return "ReaderMode"
}
required init(tab: Tab) {
self.tab = tab
// This is a WKUserScript at the moment because webView.evaluateJavaScript() fails with an unspecified error. Possibly script size related.
if let path = Bundle.main.path(forResource: "Readability", ofType: "js") {
if let source = try? NSString(contentsOfFile: path, encoding: String.Encoding.utf8.rawValue) as String {
let userScript = WKUserScript(source: source, injectionTime: WKUserScriptInjectionTime.atDocumentEnd, forMainFrameOnly: true)
tab.webView!.configuration.userContentController.addUserScript(userScript)
}
}
// This is executed after a page has been loaded. It executes Readability and then fires a script message to let us know if the page is compatible with reader mode.
if let path = Bundle.main.path(forResource: "ReaderMode", ofType: "js") {
if let source = try? NSString(contentsOfFile: path, encoding: String.Encoding.utf8.rawValue) as String {
let userScript = WKUserScript(source: source, injectionTime: WKUserScriptInjectionTime.atDocumentEnd, forMainFrameOnly: true)
tab.webView!.configuration.userContentController.addUserScript(userScript)
}
}
}
func scriptMessageHandlerName() -> String? {
return "readerModeMessageHandler"
}
fileprivate func handleReaderPageEvent(_ readerPageEvent: ReaderPageEvent) {
switch readerPageEvent {
case .pageShow:
if let tab = tab {
delegate?.readerMode(self, didDisplayReaderizedContentForTab: tab)
}
}
}
fileprivate func handleReaderModeStateChange(_ state: ReaderModeState) {
self.state = state
guard let tab = tab else {
return
}
delegate?.readerMode(self, didChangeReaderModeState: state, forTab: tab)
}
func userContentController(_ userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage) {
if let msg = message.body as? Dictionary<String, String> {
if let messageType = ReaderModeMessageType(rawValue: msg["Type"] ?? "") {
switch messageType {
case .pageEvent:
if let readerPageEvent = ReaderPageEvent(rawValue: msg["Value"] ?? "Invalid") {
handleReaderPageEvent(readerPageEvent)
}
break
case .stateChange:
if let readerModeState = ReaderModeState(rawValue: msg["Value"] ?? "Invalid") {
handleReaderModeStateChange(readerModeState)
}
break
}
}
}
}
var style: ReaderModeStyle = DefaultReaderModeStyle {
didSet {
if state == ReaderModeState.active {
tab?.webView?.evaluateJavaScript("\(ReaderModeNamespace).setStyle(\(style.encode()))", completionHandler: { (object, error) -> Void in
return
})
}
}
}
}