Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
47E177D62CD0C1DC0033C825 /* CacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47E177D52CD0C1DC0033C825 /* CacheManager.swift */; };
47F3CABE2C63A0E8007CF14B /* ConfigConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47F3CABD2C63A05C007CF14B /* ConfigConstants.swift */; };
E92114092D2FB0EE003BC3EB /* ResourceURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E92114082D2FB0EE003BC3EB /* ResourceURLProtocol.swift */; };
E921140B2D49EECB003BC3EB /* WebResourceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E921140A2D49EECB003BC3EB /* WebResourceManager.swift */; };
E921140D2D49F441003BC3EB /* ThreadHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E921140C2D49F441003BC3EB /* ThreadHelper.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -52,6 +54,8 @@
47F3CABC2C639BF5007CF14B /* build.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = build.swift; sourceTree = "<group>"; };
47F3CABD2C63A05C007CF14B /* ConfigConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigConstants.swift; sourceTree = "<group>"; };
E92114082D2FB0EE003BC3EB /* ResourceURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceURLProtocol.swift; sourceTree = "<group>"; };
E921140A2D49EECB003BC3EB /* WebResourceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebResourceManager.swift; sourceTree = "<group>"; };
E921140C2D49F441003BC3EB /* ThreadHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadHelper.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -101,6 +105,8 @@
4777D51D2C60B87500E85AC6 /* iosnativeWebView */ = {
isa = PBXGroup;
children = (
E921140C2D49F441003BC3EB /* ThreadHelper.swift */,
E921140A2D49EECB003BC3EB /* WebResourceManager.swift */,
E92114082D2FB0EE003BC3EB /* ResourceURLProtocol.swift */,
47A91A462CD0D39100419554 /* WebViewNavigationDelegate.swift */,
47A91A442CD0D38400419554 /* WebViewModel.swift */,
Expand Down Expand Up @@ -260,9 +266,11 @@
47A91A452CD0D38400419554 /* WebViewModel.swift in Sources */,
47E177D62CD0C1DC0033C825 /* CacheManager.swift in Sources */,
4777D5212C60B87500E85AC6 /* ContentView.swift in Sources */,
E921140D2D49F441003BC3EB /* ThreadHelper.swift in Sources */,
47E177D42CD0BF950033C825 /* WebView.swift in Sources */,
47A91A472CD0D39100419554 /* WebViewNavigationDelegate.swift in Sources */,
4777D51F2C60B87500E85AC6 /* iosnativeWebViewApp.swift in Sources */,
E921140B2D49EECB003BC3EB /* WebResourceManager.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
16 changes: 11 additions & 5 deletions src/native/iosnativeWebView/iosnativeWebView/CacheManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ class CacheManager {
return regex.firstMatch(in: urlString, options: [], range: range) != nil
}
}

logger.info("📋 [\(ThreadHelper.currentThreadInfo())] Cache decision for \(url.absoluteString): \(shouldCache)")
return shouldCache
}

private func getCacheState(for timestamp: Date) -> CacheState {
Expand Down Expand Up @@ -171,18 +174,18 @@ class CacheManager {
}

private func revalidateResource(request: URLRequest) async {
logger.info("🔄 Starting revalidation for: \(request.url?.absoluteString ?? "")")
logger.info("🔄 [\(ThreadHelper.currentThreadInfo())] Revalidating: \(request.url?.absoluteString ?? "")")

do {
let (data, response) = try await session.data(for: request)

if let httpResponse = response as? HTTPURLResponse,
isCacheableResponse(httpResponse) {
storeCachedResponse(httpResponse, data: data, for: request)
logger.info("Resource revalidated: \(request.url?.absoluteString ?? "")")
await storeCachedResponse(httpResponse, data: data, for: request)
logger.info("✅ [\(ThreadHelper.currentThreadInfo())] Resource revalidated successfully")
}
} catch {
logger.error("Revalidation failed: \(error.localizedDescription)")
logger.error("❌ [\(ThreadHelper.currentThreadInfo())] Revalidation failed: \(error.localizedDescription)")
}
}

Expand Down Expand Up @@ -235,14 +238,17 @@ class CacheManager {
}

func createCacheableRequest(from url: URL) -> URLRequest {
logger.info("📝 [\(ThreadHelper.currentThreadInfo())] Creating cacheable request for: \(url.absoluteString)")
var request = URLRequest(url: url)
request.cachePolicy = .returnCacheDataElseLoad
return request
}

func isCacheableResponse(_ response: HTTPURLResponse) -> Bool {
guard let url = response.url else { return false }
return (200...299 ~= response.statusCode) && shouldCacheURL(url)
let isCacheable = (200...299 ~= response.statusCode) && shouldCacheURL(url)
logger.info("🔍 [\(ThreadHelper.currentThreadInfo())] Response cacheable check: \(isCacheable) for \(url.absoluteString)")
return isCacheable
}

func getCacheStatistics() -> (memoryUsed: Int, diskUsed: Int) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ struct ContentView: View {
.background(Color.black.opacity(0.1))
}
}
.alert("Error", isPresented: .constant(webViewModel.error != nil)) {
Button("OK") {
webViewModel.setError(nil)
}
} message: {
Text(webViewModel.error?.localizedDescription ?? "")
}
.onAppear {
logger.info("ContentView appeared")
}
Expand Down
13 changes: 13 additions & 0 deletions src/native/iosnativeWebView/iosnativeWebView/ThreadHelper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@


import Foundation

enum ThreadHelper {
static func currentThreadInfo() -> String {
if Thread.isMainThread {
return "🏠 Main Thread"
} else {
return "🧵 Background Thread: \(Thread.current.description)"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Foundation
import os

private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: "WebResourceManager")

actor WebResourceManager {
static let shared = WebResourceManager()
private var activeRequests: [URL: Task<(Data, String?), Error>] = [:]
private let cacheManager = CacheManager.shared

func loadResource(url: URL, cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy) async throws -> (Data, String?) {
logger.info("📥 [\(ThreadHelper.currentThreadInfo())] Starting resource load for: \(url.absoluteString)")

// Cancel existing request for the same URL if any
activeRequests[url]?.cancel()

let task = Task {
logger.info("🔄 [\(ThreadHelper.currentThreadInfo())] Processing resource request for: \(url.absoluteString)")

// Try cache first
let request = URLRequest(url: url, cachePolicy: cachePolicy)
let (cachedData, cacheState, mimeType) = await cacheManager.getCachedResource(for: request)

if let data = cachedData, cacheState != .expired {
logger.info("💾 [\(ThreadHelper.currentThreadInfo())] Using cached resource: \(url.absoluteString)")
return (data, mimeType)
}

// Fallback to network request
logger.info("🌐 [\(ThreadHelper.currentThreadInfo())] Fetching resource: \(url.absoluteString)")
let (data, response) = try await URLSession.shared.data(for: request)
let httpResponse = response as? HTTPURLResponse
let responseMimeType = httpResponse?.mimeType

// Cache the response in background
if let httpResponse = httpResponse,
await cacheManager.isCacheableResponse(httpResponse) {
logger.info("💾 [\(ThreadHelper.currentThreadInfo())] Caching new resource")
await cacheManager.storeCachedResponse(httpResponse, data: data, for: request)
}

return (data, responseMimeType)
}

activeRequests[url] = task
defer {
activeRequests[url] = nil
logger.info("🏁 [\(ThreadHelper.currentThreadInfo())] Completed resource load for: \(url.absoluteString)")
}

return try await task.value
}

func cancelAllRequests() {
logger.info("🚫 [\(ThreadHelper.currentThreadInfo())] Cancelling all active requests")
for (_, task) in activeRequests {
task.cancel()
}
activeRequests.removeAll()
}
}
20 changes: 12 additions & 8 deletions src/native/iosnativeWebView/iosnativeWebView/WebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,10 @@ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app"
struct WebView: UIViewRepresentable {
let urlString: String
@ObservedObject var viewModel: WebViewModel
private let navigationDelegate: WebViewNavigationDelegate

init(urlString: String, viewModel: WebViewModel) {
self.urlString = urlString
self.viewModel = viewModel
self.navigationDelegate = WebViewNavigationDelegate(viewModel: viewModel)

// Register our custom URL protocol
ResourceURLProtocol.register()
Expand All @@ -26,10 +24,14 @@ struct WebView: UIViewRepresentable {
configuration.defaultWebpagePreferences = preferences

let webView = WKWebView(frame: .zero, configuration: configuration)
webView.navigationDelegate = navigationDelegate
let navigationHandler = WebViewNavigationDelegate(viewModel: viewModel)
webView.navigationDelegate = navigationHandler
webView.allowsBackForwardNavigationGestures = true
webView.isInspectable = true

// Store navigation handler in coordinator
context.coordinator.navigationHandler = navigationHandler

webView.addObserver(context.coordinator,
forKeyPath: #keyPath(WKWebView.estimatedProgress),
options: .new,
Expand All @@ -52,16 +54,13 @@ struct WebView: UIViewRepresentable {
Coordinator(self)
}

static func dismantleUIView(_ webView: WKWebView, coordinator: Coordinator) {
webView.removeObserver(coordinator, forKeyPath: #keyPath(WKWebView.estimatedProgress))
ResourceURLProtocol.unregister()
}

class Coordinator: NSObject {
var parent: WebView
var navigationHandler: WebViewNavigationDelegate?

init(_ parent: WebView) {
self.parent = parent
super.init()
}

override func observeValue(forKeyPath keyPath: String?,
Expand All @@ -76,4 +75,9 @@ struct WebView: UIViewRepresentable {
}
}
}

static func dismantleUIView(_ webView: WKWebView, coordinator: Coordinator) {
webView.removeObserver(coordinator, forKeyPath: #keyPath(WKWebView.estimatedProgress))
ResourceURLProtocol.unregister()
}
}
51 changes: 33 additions & 18 deletions src/native/iosnativeWebView/iosnativeWebView/WebViewModel.swift
Original file line number Diff line number Diff line change
@@ -1,47 +1,62 @@
import Foundation
import WebKit
import os

private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: "WebViewModel")

@MainActor
class WebViewModel: ObservableObject {
@Published var isLoading: Bool = true // Start as true for initial load
@Published var canGoBack: Bool = false
@Published var loadingProgress: Double = 0.0
@Published var lastLoadedURL: URL?
@Published var isLoadingFromCache: Bool = false
@Published private(set) var isLoading = false
@Published private(set) var loadingProgress: Double = 0.0
@Published private(set) var isLoadingFromCache = false
@Published private(set) var lastLoadedURL: URL?
@Published private(set) var canGoBack = false
@Published private(set) var error: Error?

var navigationHistory: [String] = []
private var visitedURLs: [String] = []

func setLoading(_ loading: Bool, fromCache: Bool = false) {
func setLoading(_ loading: Bool, fromCache: Bool) {
logger.info("🔄 [\(ThreadHelper.currentThreadInfo())] Setting loading state: loading=\(loading), fromCache=\(fromCache)")
isLoading = loading
isLoadingFromCache = fromCache

if !loading {
loadingProgress = 1.0
// Reset loading state after a short delay
Task { @MainActor in
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
self.isLoading = false
self.loadingProgress = 0.0
self.isLoadingFromCache = false
}
}

logger.info("Loading state changed: loading=\(loading), fromCache=\(fromCache)")
}

func setProgress(_ progress: Double) {
logger.info("📊 [\(ThreadHelper.currentThreadInfo())] Updating progress: \(Int(progress * 100))%")
loadingProgress = progress
}

func setError(_ error: Error?) {
logger.info("⚠️ [\(ThreadHelper.currentThreadInfo())] Setting error: \(error?.localizedDescription ?? "nil")")
self.error = error
}

func setLastLoadedURL(_ url: URL?) {
logger.info("🔗 [\(ThreadHelper.currentThreadInfo())] Setting last loaded URL: \(url?.absoluteString ?? "nil")")
self.lastLoadedURL = url
}

func setCanGoBack(_ canGoBack: Bool) {
logger.info("◀️ [\(ThreadHelper.currentThreadInfo())] Setting canGoBack: \(canGoBack)")
self.canGoBack = canGoBack
}

func addToHistory(_ urlString: String) {
navigationHistory.append(urlString)
logger.info("📝 [\(ThreadHelper.currentThreadInfo())] Adding to history: \(urlString)")
if !visitedURLs.contains(urlString) {
visitedURLs.append(urlString)
}
}

func reset() {
logger.info("🔄 [\(ThreadHelper.currentThreadInfo())] Resetting view model state")
isLoading = false
loadingProgress = 0
loadingProgress = 0.0
isLoadingFromCache = false
error = nil
}
}
Loading