From cb802c524e3b4687ae8e1147da21e5010f5121d5 Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Wed, 4 Feb 2026 18:01:32 +0800 Subject: [PATCH] Revert "Experiment" This reverts commit 8c54db62f36960095153dec68a5986d18ce63542. --- .../Sources/NotificationService.swift | 11 +- .../Sources/DebugController.swift | 29 +- .../Network/FetchedMediaResource.swift | 2 +- .../SyncCore/SyncCore_Namespaces.swift | 2 - .../Messages/AttachMenuBots.swift | 70 +- .../TelegramEngine/Messages/BotWebView.swift | 142 +--- .../Messages/TelegramEngineMessages.swift | 8 +- .../Sources/ContextActionsContainerNode.swift | 12 +- .../Chat/ChatControllerOpenWebApp.swift | 9 +- .../Sources/ExperimentalUISettings.swift | 10 +- submodules/WebUI/BUILD | 1 - .../WebUI/Sources/WebAppController.swift | 115 +++- .../WebUI/Sources/WebAppPWAWebView.swift | 293 --------- submodules/WebUI/Sources/WebAppWebView.swift | 20 +- submodules/WebWorkerShim/BUILD | 60 -- .../Resources/sw-context-shim.js | 619 ------------------ .../Resources/sw-registration-shim.js | 478 -------------- .../Sources/CachePersistence.swift | 282 -------- .../WebWorkerShim/Sources/CacheStorage.swift | 150 ----- .../Sources/PWAURLProtocol.swift | 486 -------------- .../WebWorkerShim/Sources/PWAWebView.swift | 365 ----------- .../Sources/ServiceWorkerContext.swift | 529 --------------- .../Sources/ServiceWorkerRegistration.swift | 333 ---------- 23 files changed, 125 insertions(+), 3901 deletions(-) delete mode 100644 submodules/WebUI/Sources/WebAppPWAWebView.swift delete mode 100644 submodules/WebWorkerShim/BUILD delete mode 100644 submodules/WebWorkerShim/Resources/sw-context-shim.js delete mode 100644 submodules/WebWorkerShim/Resources/sw-registration-shim.js delete mode 100644 submodules/WebWorkerShim/Sources/CachePersistence.swift delete mode 100644 submodules/WebWorkerShim/Sources/CacheStorage.swift delete mode 100644 submodules/WebWorkerShim/Sources/PWAURLProtocol.swift delete mode 100644 submodules/WebWorkerShim/Sources/PWAWebView.swift delete mode 100644 submodules/WebWorkerShim/Sources/ServiceWorkerContext.swift delete mode 100644 submodules/WebWorkerShim/Sources/ServiceWorkerRegistration.swift diff --git a/Telegram/NotificationService/Sources/NotificationService.swift b/Telegram/NotificationService/Sources/NotificationService.swift index bde66723dd..de697ef595 100644 --- a/Telegram/NotificationService/Sources/NotificationService.swift +++ b/Telegram/NotificationService/Sources/NotificationService.swift @@ -1978,18 +1978,13 @@ private final class NotificationServiceHandler { if let media = message.media.first { parsedMedia = media } - if enableInlineEmoji, let textEntitiesAttribute = message.textEntitiesAttribute, let author = message.author { - let authorTitle = author.debugDisplayTitle - let messagePrefix = "\(authorTitle): " - let messagePrefixLength = (messagePrefix as NSString).length + if enableInlineEmoji, let textEntitiesAttribute = message.textEntitiesAttribute { + content.body = message.text for entity in textEntitiesAttribute.entities { if case let .CustomEmoji(_, fileId) = entity.type { - content.customEmoji.append(NotificationContent.CustomEmoji(range: (entity.range.lowerBound + messagePrefixLength) ..< (entity.range.upperBound + messagePrefixLength), fileId: fileId)) + content.customEmoji.append(NotificationContent.CustomEmoji(range: entity.range, fileId: fileId)) } } - if !content.customEmoji.isEmpty { - content.body = messagePrefix + message.text - } } } diff --git a/submodules/DebugSettingsUI/Sources/DebugController.swift b/submodules/DebugSettingsUI/Sources/DebugController.swift index 1e5ba038c7..27725379af 100644 --- a/submodules/DebugSettingsUI/Sources/DebugController.swift +++ b/submodules/DebugSettingsUI/Sources/DebugController.swift @@ -109,7 +109,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { case playerV2(Bool) case devRequests(Bool) case enableUpdates(Bool) - case pwa(Bool) + case fakeAds(Bool) case enableLocalTranslation(Bool) case preferredVideoCodec(Int, String, String?, Bool) case disableVideoAspectScaling(Bool) @@ -135,7 +135,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { return DebugControllerSection.web.rawValue case .keepChatNavigationStack, .skipReadHistory, .alwaysDisplayTyping, .debugRatingLayout, .crashOnSlowQueries, .crashOnMemoryPressure: return DebugControllerSection.experiments.rawValue - case .clearTips, .resetNotifications, .crash, .fillLocalSavedMessageCache, .resetDatabase, .resetDatabaseAndCache, .resetHoles, .resetTagHoles, .reindexUnread, .resetCacheIndex, .reindexCache, .resetBiometricsData, .optimizeDatabase, .photoPreview, .knockoutWallpaper, .compressedEmojiCache, .storiesJpegExperiment, .checkSerializedData, .enableQuickReactionSwitch, .experimentalCompatibility, .enableDebugDataDisplay, .fakeGlass, .browserExperiment, .allForumsHaveTabs, .enableReactionOverrides, .restorePurchases, .disableReloginTokens, .liveStreamV2, .experimentalCallMute, .playerV2, .devRequests, .enableUpdates, .pwa, .enableLocalTranslation: + case .clearTips, .resetNotifications, .crash, .fillLocalSavedMessageCache, .resetDatabase, .resetDatabaseAndCache, .resetHoles, .resetTagHoles, .reindexUnread, .resetCacheIndex, .reindexCache, .resetBiometricsData, .optimizeDatabase, .photoPreview, .knockoutWallpaper, .compressedEmojiCache, .storiesJpegExperiment, .checkSerializedData, .enableQuickReactionSwitch, .experimentalCompatibility, .enableDebugDataDisplay, .fakeGlass, .browserExperiment, .allForumsHaveTabs, .enableReactionOverrides, .restorePurchases, .disableReloginTokens, .liveStreamV2, .experimentalCallMute, .playerV2, .devRequests, .enableUpdates, .fakeAds, .enableLocalTranslation: return DebugControllerSection.experiments.rawValue case .logTranslationRecognition, .resetTranslationStates: return DebugControllerSection.translation.rawValue @@ -258,7 +258,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { return 54 case .devRequests: return 55 - case .pwa: + case .fakeAds: return 56 case .enableLocalTranslation: return 57 @@ -1392,12 +1392,12 @@ private enum DebugControllerEntry: ItemListNodeEntry { }) }).start() }) - case let .pwa(value): - return ItemListSwitchItem(presentationData: presentationData, systemStyle: .glass, title: "Test1", value: value, sectionId: self.section, style: .blocks, updated: { value in + case let .fakeAds(value): + return ItemListSwitchItem(presentationData: presentationData, systemStyle: .glass, title: "Fake Ads", value: value, sectionId: self.section, style: .blocks, updated: { value in let _ = arguments.sharedContext.accountManager.transaction ({ transaction in transaction.updateSharedData(ApplicationSpecificSharedDataKeys.experimentalUISettings, { settings in var settings = settings?.get(ExperimentalUISettings.self) ?? ExperimentalUISettings.defaultSettings - settings.enablePWA = value + settings.fakeAds = value return PreferencesEntry(settings) }) }).start() @@ -1488,7 +1488,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { } } -private func debugControllerEntries(context: AccountContext?, sharedContext: SharedAccountContext, presentationData: PresentationData, loggingSettings: LoggingSettings, mediaInputSettings: MediaInputSettings, experimentalSettings: ExperimentalUISettings, networkSettings: NetworkSettings?, hasLegacyAppData: Bool, useBetaFeatures: Bool) -> [DebugControllerEntry] { +private func debugControllerEntries(sharedContext: SharedAccountContext, presentationData: PresentationData, loggingSettings: LoggingSettings, mediaInputSettings: MediaInputSettings, experimentalSettings: ExperimentalUISettings, networkSettings: NetworkSettings?, hasLegacyAppData: Bool, useBetaFeatures: Bool) -> [DebugControllerEntry] { var entries: [DebugControllerEntry] = [] let isMainApp = sharedContext.applicationBindings.isMainApp @@ -1572,18 +1572,7 @@ private func debugControllerEntries(context: AccountContext?, sharedContext: Sha entries.append(.playerV2(experimentalSettings.playerV2)) entries.append(.devRequests(experimentalSettings.devRequests)) - - if let data = context?.currentAppConfiguration.with({ $0 }).data { - var displayPwa = false - if let _ = data["ios_display_pwa"] { - displayPwa = true - } else if let isDev = data["dev"] as? Double, isDev == 1.0 { - displayPwa = true - } - if displayPwa { - entries.append(.pwa(experimentalSettings.enablePWA)) - } - } + entries.append(.fakeAds(experimentalSettings.fakeAds)) entries.append(.enableLocalTranslation(experimentalSettings.enableLocalTranslation)) entries.append(.enableUpdates(experimentalSettings.enableUpdates)) } @@ -1669,7 +1658,7 @@ public func debugController(sharedContext: SharedAccountContext, context: Accoun } let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text("Debug"), leftNavigationButton: leftNavigationButton, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)) - let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: debugControllerEntries(context: context, sharedContext: sharedContext, presentationData: presentationData, loggingSettings: loggingSettings, mediaInputSettings: mediaInputSettings, experimentalSettings: experimentalSettings, networkSettings: networkSettings, hasLegacyAppData: hasLegacyAppData, useBetaFeatures: useBetaFeatures), style: .blocks) + let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: debugControllerEntries(sharedContext: sharedContext, presentationData: presentationData, loggingSettings: loggingSettings, mediaInputSettings: mediaInputSettings, experimentalSettings: experimentalSettings, networkSettings: networkSettings, hasLegacyAppData: hasLegacyAppData, useBetaFeatures: useBetaFeatures), style: .blocks) return (controllerState, (listState, arguments)) } diff --git a/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift b/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift index e790c0512a..40fd07692b 100644 --- a/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift +++ b/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift @@ -729,7 +729,7 @@ final class MediaReferenceRevalidationContext { func attachBot(accountPeerId: PeerId, postbox: Postbox, network: Network, background: Bool, peer: PeerReference) -> Signal { return self.genericItem(key: .attachBot(peer: peer), background: background, request: { next, error in - return (_internal_getAttachMenuBot(accountPeerId: accountPeerId, postbox: postbox, network: network, botId: peer.id, cached: false, allowManuallyCached: false) + return (_internal_getAttachMenuBot(accountPeerId: accountPeerId, postbox: postbox, network: network, botId: peer.id, cached: false) |> mapError { _ -> RevalidateMediaReferenceError in return .generic }).start(next: { value in diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_Namespaces.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_Namespaces.swift index c547ffd82a..a6898bfbac 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_Namespaces.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_Namespaces.swift @@ -149,8 +149,6 @@ public struct Namespaces { public static let cachedChatThemes: Int8 = 50 public static let cachedLiveStorySendAsPeers: Int8 = 51 public static let cachedGiftUpgradesAttributes: Int8 = 52 - public static let manuallyCachedAttachMenuBots: Int8 = 53 - public static let cachedBotWebViews: Int8 = 54 } public struct UnorderedItemList { diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/AttachMenuBots.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/AttachMenuBots.swift index 3e886b3913..45520a0159 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/AttachMenuBots.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/AttachMenuBots.swift @@ -283,51 +283,6 @@ private func removeCachedAttachMenuBot(postbox: Postbox, botId: PeerId) -> Signa } } -enum CachedAttachMenuBotResult { - case empty - case bot(AttachMenuBots.Bot) -} - -private func manuallyCachedAttachMenuBot(transaction: Transaction, peerId: PeerId) -> CachedAttachMenuBotResult? { - let key = ValueBoxKey(length: 8) - key.setInt64(0, value: peerId.toInt64()) - - if let entry = transaction.retrieveItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.manuallyCachedAttachMenuBots, key: key)) { - let cached = entry.get(AttachMenuBots.Bot.self) - if let cached { - return .bot(cached) - } else if let _ = entry.get(EmptyAttachMenuBotMarker.self) { - return .empty - } else { - return nil - } - } else { - return nil - } -} - -struct EmptyAttachMenuBotMarker: Codable { - var trueValue: Bool -} - -private func setManuallyCachedAttachMenuBot(transaction: Transaction, peerId: PeerId, bot: AttachMenuBots.Bot?) { - let key = ValueBoxKey(length: 8) - key.setInt64(0, value: peerId.toInt64()) - - let entryId = ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.manuallyCachedAttachMenuBots, key: key) - if let bot { - if let entry = CodableEntry(bot) { - transaction.putItemCacheEntry(id: entryId, entry: entry) - } else { - transaction.removeItemCacheEntry(id: entryId) - } - } else { - if let entry = CodableEntry(EmptyAttachMenuBotMarker(trueValue: true)) { - transaction.putItemCacheEntry(id: entryId, entry: entry) - } - } -} - func managedSynchronizeAttachMenuBots(accountPeerId: PeerId, postbox: Postbox, network: Network, force: Bool = false) -> Signal { let poll = Signal { subscriber in let signal: Signal = cachedAttachMenuBots(postbox: postbox) @@ -554,33 +509,19 @@ public enum GetAttachMenuBotError { case generic } -func _internal_getAttachMenuBot(accountPeerId: PeerId, postbox: Postbox, network: Network, botId: PeerId, cached: Bool, allowManuallyCached: Bool) -> Signal { +func _internal_getAttachMenuBot(accountPeerId: PeerId, postbox: Postbox, network: Network, botId: PeerId, cached: Bool) -> Signal { return postbox.transaction { transaction -> Signal in if cached, let cachedBots = cachedAttachMenuBots(transaction: transaction)?.bots { if let bot = cachedBots.first(where: { $0.peerId == botId }), let peer = transaction.getPeer(bot.peerId) { return .single(AttachMenuBot(peer: EnginePeer(peer), shortName: bot.name, icons: bot.icons, peerTypes: bot.peerTypes, flags: bot.flags)) } } - if allowManuallyCached, let bot = manuallyCachedAttachMenuBot(transaction: transaction, peerId: botId) { - switch bot { - case let .bot(bot): - if let peer = transaction.getPeer(bot.peerId) { - return .single(AttachMenuBot(peer: EnginePeer(peer), shortName: bot.name, icons: bot.icons, peerTypes: bot.peerTypes, flags: bot.flags)) - } - case .empty: - return .fail(.generic) - } - } guard let peer = transaction.getPeer(botId), let inputUser = apiInputUser(peer) else { return .complete() } return network.request(Api.functions.messages.getAttachMenuBot(bot: inputUser)) |> mapError { _ -> GetAttachMenuBotError in - let _ = postbox.transaction({ transaction -> Void in - setManuallyCachedAttachMenuBot(transaction: transaction, peerId: peer.id, bot: nil) - }).startStandalone() - return .generic } |> mapToSignal { result -> Signal in @@ -645,15 +586,6 @@ func _internal_getAttachMenuBot(accountPeerId: PeerId, postbox: Postbox, network if (apiFlags & (1 << 5)) != 0 { flags.insert(.showInSettingsDisclaimer) } - - setManuallyCachedAttachMenuBot(transaction: transaction, peerId: peer.id, bot: AttachMenuBots.Bot( - peerId: peer.id, - name: name, - icons: icons, - peerTypes: peerTypes, - flags: flags - )) - return .single(AttachMenuBot(peer: EnginePeer(peer), shortName: name, icons: icons, peerTypes: peerTypes, flags: flags)) } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/BotWebView.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/BotWebView.swift index 16508c21c8..8b37f8e937 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/BotWebView.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/BotWebView.swift @@ -203,24 +203,9 @@ private func keepWebViewSignal(network: Network, stateManager: AccountStateManag return signal } -func _internal_requestWebView( - postbox: Postbox, - network: Network, - stateManager: AccountStateManager, - peerId: PeerId, - botId: PeerId, - url: String?, - payload: String?, - themeParams: [String: Any]?, - fromMenu: Bool, - replyToMessageId: MessageId?, - threadId: Int64?, - enableCached: Bool -) -> Signal { +func _internal_requestWebView(postbox: Postbox, network: Network, stateManager: AccountStateManager, peerId: PeerId, botId: PeerId, url: String?, payload: String?, themeParams: [String: Any]?, fromMenu: Bool, replyToMessageId: MessageId?, threadId: Int64?) -> Signal { var serializedThemeParams: Api.DataJSON? - var themeParamsJson: String? if let themeParams = themeParams, let data = try? JSONSerialization.data(withJSONObject: themeParams, options: []), let dataString = String(data: data, encoding: .utf8) { - themeParamsJson = dataString serializedThemeParams = .dataJSON(.init(data: dataString)) } @@ -228,28 +213,6 @@ func _internal_requestWebView( guard let peer = transaction.getPeer(peerId), let inputPeer = apiInputPeer(peer), let bot = transaction.getPeer(botId), let inputBot = apiInputUser(bot) else { return .fail(.generic) } - - let _ = themeParamsJson - let cacheKey = CachedBotWebView.Key( - peerId: peerId, - botId: botId, - url: url, - payload: payload, - themeParamsJson: nil, - fromMenu: fromMenu, - replyToMessageId: replyToMessageId, - threadId: threadId - ) - if enableCached { - if let value = cachedBotWebView(transaction: transaction, key: cacheKey) { - return .single(RequestWebViewResult( - flags: value.flags, - queryId: value.queryId, - url: value.url, - keepAliveSignal: nil - )) - } - } var flags: Int32 = 0 if let _ = url { @@ -313,16 +276,6 @@ func _internal_requestWebView( } else { keepAlive = nil } - - if enableCached { - let _ = (postbox.transaction { transaction -> Void in - setCachedBotWebView(transaction: transaction, key: cacheKey, value: CachedBotWebView( - flags: resultFlags, - queryId: queryId, - url: url - )) - }).startStandalone() - } return .single(RequestWebViewResult(flags: resultFlags, queryId: queryId, url: url, keepAliveSignal: keepAlive)) } @@ -332,99 +285,6 @@ func _internal_requestWebView( |> switchToLatest } -final class CachedBotWebView: Codable { - struct Key { - let peerId: PeerId - let botId: PeerId - let url: String? - let payload: String? - let themeParamsJson: String? - let fromMenu: Bool - let replyToMessageId: MessageId? - let threadId: Int64? - - func toData() -> ValueBoxKey { - var string = "" - string.append("_") - string.append("\(self.peerId.toInt64())") - string.append("_") - string.append("\(self.botId.toInt64())") - string.append("_") - string.append("\(self.url ?? "-")") - string.append("_") - string.append("\(self.payload ?? "-")") - string.append("_") - string.append("\(self.themeParamsJson ?? "-")") - string.append("_") - string.append("\(self.fromMenu)") - string.append("_") - string.append("\(String(describing: self.replyToMessageId))") - string.append("_") - string.append("\(self.threadId ?? -1)") - let data = sha256Digest(string.data(using: .utf8)!) - - let key = ValueBoxKey(length: data.count) - key.setData(0, value: data) - return key - } - } - - private enum CodingKeys: String, CodingKey { - case flags - case queryId - case url - } - - let flags: RequestWebViewResult.Flags - let queryId: Int64? - let url: String - - init(flags: RequestWebViewResult.Flags, queryId: Int64?, url: String) { - self.flags = flags - self.queryId = queryId - self.url = url - } - - init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.flags = RequestWebViewResult.Flags(rawValue: try container.decode(Int32.self, forKey: .flags)) - self.queryId = try container.decodeIfPresent(Int64.self, forKey: .queryId) - self.url = try container.decode(String.self, forKey: .url) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(self.flags.rawValue, forKey: .flags) - try container.encodeIfPresent(self.queryId, forKey: .queryId) - try container.encodeIfPresent(self.url, forKey: .url) - } -} - -private func cachedBotWebView(transaction: Transaction, key: CachedBotWebView.Key) -> CachedBotWebView? { - let key = key.toData() - - if let entry = transaction.retrieveItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedBotWebViews, key: key)) { - return entry.get(CachedBotWebView.self) - } else { - return nil - } -} - -private func setCachedBotWebView(transaction: Transaction, key: CachedBotWebView.Key, value: CachedBotWebView?) { - let key = key.toData() - - let entryId = ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedBotWebViews, key: key) - if let value { - if let entry = CodableEntry(value) { - transaction.putItemCacheEntry(id: entryId, entry: entry) - } else { - transaction.removeItemCacheEntry(id: entryId) - } - } else { - transaction.removeItemCacheEntry(id: entryId) - } -} - public enum SendWebViewDataError { case generic } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift index c2fdf13646..0b25931122 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -671,8 +671,8 @@ public extension TelegramEngine { return _internal_rateAudioTranscription(postbox: self.account.postbox, network: self.account.network, messageId: messageId, id: id, isGood: isGood) } - public func requestWebView(peerId: PeerId, botId: PeerId, url: String?, payload: String?, themeParams: [String: Any]?, fromMenu: Bool, replyToMessageId: MessageId?, threadId: Int64?, enableCached: Bool = false) -> Signal { - return _internal_requestWebView(postbox: self.account.postbox, network: self.account.network, stateManager: self.account.stateManager, peerId: peerId, botId: botId, url: url, payload: payload, themeParams: themeParams, fromMenu: fromMenu, replyToMessageId: replyToMessageId, threadId: threadId, enableCached: enableCached) + public func requestWebView(peerId: PeerId, botId: PeerId, url: String?, payload: String?, themeParams: [String: Any]?, fromMenu: Bool, replyToMessageId: MessageId?, threadId: Int64?) -> Signal { + return _internal_requestWebView(postbox: self.account.postbox, network: self.account.network, stateManager: self.account.stateManager, peerId: peerId, botId: botId, url: url, payload: payload, themeParams: themeParams, fromMenu: fromMenu, replyToMessageId: replyToMessageId, threadId: threadId) } public func requestSimpleWebView(botId: PeerId, url: String?, source: RequestSimpleWebViewSource, themeParams: [String: Any]?) -> Signal { @@ -719,8 +719,8 @@ public extension TelegramEngine { return _internal_acceptAttachMenuBotDisclaimer(postbox: self.account.postbox, botId: botId) } - public func getAttachMenuBot(botId: PeerId, cached: Bool = false, allowManuallyCached: Bool = false) -> Signal { - return _internal_getAttachMenuBot(accountPeerId: self.account.peerId, postbox: self.account.postbox, network: self.account.network, botId: botId, cached: cached, allowManuallyCached: allowManuallyCached) + public func getAttachMenuBot(botId: PeerId, cached: Bool = false) -> Signal { + return _internal_getAttachMenuBot(accountPeerId: self.account.peerId, postbox: self.account.postbox, network: self.account.network, botId: botId, cached: cached) } public func attachMenuBots() -> Signal<[AttachMenuBot], NoError> { diff --git a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextActionsContainerNode.swift b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextActionsContainerNode.swift index 2393e57cfc..bc21e267ef 100644 --- a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextActionsContainerNode.swift +++ b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextActionsContainerNode.swift @@ -549,18 +549,18 @@ final class InnerTextSelectionTipContainerNode: ASDisplayNode { shimmeringForegroundColor = presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.07) } - let textRightInset: CGFloat = 8.0 - var textLeftInset: CGFloat = horizontalInset - + let textRightInset: CGFloat if let _ = self.iconNode.image { - textLeftInset = 60.0 + textRightInset = iconSize.width - 2.0 + } else { + textRightInset = 0.0 } let makeTextLayout = TextNodeWithEntities.asyncLayout(self.textNode) - let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, minimumNumberOfLines: 0, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: width - textLeftInset - textRightInset, height: .greatestFiniteMagnitude), alignment: .left, lineSpacing: 0.12, cutout: nil, insets: UIEdgeInsets(), lineColor: nil, textShadowColor: nil, textStroke: nil)) + let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, minimumNumberOfLines: 0, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: width - horizontalInset * 2.0 - textRightInset, height: .greatestFiniteMagnitude), alignment: .left, lineSpacing: 0.12, cutout: nil, insets: UIEdgeInsets(), lineColor: nil, textShadowColor: nil, textStroke: nil)) let _ = textApply(self.arguments?.withUpdatedPlaceholderColor(shimmeringForegroundColor)) - let textFrame = CGRect(origin: CGPoint(x: textLeftInset, y: topInset), size: textLayout.size) + let textFrame = CGRect(origin: CGPoint(x: horizontalInset, y: topInset), size: textLayout.size) transition.updateFrame(node: self.textNode.textNode, frame: textFrame) if textFrame.size.height.isZero { self.textNode.textNode.alpha = 0.0 diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenWebApp.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenWebApp.swift index bc66294c5e..0690ff8f3d 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenWebApp.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenWebApp.swift @@ -115,17 +115,12 @@ func openWebAppImpl( if case let .inline(bot) = source { botPeer = bot } - - var allowManuallyCached = false - if context.sharedContext.immediateExperimentalUISettings.enablePWA { - allowManuallyCached = true - } - + let _ = combineLatest(queue: Queue.mainQueue(), context.engine.data.get(TelegramEngine.EngineData.Item.Peer.BotAppSettings(id: botPeer.id)), ApplicationSpecificNotice.getBotGameNotice(accountManager: context.sharedContext.accountManager, peerId: botPeer.id), context.engine.messages.attachMenuBots(), - context.engine.messages.getAttachMenuBot(botId: botPeer.id, cached: true, allowManuallyCached: allowManuallyCached) + context.engine.messages.getAttachMenuBot(botId: botPeer.id, cached: true) |> map(Optional.init) |> `catch` { _ -> Signal in return .single(nil) diff --git a/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift b/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift index 4bf6e9ec9a..77f4684457 100644 --- a/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift +++ b/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift @@ -69,7 +69,6 @@ public struct ExperimentalUISettings: Codable, Equatable { public var allForumsHaveTabs: Bool public var debugRatingLayout: Bool public var enableUpdates: Bool - public var enablePWA: Bool public static var defaultSettings: ExperimentalUISettings { return ExperimentalUISettings( @@ -116,8 +115,7 @@ public struct ExperimentalUISettings: Codable, Equatable { checkSerializedData: false, allForumsHaveTabs: false, debugRatingLayout: false, - enableUpdates: false, - enablePWA: false + enableUpdates: false ) } @@ -165,8 +163,7 @@ public struct ExperimentalUISettings: Codable, Equatable { checkSerializedData: Bool, allForumsHaveTabs: Bool, debugRatingLayout: Bool, - enableUpdates: Bool, - enablePWA: Bool + enableUpdates: Bool ) { self.keepChatNavigationStack = keepChatNavigationStack self.skipReadHistory = skipReadHistory @@ -212,7 +209,6 @@ public struct ExperimentalUISettings: Codable, Equatable { self.allForumsHaveTabs = allForumsHaveTabs self.debugRatingLayout = debugRatingLayout self.enableUpdates = enableUpdates - self.enablePWA = enablePWA } public init(from decoder: Decoder) throws { @@ -262,7 +258,6 @@ public struct ExperimentalUISettings: Codable, Equatable { self.allForumsHaveTabs = try container.decodeIfPresent(Bool.self, forKey: "allForumsHaveTabs") ?? false self.debugRatingLayout = try container.decodeIfPresent(Bool.self, forKey: "debugRatingLayout") ?? false self.enableUpdates = try container.decodeIfPresent(Bool.self, forKey: "enableUpdates") ?? false - self.enablePWA = try container.decodeIfPresent(Bool.self, forKey: "enablePWA") ?? false } public func encode(to encoder: Encoder) throws { @@ -312,7 +307,6 @@ public struct ExperimentalUISettings: Codable, Equatable { try container.encodeIfPresent(self.allForumsHaveTabs, forKey: "allForumsHaveTabs") try container.encodeIfPresent(self.debugRatingLayout, forKey: "debugRatingLayout") try container.encodeIfPresent(self.enableUpdates, forKey: "enableUpdates") - try container.encodeIfPresent(self.enablePWA, forKey: "enablePWA") } } diff --git a/submodules/WebUI/BUILD b/submodules/WebUI/BUILD index 7162448e44..23c3251494 100644 --- a/submodules/WebUI/BUILD +++ b/submodules/WebUI/BUILD @@ -61,7 +61,6 @@ swift_library( "//submodules/TelegramUI/Components/AvatarComponent", "//submodules/TelegramUI/Components/AlertComponent/AlertCheckComponent", "//submodules/TelegramUI/Components/AlertComponent/AlertTransferHeaderComponent", - "//submodules/WebWorkerShim", ], visibility = [ "//visibility:public", diff --git a/submodules/WebUI/Sources/WebAppController.swift b/submodules/WebUI/Sources/WebAppController.swift index 1566fb2c98..f2bfddf4a5 100644 --- a/submodules/WebUI/Sources/WebAppController.swift +++ b/submodules/WebUI/Sources/WebAppController.swift @@ -140,6 +140,53 @@ public func generateWebAppThemeParams(_ theme: PresentationTheme) -> [String: An ] } +#if DEBUG +private let registeredProtocols: Void = { + class AppURLProtocol: URLProtocol { + var urlTask: URLSessionDataTask? + + override class func canInit(with request: URLRequest) -> Bool { + if request.url?.scheme == "https" { + return false + } + return false + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override func startLoading() { + super.startLoading() + + /*if self.urlTask != nil { + return + } + self.urlTask = URLSession.shared.dataTask(with: self.request, completionHandler: { [weak self] _, response, error in + guard let self else { + return + } + if let error { + self.client?.urlProtocol(self, didFailWithError: error) + } else { + if let response = response as? HTTPURLResponse { + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + } else { + } + self.client?.urlProtocolDidFinishLoading(self) + } + }) + self.urlTask?.resume()*/ + } + + override func stopLoading() { + self.urlTask?.cancel() + } + } + URLProtocol.registerClass(AppURLProtocol.self) +}() +#endif + public final class WebAppController: ViewController, AttachmentContainable { public var requestAttachmentMenuExpansion: () -> Void = { } public var updateNavigationStack: (@escaping ([AttachmentContainable]) -> ([AttachmentContainable], AttachmentMediaPickerContext?)) -> Void = { _ in } @@ -204,6 +251,10 @@ public final class WebAppController: ViewController, AttachmentContainable { private var validLayout: (ContainerViewLayout, CGFloat)? init(context: AccountContext, controller: WebAppController) { + #if DEBUG + let _ = registeredProtocols + #endif + self.context = context self.controller = controller self.presentationData = controller.presentationData @@ -220,12 +271,7 @@ public final class WebAppController: ViewController, AttachmentContainable { self.backgroundColor = self.presentationData.theme.list.plainBackgroundColor } - let webView: WebAppWebView - if context.sharedContext.immediateExperimentalUISettings.enablePWA { - webView = WebAppPWAWebViewImpl(account: context.account) - } else { - webView = WebAppWebViewImpl(account: context.account) - } + let webView = WebAppWebView(account: context.account) webView.alpha = 0.0 webView.navigationDelegate = self webView.uiDelegate = self @@ -433,12 +479,45 @@ public final class WebAppController: ViewController, AttachmentContainable { webView.scrollView.insertSubview(self.topOverscrollNode.view, at: 0) } - private func load(url: URL, isMainURL: Bool) { - if isMainURL { - self.webView?.loadMainUrl(url: url) - } else { - self.webView?.load(URLRequest(url: url)) + private func load(url: URL) { + /*#if DEBUG + if "".isEmpty { + if #available(iOS 16.0, *) { + let documentsPath = URL.documentsDirectory.path(percentEncoded: false) + + var hasher = SHA256() + var urlString = url.absoluteString + if let range = urlString.firstRange(of: "#") { + urlString.removeSubrange(range.lowerBound...) + } + hasher.update(data: urlString.data(using: .utf8)!) + let digest = Data(hasher.finalize()) + let urlHash = hexString(digest) + + let cachedFilePath = documentsPath.appending("\(urlHash).bin") + + Task { + do { + let data: Data + if let cachedData = try? Data(contentsOf: URL(fileURLWithPath: cachedFilePath)) { + data = cachedData + print("Loaded from cache at \(cachedFilePath)") + } else { + let (loadedData, _) = try await URLSession.shared.data(from: url) + data = loadedData + try loadedData.write(to: URL(fileURLWithPath: cachedFilePath), options: .atomic) + } + self.webView?.load(data, mimeType: "text/html", characterEncodingName: "utf-8", baseURL: url) + } catch let e { + print("\(e)") + } + } + } + + return } + #endif*/ + self.webView?.load(URLRequest(url: url)) } func setupWebView() { @@ -449,7 +528,7 @@ public final class WebAppController: ViewController, AttachmentContainable { if let url = controller.url, controller.source != .menu { self.queryId = controller.queryId if let parsedUrl = URL(string: url) { - self.load(url: parsedUrl, isMainURL: true) + self.load(url: parsedUrl) } if let keepAliveSignal = controller.keepAliveSignal { self.keepAliveDisposable = (keepAliveSignal @@ -472,7 +551,7 @@ public final class WebAppController: ViewController, AttachmentContainable { } if let parsedUrl = URL(string: result.url) { strongSelf.queryId = result.queryId - strongSelf.load(url: parsedUrl, isMainURL: true) + strongSelf.load(url: parsedUrl) } }) } else { @@ -492,21 +571,17 @@ public final class WebAppController: ViewController, AttachmentContainable { return } self.controller?.titleView?.title = WebAppTitle(title: botApp.title, counter: self.presentationData.strings.WebApp_Miniapp, isVerified: controller.botVerified) - self.load(url: parsedUrl, isMainURL: true) + self.load(url: parsedUrl) }) }) } else { - var enableCached = false - if self.context.sharedContext.immediateExperimentalUISettings.enablePWA { - enableCached = true - } - let _ = (self.context.engine.messages.requestWebView(peerId: controller.peerId, botId: controller.botId, url: controller.url, payload: controller.payload, themeParams: generateWebAppThemeParams(presentationData.theme), fromMenu: controller.source == .menu, replyToMessageId: controller.replyToMessageId, threadId: controller.threadId, enableCached: enableCached) + let _ = (self.context.engine.messages.requestWebView(peerId: controller.peerId, botId: controller.botId, url: controller.url, payload: controller.payload, themeParams: generateWebAppThemeParams(presentationData.theme), fromMenu: controller.source == .menu, replyToMessageId: controller.replyToMessageId, threadId: controller.threadId) |> deliverOnMainQueue).start(next: { [weak self] result in guard let strongSelf = self, let parsedUrl = URL(string: result.url) else { return } strongSelf.queryId = result.queryId - strongSelf.load(url: parsedUrl, isMainURL: true) + strongSelf.load(url: parsedUrl) if let keepAliveSignal = result.keepAliveSignal { strongSelf.keepAliveDisposable = (keepAliveSignal diff --git a/submodules/WebUI/Sources/WebAppPWAWebView.swift b/submodules/WebUI/Sources/WebAppPWAWebView.swift deleted file mode 100644 index d45967150f..0000000000 --- a/submodules/WebUI/Sources/WebAppPWAWebView.swift +++ /dev/null @@ -1,293 +0,0 @@ -import Foundation -import UIKit -import Display -import WebKit -import SwiftSignalKit -import TelegramCore -import WebWorkerShim - -private let findActiveElementY = """ -function getOffset(el) { - const rect = el.getBoundingClientRect(); - return { - left: rect.left + window.scrollX, - top: rect.top + window.scrollY - }; -} -getOffset(document.activeElement).top; -""" - -private class WeakGameScriptMessageHandler: NSObject, WKScriptMessageHandler { - private let f: (WKScriptMessage) -> () - - init(_ f: @escaping (WKScriptMessage) -> ()) { - self.f = f - - super.init() - } - - func userContentController(_ controller: WKUserContentController, didReceive scriptMessage: WKScriptMessage) { - self.f(scriptMessage) - } -} - -private class WebViewTouchGestureRecognizer: UITapGestureRecognizer { - override func touchesBegan(_ touches: Set, with event: UIEvent) { - self.state = .began - } -} - -private let eventProxySource = "var TelegramWebviewProxyProto = function() {}; " + - "TelegramWebviewProxyProto.prototype.postEvent = function(eventName, eventData) { " + - "window.webkit.messageHandlers.performAction.postMessage({'eventName': eventName, 'eventData': eventData}); " + - "}; " + -"var TelegramWebviewProxy = new TelegramWebviewProxyProto();" - -private let selectionSource = "var css = '*{-webkit-touch-callout:none;} :not(input):not(textarea):not([\"contenteditable\"=\"true\"]){-webkit-user-select:none;}';" - + " var head = document.head || document.getElementsByTagName('head')[0];" - + " var style = document.createElement('style'); style.type = 'text/css';" + - " style.appendChild(document.createTextNode(css)); head.appendChild(style);" - -private let videoSource = """ -document.addEventListener('DOMContentLoaded', () => { -function tgBrowserDisableWebkitEnterFullscreen(videoElement) { - if (videoElement && videoElement.webkitEnterFullscreen) { - videoElement.setAttribute('playsinline', ''); - } -} - -function tgBrowserDisableFullscreenOnExistingVideos() { - document.querySelectorAll('video').forEach(tgBrowserDisableWebkitEnterFullscreen); -} - -function tgBrowserHandleMutations(mutations) { - mutations.forEach((mutation) => { - if (mutation.addedNodes && mutation.addedNodes.length > 0) { - mutation.addedNodes.forEach((newNode) => { - if (newNode.tagName === 'VIDEO') { - tgBrowserDisableWebkitEnterFullscreen(newNode); - } - if (newNode.querySelectorAll) { - newNode.querySelectorAll('video').forEach(tgBrowserDisableWebkitEnterFullscreen); - } - }); - } - }); -} - -tgBrowserDisableFullscreenOnExistingVideos(); - -const _tgbrowser_observer = new MutationObserver(tgBrowserHandleMutations); - -_tgbrowser_observer.observe(document.body, { - childList: true, - subtree: true -}); - -function tgBrowserDisconnectObserver() { - _tgbrowser_observer.disconnect(); -} -}); -""" - -final class WebAppPWAWebViewImpl: PWAWebView, WebAppWebView { - var handleScriptMessage: (WKScriptMessage) -> Void = { _ in } - - var customInsets: UIEdgeInsets = .zero { - didSet { - if self.customInsets != oldValue { - self.setNeedsLayout() - } - } - } - - override var safeAreaInsets: UIEdgeInsets { - return UIEdgeInsets(top: self.customInsets.top, left: self.customInsets.left, bottom: self.customInsets.bottom, right: self.customInsets.right) - } - - init(account: Account) { - let configuration = WKWebViewConfiguration() - - if #available(iOS 17.0, *) { - var uuid: UUID? - if let current = UserDefaults.standard.object(forKey: "TelegramWebStoreUUID_\(account.id.int64)") as? String { - uuid = UUID(uuidString: current)! - } else { - let mainAccountId: Int64 - if let current = UserDefaults.standard.object(forKey: "TelegramWebStoreMainAccountId") as? Int64 { - mainAccountId = current - } else { - mainAccountId = account.id.int64 - UserDefaults.standard.set(mainAccountId, forKey: "TelegramWebStoreMainAccountId") - } - - if account.id.int64 != mainAccountId { - uuid = UUID() - UserDefaults.standard.set(uuid!.uuidString, forKey: "TelegramWebStoreUUID_\(account.id.int64)") - } - } - - if let uuid { - configuration.websiteDataStore = WKWebsiteDataStore(forIdentifier: uuid) - } - } - - let contentController = WKUserContentController() - - var handleScriptMessageImpl: ((WKScriptMessage) -> Void)? - let eventProxyScript = WKUserScript(source: eventProxySource, injectionTime: .atDocumentStart, forMainFrameOnly: false) - contentController.addUserScript(eventProxyScript) - contentController.add(WeakGameScriptMessageHandler { message in - handleScriptMessageImpl?(message) - }, name: "performAction") - - let selectionScript = WKUserScript(source: selectionSource, injectionTime: .atDocumentEnd, forMainFrameOnly: true) - contentController.addUserScript(selectionScript) - - let videoScript = WKUserScript(source: videoSource, injectionTime: .atDocumentStart, forMainFrameOnly: false) - contentController.addUserScript(videoScript) - - configuration.userContentController = contentController - - configuration.allowsInlineMediaPlayback = true - configuration.allowsPictureInPictureMediaPlayback = false - if #available(iOS 10.0, *) { - configuration.mediaTypesRequiringUserActionForPlayback = [] - } else { - configuration.mediaPlaybackRequiresUserAction = false - } - - super.init(frame: CGRect(), configuration: configuration) - - self.disablesInteractiveKeyboardGestureRecognizer = true - - self.isOpaque = false - self.backgroundColor = .clear - if #available(iOS 9.0, *) { - self.allowsLinkPreview = false - } - if #available(iOS 11.0, *) { - self.scrollView.contentInsetAdjustmentBehavior = .never - } - self.interactiveTransitionGestureRecognizerTest = { point -> Bool in - return point.x > 30.0 - } - self.allowsBackForwardNavigationGestures = false - if #available(iOS 16.4, *) { - self.isInspectable = true - } - - handleScriptMessageImpl = { [weak self] message in - if let strongSelf = self { - strongSelf.handleScriptMessage(message) - } - } - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - print() - } - - func loadMainUrl(url: URL) { - var url = url - if let range = url.absoluteString.range(of: "#") { - url = URL(string: String(url.absoluteString[.. 1 }) - guard let dragInteraction = (contentView?.interactions.compactMap { $0 as? UIDragInteraction }.first) else { - return - } - contentView?.removeInteraction(dragInteraction) - }) - - NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillChangeFrameNotification, object: nil) - NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) - NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) - } - } - - func hideScrollIndicators() { - var hiddenViews: [UIView] = [] - for view in self.scrollView.subviews.reversed() { - let minSize = min(view.frame.width, view.frame.height) - if minSize < 4.0 { - view.isHidden = true - hiddenViews.append(view) - } - } - Queue.mainQueue().after(2.0) { - for view in hiddenViews { - view.isHidden = false - } - } - } - - func sendEvent(name: String, data: String?) { - let script = "window.TelegramGameProxy && window.TelegramGameProxy.receiveEvent && window.TelegramGameProxy.receiveEvent(\"\(name)\", \(data ?? "null"))" - self.evaluateJavaScript(script, completionHandler: { _, _ in - }) - } - - func updateMetrics(height: CGFloat, isExpanded: Bool, isStable: Bool, transition: ContainedViewLayoutTransition) { - let viewportData = "{height:\(height), is_expanded:\(isExpanded ? "true" : "false"), is_state_stable:\(isStable ? "true" : "false")}" - self.sendEvent(name: "viewport_changed", data: viewportData) - - let safeInsetsData = "{top:\(self.customInsets.top), bottom:\(self.customInsets.bottom), left:\(self.customInsets.left), right:\(self.customInsets.right)}" - self.sendEvent(name: "safe_area_changed", data: safeInsetsData) - } - - var lastTouchTimestamp: Double? - private(set) var didTouchOnce = false - var onFirstTouch: () -> Void = {} - - func scrollToActiveElement(layout: ContainerViewLayout, completion: @escaping (CGPoint) -> Void, transition: ContainedViewLayoutTransition) { - self.evaluateJavaScript(findActiveElementY, completionHandler: { result, _ in - if let result = result as? CGFloat { - Queue.mainQueue().async { - let convertedY = result - self.scrollView.contentOffset.y - let viewportHeight = self.frame.height - if convertedY < 0.0 || (convertedY + 44.0) > viewportHeight { - let targetOffset: CGFloat - if convertedY < 0.0 { - targetOffset = max(0.0, result - 36.0) - } else { - targetOffset = max(0.0, result + 60.0 - viewportHeight) - } - let contentOffset = CGPoint(x: 0.0, y: targetOffset) - completion(contentOffset) - transition.animateView({ - self.scrollView.contentOffset = contentOffset - }) - } - } - } - }) - } - - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - let result = super.hitTest(point, with: event) - self.lastTouchTimestamp = CACurrentMediaTime() - if result != nil && !self.didTouchOnce { - self.didTouchOnce = true - self.onFirstTouch() - } - return result - } - - override var inputAccessoryView: UIView? { - return nil - } -} diff --git a/submodules/WebUI/Sources/WebAppWebView.swift b/submodules/WebUI/Sources/WebAppWebView.swift index 1e305a9113..2c82e6c57a 100644 --- a/submodules/WebUI/Sources/WebAppWebView.swift +++ b/submodules/WebUI/Sources/WebAppWebView.swift @@ -89,21 +89,7 @@ function tgBrowserDisconnectObserver() { }); """ -protocol WebAppWebView: WKWebView { - var handleScriptMessage: (WKScriptMessage) -> Void { get set } - var customInsets: UIEdgeInsets { get set } - var lastTouchTimestamp: Double? { get set } - var didTouchOnce: Bool { get } - var onFirstTouch: () -> Void { get set } - - func sendEvent(name: String, data: String?) - func scrollToActiveElement(layout: ContainerViewLayout, completion: @escaping (CGPoint) -> Void, transition: ContainedViewLayoutTransition) - func updateMetrics(height: CGFloat, isExpanded: Bool, isStable: Bool, transition: ContainedViewLayoutTransition) - func hideScrollIndicators() - func loadMainUrl(url: URL) -} - -final class WebAppWebViewImpl: WKWebView, WebAppWebView { +final class WebAppWebView: WKWebView { var handleScriptMessage: (WKScriptMessage) -> Void = { _ in } var customInsets: UIEdgeInsets = .zero { @@ -205,10 +191,6 @@ final class WebAppWebViewImpl: WKWebView, WebAppWebView { print() } - func loadMainUrl(url: URL) { - self.load(URLRequest(url: url)) - } - override func didMoveToSuperview() { super.didMoveToSuperview() diff --git a/submodules/WebWorkerShim/BUILD b/submodules/WebWorkerShim/BUILD deleted file mode 100644 index 1bf908f711..0000000000 --- a/submodules/WebWorkerShim/BUILD +++ /dev/null @@ -1,60 +0,0 @@ -load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") -load( - "@build_bazel_rules_apple//apple:resources.bzl", - "apple_resource_bundle", - "apple_resource_group", -) -load("//build-system/bazel-utils:plist_fragment.bzl", - "plist_fragment", -) - -filegroup( - name = "WebWorkerShimResources", - srcs = glob([ - "Resources/**/*.js", - ]), - visibility = ["//visibility:public"], -) - -plist_fragment( - name = "WebWorkerShimBundleInfoPlist", - extension = "plist", - template = - """ - CFBundleIdentifier - org.telegram.WebWorkerShim - CFBundleDevelopmentRegion - en - CFBundleName - WebWorkerShim - """ -) - -apple_resource_bundle( - name = "WebWorkerShimBundle", - infoplists = [ - ":WebWorkerShimBundleInfoPlist", - ], - resources = [ - ":WebWorkerShimResources", - ], -) - -swift_library( - name = "WebWorkerShim", - module_name = "WebWorkerShim", - srcs = glob([ - "Sources/**/*.swift", - ]), - data = [ - ":WebWorkerShimBundle", - ], - copts = [ - #"-warnings-as-errors", - ], - deps = [ - ], - visibility = [ - "//visibility:public", - ], -) diff --git a/submodules/WebWorkerShim/Resources/sw-context-shim.js b/submodules/WebWorkerShim/Resources/sw-context-shim.js deleted file mode 100644 index 5750adeee8..0000000000 --- a/submodules/WebWorkerShim/Resources/sw-context-shim.js +++ /dev/null @@ -1,619 +0,0 @@ -(function() { - 'use strict'; - - const eventHandlers = { - install: [], - activate: [], - fetch: [] - }; - - // Helper function for base64 encoding large binary data without stack overflow - function arrayBufferToBase64(buffer) { - const bytes = new Uint8Array(buffer); - const chunkSize = 8192; - let binary = ''; - for (let i = 0; i < bytes.length; i += chunkSize) { - const chunk = bytes.subarray(i, i + chunkSize); - binary += String.fromCharCode.apply(null, chunk); - } - return btoa(binary); - } - - // ServiceWorkerGlobalScope shim - const swSelf = { - addEventListener: function(event, handler) { - if (eventHandlers[event]) { - eventHandlers[event].push(handler); - } - }, - - removeEventListener: function(event, handler) { - if (eventHandlers[event]) { - const idx = eventHandlers[event].indexOf(handler); - if (idx !== -1) eventHandlers[event].splice(idx, 1); - } - }, - - skipWaiting: function() { - return Promise.resolve(); - }, - - clients: { - claim: function() { return Promise.resolve(); }, - get: function() { return Promise.resolve(null); }, - matchAll: function() { return Promise.resolve([]); }, - openWindow: function() { return Promise.resolve(null); } - }, - - registration: { - scope: '/', - navigationPreload: { - enable: async () => {}, - disable: async () => {}, - setHeaderValue: async () => {} - } - } - }; - - // Expose as self - Object.assign(self, swSelf); - - // Will be set when SW script is loaded - window.__swOrigin = ''; - window.__swScriptURL = ''; - - // importScripts shim - synchronous script loading is not possible in WebView, - // but we can provide an async polyfill that warns developers - self.importScripts = function(...urls) { - console.warn('[SW] importScripts called but synchronous script loading is not supported in this polyfill. Scripts:', urls); - // This is a limitation - real importScripts is synchronous - // We could potentially pre-load scripts during SW registration - throw new Error('importScripts is not supported in this service worker polyfill'); - }; - - // Resolve a URL relative to the SW script location - function resolveRelativeURL(url) { - if (!url) return url; - - // Already absolute - if (url.startsWith('http://') || url.startsWith('https://')) { - return url; - } - - // Use standard URL resolution with script URL as base - if (window.__swScriptURL) { - try { - return new URL(url, window.__swScriptURL).href; - } catch (e) { - console.error('[SW] Failed to resolve URL:', url, e); - } - } - - // Fallback: prepend origin - if (url.startsWith('/')) { - return window.__swOrigin + url; - } - return window.__swOrigin + '/' + url; - } - - // Cache API bridge - class CacheShim { - constructor(name, origin) { - this.name = name; - this.origin = origin; - } - - _normalizeUrl(request) { - const url = typeof request === 'string' ? request : request.url; - return resolveRelativeURL(url); - } - - async match(request) { - const url = this._normalizeUrl(request); - console.log('[SW Cache] match:', url, 'in cache:', this.name); - return new Promise((resolve) => { - const callbackId = 'cache_' + Date.now() + '_' + Math.random(); - window.__cacheCallbacks = window.__cacheCallbacks || {}; - - const timeoutId = setTimeout(() => { - console.log('[SW Cache] match timeout for:', url); - delete window.__cacheCallbacks[callbackId]; - resolve(undefined); - }, 10000); - - window.__cacheCallbacks[callbackId] = (response) => { - clearTimeout(timeoutId); - delete window.__cacheCallbacks[callbackId]; - if (response) { - console.log('[SW Cache] match HIT:', url); - resolve(new Response( - Uint8Array.from(atob(response.bodyBase64), c => c.charCodeAt(0)), - { - status: response.status, - statusText: response.statusText, - headers: response.headers - } - )); - } else { - console.log('[SW Cache] match MISS:', url); - resolve(undefined); - } - }; - webkit.messageHandlers.swBridge.postMessage({ - type: 'cacheMatch', - callbackId: callbackId, - origin: this.origin, - cacheName: this.name, - url: url - }); - }); - } - - async put(request, response) { - const url = this._normalizeUrl(request); - const clonedResponse = response.clone(); - const body = await clonedResponse.arrayBuffer(); - const bodyBase64 = arrayBufferToBase64(body); - - const headers = {}; - clonedResponse.headers.forEach((value, key) => { - headers[key] = value; - }); - - webkit.messageHandlers.swBridge.postMessage({ - type: 'cachePut', - origin: this.origin, - cacheName: this.name, - url: url, - response: { - status: clonedResponse.status, - statusText: clonedResponse.statusText, - headers: headers, - bodyBase64: bodyBase64 - } - }); - } - - async add(request) { - const url = this._normalizeUrl(request); - const response = await fetch(url); - if (!response.ok) { - throw new TypeError('Bad response status'); - } - return this.put(url, response); - } - - async addAll(requests) { - const urls = requests.map(r => this._normalizeUrl(r)); - await Promise.all(urls.map(url => this.add(url))); - } - - async delete(request) { - const url = this._normalizeUrl(request); - return new Promise((resolve) => { - const callbackId = 'cache_del_' + Date.now() + '_' + Math.random(); - window.__cacheCallbacks = window.__cacheCallbacks || {}; - - const timeoutId = setTimeout(() => { - delete window.__cacheCallbacks[callbackId]; - resolve(false); - }, 10000); - - window.__cacheCallbacks[callbackId] = (result) => { - clearTimeout(timeoutId); - delete window.__cacheCallbacks[callbackId]; - resolve(result); - }; - - webkit.messageHandlers.swBridge.postMessage({ - type: 'cacheEntryDelete', - callbackId: callbackId, - origin: this.origin, - cacheName: this.name, - url: url - }); - }); - } - - async keys() { - return []; // Simplified for now - } - } - - class CacheStorageShim { - constructor(origin) { - this.origin = origin; - this._caches = {}; - } - - async open(name) { - if (!this._caches[name]) { - webkit.messageHandlers.swBridge.postMessage({ - type: 'cacheOpen', - origin: this.origin, - cacheName: name - }); - this._caches[name] = new CacheShim(name, this.origin); - } - return this._caches[name]; - } - - async has(name) { - const keys = await this.keys(); - return keys.includes(name); - } - - async delete(name) { - delete this._caches[name]; - return new Promise((resolve) => { - const callbackId = 'caches_del_' + Date.now() + '_' + Math.random(); - window.__cacheCallbacks = window.__cacheCallbacks || {}; - - const timeoutId = setTimeout(() => { - delete window.__cacheCallbacks[callbackId]; - resolve(false); - }, 10000); - - window.__cacheCallbacks[callbackId] = (result) => { - clearTimeout(timeoutId); - delete window.__cacheCallbacks[callbackId]; - resolve(result); - }; - - webkit.messageHandlers.swBridge.postMessage({ - type: 'cacheStorageDelete', - callbackId: callbackId, - origin: this.origin, - cacheName: name - }); - }); - } - - async keys() { - return new Promise((resolve) => { - const callbackId = 'caches_keys_' + Date.now() + '_' + Math.random(); - window.__cacheCallbacks = window.__cacheCallbacks || {}; - - const timeoutId = setTimeout(() => { - delete window.__cacheCallbacks[callbackId]; - resolve(Object.keys(this._caches)); // Fall back to local - }, 10000); - - window.__cacheCallbacks[callbackId] = (names) => { - clearTimeout(timeoutId); - delete window.__cacheCallbacks[callbackId]; - // Sync local cache map with actual stored caches - (names || []).forEach(n => { - if (!this._caches[n]) { - this._caches[n] = new CacheShim(n, this.origin); - } - }); - resolve(names || []); - }; - - webkit.messageHandlers.swBridge.postMessage({ - type: 'cachesKeys', - callbackId: callbackId, - origin: this.origin - }); - }); - } - - async match(request) { - const url = typeof request === 'string' ? request : request.url; - for (const cache of Object.values(this._caches)) { - const response = await cache.match(url); - if (response) return response; - } - return undefined; - } - } - - Object.defineProperty(self, 'caches', { - get: function() { - if (!window.__cachesInstance) { - window.__cachesInstance = new CacheStorageShim(window.__swOrigin); - } - return window.__cachesInstance; - } - }); - - // FetchEvent shim - class FetchEvent { - constructor(request, requestId) { - this.request = request; - this.requestId = requestId; - this._responded = false; - this._responseSent = false; // Tracks if response actually sent to native - this._waitUntilPromises = []; - } - - respondWith(responsePromise) { - this._responded = true; - console.log('[SW] respondWith called for:', this.request.url); - Promise.resolve(responsePromise).then(async (response) => { - // Prevent double-response - if (this._responseSent) { - console.log('[SW] Response already sent, ignoring duplicate'); - return; - } - this._responseSent = true; - - console.log('[SW] Response received:', response.status, response.url || 'synthetic'); - const cloned = response.clone(); - const body = await cloned.arrayBuffer(); - const bodyBase64 = arrayBufferToBase64(body); - - const headers = {}; - cloned.headers.forEach((value, key) => { - headers[key] = value; - }); - - webkit.messageHandlers.swBridge.postMessage({ - type: 'fetchResponse', - requestId: this.requestId, - response: { - status: cloned.status, - statusText: cloned.statusText, - headers: headers, - bodyBase64: bodyBase64 - } - }); - }).catch((error) => { - // Prevent double-response - if (this._responseSent) { - console.log('[SW] Response already sent, ignoring error'); - return; - } - this._responseSent = true; - - console.log('[SW] respondWith error:', error.message); - webkit.messageHandlers.swBridge.postMessage({ - type: 'fetchResponse', - requestId: this.requestId, - error: error.message - }); - }); - } - - waitUntil(promise) { - this._waitUntilPromises.push(promise); - } - } - - // ExtendableEvent shim - class ExtendableEvent { - constructor(type) { - this.type = type; - this._waitUntilPromises = []; - } - - waitUntil(promise) { - this._waitUntilPromises.push(promise); - } - } - - // Global dispatch functions called from Swift - window.dispatchFetchEvent = function(requestId, url, method, headers, bodyBase64, options) { - console.log('[SW] dispatchFetchEvent:', url, 'method:', method); - - // Build request init - const init = { - method: method, - headers: headers - }; - - // Add body for methods that support it (not GET/HEAD) - if (bodyBase64 && method !== 'GET' && method !== 'HEAD') { - const bodyBytes = Uint8Array.from(atob(bodyBase64), c => c.charCodeAt(0)); - init.body = bodyBytes; - } - - // Add request options if provided - if (options) { - if (options.credentials) init.credentials = options.credentials; - if (options.mode) init.mode = options.mode; - if (options.redirect) init.redirect = options.redirect; - if (options.cache) init.cache = options.cache; - } - - const request = new Request(url, init); - const event = new FetchEvent(request, requestId); - - for (const handler of eventHandlers.fetch) { - handler(event); - } - - // If no handler called respondWith, tell Swift to pass through to network - // Use a longer timeout (50ms) to allow for async handlers like: - // event.respondWith((async () => { ... })()) - // Also check _responseSent to prevent double-sends - setTimeout(() => { - if (!event._responded && !event._responseSent) { - event._responseSent = true; - console.log('[SW] No handler responded for:', url); - webkit.messageHandlers.swBridge.postMessage({ - type: 'fetchResponse', - requestId: requestId, - passthrough: true - }); - } - }, 50); - }; - - window.dispatchInstallEvent = function() { - return new Promise((resolve) => { - const event = new ExtendableEvent('install'); - for (const handler of eventHandlers.install) { - handler(event); - } - Promise.all(event._waitUntilPromises).then(() => resolve(true)).catch(() => resolve(false)); - }); - }; - - window.dispatchActivateEvent = function() { - return new Promise((resolve) => { - const event = new ExtendableEvent('activate'); - for (const handler of eventHandlers.activate) { - handler(event); - } - Promise.all(event._waitUntilPromises).then(() => resolve(true)).catch(() => resolve(false)); - }); - }; - - window.setOrigin = function(origin, scriptURL) { - window.__swOrigin = origin; - window.__swScriptURL = scriptURL || ''; - - // Set up self.location to reflect the SW script URL - if (scriptURL) { - try { - const url = new URL(scriptURL); - const locationShim = { - href: scriptURL, - origin: url.origin, - protocol: url.protocol, - host: url.host, - hostname: url.hostname, - port: url.port, - pathname: url.pathname, - search: url.search, - hash: url.hash, - toString: function() { return scriptURL; } - }; - // Override self.location (it's about:blank by default) - Object.defineProperty(self, 'location', { - value: locationShim, - writable: false, - configurable: true - }); - } catch (e) { - console.error('[SW] Failed to set location:', e); - } - } - }; - - // Override fetch to route requests through native handler - // This is needed because the SW context WebView can't make real network requests - window.__fetchCallbacks = window.__fetchCallbacks || {}; - - window.resolveSWFetchCallback = function(requestId, response, error) { - if (window.__fetchCallbacks && window.__fetchCallbacks[requestId]) { - window.__fetchCallbacks[requestId](response, error); - delete window.__fetchCallbacks[requestId]; - } - }; - - const originalFetch = window.fetch; - window.fetch = function(input, init) { - let url; - let method = 'GET'; - let headers = {}; - - if (typeof input === 'string') { - url = input; - } else if (input instanceof Request) { - url = input.url; - method = input.method; - input.headers.forEach((value, key) => { headers[key] = value; }); - } else if (input instanceof URL) { - url = input.href; - } else { - return originalFetch.call(window, input, init); - } - - if (init) { - method = init.method || method; - if (init.headers) { - if (init.headers instanceof Headers) { - init.headers.forEach((value, key) => { headers[key] = value; }); - } else { - Object.assign(headers, init.headers); - } - } - } - - // Resolve relative URLs - url = resolveRelativeURL(url); - - // Helper to send the request to native - function sendRequest(bodyBase64) { - return new Promise((resolve, reject) => { - const requestId = 'swfetch_' + Date.now() + '_' + Math.random(); - - const timeoutId = setTimeout(() => { - delete window.__fetchCallbacks[requestId]; - reject(new TypeError('Network request failed (timeout)')); - }, 30000); - - window.__fetchCallbacks[requestId] = (response, error) => { - clearTimeout(timeoutId); - if (error) { - reject(new TypeError(error)); - } else if (response) { - const bodyData = response.bodyBase64 - ? Uint8Array.from(atob(response.bodyBase64), c => c.charCodeAt(0)) - : new Uint8Array(0); - resolve(new Response(bodyData, { - status: response.status || 200, - statusText: response.statusText || 'OK', - headers: response.headers || {} - })); - } else { - reject(new TypeError('Network request failed')); - } - }; - - webkit.messageHandlers.swBridge.postMessage({ - type: 'swFetch', - requestId: requestId, - url: url, - method: method, - headers: headers, - bodyBase64: bodyBase64, - origin: window.__swOrigin - }); - }); - } - - // For GET/HEAD, no body allowed - if (method === 'GET' || method === 'HEAD') { - return sendRequest(null); - } - - // Handle body from init - if (init && init.body !== undefined && init.body !== null) { - const body = init.body; - if (typeof body === 'string') { - return sendRequest(arrayBufferToBase64(new TextEncoder().encode(body).buffer)); - } else if (body instanceof ArrayBuffer) { - return sendRequest(arrayBufferToBase64(body)); - } else if (ArrayBuffer.isView(body)) { - return sendRequest(arrayBufferToBase64(body.buffer)); - } else if (body instanceof URLSearchParams) { - return sendRequest(arrayBufferToBase64(new TextEncoder().encode(body.toString()).buffer)); - } else if (body instanceof Blob) { - return body.arrayBuffer().then(ab => sendRequest(arrayBufferToBase64(ab))); - } - } - - // Handle body from Request object - if (input instanceof Request && input.body) { - return input.clone().arrayBuffer() - .then(ab => sendRequest(arrayBufferToBase64(ab))) - .catch(() => sendRequest(null)); - } - - // No body - return sendRequest(null); - }; - - window.resolveCacheCallback = function(callbackId, response) { - if (window.__cacheCallbacks && window.__cacheCallbacks[callbackId]) { - window.__cacheCallbacks[callbackId](response); - } - }; - - // Signal ready - webkit.messageHandlers.swBridge.postMessage({ type: 'contextReady' }); -})(); diff --git a/submodules/WebWorkerShim/Resources/sw-registration-shim.js b/submodules/WebWorkerShim/Resources/sw-registration-shim.js deleted file mode 100644 index 580d93d2ee..0000000000 --- a/submodules/WebWorkerShim/Resources/sw-registration-shim.js +++ /dev/null @@ -1,478 +0,0 @@ -(function() { - 'use strict'; - - if (window.__pwaShimInstalled) return; - window.__pwaShimInstalled = true; - - const registrations = new Map(); - - // Helper function for base64 encoding large binary data without stack overflow - function arrayBufferToBase64(buffer) { - const bytes = new Uint8Array(buffer); - const chunkSize = 8192; - let binary = ''; - for (let i = 0; i < bytes.length; i += chunkSize) { - const chunk = bytes.subarray(i, i + chunkSize); - binary += String.fromCharCode.apply(null, chunk); - } - return btoa(binary); - } - - // Get origin from current page URL - function getCurrentOrigin() { - return location.origin; - } - - // Callback system for async cache operations - window.__cacheCallbacks = window.__cacheCallbacks || {}; - - window.resolveCacheCallback = function(callbackId, response) { - if (window.__cacheCallbacks && window.__cacheCallbacks[callbackId]) { - window.__cacheCallbacks[callbackId](response); - } - }; - - // Cache API shim for page context - class CacheShim { - constructor(name, origin) { - this.name = name; - this.origin = origin; - } - - async match(request) { - const url = this._normalizeUrl(request); - return new Promise((resolve) => { - const callbackId = 'cache_' + Date.now() + '_' + Math.random(); - - const timeoutId = setTimeout(() => { - delete window.__cacheCallbacks[callbackId]; - resolve(undefined); - }, 10000); - - window.__cacheCallbacks[callbackId] = (response) => { - clearTimeout(timeoutId); - delete window.__cacheCallbacks[callbackId]; - if (response) { - resolve(new Response( - Uint8Array.from(atob(response.bodyBase64), c => c.charCodeAt(0)), - { - status: response.status, - statusText: response.statusText, - headers: response.headers - } - )); - } else { - resolve(undefined); - } - }; - - webkit.messageHandlers.swBridge.postMessage({ - type: 'cacheMatch', - callbackId: callbackId, - origin: this.origin, - cacheName: this.name, - url: url - }); - }); - } - - async put(request, response) { - const url = this._normalizeUrl(request); - const clonedResponse = response.clone(); - const body = await clonedResponse.arrayBuffer(); - const bodyBase64 = arrayBufferToBase64(body); - - const headers = {}; - clonedResponse.headers.forEach((value, key) => { - headers[key] = value; - }); - - webkit.messageHandlers.swBridge.postMessage({ - type: 'cachePut', - origin: this.origin, - cacheName: this.name, - url: url, - response: { - status: clonedResponse.status, - statusText: clonedResponse.statusText, - headers: headers, - bodyBase64: bodyBase64 - } - }); - } - - async add(request) { - const url = this._normalizeUrl(request); - const response = await fetch(url); - if (!response.ok) { - throw new TypeError('Bad response status'); - } - return this.put(url, response); - } - - async addAll(requests) { - const urls = requests.map(r => this._normalizeUrl(r)); - await Promise.all(urls.map(url => this.add(url))); - } - - async delete(request) { - const url = this._normalizeUrl(request); - return new Promise((resolve) => { - const callbackId = 'cache_del_' + Date.now() + '_' + Math.random(); - - const timeoutId = setTimeout(() => { - delete window.__cacheCallbacks[callbackId]; - resolve(false); - }, 10000); - - window.__cacheCallbacks[callbackId] = (result) => { - clearTimeout(timeoutId); - delete window.__cacheCallbacks[callbackId]; - resolve(result); - }; - - webkit.messageHandlers.swBridge.postMessage({ - type: 'cacheEntryDelete', - callbackId: callbackId, - origin: this.origin, - cacheName: this.name, - url: url - }); - }); - } - - async keys() { - return new Promise((resolve) => { - const callbackId = 'cache_keys_' + Date.now() + '_' + Math.random(); - - const timeoutId = setTimeout(() => { - delete window.__cacheCallbacks[callbackId]; - resolve([]); - }, 10000); - - window.__cacheCallbacks[callbackId] = (urls) => { - clearTimeout(timeoutId); - delete window.__cacheCallbacks[callbackId]; - resolve((urls || []).map(url => new Request(url))); - }; - - webkit.messageHandlers.swBridge.postMessage({ - type: 'cacheKeys', - callbackId: callbackId, - origin: this.origin, - cacheName: this.name - }); - }); - } - - _normalizeUrl(request) { - const url = typeof request === 'string' ? request : request.url; - // Convert relative URLs to absolute - return new URL(url, location.href).href; - } - } - - class CacheStorageShim { - constructor() { - this._caches = {}; - } - - get origin() { - return getCurrentOrigin(); - } - - async open(name) { - if (!this._caches[name]) { - webkit.messageHandlers.swBridge.postMessage({ - type: 'cacheOpen', - origin: this.origin, - cacheName: name - }); - this._caches[name] = new CacheShim(name, this.origin); - } - return this._caches[name]; - } - - async has(name) { - return this._caches.hasOwnProperty(name); - } - - async delete(name) { - delete this._caches[name]; - return new Promise((resolve) => { - const callbackId = 'caches_del_' + Date.now() + '_' + Math.random(); - - const timeoutId = setTimeout(() => { - delete window.__cacheCallbacks[callbackId]; - resolve(false); - }, 10000); - - window.__cacheCallbacks[callbackId] = (result) => { - clearTimeout(timeoutId); - delete window.__cacheCallbacks[callbackId]; - resolve(result); - }; - - webkit.messageHandlers.swBridge.postMessage({ - type: 'cacheStorageDelete', - callbackId: callbackId, - origin: this.origin, - cacheName: name - }); - }); - } - - async keys() { - return new Promise((resolve) => { - const callbackId = 'caches_keys_' + Date.now() + '_' + Math.random(); - - const timeoutId = setTimeout(() => { - delete window.__cacheCallbacks[callbackId]; - resolve([]); - }, 10000); - - window.__cacheCallbacks[callbackId] = (names) => { - clearTimeout(timeoutId); - delete window.__cacheCallbacks[callbackId]; - (names || []).forEach(n => { - if (!this._caches[n]) { - this._caches[n] = new CacheShim(n, this.origin); - } - }); - resolve(names || []); - }; - - webkit.messageHandlers.swBridge.postMessage({ - type: 'cachesKeys', - callbackId: callbackId, - origin: this.origin - }); - }); - } - - async match(request) { - const url = typeof request === 'string' ? request : request.url; - for (const cache of Object.values(this._caches)) { - const response = await cache.match(url); - if (response) return response; - } - return undefined; - } - } - - // Install the caches shim - const cachesShim = new CacheStorageShim(); - Object.defineProperty(window, 'caches', { - value: cachesShim, - writable: false, - configurable: false - }); - - const mockServiceWorker = { - state: 'activated', - scriptURL: '', - addEventListener: function() {}, - removeEventListener: function() {}, - postMessage: function() {} - }; - - function createMockRegistration(scope, scriptURL) { - const scopePath = new URL(scope, location.origin).pathname; - return { - scope: scope, - installing: null, - waiting: null, - active: Object.assign({}, mockServiceWorker, { scriptURL: scriptURL }), - navigationPreload: { - enable: async () => {}, - disable: async () => {}, - setHeaderValue: async () => {}, - getState: async () => ({ enabled: false, headerValue: '' }) - }, - addEventListener: function() {}, - removeEventListener: function() {}, - update: async function() { return this; }, - unregister: async function() { - return new Promise((resolve) => { - const callbackId = 'unreg_' + Date.now() + '_' + Math.random(); - - const timeoutId = setTimeout(() => { - delete window.__registrationCallbacks[callbackId]; - // Still remove from local map even on timeout - registrations.delete(scopePath); - resolve(true); - }, 10000); - - window.__registrationCallbacks[callbackId] = (success) => { - clearTimeout(timeoutId); - registrations.delete(scopePath); - resolve(success); - }; - - webkit.messageHandlers.swBridge.postMessage({ - type: 'unregister', - callbackId: callbackId, - scope: scopePath - }); - }); - }, - showNotification: async function() {}, - getNotifications: async function() { return []; } - }; - } - - // Pending registration callbacks - window.__registrationCallbacks = window.__registrationCallbacks || {}; - - window.resolveRegistrationCallback = function(callbackId, success, error) { - if (window.__registrationCallbacks && window.__registrationCallbacks[callbackId]) { - window.__registrationCallbacks[callbackId](success, error); - delete window.__registrationCallbacks[callbackId]; - } - }; - - // State update handler - native sends this when SW state changes - window.updateSWState = function(scope, state) { - const registration = registrations.get(scope); - if (registration) { - if (state === 'installing') { - registration.installing = Object.assign({}, mockServiceWorker, { scriptURL: registration.active?.scriptURL || '' }); - registration.active = null; - } else if (state === 'installed') { - registration.waiting = registration.installing; - registration.installing = null; - } else if (state === 'activated') { - registration.active = registration.waiting || registration.installing; - registration.installing = null; - registration.waiting = null; - } - } - }; - - navigator.serviceWorker = { - register: async function(scriptURL, options) { - // Resolve script URL to absolute - const absoluteScriptURL = new URL(scriptURL, location.href).href; - - // Resolve scope - store as absolute URL for correct matching - let scopeURL; - if (options?.scope) { - scopeURL = new URL(options.scope, location.href).href; - } else { - // Default: directory of the SW script - const scriptPath = new URL(absoluteScriptURL).pathname; - const scopePath = scriptPath.substring(0, scriptPath.lastIndexOf('/') + 1); - scopeURL = new URL(scopePath, location.origin).href; - } - - const scopePath = new URL(scopeURL).pathname; - - return new Promise((resolve, reject) => { - const callbackId = 'reg_' + Date.now() + '_' + Math.random(); - - const timeoutId = setTimeout(() => { - delete window.__registrationCallbacks[callbackId]; - reject(new TypeError('Service worker registration timed out')); - }, 30000); - - window.__registrationCallbacks[callbackId] = (success, error) => { - clearTimeout(timeoutId); - if (success) { - const registration = createMockRegistration(scopeURL, absoluteScriptURL); - // Start with installing state - registration.installing = Object.assign({}, mockServiceWorker, { scriptURL: absoluteScriptURL }); - registration.active = null; - registrations.set(scopePath, registration); - resolve(registration); - } else { - reject(new TypeError(error || 'Service worker registration failed')); - } - }; - - webkit.messageHandlers.swBridge.postMessage({ - type: 'register', - callbackId: callbackId, - scriptURL: absoluteScriptURL, - scope: scopePath - }); - }); - }, - - getRegistration: async function(scope) { - if (!scope) { - // No scope provided - return registration matching current page - const pageURL = new URL(location.href); - for (const [regScope, reg] of registrations) { - // Check if current page is within this scope - if (pageURL.pathname.startsWith(new URL(reg.scope).pathname)) { - return reg; - } - } - return undefined; - } - - // Normalize scope to absolute URL for lookup - let normalizedScope; - try { - normalizedScope = new URL(scope, location.href).pathname; - } catch (e) { - normalizedScope = scope; - } - - // Try direct match first, then path match - if (registrations.has(normalizedScope)) { - return registrations.get(normalizedScope); - } - - // Try matching as full URL - for (const [regScope, reg] of registrations) { - if (reg.scope === scope || new URL(reg.scope).pathname === normalizedScope) { - return reg; - } - } - - return undefined; - }, - - getRegistrations: async function() { - return Array.from(registrations.values()); - }, - - get ready() { - return new Promise((resolve) => { - const check = () => { - // Find a registration with an active worker - for (const reg of registrations.values()) { - if (reg.active) { - resolve(reg); - return; - } - } - setTimeout(check, 50); - }; - setTimeout(check, 100); - }); - }, - - get controller() { - // Controller should only be non-null if there's an active SW that controls this page - for (const reg of registrations.values()) { - if (reg.active) { - return reg.active; - } - } - return null; - }, - - addEventListener: function() {}, - removeEventListener: function() {} - }; - - Object.defineProperty(navigator, 'serviceWorker', { - value: navigator.serviceWorker, - writable: false, - configurable: false - }); - - // Note: No custom fetch override needed - NSURLProtocol handles all requests -})(); diff --git a/submodules/WebWorkerShim/Sources/CachePersistence.swift b/submodules/WebWorkerShim/Sources/CachePersistence.swift deleted file mode 100644 index ade98a8ae7..0000000000 --- a/submodules/WebWorkerShim/Sources/CachePersistence.swift +++ /dev/null @@ -1,282 +0,0 @@ -import Foundation -import CryptoKit - -class CachePersistence { - static let shared = CachePersistence() - - let baseDirectory: URL - private let ioQueue = DispatchQueue(label: "com.workershim.persistence", qos: .utility) - - init(baseDirectory: URL? = nil) { - if let dir = baseDirectory { - self.baseDirectory = dir - } else { - let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - self.baseDirectory = appSupport.appendingPathComponent("WorkerShim_v5") - } - - // Create base directory if needed - try? FileManager.default.createDirectory(at: self.baseDirectory, withIntermediateDirectories: true) - } - - // MARK: - Helpers - - func originHash(_ origin: String) -> String { - let data = Data(origin.utf8) - let hash = SHA256.hash(data: data) - return hash.prefix(16).map { String(format: "%02x", $0) }.joined() - } - - func originDirectory(for origin: String) -> URL { - baseDirectory.appendingPathComponent(originHash(origin)) - } - - // MARK: - Registration Persistence - - func saveRegistration(_ snapshot: RegistrationSnapshot) { - ioQueue.async { [weak self] in - guard let self = self else { return } - - let originDir = self.originDirectory(for: snapshot.origin) - try? FileManager.default.createDirectory(at: originDir, withIntermediateDirectories: true) - - // Save origin.txt for debugging - let originFile = originDir.appendingPathComponent("origin.txt") - try? snapshot.origin.write(to: originFile, atomically: true, encoding: .utf8) - - // Save registration.json - let registrationFile = originDir.appendingPathComponent("registration.json") - do { - let encoder = JSONEncoder() - encoder.outputFormatting = .prettyPrinted - let data = try encoder.encode(snapshot) - try data.write(to: registrationFile, options: .atomic) - } catch { - print("[CachePersistence] Failed to save registration: \(error)") - } - } - } - - func loadAllRegistrations() -> [RegistrationSnapshot] { - var registrations: [RegistrationSnapshot] = [] - - guard let contents = try? FileManager.default.contentsOfDirectory( - at: baseDirectory, - includingPropertiesForKeys: nil - ) else { - return registrations - } - - for originDir in contents { - let registrationFile = originDir.appendingPathComponent("registration.json") - guard let data = try? Data(contentsOf: registrationFile), - let snapshot = try? JSONDecoder().decode(RegistrationSnapshot.self, from: data) else { - continue - } - registrations.append(snapshot) - } - - return registrations - } - - // Returns list of origins without creating full registration objects - func loadAllOrigins() -> [String] { - var origins: [String] = [] - - guard let contents = try? FileManager.default.contentsOfDirectory( - at: baseDirectory, - includingPropertiesForKeys: nil - ) else { - return origins - } - - for originDir in contents { - // Read origin.txt for the actual origin string - let originFile = originDir.appendingPathComponent("origin.txt") - if let origin = try? String(contentsOf: originFile, encoding: .utf8) { - origins.append(origin.trimmingCharacters(in: .whitespacesAndNewlines)) - } - } - - return origins - } - - // MARK: - Script Persistence - - func saveScript(_ script: String, for origin: String) { - ioQueue.async { [weak self] in - guard let self = self else { return } - - let originDir = self.originDirectory(for: origin) - try? FileManager.default.createDirectory(at: originDir, withIntermediateDirectories: true) - - let scriptFile = originDir.appendingPathComponent("sw-script.js") - do { - try script.write(to: scriptFile, atomically: true, encoding: .utf8) - } catch { - print("[CachePersistence] Failed to save script: \(error)") - } - } - } - - func loadScript(for origin: String) -> String? { - let scriptFile = originDirectory(for: origin).appendingPathComponent("sw-script.js") - return try? String(contentsOf: scriptFile, encoding: .utf8) - } - - // MARK: - Cache Entry Persistence - - struct CacheManifestEntry: Codable { - let bodyFile: String - let status: Int - let statusText: String - let headers: [String: String] - } - - struct CacheManifest: Codable { - var entries: [String: CacheManifestEntry] = [:] - } - - func saveCacheEntry(origin: String, cacheName: String, url: String, response: CachedResponse) { - ioQueue.async { [weak self] in - guard let self = self else { return } - - let cacheDir = self.originDirectory(for: origin) - .appendingPathComponent("caches") - .appendingPathComponent(cacheName) - let bodiesDir = cacheDir.appendingPathComponent("bodies") - - try? FileManager.default.createDirectory(at: bodiesDir, withIntermediateDirectories: true) - - // Generate body filename from URL hash - let urlHash = self.originHash(url) - let bodyFile = "\(urlHash).body" - let bodyPath = bodiesDir.appendingPathComponent(bodyFile) - - // Write body - do { - try response.body.write(to: bodyPath, options: .atomic) - } catch { - print("[CachePersistence] Failed to write body: \(error)") - return - } - - // Update manifest - let manifestPath = cacheDir.appendingPathComponent("manifest.json") - var manifest = self.loadManifest(at: manifestPath) - manifest.entries[url] = CacheManifestEntry( - bodyFile: bodyFile, - status: response.status, - statusText: response.statusText, - headers: response.headers - ) - - do { - let encoder = JSONEncoder() - encoder.outputFormatting = .prettyPrinted - let data = try encoder.encode(manifest) - try data.write(to: manifestPath, options: .atomic) - } catch { - print("[CachePersistence] Failed to write manifest: \(error)") - } - } - } - - private func loadManifest(at path: URL) -> CacheManifest { - guard let data = try? Data(contentsOf: path), - let manifest = try? JSONDecoder().decode(CacheManifest.self, from: data) else { - return CacheManifest() - } - return manifest - } - - func loadCache(origin: String) -> [String: [String: CachedResponse]] { - var result: [String: [String: CachedResponse]] = [:] - - let cachesDir = originDirectory(for: origin).appendingPathComponent("caches") - guard let cacheNames = try? FileManager.default.contentsOfDirectory( - at: cachesDir, - includingPropertiesForKeys: nil - ) else { - return result - } - - for cacheDir in cacheNames { - let cacheName = cacheDir.lastPathComponent - let manifestPath = cacheDir.appendingPathComponent("manifest.json") - let manifest = loadManifest(at: manifestPath) - - var cacheEntries: [String: CachedResponse] = [:] - for (url, entry) in manifest.entries { - let bodyPath = cacheDir.appendingPathComponent("bodies").appendingPathComponent(entry.bodyFile) - guard let body = try? Data(contentsOf: bodyPath) else { - continue - } - cacheEntries[url] = CachedResponse( - url: url, - status: entry.status, - statusText: entry.statusText, - headers: entry.headers, - body: body - ) - } - - if !cacheEntries.isEmpty { - result[cacheName] = cacheEntries - } - } - - return result - } - - // MARK: - Delete Operations - - func deleteCacheEntry(origin: String, cacheName: String, url: String) { - ioQueue.async { [weak self] in - guard let self = self else { return } - - let cacheDir = self.originDirectory(for: origin) - .appendingPathComponent("caches") - .appendingPathComponent(cacheName) - let manifestPath = cacheDir.appendingPathComponent("manifest.json") - - var manifest = self.loadManifest(at: manifestPath) - guard let entry = manifest.entries.removeValue(forKey: url) else { return } - - // Delete body file - let bodyPath = cacheDir.appendingPathComponent("bodies").appendingPathComponent(entry.bodyFile) - try? FileManager.default.removeItem(at: bodyPath) - - // Update manifest - do { - let encoder = JSONEncoder() - encoder.outputFormatting = .prettyPrinted - let data = try encoder.encode(manifest) - try data.write(to: manifestPath, options: .atomic) - } catch { - print("[CachePersistence] Failed to update manifest: \(error)") - } - } - } - - func deleteCache(origin: String, name: String) { - ioQueue.async { [weak self] in - guard let self = self else { return } - - let cacheDir = self.originDirectory(for: origin) - .appendingPathComponent("caches") - .appendingPathComponent(name) - - try? FileManager.default.removeItem(at: cacheDir) - } - } - - func deleteOrigin(_ origin: String) { - ioQueue.async { [weak self] in - guard let self = self else { return } - - let originDir = self.originDirectory(for: origin) - try? FileManager.default.removeItem(at: originDir) - } - } -} diff --git a/submodules/WebWorkerShim/Sources/CacheStorage.swift b/submodules/WebWorkerShim/Sources/CacheStorage.swift deleted file mode 100644 index 8ba3bf028c..0000000000 --- a/submodules/WebWorkerShim/Sources/CacheStorage.swift +++ /dev/null @@ -1,150 +0,0 @@ -import Foundation - -struct CachedResponse: Codable { - let url: String - let status: Int - let statusText: String - let headers: [String: String] - let body: Data - - func toDictionary() -> [String: Any] { - return [ - "url": url, - "status": status, - "statusText": statusText, - "headers": headers, - "bodyBase64": body.base64EncodedString() - ] - } -} - -class CacheStorage { - static let shared = CacheStorage() - - // [origin: [cacheName: [url: CachedResponse]]] - private var storage: [String: [String: [String: CachedResponse]]] = [:] - private let queue = DispatchQueue(label: "com.workershim.cache", attributes: .concurrent) - - // Persistence layer (optional, nil during some tests) - var persistence: CachePersistence? - - private init() {} - - // MARK: - Persistence Integration - - func loadFromDisk() { - guard let persistence = persistence else { return } - - // Load all origins (without creating ServiceWorkerRegistration objects - // which have deallocation issues) - let origins = persistence.loadAllOrigins() - for origin in origins { - let caches = persistence.loadCache(origin: origin) - - queue.async(flags: .barrier) { - if self.storage[origin] == nil { - self.storage[origin] = [:] - } - for (cacheName, entries) in caches { - self.storage[origin]?[cacheName] = entries - } - } - } - } - - func open(origin: String, name: String) { - queue.async(flags: .barrier) { - if self.storage[origin] == nil { - self.storage[origin] = [:] - } - if self.storage[origin]![name] == nil { - self.storage[origin]![name] = [:] - } - } - } - - func keys(origin: String) -> [String] { - queue.sync { - guard let originStorage = self.storage[origin] else { return [] } - return Array(originStorage.keys) - } - } - - func delete(origin: String, name: String) -> Bool { - let result = queue.sync(flags: .barrier) { - return self.storage[origin]?.removeValue(forKey: name) != nil - } - persistence?.deleteCache(origin: origin, name: name) - return result - } - - func put(origin: String, cacheName: String, url: String, response: CachedResponse) { - print("[CacheStorage] PUT origin: \(origin), cache: \(cacheName), url: \(url)") - queue.async(flags: .barrier) { - // Ensure nested structure exists - if self.storage[origin] == nil { - self.storage[origin] = [:] - } - if self.storage[origin]![cacheName] == nil { - self.storage[origin]![cacheName] = [:] - } - self.storage[origin]![cacheName]![url] = response - } - persistence?.saveCacheEntry(origin: origin, cacheName: cacheName, url: url, response: response) - } - - func match(origin: String, cacheName: String?, url: String) -> CachedResponse? { - queue.sync { - print("[CacheStorage] match origin: \(origin), url: \(url)") - if let caches = self.storage[origin] { - print("[CacheStorage] Available caches for origin: \(caches.keys)") - for (name, cache) in caches { - print("[CacheStorage] Cache '\(name)' has \(cache.count) entries: \(Array(cache.keys).prefix(5))") - } - } else { - print("[CacheStorage] No caches for origin \(origin)") - } - - if let cacheName = cacheName { - return self.storage[origin]?[cacheName]?[url] - } - // Search all caches for this origin - guard let caches = self.storage[origin] else { return nil } - for (_, cache) in caches { - if let response = cache[url] { - return response - } - } - return nil - } - } - - func deleteEntry(origin: String, cacheName: String, url: String) -> Bool { - let result = queue.sync(flags: .barrier) { - return self.storage[origin]?[cacheName]?.removeValue(forKey: url) != nil - } - persistence?.deleteCacheEntry(origin: origin, cacheName: cacheName, url: url) - return result - } - - func cacheKeys(origin: String, cacheName: String) -> [String] { - queue.sync { - guard let cache = self.storage[origin]?[cacheName] else { return [] } - return Array(cache.keys) - } - } - - func clear(origin: String) { - queue.async(flags: .barrier) { - self.storage.removeValue(forKey: origin) - } - persistence?.deleteOrigin(origin) - } - - func clearAll() { - queue.async(flags: .barrier) { - self.storage.removeAll() - } - // Note: clearAll doesn't delete from disk - that's intentional for test isolation - } -} diff --git a/submodules/WebWorkerShim/Sources/PWAURLProtocol.swift b/submodules/WebWorkerShim/Sources/PWAURLProtocol.swift deleted file mode 100644 index c29f2e5c94..0000000000 --- a/submodules/WebWorkerShim/Sources/PWAURLProtocol.swift +++ /dev/null @@ -1,486 +0,0 @@ -import Foundation -import WebKit - -class PWAURLProtocol: URLProtocol { - private static let handledKey = "PWAURLProtocol.handled" - static var simulateOffline = false - static var testResourceBundle: Bundle? - - /// Build origin string including port if non-standard - static func buildOrigin(scheme: String, host: String, port: Int?) -> String { - var origin = "\(scheme)://\(host)" - if let port = port { - // Only add port if non-standard for the scheme - if (scheme == "http" && port != 80) || (scheme == "https" && port != 443) { - origin += ":\(port)" - } - } - return origin - } - - // MARK: - Network Fetching - - private var dataTask: URLSessionDataTask? - - // MARK: - Registration - - static func register() { - URLProtocol.registerClass(PWAURLProtocol.self) - - // Private API to make WKWebView use NSURLProtocol - let className = "WKBrowsingContextController" - let selectorName = "registerSchemeForCustomProtocol:" - - guard let cls = NSClassFromString(className) as? NSObject.Type else { - print("[PWAURLProtocol] Failed to find \(className)") - return - } - - let selector = NSSelectorFromString(selectorName) - guard cls.responds(to: selector) else { - print("[PWAURLProtocol] \(className) doesn't respond to \(selectorName)") - return - } - - cls.perform(selector, with: "https") - cls.perform(selector, with: "http") - print("[PWAURLProtocol] Registered for http and https schemes") - } - - static func unregister() { - URLProtocol.unregisterClass(PWAURLProtocol.self) - - let className = "WKBrowsingContextController" - let selectorName = "unregisterSchemeForCustomProtocol:" - - guard let cls = NSClassFromString(className) as? NSObject.Type else { return } - let selector = NSSelectorFromString(selectorName) - guard cls.responds(to: selector) else { return } - - cls.perform(selector, with: "https") - cls.perform(selector, with: "http") - } - - // MARK: - URLProtocol Overrides - - override class func canInit(with request: URLRequest) -> Bool { - // Already handled - prevent infinite loop - if property(forKey: handledKey, in: request) != nil { - return false - } - - if let url = request.url, url.path.hasSuffix("/rpc") { - return false - } - - // Skip SW context requests (marked with header) - if request.value(forHTTPHeaderField: "X-SW-Context") != nil { - return false - } - - // Only intercept http/https - guard let url = request.url, - let scheme = url.scheme, - (scheme == "http" || scheme == "https"), - let host = url.host else { - return false - } - - // Only intercept origins we're managing (have SW registered or being registered) - let origin = buildOrigin(scheme: scheme, host: host, port: url.port) - if origin.range(of: "ddns.net") == nil { - return false - } - return ServiceWorkerManager.shared.hasManagedOrigin(origin) - } - - override class func canonicalRequest(for request: URLRequest) -> URLRequest { - return request - } - - override func startLoading() { - // Mark as handled to prevent re-entry - let mutableRequest = (request as NSURLRequest).mutableCopy() as! NSMutableURLRequest - URLProtocol.setProperty(true, forKey: Self.handledKey, in: mutableRequest) - - guard let url = request.url, - let scheme = url.scheme, - let host = url.host else { - client?.urlProtocol(self, didFailWithError: URLError(.badURL)) - return - } - let urlPath: String - if #available(iOS 16.0, *) { - urlPath = url.path() - } else { - urlPath = url.path - } - - let origin = Self.buildOrigin(scheme: scheme, host: host, port: url.port) - - // Check for offline simulation (applies to all hosts including test host) - if Self.simulateOffline { - // Check cache first - if let cached = CacheStorage.shared.match(origin: origin, cacheName: nil, url: url.absoluteString) { - deliverCachedResponse(cached, for: url) - return - } - client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet)) - return - } - - // Check for active service worker first (even for test host) - // This allows SW to intercept requests before falling back to test bundle - Task { - if let registration = await ServiceWorkerManager.shared.getRegistration(for: origin, path: urlPath), - let context = await ServiceWorkerManager.shared.getContext(for: origin, path: urlPath) { - // Dispatch fetch event to SW - await self.dispatchToServiceWorker(context: context, origin: origin) - } else if host == "testpwa.local" { - // No SW, serve from test bundle for test host - await MainActor.run { self.serveTestResource(url: url) } - } else { - // No SW, fetch directly - self.fetchFromNetwork(mutableRequest as URLRequest) - } - } - } - - override func stopLoading() { - dataTask?.cancel() - } - - // MARK: - Network Fetching Methods - - func fetchFromNetwork(_ request: URLRequest) { - dataTask = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in - guard let self = self else { return } - - if let error = error { - self.client?.urlProtocol(self, didFailWithError: error) - return - } - - guard let httpResponse = response as? HTTPURLResponse, - var data = data else { - self.client?.urlProtocol(self, didFailWithError: URLError(.cannotParseResponse)) - return - } - - // Convert headers for passing to injection method - var headers = httpResponse.allHeaderFields as? [String: String] ?? [:] - - // Inject shim into HTML responses - if let mimeType = httpResponse.mimeType, mimeType.contains("html") { - data = self.injectShimIntoHTML(data, headers: headers) ?? data - } - - // Update content length - headers["Content-Length"] = "\(data.count)" - - guard let newResponse = HTTPURLResponse( - url: self.request.url!, - statusCode: httpResponse.statusCode, - httpVersion: nil, - headerFields: headers - ) else { - self.client?.urlProtocol(self, didFailWithError: URLError(.cannotParseResponse)) - return - } - - self.client?.urlProtocol(self, didReceive: newResponse, cacheStoragePolicy: .notAllowed) - self.client?.urlProtocol(self, didLoad: data) - self.client?.urlProtocolDidFinishLoading(self) - } - dataTask?.resume() - } - - // MARK: - HTML Injection - - /// Extract nonce from CSP header if present - private func extractCSPNonce(from headers: [String: String]?) -> String? { - guard let headers = headers else { return nil } - - // Check both standard and report-only CSP headers - let cspHeader = headers.first { key, _ in - key.lowercased() == "content-security-policy" || - key.lowercased() == "content-security-policy-report-only" - }?.value - - guard let csp = cspHeader else { return nil } - - // Parse script-src directive and extract nonce - // Format: script-src 'nonce-BASE64VALUE' ... - let pattern = "'nonce-([A-Za-z0-9+/=]+)'" - guard let regex = try? NSRegularExpression(pattern: pattern, options: []), - let match = regex.firstMatch(in: csp, options: [], range: NSRange(csp.startIndex..., in: csp)), - let nonceRange = Range(match.range(at: 1), in: csp) else { - return nil - } - - return String(csp[nonceRange]) - } - - /// Generate a random nonce for script injection - private func generateNonce() -> String { - var bytes = [UInt8](repeating: 0, count: 16) - _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - return Data(bytes).base64EncodedString() - } - - private func injectShimIntoHTML(_ data: Data, headers: [String: String]? = nil) -> Data? { - guard var html = String(data: data, encoding: .utf8) else { - return nil - } - let mainBundle = Bundle(for: PWAWebView.self) - guard let bundle = Bundle(path: mainBundle.bundlePath + "/WebWorkerShimBundle.bundle") else { - return nil - } - guard let shimURL = bundle.url(forResource: "sw-registration-shim", withExtension: "js"), - let shimCode = try? String(contentsOf: shimURL, encoding: .utf8) else { - return nil - } - - // Try to use existing nonce from CSP, or generate one and add to CSP - let nonce = extractCSPNonce(from: headers) - let nonceAttr = nonce.map { " nonce=\"\($0)\"" } ?? "" - - let injection = "\(shimCode)" - - // Inject at start of or after - if let headRange = html.range(of: "", options: .caseInsensitive) { - html.insert(contentsOf: injection, at: headRange.upperBound) - } else if let doctypeRange = html.range(of: ">", options: [], range: html.startIndex...allocate(capacity: bufferSize) - defer { buffer.deallocate() } - - var streamError = false - while bodyStream.hasBytesAvailable { - let bytesRead = bodyStream.read(buffer, maxLength: bufferSize) - if bytesRead > 0 { - data.append(buffer, count: bytesRead) - } else if bytesRead < 0 { - // Stream error - print("[PWAURLProtocol] Stream read error: \(bodyStream.streamError?.localizedDescription ?? "unknown")") - streamError = true - break - } else { - // EOF (bytesRead == 0) - break - } - } - body = streamError ? nil : (data.isEmpty ? nil : data) - } - } - - // Map request options - var options: [String: String] = [:] - - // cachePolicy -> cache - switch request.cachePolicy { - case .useProtocolCachePolicy: - options["cache"] = "default" - case .reloadIgnoringLocalCacheData: - options["cache"] = "default" - case .reloadIgnoringLocalAndRemoteCacheData: - options["cache"] = "no-store" - case .returnCacheDataElseLoad: - options["cache"] = "force-cache" - case .returnCacheDataDontLoad: - options["cache"] = "only-if-cached" - case .reloadRevalidatingCacheData: - options["cache"] = "no-cache" - @unknown default: - break - } - - // httpShouldHandleCookies -> credentials - if request.httpShouldHandleCookies { - options["credentials"] = "same-origin" - } else { - options["credentials"] = "omit" - } - - // Register callback - await ServiceWorkerManager.shared._registerPendingRequest(requestId, protocol: self) - - // Dispatch to SW context - await MainActor.run { - print("[PWAURLProtocol] Dispatching fetch to SW for: \(url)") - context.dispatchFetchEvent( - requestId: requestId, - url: url, - method: method, - headers: headers, - body: body, - options: options.isEmpty ? nil : options - ) - } - } - - // Called by ServiceWorkerManager when SW responds - func handleFetchResponse(_ response: [String: Any]) { - guard let url = request.url else { - client?.urlProtocol(self, didFailWithError: URLError(.badURL)) - return - } - - let status = response["status"] as? Int ?? 200 - var headers = response["headers"] as? [String: String] ?? [:] - let bodyBase64 = response["bodyBase64"] as? String ?? "" - var body = Data(base64Encoded: bodyBase64) ?? Data() - - // Inject shim into HTML responses - let contentType = headers.first { $0.key.lowercased() == "content-type" }?.value.lowercased() ?? "" - if contentType.contains("html") { - body = injectShimIntoHTML(body, headers: headers) ?? body - } - headers["Content-Length"] = "\(body.count)" - - guard let httpResponse = HTTPURLResponse( - url: url, - statusCode: status, - httpVersion: nil, - headerFields: headers - ) else { - client?.urlProtocol(self, didFailWithError: URLError(.cannotParseResponse)) - return - } - - client?.urlProtocol(self, didReceive: httpResponse, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: body) - client?.urlProtocolDidFinishLoading(self) - } - - // Called when SW passes through (doesn't handle) - func handlePassthrough() { - guard let url = request.url else { - client?.urlProtocol(self, didFailWithError: URLError(.badURL)) - return - } - - let scheme = url.scheme ?? "https" - let host = url.host ?? "" - let origin = Self.buildOrigin(scheme: scheme, host: host, port: url.port) - - print("[PWAURLProtocol] SW passthrough for: \(url.absoluteString)") - - // Check cache before network - if let cached = CacheStorage.shared.match(origin: origin, cacheName: nil, url: url.absoluteString) { - print("[PWAURLProtocol] Passthrough cache HIT") - deliverCachedResponse(cached, for: url) - return - } - - // For test host, serve from test bundle instead of network - if host == "testpwa.local" { - print("[PWAURLProtocol] Passthrough serving from test bundle") - serveTestResource(url: url) - return - } - - print("[PWAURLProtocol] Passthrough cache MISS, fetching from network") - - // Mark request and fetch - let mutableRequest = (request as NSURLRequest).mutableCopy() as! NSMutableURLRequest - URLProtocol.setProperty(true, forKey: Self.handledKey, in: mutableRequest) - fetchFromNetwork(mutableRequest as URLRequest) - } -} diff --git a/submodules/WebWorkerShim/Sources/PWAWebView.swift b/submodules/WebWorkerShim/Sources/PWAWebView.swift deleted file mode 100644 index 5f058a2c58..0000000000 --- a/submodules/WebWorkerShim/Sources/PWAWebView.swift +++ /dev/null @@ -1,365 +0,0 @@ -import WebKit - -let registeredPWAProtocol: Bool = { - PWAURLProtocol.register() - - // Initialize persistence - let persistence = CachePersistence.shared - CacheStorage.shared.persistence = persistence - - // Load persisted data - CacheStorage.shared.loadFromDisk() - - Task { - await ServiceWorkerManager.shared.setPersistence(persistence) - await ServiceWorkerManager.shared.loadFromDisk() - } - - return true -}() - -open class PWAWebView: WKWebView { - - /// Creates a PWAWebView with message handlers configured. - override public init(frame: CGRect, configuration: WKWebViewConfiguration) { - let _ = registeredPWAProtocol - - // Add message handler for SW registration and cache operations - let messageHandler = PWAMessageHandler() - configuration.userContentController.add(messageHandler, name: "swBridge") - - super.init(frame: frame, configuration: configuration) - } - - required public init?(coder: NSCoder) { - fatalError("PWAWebView does not support initialization from storyboard.") - } - - deinit { - self.configuration.userContentController.removeScriptMessageHandler(forName: "swBridge") - } - - public func loadPWA(url: URL) { - // Pre-register this origin so PWAURLProtocol knows to intercept it - if let scheme = url.scheme, let host = url.host { - let origin = PWAURLProtocol.buildOrigin(scheme: scheme, host: host, port: url.port) - Task { - await ServiceWorkerManager.shared.addManagedOrigin(origin) - } - } - - // Load the real URL directly - PWAURLProtocol will intercept it - var request = URLRequest(url: url) - request.cachePolicy = .reloadIgnoringLocalCacheData - load(request) - } -} - -private class PWAMessageHandler: NSObject, WKScriptMessageHandler { - /// Extract the actual origin from the page URL for validation - private func getPageOrigin(from message: WKScriptMessage) -> String? { - guard let pageURL = message.frameInfo.request.url, - let host = pageURL.host, - let scheme = pageURL.scheme else { return nil } - - var origin = "\(scheme)://\(host)" - if let port = pageURL.port, port != 80 && port != 443 { - origin += ":\(port)" - } - return origin - } - - /// Validate that the claimed origin matches the page's actual origin - private func validateOrigin(_ claimedOrigin: String, message: WKScriptMessage) -> Bool { - guard let pageOrigin = getPageOrigin(from: message) else { return false } - return claimedOrigin == pageOrigin - } - - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - guard let body = message.body as? [String: Any], - let type = body["type"] as? String else { return } - - switch type { - case "register": - handleRegister(body: body, message: message) - - case "unregister": - handleUnregister(body: body, message: message) - - case "cacheOpen": - if let claimedOrigin = body["origin"] as? String, - let name = body["cacheName"] as? String, - validateOrigin(claimedOrigin, message: message) { - CacheStorage.shared.open(origin: claimedOrigin, name: name) - } - - case "cachePut": - if let claimedOrigin = body["origin"] as? String, - let cacheName = body["cacheName"] as? String, - let url = body["url"] as? String, - let responseDict = body["response"] as? [String: Any], - validateOrigin(claimedOrigin, message: message) { - let response = CachedResponse( - url: url, - status: responseDict["status"] as? Int ?? 200, - statusText: responseDict["statusText"] as? String ?? "OK", - headers: responseDict["headers"] as? [String: String] ?? [:], - body: Data(base64Encoded: responseDict["bodyBase64"] as? String ?? "") ?? Data() - ) - CacheStorage.shared.put(origin: claimedOrigin, cacheName: cacheName, url: url, response: response) - } - - case "cacheMatch": - handleCacheMatch(body: body, message: message) - - case "cacheEntryDelete": - handleCacheEntryDelete(body: body, message: message) - - case "cacheKeys": - handleCacheKeys(body: body, message: message) - - case "cacheStorageDelete": - handleCacheStorageDelete(body: body, message: message) - - case "cachesKeys": - handleCachesKeys(body: body, message: message) - - default: - break - } - } - - private func handleRegister(body: [String: Any], message: WKScriptMessage) { - guard let scriptURLString = body["scriptURL"] as? String, - let scriptURL = URL(string: scriptURLString), - let scope = body["scope"] as? String else { return } - - let callbackId = body["callbackId"] as? String - - // Get origin from the page URL (now a real https:// URL) - guard let pageURL = message.frameInfo.request.url, - let host = pageURL.host, - let scheme = pageURL.scheme else { - if let callbackId = callbackId { - message.webView?.evaluateJavaScript( - "window.resolveRegistrationCallback('\(callbackId)', false, 'Invalid page URL');", - completionHandler: nil - ) - } - return - } - - // Include port in origin - var origin = "\(scheme)://\(host)" - if let port = pageURL.port, port != 80 && port != 443 { - origin += ":\(port)" - } - - // Register the service worker - Task { - let registration = await ServiceWorkerManager.shared.register( - scriptURL: scriptURL, - scope: scope, - origin: origin - ) - - // Notify JS that registration started successfully - if let callbackId = callbackId { - await MainActor.run { - message.webView?.evaluateJavaScript( - "window.resolveRegistrationCallback('\(callbackId)', true, null);", - completionHandler: nil - ) - } - } - - // Fetch the SW script - await self.fetchAndLoadServiceWorker( - scriptURL: scriptURL, - registration: registration, - origin: origin, - webView: message.webView, - scope: scope - ) - } - } - - private func fetchAndLoadServiceWorker(scriptURL: URL, registration: ServiceWorkerRegistration, origin: String, webView: WKWebView?, scope: String) async { - // Handle test host - if scriptURL.host == "testpwa.local" { - let path = scriptURL.path - let filename = (path as NSString).lastPathComponent - let ext = (filename as NSString).pathExtension - let name = (filename as NSString).deletingPathExtension - - let bundle = PWAURLProtocol.testResourceBundle ?? Bundle(for: PWAMessageHandler.self) - if let fileURL = bundle.url(forResource: name, withExtension: ext), - let script = try? String(contentsOf: fileURL, encoding: .utf8) { - await loadServiceWorkerScript(script, registration: registration, origin: origin, webView: webView, scope: scope) - } else { - print("[PWAMessageHandler] Failed to load test SW script: \(filename)") - } - return - } - - // Fetch from network - do { - // Mark as SW context request to avoid interception - var request = URLRequest(url: scriptURL) - request.setValue("true", forHTTPHeaderField: "X-SW-Context") - - let (data, _) = try await URLSession.shared.data(for: request) - guard let script = String(data: data, encoding: .utf8) else { - print("[PWAMessageHandler] Failed to decode SW script") - return - } - await loadServiceWorkerScript(script, registration: registration, origin: origin, webView: webView, scope: scope) - } catch { - print("[PWAMessageHandler] Failed to fetch SW script: \(error)") - } - } - - private func loadServiceWorkerScript(_ script: String, registration: ServiceWorkerRegistration, origin: String, webView: WKWebView?, scope: String) async { - await registration.setScriptContent(script) - - // Persist the script - CachePersistence.shared.saveScript(script, for: origin) - - await MainActor.run { - // Create context if needed - Task { - var context = await ServiceWorkerManager.shared.getContext(for: origin, scope: scope) - if context == nil { - context = ServiceWorkerContext(registration: registration, origin: origin, pageWebView: webView, scope: scope) - await ServiceWorkerManager.shared.setContext(context!, for: origin, scope: scope) - } - context?.loadServiceWorkerScript(script) - } - } - } - - private func handleUnregister(body: [String: Any], message: WKScriptMessage) { - guard let callbackId = body["callbackId"] as? String else { return } - - // Get origin from the page URL - guard let pageURL = message.frameInfo.request.url, - let host = pageURL.host, - let scheme = pageURL.scheme else { - message.webView?.evaluateJavaScript( - "window.resolveRegistrationCallback('\(callbackId)', false, 'Invalid page URL');", - completionHandler: nil - ) - return - } - - // Include port in origin - var origin = "\(scheme)://\(host)" - if let port = pageURL.port, port != 80 && port != 443 { - origin += ":\(port)" - } - - Task { - let success = await ServiceWorkerManager.shared.unregister(origin: origin) - await ServiceWorkerManager.shared.removeContext(for: origin) - await ServiceWorkerManager.shared.removeManagedOrigin(origin) - - await MainActor.run { - message.webView?.evaluateJavaScript( - "window.resolveRegistrationCallback('\(callbackId)', \(success), null);", - completionHandler: nil - ) - } - } - } - - private func handleCacheMatch(body: [String: Any], message: WKScriptMessage) { - guard let callbackId = body["callbackId"] as? String, - let claimedOrigin = body["origin"] as? String, - let url = body["url"] as? String, - validateOrigin(claimedOrigin, message: message) else { - // Return null for invalid/missing origin - if let callbackId = body["callbackId"] as? String { - message.webView?.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', null);", completionHandler: nil) - } - return - } - - let cacheName = body["cacheName"] as? String - let response = CacheStorage.shared.match(origin: claimedOrigin, cacheName: cacheName, url: url) - - let responseJS: String - if let r = response { - let dict = r.toDictionary() - let data = try? JSONSerialization.data(withJSONObject: dict, options: []) - responseJS = data.flatMap { String(data: $0, encoding: .utf8) } ?? "null" - } else { - responseJS = "null" - } - - message.webView?.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', \(responseJS));", completionHandler: nil) - } - - private func handleCacheEntryDelete(body: [String: Any], message: WKScriptMessage) { - guard let callbackId = body["callbackId"] as? String, - let claimedOrigin = body["origin"] as? String, - let cacheName = body["cacheName"] as? String, - let url = body["url"] as? String, - validateOrigin(claimedOrigin, message: message) else { - if let callbackId = body["callbackId"] as? String { - message.webView?.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', false);", completionHandler: nil) - } - return - } - - let deleted = CacheStorage.shared.deleteEntry(origin: claimedOrigin, cacheName: cacheName, url: url) - message.webView?.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', \(deleted));", completionHandler: nil) - } - - private func handleCacheKeys(body: [String: Any], message: WKScriptMessage) { - guard let callbackId = body["callbackId"] as? String, - let claimedOrigin = body["origin"] as? String, - let cacheName = body["cacheName"] as? String, - validateOrigin(claimedOrigin, message: message) else { - if let callbackId = body["callbackId"] as? String { - message.webView?.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', []);", completionHandler: nil) - } - return - } - - let urls = CacheStorage.shared.cacheKeys(origin: claimedOrigin, cacheName: cacheName) - let data = try? JSONSerialization.data(withJSONObject: urls, options: []) - let urlsJS = data.flatMap { String(data: $0, encoding: .utf8) } ?? "[]" - message.webView?.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', \(urlsJS));", completionHandler: nil) - } - - private func handleCacheStorageDelete(body: [String: Any], message: WKScriptMessage) { - guard let callbackId = body["callbackId"] as? String, - let claimedOrigin = body["origin"] as? String, - let name = body["cacheName"] as? String, - validateOrigin(claimedOrigin, message: message) else { - if let callbackId = body["callbackId"] as? String { - message.webView?.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', false);", completionHandler: nil) - } - return - } - - let deleted = CacheStorage.shared.delete(origin: claimedOrigin, name: name) - message.webView?.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', \(deleted));", completionHandler: nil) - } - - private func handleCachesKeys(body: [String: Any], message: WKScriptMessage) { - guard let callbackId = body["callbackId"] as? String, - let claimedOrigin = body["origin"] as? String, - validateOrigin(claimedOrigin, message: message) else { - if let callbackId = body["callbackId"] as? String { - message.webView?.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', []);", completionHandler: nil) - } - return - } - - let names = CacheStorage.shared.keys(origin: claimedOrigin) - let data = try? JSONSerialization.data(withJSONObject: names, options: []) - let namesJS = data.flatMap { String(data: $0, encoding: .utf8) } ?? "[]" - message.webView?.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', \(namesJS));", completionHandler: nil) - } -} diff --git a/submodules/WebWorkerShim/Sources/ServiceWorkerContext.swift b/submodules/WebWorkerShim/Sources/ServiceWorkerContext.swift deleted file mode 100644 index 19bb98605f..0000000000 --- a/submodules/WebWorkerShim/Sources/ServiceWorkerContext.swift +++ /dev/null @@ -1,529 +0,0 @@ -import WebKit - -protocol ServiceWorkerContextDelegate: AnyObject { - func serviceWorkerContext(_ context: ServiceWorkerContext, didReceiveFetchResponse response: [String: Any], forRequestId requestId: String) - func serviceWorkerContext(_ context: ServiceWorkerContext, requestPassthroughForRequestId requestId: String) -} - -class ServiceWorkerContext: NSObject { - private var webView: WKWebView! - private let registration: ServiceWorkerRegistration - private let origin: String // Cached for synchronous access - private weak var pageWebView: WKWebView? // Page WebView for state updates - private let scope: String - weak var delegate: ServiceWorkerContextDelegate? - - private var isReady = false - private var pendingScript: String? - private var loadedScriptHash: Int? // Track loaded script to prevent duplicate execution - private var pendingInstallCallback: ((Bool) -> Void)? - private var pendingActivateCallback: ((Bool) -> Void)? - - init(registration: ServiceWorkerRegistration, origin: String, pageWebView: WKWebView? = nil, scope: String = "/") { - self.registration = registration - self.origin = origin - self.pageWebView = pageWebView - self.scope = scope - super.init() - - setupWebView() - } - - /// Notify the page WebView of SW state changes - private func notifyStateChange(_ state: String) { - let escapedScope = scope.replacingOccurrences(of: "'", with: "\\'") - let js = "if (window.updateSWState) { window.updateSWState('\(escapedScope)', '\(state)'); }" - pageWebView?.evaluateJavaScript(js, completionHandler: nil) - } - - deinit { - webView?.configuration.userContentController.removeScriptMessageHandler(forName: "swBridge") - } - - private func setupWebView() { - let config = WKWebViewConfiguration() - config.userContentController = WKUserContentController() - - // Add message handler - config.userContentController.add(self, name: "swBridge") - - // Inject context shim - let mainBundle = Bundle(for: PWAWebView.self) - if let bundle = Bundle(path: mainBundle.bundlePath + "/WebWorkerShimBundle.bundle") { - if let shimURL = bundle.url(forResource: "sw-context-shim", withExtension: "js"), - let shimCode = try? String(contentsOf: shimURL, encoding: .utf8) { - let script = WKUserScript(source: shimCode, injectionTime: .atDocumentStart, forMainFrameOnly: true) - config.userContentController.addUserScript(script) - } - } - - webView = WKWebView(frame: .zero, configuration: config) - webView.loadHTMLString("", baseURL: nil) - } - - func loadServiceWorkerScript(_ script: String) { - let scriptHash = script.hashValue - - // Skip if same script is already loaded (prevents duplicate variable errors on page reload) - if let existingHash = loadedScriptHash, existingHash == scriptHash { - // Script already loaded - notify page of current state - Task { - let state = await registration.state - if state == .activated { - await MainActor.run { self.notifyStateChange("activated") } - } - } - return - } - - // If a different script, we need to reset the WebView (SW update scenario) - if loadedScriptHash != nil { - resetWebView() - } - - loadedScriptHash = scriptHash - - if isReady { - executeScript(script) - } else { - pendingScript = script - } - } - - private func resetWebView() { - webView?.configuration.userContentController.removeScriptMessageHandler(forName: "swBridge") - isReady = false - pendingScript = nil - setupWebView() - } - - private func escapeForJS(_ string: String) -> String { - // Wrap string in array since JSONSerialization requires array/dict as top-level - guard let data = try? JSONSerialization.data(withJSONObject: [string], options: []), - let jsonArray = String(data: data, encoding: .utf8) else { - return "\"\"" - } - // Remove the array brackets: ["string"] -> "string" - let trimmed = jsonArray.dropFirst().dropLast() - return String(trimmed) - } - - private func executeScript(_ script: String) { - // Notify page that SW is installing - notifyStateChange("installing") - - Task { - let scriptURL = await registration.scriptURL - let realScriptURL = scriptURL.absoluteString // Already a real https:// URL - - await MainActor.run { - let escapedOrigin = self.escapeForJS(self.origin) - let escapedScriptURL = self.escapeForJS(realScriptURL) - let setOrigin = "window.setOrigin(\(escapedOrigin), \(escapedScriptURL));" - self.webView.evaluateJavaScript(setOrigin) { [weak self] _, _ in - self?.webView.evaluateJavaScript(script) { [weak self] _, error in - if let error = error { - print("SW script error: \(error)") - return - } - self?.dispatchInstallEvent() - } - } - } - } - } - - private func dispatchInstallEvent() { - // The JS function returns a Promise that resolves after all waitUntil promises complete. - // We use a callback-based approach to properly wait for the Promise. - let callbackId = "install_\(UUID().uuidString)" - let js = """ - (function() { - window.dispatchInstallEvent().then(function(success) { - webkit.messageHandlers.swBridge.postMessage({ - type: 'installComplete', - callbackId: '\(callbackId)', - success: success - }); - }).catch(function(err) { - webkit.messageHandlers.swBridge.postMessage({ - type: 'installComplete', - callbackId: '\(callbackId)', - success: false, - error: err.message || String(err) - }); - }); - })(); - """ - - pendingInstallCallback = { [weak self] success in - guard let self = self else { return } - if success { - Task { - await ServiceWorkerManager.shared.updateState(self.registration, to: .installed) - await MainActor.run { - self.notifyStateChange("installed") - self.dispatchActivateEvent() - } - } - } else { - Task { await ServiceWorkerManager.shared.updateState(self.registration, to: .redundant) } - } - } - - webView.evaluateJavaScript(js) { [weak self] _, error in - if let error = error { - print("SW install event error: \(error)") - self?.pendingInstallCallback?(false) - self?.pendingInstallCallback = nil - } - } - } - - private func dispatchActivateEvent() { - // The JS function returns a Promise that resolves after all waitUntil promises complete. - // We use a callback-based approach to properly wait for the Promise. - let callbackId = "activate_\(UUID().uuidString)" - let js = """ - (function() { - window.dispatchActivateEvent().then(function(success) { - webkit.messageHandlers.swBridge.postMessage({ - type: 'activateComplete', - callbackId: '\(callbackId)', - success: success - }); - }).catch(function(err) { - webkit.messageHandlers.swBridge.postMessage({ - type: 'activateComplete', - callbackId: '\(callbackId)', - success: false, - error: err.message || String(err) - }); - }); - })(); - """ - - pendingActivateCallback = { [weak self] success in - guard let self = self else { return } - if success { - Task { - await ServiceWorkerManager.shared.updateState(self.registration, to: .activated) - await MainActor.run { self.notifyStateChange("activated") } - } - } else { - Task { await ServiceWorkerManager.shared.updateState(self.registration, to: .redundant) } - } - } - - webView.evaluateJavaScript(js) { [weak self] _, error in - if let error = error { - print("SW activate event error: \(error)") - self?.pendingActivateCallback?(false) - self?.pendingActivateCallback = nil - } - } - } - - func dispatchFetchEvent(requestId: String, url: String, method: String, headers: [String: String], body: Data?, options: [String: String]?) { - let escapedRequestId = escapeForJS(requestId) - let escapedUrl = escapeForJS(url) - let escapedMethod = escapeForJS(method) - let headersJSON = (try? JSONSerialization.data(withJSONObject: headers, options: [])) - .flatMap { String(data: $0, encoding: .utf8) } ?? "{}" - - // Encode body as base64, or null if empty - let bodyBase64: String - if let body = body, !body.isEmpty { - bodyBase64 = "\"\(body.base64EncodedString())\"" - } else { - bodyBase64 = "null" - } - - // Encode options as JSON, or null if empty - let optionsJSON: String - if let options = options, !options.isEmpty { - optionsJSON = (try? JSONSerialization.data(withJSONObject: options, options: [])) - .flatMap { String(data: $0, encoding: .utf8) } ?? "null" - } else { - optionsJSON = "null" - } - - let js = "window.dispatchFetchEvent(\(escapedRequestId), \(escapedUrl), \(escapedMethod), \(headersJSON), \(bodyBase64), \(optionsJSON));" - webView.evaluateJavaScript(js, completionHandler: nil) - } -} - -// MARK: - SW Fetch Handling - -extension ServiceWorkerContext { - func handleSWFetch(requestId: String, urlString: String, origin: String, method: String, headers: [String: String], body: Data?) { - // Check for test host - serve from test bundle - if let url = URL(string: urlString), url.host == "testpwa.local" { - serveTestResource(requestId: requestId, url: url, method: method, body: body) - return - } - - // Fetch from network - guard let url = URL(string: urlString) else { - sendSWFetchError(requestId: requestId, error: "Invalid URL") - return - } - - var request = URLRequest(url: url) - request.httpMethod = method - for (key, value) in headers { - request.setValue(value, forHTTPHeaderField: key) - } - - // Mark as SW context request to prevent re-interception by PWAURLProtocol - request.setValue("true", forHTTPHeaderField: "X-SW-Context") - - // Set request body if provided (for POST, PUT, etc.) - if let body = body, !body.isEmpty { - request.httpBody = body - } - - URLSession.shared.dataTask(with: request) { [weak self] data, response, error in - DispatchQueue.main.async { - if let error = error { - self?.sendSWFetchError(requestId: requestId, error: error.localizedDescription) - return - } - - guard let httpResponse = response as? HTTPURLResponse, - let data = data else { - self?.sendSWFetchError(requestId: requestId, error: "Invalid response") - return - } - - var responseHeaders: [String: String] = [:] - httpResponse.allHeaderFields.forEach { key, value in - if let keyStr = key as? String, let valueStr = value as? String { - responseHeaders[keyStr] = valueStr - } - } - - let responseDict: [String: Any] = [ - "status": httpResponse.statusCode, - "statusText": HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode), - "headers": responseHeaders, - "bodyBase64": data.base64EncodedString() - ] - - self?.sendSWFetchResponse(requestId: requestId, response: responseDict) - } - }.resume() - } - - private func serveTestResource(requestId: String, url: URL, method: String = "GET", body: Data? = nil) { - let path = url.path - - // Special endpoint: /echo-body - echoes the request body back as JSON - if path == "/echo-body" || path.hasSuffix("/echo-body") { - let bodyString = body.flatMap { String(data: $0, encoding: .utf8) } ?? "" - let responseJSON: [String: Any] = [ - "method": method, - "bodyReceived": bodyString, - "bodyLength": body?.count ?? 0 - ] - guard let jsonData = try? JSONSerialization.data(withJSONObject: responseJSON, options: []) else { - sendSWFetchError(requestId: requestId, error: "Failed to serialize echo response") - return - } - let responseDict: [String: Any] = [ - "status": 200, - "statusText": "OK", - "headers": ["Content-Type": "application/json"], - "bodyBase64": jsonData.base64EncodedString() - ] - sendSWFetchResponse(requestId: requestId, response: responseDict) - return - } - - let filename = (path as NSString).lastPathComponent - let ext = (filename as NSString).pathExtension - let name = (filename as NSString).deletingPathExtension - - let bundle = PWAURLProtocol.testResourceBundle ?? Bundle.main - guard let fileURL = bundle.url(forResource: name, withExtension: ext), - let data = try? Data(contentsOf: fileURL) else { - sendSWFetchError(requestId: requestId, error: "Resource not found: \(filename)") - return - } - - let mimeType: String - switch ext.lowercased() { - case "html": mimeType = "text/html" - case "js": mimeType = "application/javascript" - case "json": mimeType = "application/json" - case "css": mimeType = "text/css" - default: mimeType = "application/octet-stream" - } - - let responseDict: [String: Any] = [ - "status": 200, - "statusText": "OK", - "headers": ["Content-Type": mimeType], - "bodyBase64": data.base64EncodedString() - ] - - sendSWFetchResponse(requestId: requestId, response: responseDict) - } - - private func sendSWFetchResponse(requestId: String, response: [String: Any]) { - guard let data = try? JSONSerialization.data(withJSONObject: response, options: []), - let responseJS = String(data: data, encoding: .utf8) else { - sendSWFetchError(requestId: requestId, error: "Failed to serialize response") - return - } - - let js = "window.resolveSWFetchCallback('\(requestId)', \(responseJS), null);" - webView.evaluateJavaScript(js, completionHandler: nil) - } - - private func sendSWFetchError(requestId: String, error: String) { - let escapedError = error.replacingOccurrences(of: "'", with: "\\'") - let js = "window.resolveSWFetchCallback('\(requestId)', null, '\(escapedError)');" - webView.evaluateJavaScript(js, completionHandler: nil) - } -} - -extension ServiceWorkerContext: WKScriptMessageHandler { - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - guard let body = message.body as? [String: Any], - let type = body["type"] as? String else { return } - - switch type { - case "contextReady": - isReady = true - if let script = pendingScript { - pendingScript = nil - executeScript(script) - } - - case "installComplete": - let success = body["success"] as? Bool ?? false - pendingInstallCallback?(success) - pendingInstallCallback = nil - - case "activateComplete": - let success = body["success"] as? Bool ?? false - pendingActivateCallback?(success) - pendingActivateCallback = nil - - case "fetchResponse": - guard let requestId = body["requestId"] as? String else { return } - - // Route to PWAURLProtocol if this is a protocol request - Task { - if let urlProtocol = await ServiceWorkerManager.shared.getPendingRequest(requestId) { - await MainActor.run { - if body["passthrough"] as? Bool == true { - urlProtocol.handlePassthrough() - } else if let response = body["response"] as? [String: Any] { - urlProtocol.handleFetchResponse(response) - } else if body["error"] != nil { - urlProtocol.handlePassthrough() - } - } - } else { - // Fallback to delegate (for backwards compatibility during migration) - await MainActor.run { - if body["passthrough"] as? Bool == true { - self.delegate?.serviceWorkerContext(self, requestPassthroughForRequestId: requestId) - } else if let response = body["response"] as? [String: Any] { - self.delegate?.serviceWorkerContext(self, didReceiveFetchResponse: response, forRequestId: requestId) - } else if body["error"] != nil { - self.delegate?.serviceWorkerContext(self, requestPassthroughForRequestId: requestId) - } - } - } - } - - case "cacheOpen": - if let origin = body["origin"] as? String, - let name = body["cacheName"] as? String { - CacheStorage.shared.open(origin: origin, name: name) - } - - case "cachePut": - if let origin = body["origin"] as? String, - let cacheName = body["cacheName"] as? String, - let url = body["url"] as? String, - let responseDict = body["response"] as? [String: Any] { - let response = CachedResponse( - url: url, - status: responseDict["status"] as? Int ?? 200, - statusText: responseDict["statusText"] as? String ?? "OK", - headers: responseDict["headers"] as? [String: String] ?? [:], - body: Data(base64Encoded: responseDict["bodyBase64"] as? String ?? "") ?? Data() - ) - CacheStorage.shared.put(origin: origin, cacheName: cacheName, url: url, response: response) - } - - case "cacheMatch": - if let callbackId = body["callbackId"] as? String, - let origin = body["origin"] as? String, - let url = body["url"] as? String { - let cacheName = body["cacheName"] as? String - let response = CacheStorage.shared.match(origin: origin, cacheName: cacheName, url: url) - let responseJS: String - if let r = response { - let dict = r.toDictionary() - let data = try? JSONSerialization.data(withJSONObject: dict, options: []) - responseJS = data.flatMap { String(data: $0, encoding: .utf8) } ?? "null" - } else { - responseJS = "null" - } - webView.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', \(responseJS));", completionHandler: nil) - } - - case "cacheEntryDelete": - if let callbackId = body["callbackId"] as? String, - let origin = body["origin"] as? String, - let cacheName = body["cacheName"] as? String, - let url = body["url"] as? String { - let deleted = CacheStorage.shared.deleteEntry(origin: origin, cacheName: cacheName, url: url) - webView.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', \(deleted));", completionHandler: nil) - } - - case "cachesKeys": - if let callbackId = body["callbackId"] as? String, - let origin = body["origin"] as? String { - let names = CacheStorage.shared.keys(origin: origin) - let data = try? JSONSerialization.data(withJSONObject: names, options: []) - let namesJS = data.flatMap { String(data: $0, encoding: .utf8) } ?? "[]" - webView.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', \(namesJS));", completionHandler: nil) - } - - case "cacheStorageDelete": - // Handle both callback-based and fire-and-forget versions - if let callbackId = body["callbackId"] as? String, - let origin = body["origin"] as? String, - let name = body["cacheName"] as? String { - let deleted = CacheStorage.shared.delete(origin: origin, name: name) - webView.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', \(deleted));", completionHandler: nil) - } else if let origin = body["origin"] as? String, - let name = body["cacheName"] as? String { - _ = CacheStorage.shared.delete(origin: origin, name: name) - } - - case "swFetch": - if let requestId = body["requestId"] as? String, - let urlString = body["url"] as? String, - let origin = body["origin"] as? String { - let method = body["method"] as? String ?? "GET" - let headers = body["headers"] as? [String: String] ?? [:] - // Decode body from base64 if provided - var requestBody: Data? - if let bodyBase64 = body["bodyBase64"] as? String { - requestBody = Data(base64Encoded: bodyBase64) - } - handleSWFetch(requestId: requestId, urlString: urlString, origin: origin, method: method, headers: headers, body: requestBody) - } - - default: - break - } - } -} diff --git a/submodules/WebWorkerShim/Sources/ServiceWorkerRegistration.swift b/submodules/WebWorkerShim/Sources/ServiceWorkerRegistration.swift deleted file mode 100644 index 08892250fa..0000000000 --- a/submodules/WebWorkerShim/Sources/ServiceWorkerRegistration.swift +++ /dev/null @@ -1,333 +0,0 @@ -import Foundation - -enum ServiceWorkerState: String, Codable, Sendable { - case installing - case installed - case activating - case activated - case redundant -} - -/// Thread-safe service worker registration using actor isolation -actor ServiceWorkerRegistration { - let id: UUID - let scriptURL: URL - let scope: String - let origin: String - var state: ServiceWorkerState = .installing - var scriptContent: String? - - init(scriptURL: URL, scope: String, origin: String) { - self.id = UUID() - self.scriptURL = scriptURL - self.scope = scope - self.origin = origin - } - - func matchesScope(path: String) -> Bool { - // Direct match - if path.hasPrefix(scope) { - return true - } - // If scope ends with / and path doesn't, try adding / to path - // e.g., scope="/app/" should match path="/app" - if scope.hasSuffix("/") && !path.hasSuffix("/") { - return (path + "/").hasPrefix(scope) - } - return false - } - - func setState(_ newState: ServiceWorkerState) { - state = newState - } - - func setScriptContent(_ content: String?) { - scriptContent = content - } - - // Snapshot for persistence (captures current state synchronously within actor) - func snapshot() -> RegistrationSnapshot { - RegistrationSnapshot( - id: id, - scriptURL: scriptURL, - scope: scope, - origin: origin, - state: state - ) - } - - // Restore from snapshot - init(from snapshot: RegistrationSnapshot) { - self.id = snapshot.id - self.scriptURL = snapshot.scriptURL - self.scope = snapshot.scope - self.origin = snapshot.origin - self.state = snapshot.state - self.scriptContent = nil - } -} - -/// Codable snapshot for persistence -struct RegistrationSnapshot: Codable, Sendable { - let id: UUID - let scriptURL: URL - let scope: String - let origin: String - let state: ServiceWorkerState -} - -/// Thread-safe set for tracking managed origins (accessed from URLProtocol.canInit) -private class ManagedOriginsStorage { - private var origins: Set = [] - private let queue = DispatchQueue(label: "com.workershim.managedOrigins", attributes: .concurrent) - - func contains(_ origin: String) -> Bool { - queue.sync { origins.contains(origin) } - } - - func insert(_ origin: String) { - queue.async(flags: .barrier) { self.origins.insert(origin) } - } - - func remove(_ origin: String) { - queue.async(flags: .barrier) { self.origins.remove(origin) } - } - - func removeAll() { - queue.async(flags: .barrier) { self.origins.removeAll() } - } -} - -actor ServiceWorkerManager { - static let shared = ServiceWorkerManager() - - // Thread-safe storage for managed origins (accessible outside actor) - private static let _managedOriginsStorage = ManagedOriginsStorage() - - // Changed from [origin: registration] to [origin: [scope: registration]] - // to support multiple scopes per origin - private var registrations: [String: [String: ServiceWorkerRegistration]] = [:] - private var contexts: [String: [String: ServiceWorkerContext]] = [:] // [origin: [scope: context]] - private var pendingRequests: [String: PWAURLProtocol] = [:] - - // Persistence layer (optional, nil during some tests) - private var persistence: CachePersistence? - - private init() {} - - func setPersistence(_ persistence: CachePersistence?) { - self.persistence = persistence - } - - // MARK: - Persistence Integration - - func loadFromDisk() async { - guard let persistence = persistence else { return } - - let loaded = persistence.loadAllRegistrations() - for snapshot in loaded { - if snapshot.state == .activated { - let registration = ServiceWorkerRegistration(from: snapshot) - if registrations[snapshot.origin] == nil { - registrations[snapshot.origin] = [:] - } - registrations[snapshot.origin]?[snapshot.scope] = registration - // Add to managed origins so URLProtocol knows to intercept - addManagedOrigin(snapshot.origin) - } - } - } - - func register(scriptURL: URL, scope: String, origin: String) async -> ServiceWorkerRegistration { - // Add to managed origins (thread-safe) - addManagedOrigin(origin) - - // Initialize origin dict if needed - if registrations[origin] == nil { - registrations[origin] = [:] - } - - // If registration already exists for this origin+scope, return it - if let existing = registrations[origin]?[scope] { - let existingScript = await existing.scriptURL - if existingScript == scriptURL { - return existing - } - // Different script - this is an update, let the existing flow continue - } - - let registration = ServiceWorkerRegistration(scriptURL: scriptURL, scope: scope, origin: origin) - registrations[origin]?[scope] = registration - if let persistence = persistence { - let snapshot = await registration.snapshot() - persistence.saveRegistration(snapshot) - } - return registration - } - - func getRegistration(for origin: String, path: String) async -> ServiceWorkerRegistration? { - guard let scopeRegs = registrations[origin] else { - print("[ServiceWorkerManager] No registration for origin '\(origin)'. Known origins: \(Array(registrations.keys))") - return nil - } - - // Find the most specific matching scope (longest scope that matches) - var bestMatch: ServiceWorkerRegistration? - var bestScopeLength = -1 - - for (scope, reg) in scopeRegs { - let matches = await reg.matchesScope(path: path) - let state = await reg.state - if matches && state == .activated && scope.count > bestScopeLength { - bestMatch = reg - bestScopeLength = scope.count - } - } - - if bestMatch == nil { - print("[ServiceWorkerManager] No matching activated registration for path '\(path)' in origin '\(origin)'") - } - - return bestMatch - } - - func getRegistration(for origin: String) -> ServiceWorkerRegistration? { - // Return first registration for this origin (for backward compatibility) - return registrations[origin]?.values.first - } - - func getRegistration(for origin: String, scope: String) -> ServiceWorkerRegistration? { - return registrations[origin]?[scope] - } - - func getState(for origin: String) async -> ServiceWorkerState? { - // Return state of first registration (for backward compatibility) - guard let reg = registrations[origin]?.values.first else { return nil } - return await reg.state - } - - func getState(for origin: String, scope: String) async -> ServiceWorkerState? { - guard let reg = registrations[origin]?[scope] else { return nil } - return await reg.state - } - - func updateState(_ registration: ServiceWorkerRegistration, to state: ServiceWorkerState) async { - let origin = await registration.origin - let scope = await registration.scope - print("[ServiceWorkerManager] Updating state for origin '\(origin)', scope '\(scope)' to '\(state)'") - await registration.setState(state) - if let persistence = persistence { - let snapshot = await registration.snapshot() - persistence.saveRegistration(snapshot) - } - } - - func unregister(origin: String) -> Bool { - let removed = registrations.removeValue(forKey: origin) != nil - contexts.removeValue(forKey: origin) - removeManagedOrigin(origin) - persistence?.deleteOrigin(origin) - return removed - } - - func unregister(origin: String, scope: String) -> Bool { - let removed = registrations[origin]?.removeValue(forKey: scope) != nil - contexts[origin]?.removeValue(forKey: scope) - // Only delete from persistence and managed origins if no more scopes for this origin - if registrations[origin]?.isEmpty == true { - registrations.removeValue(forKey: origin) - contexts.removeValue(forKey: origin) - removeManagedOrigin(origin) - persistence?.deleteOrigin(origin) - } - return removed - } - - func clearAll() { - registrations.removeAll() - clearManagedOrigins() - contexts.removeAll() - pendingRequests.removeAll() - } - - // MARK: - Managed Origins - - nonisolated func hasManagedOrigin(_ origin: String) -> Bool { - // Thread-safe check - uses concurrent queue for reads - return Self._managedOriginsStorage.contains(origin) - } - - func addManagedOrigin(_ origin: String) { - Self._managedOriginsStorage.insert(origin) - } - - func removeManagedOrigin(_ origin: String) { - Self._managedOriginsStorage.remove(origin) - } - - func clearManagedOrigins() { - Self._managedOriginsStorage.removeAll() - } - - // MARK: - Context Management - - func setContext(_ context: ServiceWorkerContext, for origin: String, scope: String = "/") { - if contexts[origin] == nil { - contexts[origin] = [:] - } - contexts[origin]?[scope] = context - } - - func getContext(for origin: String) -> ServiceWorkerContext? { - // Return first context for this origin (for backward compatibility) - return contexts[origin]?.values.first - } - - func getContext(for origin: String, scope: String) -> ServiceWorkerContext? { - return contexts[origin]?[scope] - } - - func getContext(for origin: String, path: String) async -> ServiceWorkerContext? { - guard let scopeContexts = contexts[origin] else { return nil } - - // Find the most specific matching scope (longest scope that matches) - var bestMatch: ServiceWorkerContext? - var bestScopeLength = -1 - - for (scope, context) in scopeContexts { - if path.hasPrefix(scope) && scope.count > bestScopeLength { - bestMatch = context - bestScopeLength = scope.count - } - } - - return bestMatch - } - - func removeContext(for origin: String) { - contexts.removeValue(forKey: origin) - } - - func removeContext(for origin: String, scope: String) { - contexts[origin]?.removeValue(forKey: scope) - if contexts[origin]?.isEmpty == true { - contexts.removeValue(forKey: origin) - } - } - - // MARK: - Pending Requests (for URLProtocol callbacks) - - nonisolated func registerPendingRequest(_ requestId: String, protocol urlProtocol: PWAURLProtocol) { - Task { - await self._registerPendingRequest(requestId, protocol: urlProtocol) - } - } - - func _registerPendingRequest(_ requestId: String, protocol urlProtocol: PWAURLProtocol) { - pendingRequests[requestId] = urlProtocol - } - - func getPendingRequest(_ requestId: String) -> PWAURLProtocol? { - return pendingRequests.removeValue(forKey: requestId) - } -}