diff --git a/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj b/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj index 94dea1d5c79..2365c073a56 100644 --- a/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj +++ b/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj @@ -145,6 +145,7 @@ CE8B0BBD248734240045EB2E /* KeymanPackageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8B0BBC248734240045EB2E /* KeymanPackageTests.swift */; }; CE8B0BBF248764ED0045EB2E /* KMPResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8B0BBE248764ED0045EB2E /* KMPResource.swift */; }; CE8B5BB22491DA540075CCB0 /* 13.0 Cloud to Package Migration.bundle in Resources */ = {isa = PBXBuildFile; fileRef = CE8B5BB12491DA530075CCB0 /* 13.0 Cloud to Package Migration.bundle */; }; + CE8E6B1E2FEC17B100F5E731 /* WebViewSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8E6B1D2FEC17A900F5E731 /* WebViewSchemeHandler.swift */; }; CE8EDEB123F53D1A009E1FF6 /* FileManagementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A079DD1223194B100581263 /* FileManagementTests.swift */; }; CE8EDEB323F53F96009E1FF6 /* VersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8EDEB223F53F96009E1FF6 /* VersionTests.swift */; }; CE969BE8251AD8B500376D6A /* PackageWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE969BE7251AD8B500376D6A /* PackageWebViewController.swift */; }; @@ -459,6 +460,7 @@ CE8B0BBC248734240045EB2E /* KeymanPackageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeymanPackageTests.swift; sourceTree = ""; }; CE8B0BBE248764ED0045EB2E /* KMPResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KMPResource.swift; sourceTree = ""; }; CE8B5BB12491DA530075CCB0 /* 13.0 Cloud to Package Migration.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = "13.0 Cloud to Package Migration.bundle"; sourceTree = ""; }; + CE8E6B1D2FEC17A900F5E731 /* WebViewSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewSchemeHandler.swift; sourceTree = ""; }; CE8EDEB223F53F96009E1FF6 /* VersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionTests.swift; sourceTree = ""; }; CE969BE7251AD8B500376D6A /* PackageWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageWebViewController.swift; sourceTree = ""; }; CE96E42C24D1229A005B8E5A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = ""; }; @@ -804,6 +806,7 @@ CE79B24823C711FF007E72AE /* KeyboardScaleMap.swift */, C0EF3E7A1F95B65300CE9BD4 /* KeymanWebDelegate.swift */, C0C16A881FA8146300F090BA /* KeymanWebViewController.swift */, + CE8E6B1D2FEC17A900F5E731 /* WebViewSchemeHandler.swift */, C0A5FF361F6682EB00BE740C /* PopoverView.swift */, ); path = Keyboard; @@ -1522,6 +1525,7 @@ 9A079DCA222E050E00581263 /* LexicalModelKeymanPackage.swift in Sources */, 9A60764422893A4E003BCFBA /* SettingsViewController.swift in Sources */, C06085B41F9485E40057E5B9 /* UIButton+Helpers.swift in Sources */, + CE8E6B1E2FEC17B100F5E731 /* WebViewSchemeHandler.swift in Sources */, C0959CD41F99C44E00B616BC /* Constants.swift in Sources */, C0452BAD1F9F21270064431A /* Keyboard.swift in Sources */, 29B30C232B564F9900C342A4 /* KeymanEngineLogger.swift in Sources */, diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift index 10b530d6f84..02560e69290 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift @@ -38,7 +38,8 @@ class KeymanWebViewController: UIViewController { let storage: Storage weak var delegate: KeymanWebDelegate? private var useSpecialFont = false - private var userContentController = WKUserContentController() + private let userContentController = WKUserContentController() + private let schemeHandler: WebViewSchemeHandler private let keymanWebViewName: String = "keyman" // Views @@ -69,8 +70,9 @@ class KeymanWebViewController: UIViewController { init(storage: Storage) { self.storage = storage + self.schemeHandler = WebViewSchemeHandler(storage: storage) + super.init(nibName: nil, bundle: nil) - _ = view } @@ -122,6 +124,7 @@ class KeymanWebViewController: UIViewController { config.preferences = prefs config.suppressesIncrementalRendering = false config.userContentController = self.userContentController + config.setURLSchemeHandler(schemeHandler, forURLScheme: schemeHandler.scheme) webView = KeymanWebView(frame: CGRect(origin: .zero, size: keyboardSize), configuration: config) webView!.isOpaque = false @@ -279,20 +282,28 @@ extension KeymanWebViewController { // family does not have to match the name in the font file. It only has to be unique. return [ "family": "\(keyboard.id)__\(isOsk ? "osk" : "display")", - "files": font.source.map { storage.fontURL(forResource: keyboard, filename: $0)!.absoluteString } + "files": font.source.map { + schemeHandler.buildUrlForFile( + fileURL: storage.fontURL(forResource: keyboard, filename: $0)! + ).absoluteString + } ] } func setKeyboard(_ keyboard: InstallableKeyboard) throws { let fileURL = storage.keyboardURL(for: keyboard) + let loadingURL = schemeHandler.buildUrlForFile( + fileURL: storage.keyboardURL(for: keyboard) + ) + var stub: [String: Any] = [ "KI": "Keyboard_\(keyboard.id)", "KN": keyboard.name, "KLC": keyboard.languageID, "KL": keyboard.languageName, - "KF": fileURL.absoluteString + "KF": loadingURL.absoluteString ] - + if let packageID = keyboard.packageID { stub["KP"] = packageID } @@ -367,7 +378,7 @@ extension KeymanWebViewController { let stub: [String: Any] = [ "id": lexicalModel.id, "languages": [lexicalModel.languageID], // Change when InstallableLexicalModel is updated to store an array - "path": fileURL.absoluteString + "path": schemeHandler.buildUrlForFile(fileURL: fileURL).absoluteString ] guard FileManager.default.fileExists(atPath: fileURL.path) else { @@ -861,7 +872,9 @@ extension KeymanWebViewController { // MARK: - Show/hide views func reloadKeyboard() { - webView!.loadFileURL(Storage.active.kmwURL, allowingReadAccessTo: Storage.active.baseDir) + let hostPageFileUrl = URL(fileURLWithPath: Resources.kmwFilename, relativeTo: storage.baseDir) + let hostPageUrl = schemeHandler.buildUrlForFile(fileURL: hostPageFileUrl) + webView!.load(URLRequest(url: hostPageUrl)) isLoading = true updateSpacebarText() diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift new file mode 100644 index 00000000000..28c41adaeb9 --- /dev/null +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift @@ -0,0 +1,95 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Joshua Horton on 6/24/26. + * + * WebViewKeyboardLoader implements a URLSchemeHandler that allows + * the hosted Keyman Engine for Web to access all files, consistently, + * via a http-like protocol, preventing CORS access issues for files + * loaded dynamically. + */ + +import WebKit +import UniformTypeIdentifiers +import os.log + +func getMimeType(forExtension ext: String) -> String { + // Find the UTType associated with the file extension + if let utType = UTType(filenameExtension: ext) { + // Return the preferred MIME type if it exists + return utType.preferredMIMEType ?? "application/octet-stream" + } + return "application/octet-stream" +} + +class WebViewSchemeHandler: NSObject, WKURLSchemeHandler { + let storage: Storage + let scheme = "keyman-engine" + + init(storage: Storage) { + self.storage = storage + } + + func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) { + guard let url = urlSchemeTask.request.url else { + return + } + + var components = URLComponents(url: url, resolvingAgainstBaseURL: false)! + components.scheme = "file" + + let fileUrl = components.url + + let doError = { () -> Void in + let message = "Could not load url via WKURLSchemeHandler: \(url)" + let errorInfo = [ + NSLocalizedDescriptionKey: message + ] + let error = NSError(domain: "WebViewKeyboardLoader", code: 500, userInfo: errorInfo) + + os_log("%{public}s", log:KeymanEngineLogger.settings, type: .error, message) + SentryManager.capture(error, message: message) + + urlSchemeTask.didFailWithError(error) + } + + guard fileUrl != nil else { + doError() + return + } + + do { + let fileContents = try Data(contentsOf: fileUrl!) + let fileExtension = fileUrl!.pathExtension + + let mimeType: String = getMimeType(forExtension: fileExtension) + let charset: String = mimeType.hasPrefix("text/") ? "; charset=utf-8" : "" + + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: [ + "Content-Type": "\(mimeType)\(charset)", + ] + )! + + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(fileContents) + urlSchemeTask.didFinish() + } catch { + doError() + return + } + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) { + } + + func buildUrlForFile(fileURL: URL) -> URL { + var loadingURLBuilder = URLComponents() + loadingURLBuilder.scheme = scheme + loadingURLBuilder.path = fileURL.path + return loadingURLBuilder.url! + } +} diff --git a/web/src/engine/src/interfaces/pathConfiguration.ts b/web/src/engine/src/interfaces/pathConfiguration.ts index 090dc7d4c40..4ab8432bcd0 100644 --- a/web/src/engine/src/interfaces/pathConfiguration.ts +++ b/web/src/engine/src/interfaces/pathConfiguration.ts @@ -21,24 +21,12 @@ export class PathConfiguration implements OSKResourcePathConfiguration { private _fonts: string; readonly protocol: string; - /* - * Pre-modularization code corresponding to `sourcePath`: - ``` - // Determine path and protocol of executing script, setting them as - // construction defaults. - // - // This can only be done during load when the active script will be the - // last script loaded. Otherwise the script must be identified by name. - - var scripts = document.getElementsByTagName('script'); - var ss = scripts[scripts.length-1].src; - var sPath = ss.substr(0,ss.lastIndexOf('/')+1); - ``` - */ constructor(pathSpec: Required, sourcePath: string) { + const sourceURL = new URL(sourcePath); + sourcePath = addDelimiter(sourcePath); this.sourcePath = sourcePath; - this.protocol = sourcePath.replace(/(.{3,5}:)(.*)/,'$1'); + this.protocol = sourceURL.protocol; this.updateFromOptions(pathSpec); } @@ -75,8 +63,16 @@ export class PathConfiguration implements OSKResourcePathConfiguration { p = addDelimiter(p); - // Absolute - if((p.replace(/^(http)s?:.*/,'$1') == 'http') || (p.replace(/^(file):.*/,'$1') == 'file')) { + // Absolute - with protocol specified + const protocolList = [ + 'http:', + 'https:', + 'file:', + // If using a custom origin (say, hosted in an iOS WebView via WKURLSchemeHandler) + this.protocol + ]; + + if(protocolList.find((protocol) => p.startsWith(protocol))) { return p; } diff --git a/web/src/engine/src/osk/views/oskView.ts b/web/src/engine/src/osk/views/oskView.ts index 962d943557b..ab8ab9e7b3a 100644 --- a/web/src/engine/src/osk/views/oskView.ts +++ b/web/src/engine/src/osk/views/oskView.ts @@ -130,7 +130,7 @@ export function getResourcePath(config: ViewConfiguration) { if(config.isEmbedded) { resourcePathExt = ''; } - return `${config.pathConfig.resources}/${resourcePathExt}` + return `${config.pathConfig.resources}${resourcePathExt}` } export abstract class OSKView @@ -267,6 +267,7 @@ export abstract class OSKView for(const sheetFile of OSKView.STYLESHEET_FILES) { const sheetHref = `${resourcePath}${sheetFile}`; + this.uiStyleSheetManager.linkExternalSheet(sheetHref); } @@ -856,7 +857,7 @@ export abstract class OSKView isEmbedded: this.config.isEmbedded, specialFont: { family: 'SpecialOSK', - files: [`${resourcePath}/keymanweb-osk.ttf`], + files: [`${resourcePath}keymanweb-osk.ttf`], path: '' // Not actually used. }, gestureParams: this.config.gestureParams diff --git a/web/src/engine/src/osk/visualKeyboard.ts b/web/src/engine/src/osk/visualKeyboard.ts index 3faa214a4b7..f5e2c9b59f3 100644 --- a/web/src/engine/src/osk/visualKeyboard.ts +++ b/web/src/engine/src/osk/visualKeyboard.ts @@ -1398,7 +1398,7 @@ export class VisualKeyboard extends EventEmitter implements KeyboardVi styleSheetManager: null, specialFont: { family: 'SpecialOSK', - files: [`${pathConfig.resources}/osk/keymanweb-osk.ttf`], + files: [`${pathConfig.resources}keymanweb-osk.ttf`], path: '' // Not actually used. } });